@chronicle.app/safari 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/connectors/SafariBookmarksExtractor.d.ts +105 -0
- package/dist/connectors/SafariBookmarksExtractor.js +149 -0
- package/dist/connectors/SafariExtractor.d.ts +33 -1
- package/dist/connectors/SafariExtractor.js +71 -16
- package/dist/connectors/SafariTransformer.d.ts +1 -0
- package/dist/connectors/SafariTransformer.js +34 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +33 -9
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@ Read Safari's `History.db` with `SafariExtractor`, then emit one `ViewAction` pe
|
|
|
4
4
|
|
|
5
5
|
Records are visits, newest first, with redirect hops left out. `since`/`until` are exclusive visit-time bounds, and `limit: 0` means unlimited.
|
|
6
6
|
|
|
7
|
-
Each `ViewAction` is keyed by its timestamp. The visited page is an `Entity` keyed by its URL and named after the page title, or the URL when there is none. The agent is the iCloud account owner from `@chronicle.app/
|
|
7
|
+
Each `ViewAction` is keyed by its timestamp. The visited page is an `Entity` keyed by its URL and named after the page title, or the URL when there is none. The agent is the iCloud account owner from `@chronicle.app/apple`, omitted when no account can be read. An explicit `account` object (or null) describes exported data without a host account lookup.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Bookmarks
|
|
10
|
+
|
|
11
|
+
`SafariBookmarksExtractor` reads the `bookmarks` record type from Safari's `Bookmarks.plist`, which also needs Full Disk Access. It's the one in the same folder as the `History.db` that `input` names, so `~/Library/Safari/Bookmarks.plist` by default, and a copy of Safari's folder is read as one. A `History.db` with none beside it has no bookmarks. `bookmarksFile` names another. Each bookmark, favorite, and Reading List item becomes a `BookmarkAction` whose `object` is the page and whose `target` is the folder it's in now, a `Collection`: Favorites, Bookmarks Menu, Reading List, or a folder of your own, which `isPartOf` the folder it's in. A bookmark outside any folder has no target. The file holds only what's saved now, so a run is a snapshot, asserted when the file last changed.
|
|
12
|
+
|
|
13
|
+
A bookmark is keyed by its iCloud record name, which is the same on each device, or by its ID on this Mac when it doesn't sync. A folder is keyed by its ID within the iCloud account that holds it (`inAccount`), since Safari names its own folders, such as `Favorites Bar`, the same in every account. Each is dated by when it was added. Safari records that for every Reading List item, but only for bookmarks it saved recently, so older bookmarks are left out. Records are newest first, and `since`/`until` are exclusive bounds on the date.
|
|
14
|
+
|
|
15
|
+
Tests use a synthetic history database and bookmarks file and never read the host's browsing history, bookmarks, or iCloud account.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Extractor, Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import SafariTransformer from './SafariTransformer.js';
|
|
4
|
+
/** A folder a bookmark is in. */
|
|
5
|
+
export interface BookmarkFolder {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
/** A bookmark, a favorite, or a Reading List item. */
|
|
10
|
+
export interface Bookmark {
|
|
11
|
+
id: string;
|
|
12
|
+
url: string;
|
|
13
|
+
title: string | null;
|
|
14
|
+
/** When it was saved (ISO). */
|
|
15
|
+
addedAt: string;
|
|
16
|
+
/** The folders it's in, outermost first. */
|
|
17
|
+
folders: BookmarkFolder[];
|
|
18
|
+
}
|
|
19
|
+
export declare class SafariBookmarksExtractor extends Extractor<typeof SafariBookmarksExtractor> {
|
|
20
|
+
static source: string;
|
|
21
|
+
static delivery: "local";
|
|
22
|
+
static strategy: string;
|
|
23
|
+
static description: string;
|
|
24
|
+
static default: boolean;
|
|
25
|
+
static recordTypes: string[];
|
|
26
|
+
static temporality: "snapshot";
|
|
27
|
+
static schema: z.ZodObject<{
|
|
28
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
29
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
30
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
31
|
+
} & {
|
|
32
|
+
input: z.ZodDefault<z.ZodString>;
|
|
33
|
+
bookmarksFile: z.ZodOptional<z.ZodString>;
|
|
34
|
+
account: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
35
|
+
accountID: z.ZodString;
|
|
36
|
+
email: z.ZodString;
|
|
37
|
+
displayName: z.ZodString;
|
|
38
|
+
dsid: z.ZodOptional<z.ZodString>;
|
|
39
|
+
}, "strip", z.ZodTypeAny, {
|
|
40
|
+
accountID: string;
|
|
41
|
+
email: string;
|
|
42
|
+
displayName: string;
|
|
43
|
+
dsid?: string | undefined;
|
|
44
|
+
}, {
|
|
45
|
+
accountID: string;
|
|
46
|
+
email: string;
|
|
47
|
+
displayName: string;
|
|
48
|
+
dsid?: string | undefined;
|
|
49
|
+
}>>>;
|
|
50
|
+
}, "strip", z.ZodTypeAny, {
|
|
51
|
+
input: string;
|
|
52
|
+
bookmarksFile?: string | undefined;
|
|
53
|
+
account?: {
|
|
54
|
+
accountID: string;
|
|
55
|
+
email: string;
|
|
56
|
+
displayName: string;
|
|
57
|
+
dsid?: string | undefined;
|
|
58
|
+
} | null | undefined;
|
|
59
|
+
since?: Date | undefined;
|
|
60
|
+
until?: Date | undefined;
|
|
61
|
+
limit?: number | undefined;
|
|
62
|
+
}, {
|
|
63
|
+
input?: string | undefined;
|
|
64
|
+
bookmarksFile?: string | undefined;
|
|
65
|
+
account?: {
|
|
66
|
+
accountID: string;
|
|
67
|
+
email: string;
|
|
68
|
+
displayName: string;
|
|
69
|
+
dsid?: string | undefined;
|
|
70
|
+
} | null | undefined;
|
|
71
|
+
since?: Date | undefined;
|
|
72
|
+
until?: Date | undefined;
|
|
73
|
+
limit?: number | undefined;
|
|
74
|
+
}>;
|
|
75
|
+
static defaultTransformer: typeof SafariTransformer;
|
|
76
|
+
private bookmarks;
|
|
77
|
+
private changed;
|
|
78
|
+
/**
|
|
79
|
+
* Read the bookmarks file, by default the one in the folder of the
|
|
80
|
+
* History.db a run reads, so a copy of Safari's folder is read as one. A
|
|
81
|
+
* History.db without one beside it has no bookmarks to read.
|
|
82
|
+
*/
|
|
83
|
+
setup(): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* When the bookmarks last changed, not when the run started, so a re-read of
|
|
86
|
+
* unchanged bookmarks comes out the same.
|
|
87
|
+
*/
|
|
88
|
+
protected asOfTime(): Date;
|
|
89
|
+
/** The bookmark's CloudKit record name, the same on each device, or its ID on this Mac. */
|
|
90
|
+
keyOf(record: Record): string | null;
|
|
91
|
+
occurredAt(record: Record): Date;
|
|
92
|
+
extract(): AsyncGenerator<Record>;
|
|
93
|
+
/** The bookmarks a run reads and when, from the file setup() read. */
|
|
94
|
+
survey(options?: SurveyOptions): Promise<Survey>;
|
|
95
|
+
/** The bookmarks within since/until, newest first. */
|
|
96
|
+
private inRange;
|
|
97
|
+
/**
|
|
98
|
+
* Every bookmark in the file that Safari dated: each Reading List item, and
|
|
99
|
+
* each bookmark Safari saved since it began dating them, but not those from
|
|
100
|
+
* before.
|
|
101
|
+
*/
|
|
102
|
+
private read;
|
|
103
|
+
private idOf;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=SafariBookmarksExtractor.d.ts.map
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { Extractor, countMonth, fileError, } from '@chronicle.app/etl';
|
|
4
|
+
import { getICloudAccount, parseBinaryPlist, Uid } from '@chronicle.app/apple';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { accountOption, historyInput } from './SafariExtractor.js';
|
|
7
|
+
import SafariTransformer from './SafariTransformer.js';
|
|
8
|
+
// The folders Safari makes, by the names its sidebar gives them.
|
|
9
|
+
const FOLDER_NAMES = {
|
|
10
|
+
BookmarksBar: 'Favorites',
|
|
11
|
+
BookmarksMenu: 'Bookmarks Menu',
|
|
12
|
+
'com.apple.ReadingList': 'Reading List',
|
|
13
|
+
};
|
|
14
|
+
const isNode = (value) => Boolean(value) &&
|
|
15
|
+
typeof value === 'object' &&
|
|
16
|
+
!Array.isArray(value) &&
|
|
17
|
+
!(value instanceof Date) &&
|
|
18
|
+
!(value instanceof Uint8Array) &&
|
|
19
|
+
!(value instanceof Uid);
|
|
20
|
+
const text = (value) => typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
21
|
+
const date = (value) => (value instanceof Date ? value : null);
|
|
22
|
+
const savedAt = (bookmark) => Date.parse(bookmark.addedAt);
|
|
23
|
+
export class SafariBookmarksExtractor extends Extractor {
|
|
24
|
+
static source = 'safari';
|
|
25
|
+
static delivery = 'local';
|
|
26
|
+
static strategy = 'app-db';
|
|
27
|
+
static description = 'Bookmarks, favorites, and Reading List';
|
|
28
|
+
static default = true;
|
|
29
|
+
static recordTypes = ['bookmarks'];
|
|
30
|
+
// Bookmarks.plist holds what's saved now, in the folder it's in now. See
|
|
31
|
+
// Extractor.temporality.
|
|
32
|
+
static temporality = 'snapshot';
|
|
33
|
+
static schema = Extractor.schema.extend({
|
|
34
|
+
input: historyInput,
|
|
35
|
+
bookmarksFile: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe('Path to Safari Bookmarks.plist (default: the one beside History.db)'),
|
|
39
|
+
account: accountOption,
|
|
40
|
+
});
|
|
41
|
+
static defaultTransformer = SafariTransformer;
|
|
42
|
+
bookmarks = [];
|
|
43
|
+
changed;
|
|
44
|
+
/**
|
|
45
|
+
* Read the bookmarks file, by default the one in the folder of the
|
|
46
|
+
* History.db a run reads, so a copy of Safari's folder is read as one. A
|
|
47
|
+
* History.db without one beside it has no bookmarks to read.
|
|
48
|
+
*/
|
|
49
|
+
async setup() {
|
|
50
|
+
await super.setup();
|
|
51
|
+
const { bookmarksFile, input } = this.config;
|
|
52
|
+
const path = bookmarksFile ?? join(dirname(input), 'Bookmarks.plist');
|
|
53
|
+
let bytes;
|
|
54
|
+
try {
|
|
55
|
+
bytes = await readFile(path);
|
|
56
|
+
this.changed = (await stat(path)).mtime;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (!bookmarksFile && error.code === 'ENOENT')
|
|
60
|
+
return;
|
|
61
|
+
throw fileError(error, path, 'bookmarks file');
|
|
62
|
+
}
|
|
63
|
+
this.bookmarks = this.read(parseBinaryPlist(bytes));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* When the bookmarks last changed, not when the run started, so a re-read of
|
|
67
|
+
* unchanged bookmarks comes out the same.
|
|
68
|
+
*/
|
|
69
|
+
asOfTime() {
|
|
70
|
+
return this.changed ?? super.asOfTime();
|
|
71
|
+
}
|
|
72
|
+
/** The bookmark's CloudKit record name, the same on each device, or its ID on this Mac. */
|
|
73
|
+
keyOf(record) {
|
|
74
|
+
return record.data.id;
|
|
75
|
+
}
|
|
76
|
+
occurredAt(record) {
|
|
77
|
+
return new Date(record.data.addedAt);
|
|
78
|
+
}
|
|
79
|
+
async *extract() {
|
|
80
|
+
const limit = this.getEffectiveLimit();
|
|
81
|
+
const account = this.config.account === undefined ? await getICloudAccount() : this.config.account;
|
|
82
|
+
for (const bookmark of this.inRange().slice(0, limit ?? undefined)) {
|
|
83
|
+
yield this.createRecord(bookmark, { source: 'safari', account });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The bookmarks a run reads and when, from the file setup() read. */
|
|
87
|
+
async survey(options = {}) {
|
|
88
|
+
const bookmarks = this.inRange();
|
|
89
|
+
const byMonth = {};
|
|
90
|
+
if (options.byMonth)
|
|
91
|
+
for (const { addedAt } of bookmarks)
|
|
92
|
+
countMonth(byMonth, new Date(addedAt));
|
|
93
|
+
return {
|
|
94
|
+
bookmarks: {
|
|
95
|
+
count: bookmarks.length,
|
|
96
|
+
...(bookmarks.length > 0 && {
|
|
97
|
+
earliest: new Date(bookmarks.at(-1).addedAt),
|
|
98
|
+
latest: new Date(bookmarks[0].addedAt),
|
|
99
|
+
}),
|
|
100
|
+
...(options.byMonth && { byMonth }),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The bookmarks within since/until, newest first. */
|
|
105
|
+
inRange() {
|
|
106
|
+
const { since, until } = this.config;
|
|
107
|
+
return this.bookmarks
|
|
108
|
+
.filter(b => (!since || savedAt(b) > since.getTime()) && (!until || savedAt(b) < until.getTime()))
|
|
109
|
+
.sort((a, b) => savedAt(b) - savedAt(a));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Every bookmark in the file that Safari dated: each Reading List item, and
|
|
113
|
+
* each bookmark Safari saved since it began dating them, but not those from
|
|
114
|
+
* before.
|
|
115
|
+
*/
|
|
116
|
+
read(root) {
|
|
117
|
+
const bookmarks = [];
|
|
118
|
+
const walk = (node, folders) => {
|
|
119
|
+
for (const child of Array.isArray(node.Children) ? node.Children : []) {
|
|
120
|
+
if (!isNode(child))
|
|
121
|
+
continue;
|
|
122
|
+
if (child.WebBookmarkType === 'WebBookmarkTypeList') {
|
|
123
|
+
const title = text(child.Title) ?? '';
|
|
124
|
+
walk(child, [...folders, { id: this.idOf(child), name: FOLDER_NAMES[title] ?? title }]);
|
|
125
|
+
}
|
|
126
|
+
else if (child.WebBookmarkType === 'WebBookmarkTypeLeaf' && text(child.URLString)) {
|
|
127
|
+
const readingList = isNode(child.ReadingList) ? child.ReadingList : {};
|
|
128
|
+
const addedAt = date(readingList.DateAdded) ?? date(child.dateAdded);
|
|
129
|
+
if (!addedAt)
|
|
130
|
+
continue;
|
|
131
|
+
const uri = isNode(child.URIDictionary) ? child.URIDictionary : {};
|
|
132
|
+
bookmarks.push({
|
|
133
|
+
id: this.idOf(child),
|
|
134
|
+
url: text(child.URLString),
|
|
135
|
+
title: text(uri.title),
|
|
136
|
+
addedAt: addedAt.toISOString(),
|
|
137
|
+
folders,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
if (isNode(root))
|
|
143
|
+
walk(root, []);
|
|
144
|
+
return bookmarks;
|
|
145
|
+
}
|
|
146
|
+
idOf(node) {
|
|
147
|
+
return (isNode(node.Sync) && text(node.Sync.ServerID)) || String(node.WebBookmarkUUID);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,6 +1,29 @@
|
|
|
1
|
-
import { Record } from '@chronicle.app/etl';
|
|
1
|
+
import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
|
|
2
2
|
import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
|
|
3
|
+
import { z } from 'zod';
|
|
3
4
|
import SafariTransformer from './SafariTransformer.js';
|
|
5
|
+
/** Safari's History.db; its Bookmarks.plist is in the same folder. */
|
|
6
|
+
export declare const historyInput: z.ZodDefault<z.ZodString>;
|
|
7
|
+
/**
|
|
8
|
+
* The Apple Account whose history and bookmarks these are. Omitted, it's the
|
|
9
|
+
* one signed in to this Mac; null, there's no one to name.
|
|
10
|
+
*/
|
|
11
|
+
export declare const accountOption: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
12
|
+
accountID: z.ZodString;
|
|
13
|
+
email: z.ZodString;
|
|
14
|
+
displayName: z.ZodString;
|
|
15
|
+
dsid: z.ZodOptional<z.ZodString>;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
accountID: string;
|
|
18
|
+
email: string;
|
|
19
|
+
displayName: string;
|
|
20
|
+
dsid?: string | undefined;
|
|
21
|
+
}, {
|
|
22
|
+
accountID: string;
|
|
23
|
+
email: string;
|
|
24
|
+
displayName: string;
|
|
25
|
+
dsid?: string | undefined;
|
|
26
|
+
}>>>;
|
|
4
27
|
export declare class SafariExtractor extends SqliteExtractor<typeof SafariExtractor> {
|
|
5
28
|
static source: string;
|
|
6
29
|
static delivery: "local";
|
|
@@ -9,13 +32,22 @@ export declare class SafariExtractor extends SqliteExtractor<typeof SafariExtrac
|
|
|
9
32
|
static default: boolean;
|
|
10
33
|
static recordTypes: string[];
|
|
11
34
|
static newestFirst: boolean;
|
|
35
|
+
static databases: {
|
|
36
|
+
tables: {
|
|
37
|
+
history_items: string[];
|
|
38
|
+
history_visits: string[];
|
|
39
|
+
};
|
|
40
|
+
}[];
|
|
12
41
|
static schema: any;
|
|
13
42
|
static defaultTransformer: typeof SafariTransformer;
|
|
14
43
|
/** history_visits.id — Safari's own visit id, AUTOINCREMENT so never reused. */
|
|
15
44
|
keyOf(record: Record): string | null;
|
|
45
|
+
occurredAt(record: Record): Date | undefined;
|
|
16
46
|
private visitFilter;
|
|
17
47
|
extract(): AsyncGenerator<Record>;
|
|
18
48
|
private processRow;
|
|
19
49
|
determineCount(): Promise<number | null>;
|
|
50
|
+
/** The visits a run reads and when, with how many pages they were to. */
|
|
51
|
+
survey(options?: SurveyOptions): Promise<Survey | null>;
|
|
20
52
|
}
|
|
21
53
|
//# sourceMappingURL=SafariExtractor.d.ts.map
|
|
@@ -1,9 +1,29 @@
|
|
|
1
|
-
import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, timeRangeConditions, unixToSafariTimestamp, iterateRows, } from '@chronicle.app/etl-sqlite';
|
|
2
|
-
import { getICloudAccount } from '@chronicle.app/
|
|
1
|
+
import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, countsByMonth, monthSql, timeRangeConditions, unixToSafariTimestamp, iterateRows, } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import { getICloudAccount } from '@chronicle.app/apple';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import SafariTransformer from './SafariTransformer.js';
|
|
5
5
|
// since/until bounds land on visit_time, which is seconds since the Apple epoch
|
|
6
6
|
const safariBound = (date) => unixToSafariTimestamp(Math.floor(date.getTime() / 1000));
|
|
7
|
+
// A visit's time as a record gives it: whole Unix seconds.
|
|
8
|
+
const visitTime = `cast(history_visits.visit_time + ${APPLE_EPOCH_OFFSET_SECONDS} as integer)`;
|
|
9
|
+
/** Safari's History.db; its Bookmarks.plist is in the same folder. */
|
|
10
|
+
export const historyInput = z
|
|
11
|
+
.string()
|
|
12
|
+
.describe('Path to Safari History.db file')
|
|
13
|
+
.default(`${process.env.HOME}/Library/Safari/History.db`);
|
|
14
|
+
/**
|
|
15
|
+
* The Apple Account whose history and bookmarks these are. Omitted, it's the
|
|
16
|
+
* one signed in to this Mac; null, there's no one to name.
|
|
17
|
+
*/
|
|
18
|
+
export const accountOption = z
|
|
19
|
+
.object({
|
|
20
|
+
accountID: z.string(),
|
|
21
|
+
email: z.string(),
|
|
22
|
+
displayName: z.string(),
|
|
23
|
+
dsid: z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
.nullable()
|
|
26
|
+
.optional();
|
|
7
27
|
export class SafariExtractor extends SqliteExtractor {
|
|
8
28
|
static source = 'safari';
|
|
9
29
|
static delivery = 'local';
|
|
@@ -14,20 +34,25 @@ export class SafariExtractor extends SqliteExtractor {
|
|
|
14
34
|
// extract() orders by history_visits.visit_time desc, which with keyOf opts
|
|
15
35
|
// this extractor into the frontier cursor (incremental catch-up import).
|
|
16
36
|
static newestFirst = true;
|
|
37
|
+
// What extract() and survey() read, which a History.db must have.
|
|
38
|
+
static databases = [
|
|
39
|
+
{
|
|
40
|
+
tables: {
|
|
41
|
+
history_items: ['id', 'url'],
|
|
42
|
+
history_visits: [
|
|
43
|
+
'id',
|
|
44
|
+
'history_item',
|
|
45
|
+
'visit_time',
|
|
46
|
+
'title',
|
|
47
|
+
'redirect_destination',
|
|
48
|
+
'origin',
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
17
53
|
static schema = SqliteExtractor.schema.omit({ input: true }).extend({
|
|
18
|
-
input:
|
|
19
|
-
|
|
20
|
-
.describe('Path to Safari History.db file')
|
|
21
|
-
.default(`${process.env.HOME}/Library/Safari/History.db`),
|
|
22
|
-
account: z
|
|
23
|
-
.object({
|
|
24
|
-
accountID: z.string(),
|
|
25
|
-
email: z.string(),
|
|
26
|
-
displayName: z.string(),
|
|
27
|
-
dsid: z.string().optional(),
|
|
28
|
-
})
|
|
29
|
-
.nullable()
|
|
30
|
-
.optional(),
|
|
54
|
+
input: historyInput,
|
|
55
|
+
account: accountOption,
|
|
31
56
|
});
|
|
32
57
|
static defaultTransformer = SafariTransformer;
|
|
33
58
|
/** history_visits.id — Safari's own visit id, AUTOINCREMENT so never reused. */
|
|
@@ -35,6 +60,9 @@ export class SafariExtractor extends SqliteExtractor {
|
|
|
35
60
|
const visitId = record.data.visit_id;
|
|
36
61
|
return visitId === null || visitId === undefined ? null : String(visitId);
|
|
37
62
|
}
|
|
63
|
+
occurredAt(record) {
|
|
64
|
+
return new Date(record.data.unix_timestamp * 1000);
|
|
65
|
+
}
|
|
38
66
|
// Visits that landed on a page (redirect hops excluded), within since/until.
|
|
39
67
|
visitFilter() {
|
|
40
68
|
const range = timeRangeConditions('history_visits.visit_time', this.config, {
|
|
@@ -56,7 +84,7 @@ export class SafariExtractor extends SqliteExtractor {
|
|
|
56
84
|
history_visits.visit_time,
|
|
57
85
|
history_visits.title,
|
|
58
86
|
history_items.url,
|
|
59
|
-
|
|
87
|
+
${visitTime} AS unix_timestamp,
|
|
60
88
|
datetime(history_visits.visit_time + ${APPLE_EPOCH_OFFSET_SECONDS}, 'unixepoch') AS visit_datetime,
|
|
61
89
|
history_visits.origin
|
|
62
90
|
FROM history_visits
|
|
@@ -107,4 +135,31 @@ export class SafariExtractor extends SqliteExtractor {
|
|
|
107
135
|
return null;
|
|
108
136
|
}
|
|
109
137
|
}
|
|
138
|
+
/** The visits a run reads and when, with how many pages they were to. */
|
|
139
|
+
async survey(options = {}) {
|
|
140
|
+
if (!this.db)
|
|
141
|
+
return null;
|
|
142
|
+
const { where, values } = this.visitFilter();
|
|
143
|
+
const row = this.db
|
|
144
|
+
.prepare(`SELECT COUNT(*) AS n, MIN(${visitTime}) AS first, MAX(${visitTime}) AS last,
|
|
145
|
+
COUNT(DISTINCT history_items.id) AS pages FROM history_visits
|
|
146
|
+
LEFT JOIN history_items ON history_items.id = history_visits.history_item
|
|
147
|
+
WHERE ${where}`)
|
|
148
|
+
.get(...values);
|
|
149
|
+
const byMonth = options.byMonth &&
|
|
150
|
+
countsByMonth(this.db
|
|
151
|
+
.prepare(`SELECT ${monthSql(visitTime)} AS month, COUNT(*) AS n FROM history_visits
|
|
152
|
+
LEFT JOIN history_items ON history_items.id = history_visits.history_item
|
|
153
|
+
WHERE ${where} GROUP BY month`)
|
|
154
|
+
.all(...values));
|
|
155
|
+
return {
|
|
156
|
+
views: {
|
|
157
|
+
count: row.n,
|
|
158
|
+
...(row.first !== null && { earliest: new Date(row.first * 1000) }),
|
|
159
|
+
...(row.last !== null && { latest: new Date(row.last * 1000) }),
|
|
160
|
+
...(byMonth && { byMonth }),
|
|
161
|
+
measures: [{ key: 'pages', label: 'pages', value: row.pages }],
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
110
165
|
}
|
|
@@ -3,6 +3,7 @@ import { ActionAndChildren } from '@chronicle.app/schema';
|
|
|
3
3
|
export default class SafariTransformer extends ChronicleTransformer {
|
|
4
4
|
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
5
5
|
private buildViewActions;
|
|
6
|
+
private buildBookmarkAction;
|
|
6
7
|
private buildWebPage;
|
|
7
8
|
}
|
|
8
9
|
//# sourceMappingURL=SafariTransformer.d.ts.map
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { ChronicleTransformer } from '@chronicle.app/etl';
|
|
2
|
-
import { buildICloudPersonSchema } from '@chronicle.app/
|
|
2
|
+
import { buildICloudPersonSchema } from '@chronicle.app/apple';
|
|
3
3
|
export default class SafariTransformer extends ChronicleTransformer {
|
|
4
4
|
async transform(record) {
|
|
5
5
|
const actions = [];
|
|
6
6
|
if (record.extraction.recordType === 'views') {
|
|
7
7
|
actions.push(...(await this.buildViewActions(record)));
|
|
8
8
|
}
|
|
9
|
+
else if (record.extraction.recordType === 'bookmarks') {
|
|
10
|
+
actions.push(await this.buildBookmarkAction(record));
|
|
11
|
+
}
|
|
9
12
|
return actions;
|
|
10
13
|
}
|
|
11
14
|
async buildViewActions(record) {
|
|
@@ -25,6 +28,36 @@ export default class SafariTransformer extends ChronicleTransformer {
|
|
|
25
28
|
actions.push(viewAction);
|
|
26
29
|
return actions;
|
|
27
30
|
}
|
|
31
|
+
// A bookmark, a favorite, and a Reading List item are each a page saved to
|
|
32
|
+
// a folder: the folder is the target, and a favorite's is Favorites.
|
|
33
|
+
async buildBookmarkAction(record) {
|
|
34
|
+
const bookmark = record.data;
|
|
35
|
+
const user = await buildICloudPersonSchema(record.context.account);
|
|
36
|
+
// Safari names its own folders the same in every account, such as
|
|
37
|
+
// `Favorites Bar`, so a folder's ID is unique only within its owner's.
|
|
38
|
+
let target;
|
|
39
|
+
for (const folder of bookmark.folders) {
|
|
40
|
+
target = {
|
|
41
|
+
'@type': 'Collection',
|
|
42
|
+
'@key': ['@type', 'source', 'inAccount[*].sourceId', 'sourceId'],
|
|
43
|
+
source: 'safari',
|
|
44
|
+
sourceId: folder.id,
|
|
45
|
+
name: folder.name,
|
|
46
|
+
...(user && { inAccount: [user] }),
|
|
47
|
+
...(target && { isPartOf: [target] }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
'@type': 'BookmarkAction',
|
|
52
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
53
|
+
source: 'safari',
|
|
54
|
+
sourceId: bookmark.id,
|
|
55
|
+
timestamp: new Date(bookmark.addedAt),
|
|
56
|
+
...(user && { agent: user }),
|
|
57
|
+
object: this.buildWebPage(record),
|
|
58
|
+
...(target && { target }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
28
61
|
buildWebPage(record) {
|
|
29
62
|
return {
|
|
30
63
|
'@type': 'Entity',
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { SafariExtractor } from './connectors/SafariExtractor.js';
|
|
2
|
+
export { SafariBookmarksExtractor } from './connectors/SafariBookmarksExtractor.js';
|
|
2
3
|
export { default as SafariTransformer } from './connectors/SafariTransformer.js';
|
|
3
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
// Export extractors for plugin scanning
|
|
2
2
|
export { SafariExtractor } from './connectors/SafariExtractor.js';
|
|
3
|
+
export { SafariBookmarksExtractor } from './connectors/SafariBookmarksExtractor.js';
|
|
3
4
|
export { default as SafariTransformer } from './connectors/SafariTransformer.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/safari",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
"author": "Andrew Louis",
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"type": "module",
|
|
20
|
-
"description": "Chronicle Safari browsing history plugin",
|
|
20
|
+
"description": "Chronicle Safari browsing history, bookmarks, and Reading List plugin",
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@chronicle.app/etl": "0.
|
|
23
|
-
"@chronicle.app/schema": "0.
|
|
24
|
-
"@chronicle.app/tsconfig": "0.
|
|
22
|
+
"@chronicle.app/etl": "0.5.0",
|
|
23
|
+
"@chronicle.app/schema": "0.5.0",
|
|
24
|
+
"@chronicle.app/tsconfig": "0.5.0",
|
|
25
25
|
"@types/node": "^22.10.1",
|
|
26
26
|
"typescript": "^5.7.2"
|
|
27
27
|
},
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"registry": "https://registry.npmjs.org/"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@chronicle.app/etl-sqlite": "0.
|
|
40
|
-
"@chronicle.app/
|
|
39
|
+
"@chronicle.app/etl-sqlite": "0.5.0",
|
|
40
|
+
"@chronicle.app/apple": "0.5.0",
|
|
41
41
|
"zod": "^3.24.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
@@ -61,9 +61,33 @@
|
|
|
61
61
|
"app-db": {
|
|
62
62
|
"delivery": "local",
|
|
63
63
|
"recordTypes": [
|
|
64
|
-
"views"
|
|
64
|
+
"views",
|
|
65
|
+
"bookmarks"
|
|
65
66
|
],
|
|
66
|
-
"default": true
|
|
67
|
+
"default": true,
|
|
68
|
+
"locations": [
|
|
69
|
+
{
|
|
70
|
+
"path": "~/Library/Safari/History.db"
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"databases": [
|
|
74
|
+
{
|
|
75
|
+
"tables": {
|
|
76
|
+
"history_items": [
|
|
77
|
+
"id",
|
|
78
|
+
"url"
|
|
79
|
+
],
|
|
80
|
+
"history_visits": [
|
|
81
|
+
"id",
|
|
82
|
+
"history_item",
|
|
83
|
+
"visit_time",
|
|
84
|
+
"title",
|
|
85
|
+
"redirect_destination",
|
|
86
|
+
"origin"
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
]
|
|
67
91
|
}
|
|
68
92
|
},
|
|
69
93
|
"platforms": [
|