@chronicle.app/safari 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @chronicle.app/safari
2
+
3
+ Read Safari's `History.db` with `SafariExtractor`, then emit one `ViewAction` per page visit with `SafariTransformer`. `input` defaults to `~/Library/Safari/History.db`; reading it needs Full Disk Access. The connection is read-only via built-in `node:sqlite`.
4
+
5
+ Records are visits, newest first, with redirect hops left out. `since`/`until` are exclusive visit-time bounds, and `limit: 0` means unlimited.
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/icloud`, omitted when no account can be read. An explicit `account` object (or null) describes exported data without a host account lookup.
8
+
9
+ Tests use a synthetic history database and never read the host's browsing history or iCloud account.
@@ -0,0 +1,21 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
+ import SafariTransformer from './SafariTransformer.js';
4
+ export declare class SafariExtractor extends SqliteExtractor<typeof SafariExtractor> {
5
+ static source: string;
6
+ static delivery: "local";
7
+ static strategy: string;
8
+ static description: string;
9
+ static default: boolean;
10
+ static recordTypes: string[];
11
+ static newestFirst: boolean;
12
+ static schema: any;
13
+ static defaultTransformer: typeof SafariTransformer;
14
+ /** history_visits.id — Safari's own visit id, AUTOINCREMENT so never reused. */
15
+ keyOf(record: Record): string | null;
16
+ private visitFilter;
17
+ extract(): AsyncGenerator<Record>;
18
+ private processRow;
19
+ determineCount(): Promise<number | null>;
20
+ }
21
+ //# sourceMappingURL=SafariExtractor.d.ts.map
@@ -0,0 +1,110 @@
1
+ import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, timeRangeConditions, unixToSafariTimestamp, iterateRows, } from '@chronicle.app/etl-sqlite';
2
+ import { getICloudAccount } from '@chronicle.app/icloud';
3
+ import { z } from 'zod';
4
+ import SafariTransformer from './SafariTransformer.js';
5
+ // since/until bounds land on visit_time, which is seconds since the Apple epoch
6
+ const safariBound = (date) => unixToSafariTimestamp(Math.floor(date.getTime() / 1000));
7
+ export class SafariExtractor extends SqliteExtractor {
8
+ static source = 'safari';
9
+ static delivery = 'local';
10
+ static strategy = 'app-db';
11
+ static description = 'Browsing history';
12
+ static default = true;
13
+ static recordTypes = ['views'];
14
+ // extract() orders by history_visits.visit_time desc, which with keyOf opts
15
+ // this extractor into the frontier cursor (incremental catch-up import).
16
+ static newestFirst = true;
17
+ static schema = SqliteExtractor.schema.omit({ input: true }).extend({
18
+ input: z
19
+ .string()
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(),
31
+ });
32
+ static defaultTransformer = SafariTransformer;
33
+ /** history_visits.id — Safari's own visit id, AUTOINCREMENT so never reused. */
34
+ keyOf(record) {
35
+ const visitId = record.data.visit_id;
36
+ return visitId === null || visitId === undefined ? null : String(visitId);
37
+ }
38
+ // Visits that landed on a page (redirect hops excluded), within since/until.
39
+ visitFilter() {
40
+ const range = timeRangeConditions('history_visits.visit_time', this.config, {
41
+ convert: safariBound,
42
+ });
43
+ const conditions = ['history_visits.redirect_destination IS NULL', ...range.conditions];
44
+ return { where: conditions.join(' AND '), values: range.values };
45
+ }
46
+ async *extract() {
47
+ if (!this.db) {
48
+ throw new Error('Database not initialized');
49
+ }
50
+ // 0 is the "no limit" convention (null here); the CLI's --limit default is the safety net
51
+ const limit = this.getEffectiveLimit();
52
+ const { where, values } = this.visitFilter();
53
+ let sql = `
54
+ SELECT history_visits.id AS visit_id,
55
+ history_visits.history_item,
56
+ history_visits.visit_time,
57
+ history_visits.title,
58
+ history_items.url,
59
+ cast(history_visits.visit_time + ${APPLE_EPOCH_OFFSET_SECONDS} as integer) AS unix_timestamp,
60
+ datetime(history_visits.visit_time + ${APPLE_EPOCH_OFFSET_SECONDS}, 'unixepoch') AS visit_datetime,
61
+ history_visits.origin
62
+ FROM history_visits
63
+ LEFT JOIN history_items ON history_items.id = history_visits.history_item
64
+ WHERE ${where}
65
+ ORDER BY history_visits.visit_time DESC`;
66
+ if (limit !== null) {
67
+ sql += ` LIMIT ?`;
68
+ values.push(limit);
69
+ }
70
+ const account = this.config.account === undefined ? await getICloudAccount() : this.config.account;
71
+ for (const row of iterateRows(this.db.prepare(sql), ...values)) {
72
+ const processedRow = this.processRow(row);
73
+ yield this.createRecord(processedRow, {
74
+ source: 'safari',
75
+ account,
76
+ });
77
+ }
78
+ }
79
+ processRow(row) {
80
+ return {
81
+ visit_id: row.visit_id,
82
+ history_item: row.history_item,
83
+ visit_time: row.visit_time,
84
+ title: row.title || null,
85
+ url: row.url,
86
+ unix_timestamp: row.unix_timestamp,
87
+ visit_datetime: row.visit_datetime,
88
+ origin: row.origin,
89
+ // Origin is an integer in Safari's schema - we can't determine device type from it directly
90
+ device_type: 'desktop', // Default to desktop since we can't reliably detect mobile vs desktop
91
+ };
92
+ }
93
+ async determineCount() {
94
+ if (!this.db)
95
+ return null;
96
+ try {
97
+ // Same filters as the main query, counted
98
+ const { where, values } = this.visitFilter();
99
+ const row = this.db
100
+ .prepare(`SELECT COUNT(*) AS n FROM history_visits
101
+ LEFT JOIN history_items ON history_items.id = history_visits.history_item
102
+ WHERE ${where}`)
103
+ .get(...values);
104
+ return row.n;
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,8 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class SafariTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildViewActions;
6
+ private buildWebPage;
7
+ }
8
+ //# sourceMappingURL=SafariTransformer.d.ts.map
@@ -0,0 +1,36 @@
1
+ import { ChronicleTransformer } from '@chronicle.app/etl';
2
+ import { buildICloudPersonSchema } from '@chronicle.app/icloud';
3
+ export default class SafariTransformer extends ChronicleTransformer {
4
+ async transform(record) {
5
+ const actions = [];
6
+ if (record.extraction.recordType === 'views') {
7
+ actions.push(...(await this.buildViewActions(record)));
8
+ }
9
+ return actions;
10
+ }
11
+ async buildViewActions(record) {
12
+ const actions = [];
13
+ const user = await buildICloudPersonSchema(record.context.account);
14
+ const webpage = this.buildWebPage(record);
15
+ // Create a ViewAction for the browser visit
16
+ const viewAction = {
17
+ '@type': 'ViewAction',
18
+ timestamp: new Date(record.data.unix_timestamp * 1000),
19
+ '@key': ['@type', 'source', 'timestamp'],
20
+ source: 'safari',
21
+ // Without an iCloud account the viewer can't be identified, so omit them.
22
+ ...(user && { agent: user }),
23
+ object: webpage,
24
+ };
25
+ actions.push(viewAction);
26
+ return actions;
27
+ }
28
+ buildWebPage(record) {
29
+ return {
30
+ '@type': 'Entity',
31
+ '@key': ['url'],
32
+ url: record.data.url,
33
+ name: record.data.title || record.data.url,
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,3 @@
1
+ export { SafariExtractor } from './connectors/SafariExtractor.js';
2
+ export { default as SafariTransformer } from './connectors/SafariTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // Export extractors for plugin scanning
2
+ export { SafariExtractor } from './connectors/SafariExtractor.js';
3
+ export { default as SafariTransformer } from './connectors/SafariTransformer.js';
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@chronicle.app/safari",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [],
17
+ "author": "Andrew Louis",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "description": "Chronicle Safari browsing history plugin",
21
+ "devDependencies": {
22
+ "@chronicle.app/etl": "0.3.0",
23
+ "@chronicle.app/schema": "0.3.0",
24
+ "@chronicle.app/tsconfig": "0.3.0",
25
+ "@types/node": "^22.10.1",
26
+ "typescript": "^5.7.2"
27
+ },
28
+ "homepage": "https://chronicle.app",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/chronicle-app/chronicle.git",
32
+ "directory": "plugins/safari"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
37
+ },
38
+ "dependencies": {
39
+ "@chronicle.app/etl-sqlite": "0.3.0",
40
+ "@chronicle.app/icloud": "0.3.0",
41
+ "zod": "^3.24.1"
42
+ },
43
+ "peerDependencies": {
44
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
45
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
46
+ },
47
+ "engines": {
48
+ "node": ">=22.13.0"
49
+ },
50
+ "exports": {
51
+ ".": {
52
+ "types": "./dist/index.d.ts",
53
+ "import": "./dist/index.js"
54
+ }
55
+ },
56
+ "chronicle": {
57
+ "plugin": true,
58
+ "sources": {
59
+ "safari": {
60
+ "strategies": {
61
+ "app-db": {
62
+ "delivery": "local",
63
+ "recordTypes": [
64
+ "views"
65
+ ],
66
+ "default": true
67
+ }
68
+ },
69
+ "platforms": [
70
+ "darwin"
71
+ ],
72
+ "requires": [
73
+ "full-disk-access"
74
+ ]
75
+ }
76
+ }
77
+ }
78
+ }