@chronicle.app/timing-app 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.
@@ -0,0 +1,331 @@
1
+ import { ChronicleTransformer, SystemInfo } from '@chronicle.app/etl';
2
+ import path from 'node:path';
3
+ const SOURCE = 'timing-app';
4
+ export default class TimingAppTransformer extends ChronicleTransformer {
5
+ static source = SOURCE;
6
+ meAgent;
7
+ async transform(record) {
8
+ switch (record.extraction.recordType) {
9
+ case 'app-activities':
10
+ return [this.buildAppActivity(record)];
11
+ case 'time-entries':
12
+ return [this.buildTimeEntry(record)];
13
+ default:
14
+ return [];
15
+ }
16
+ }
17
+ /**
18
+ * The account owner, tagged `@me`. Timing has no account of its own (and it is
19
+ * not an iCloud identity), so we don't assume a name — the node is just the
20
+ * `@me` tag sourced to Timing, keyed on `['@type','source']`. The `@me` token
21
+ * merges it with the subject's named identities from other sources.
22
+ */
23
+ me() {
24
+ if (!this.meAgent) {
25
+ this.meAgent = {
26
+ '@type': 'Agent',
27
+ '@key': ['@type', 'source'],
28
+ source: SOURCE,
29
+ sameAs: ['@me'],
30
+ };
31
+ }
32
+ return this.meAgent;
33
+ }
34
+ // --- Projects -------------------------------------------------------------
35
+ /**
36
+ * A Project node with its ancestry nested via isPartOf. `chain` is leaf-first
37
+ * ([self, parent, ...]); returns the leaf node, or null when unattributed.
38
+ *
39
+ * Each node asserts `isPartOf` as the complete parent set as of this read, so
40
+ * a project reparented (or promoted to root) in Timing closes its old parent
41
+ * edge instead of accumulating both.
42
+ */
43
+ buildProjectChain(chain) {
44
+ let child = null;
45
+ for (let i = chain.length - 1; i >= 0; i--) {
46
+ const node = {
47
+ '@type': 'Project',
48
+ '@key': ['@type', 'source', 'sourceId'],
49
+ source: SOURCE,
50
+ sourceId: chain[i].id,
51
+ name: chain[i].title,
52
+ '@asserts': ['isPartOf'],
53
+ };
54
+ if (child)
55
+ node.isPartOf = [child];
56
+ child = node;
57
+ }
58
+ return child;
59
+ }
60
+ // --- App activity ---------------------------------------------------------
61
+ buildAppActivity(record) {
62
+ const ctx = record.context;
63
+ const session = {
64
+ '@type': 'DeviceSession',
65
+ '@key': ['@type', 'source', 'sourceId'],
66
+ source: SOURCE,
67
+ sourceId: ctx.id,
68
+ };
69
+ const project = this.buildProjectChain(ctx.projectChain);
70
+ if (project)
71
+ session.isPartOf = [project];
72
+ // The logical machine (a name-keyed :Realm) scopes file paths and unifies
73
+ // with the same host seen from other sources; the physical device (keyed on
74
+ // its hardware, distinct per machine) is the instrument and links to the
75
+ // realm via inRealm.
76
+ const realm = this.buildRealm(ctx.device);
77
+ const device = this.buildDevice(ctx.device, realm);
78
+ const resource = this.buildResource(ctx.pathString, realm);
79
+ if (resource?.['@type'] === 'Directory') {
80
+ // A directory is where the activity happened (a terminal's CWD, a Finder
81
+ // folder) — the session's location, not the resource in focus.
82
+ session.workingDirectory = resource;
83
+ }
84
+ else if (resource) {
85
+ session.subject = [resource];
86
+ }
87
+ // The window title describes the session (what this window showed during the
88
+ // block), not the resource — a subject folds across every session that
89
+ // touches it, so it keeps its own stable name (the path basename) while the
90
+ // title, which changes block to block ("sleep 4", a path abbreviation, a page
91
+ // title), names the session. Skip it when it only repeats the subject's name
92
+ // (an editor titling itself with the file it already points at).
93
+ const title = ctx.windowTitle ? stripWindowTitle(ctx.windowTitle, ctx.app.title) : null;
94
+ if (title && title !== resource?.name) {
95
+ session.name = title;
96
+ }
97
+ const action = {
98
+ '@type': 'ExecuteAction',
99
+ '@key': ['@type', 'source', 'sourceId'],
100
+ source: SOURCE,
101
+ sourceId: ctx.id,
102
+ startTime: secondsToISO(ctx.startDate),
103
+ endTime: secondsToISO(ctx.endDate),
104
+ agent: this.me(),
105
+ result: session,
106
+ };
107
+ const app = this.buildApplication(ctx.app);
108
+ if (app)
109
+ action.object = app;
110
+ if (device)
111
+ action.instrument = device;
112
+ return action;
113
+ }
114
+ /** The on-screen resource: a URL-keyed web page, or a realm-scoped filesystem entry. */
115
+ buildResource(pathString, realm) {
116
+ if (!pathString)
117
+ return null;
118
+ // A web page: globally unique, content-addressed by URL. Its title is the
119
+ // session's (set by the caller) — here the URL is the whole identity.
120
+ if (/^https?:\/\//i.test(pathString)) {
121
+ return {
122
+ '@type': 'Entity',
123
+ '@key': ['url'],
124
+ url: pathString,
125
+ };
126
+ }
127
+ // A filesystem entry. Timing stores these as file:// URIs (or, rarely, bare
128
+ // paths). A path is not globally unique, so scope it by the machine whose
129
+ // filesystem it lives on, via `inRealm` — so the same path folds with that
130
+ // machine seen from other sources (e.g. shell) and survives a hardware
131
+ // migration, while paths on different machines stay distinct. `@type` is kept
132
+ // out of the key: a path is one entry, and neither the dir-vs-file signal (a
133
+ // trailing slash) nor a later, more specific media type may fragment identity.
134
+ //
135
+ // A trailing slash is Timing's directory marker (a terminal's CWD, a Finder
136
+ // folder) → a :Directory. Anything else IS the media object living at that
137
+ // path, leaf-typed as far as the extension can tell — a path-only source
138
+ // never sees bytes, so unknown extensions stay a plain :MediaObject and a
139
+ // byte-reader may refine later (specificity wins at fold time). The name is
140
+ // the path's basename either way.
141
+ const filePath = toFilePath(pathString);
142
+ if (filePath && realm) {
143
+ const isDir = filePath.endsWith('/');
144
+ const handle = isDir ? filePath.replace(/\/+$/, '') : filePath;
145
+ if (!handle)
146
+ return null;
147
+ return {
148
+ '@type': isDir ? 'Directory' : mediaTypeForPath(handle),
149
+ '@key': ['source', 'handle', 'inRealm.handle'],
150
+ source: 'filesystem',
151
+ handle,
152
+ name: path.basename(handle) || handle,
153
+ inRealm: realm,
154
+ };
155
+ }
156
+ return null;
157
+ }
158
+ /**
159
+ * The logical machine as a name-keyed `:Realm` — a file's `inRealm` scope and
160
+ * the device's `inRealm`. Keyed on the normalized name (Timing's device name →
161
+ * `pat-mbp`), so it folds with the same machine seen by the shell plugin, unifies
162
+ * across sources, and survives the hardware migrating.
163
+ */
164
+ buildRealm(device) {
165
+ if (!device?.name)
166
+ return null;
167
+ const handle = SystemInfo.normalizeMachineName(device.name);
168
+ if (!handle)
169
+ return null;
170
+ return {
171
+ '@type': 'Realm',
172
+ '@key': ['@type', 'source', 'handle'],
173
+ source: 'hostname',
174
+ handle,
175
+ };
176
+ }
177
+ /**
178
+ * The physical device — the action's `instrument`. Keyed on Timing's stable
179
+ * device id (with the hardware MAC linked via `sameAs`), so distinct hardware
180
+ * stays distinct: two MAC addresses are two devices, never merged. `inRealm`
181
+ * links it to the logical machine (`realm`) it runs on.
182
+ */
183
+ buildDevice(device, realm) {
184
+ if (!device)
185
+ return null;
186
+ const node = {
187
+ '@type': 'Device',
188
+ '@key': ['@type', 'source', 'sourceId'],
189
+ source: SOURCE,
190
+ sourceId: device.globalId,
191
+ name: device.name,
192
+ };
193
+ // Kind is open-ended data on :category (not a subclass); make/model on :model.
194
+ const category = deviceCategory(device.model);
195
+ if (category)
196
+ node.category = [category];
197
+ if (device.model)
198
+ node.model = device.model;
199
+ if (device.mac) {
200
+ node.sameAs = [
201
+ {
202
+ '@type': 'Device',
203
+ '@key': ['@type', 'source', 'handle'],
204
+ source: 'mac-address',
205
+ handle: device.mac,
206
+ },
207
+ ];
208
+ }
209
+ if (realm)
210
+ node.inRealm = realm;
211
+ return node;
212
+ }
213
+ buildApplication(app) {
214
+ if (app.bundleIdentifier) {
215
+ return {
216
+ '@type': 'SoftwareApplication',
217
+ '@key': ['@type', 'source', 'handle'],
218
+ source: 'apple-bundle-id',
219
+ handle: app.bundleIdentifier,
220
+ ...(app.title ? { name: app.title } : {}),
221
+ };
222
+ }
223
+ const handle = app.executable || app.title;
224
+ if (!handle)
225
+ return null; // degenerate app row (no bundle/executable/title) — can't key
226
+ return {
227
+ '@type': 'SoftwareApplication',
228
+ '@key': ['@type', 'source', 'handle'],
229
+ source: SOURCE,
230
+ handle,
231
+ ...(app.title ? { name: app.title } : {}),
232
+ };
233
+ }
234
+ // --- Time entries ---------------------------------------------------------
235
+ /**
236
+ * A manually-logged time entry. It's real lived activity (not a plan), so it's
237
+ * an `ExperienceAction` carrying the real occurrence, with the produced `Session`
238
+ * as its `result` — symmetric with app usage (`ExecuteAction` → `DeviceSession`).
239
+ */
240
+ buildTimeEntry(record) {
241
+ const ctx = record.context;
242
+ const session = {
243
+ '@type': 'Session',
244
+ '@key': ['@type', 'source', 'sourceId'],
245
+ source: SOURCE,
246
+ sourceId: ctx.id,
247
+ };
248
+ if (ctx.title)
249
+ session.name = ctx.title;
250
+ if (ctx.notes)
251
+ session.description = ctx.notes;
252
+ const project = this.buildProjectChain(ctx.projectChain);
253
+ if (project)
254
+ session.isPartOf = [project];
255
+ const action = {
256
+ '@type': 'ExperienceAction',
257
+ '@key': ['@type', 'source', 'sourceId'],
258
+ source: SOURCE,
259
+ sourceId: ctx.id,
260
+ startTime: secondsToISO(ctx.startDate),
261
+ endTime: secondsToISO(ctx.endDate),
262
+ agent: this.me(),
263
+ result: session,
264
+ };
265
+ return action;
266
+ }
267
+ }
268
+ function secondsToISO(seconds) {
269
+ return new Date(seconds * 1000).toISOString();
270
+ }
271
+ /** The device kind (open-ended :category data), inferred from Timing's model code. */
272
+ function deviceCategory(model) {
273
+ if (!model)
274
+ return null;
275
+ if (/^Mac/i.test(model))
276
+ return 'computer';
277
+ if (/^iPhone/i.test(model))
278
+ return 'phone';
279
+ if (/^iPad/i.test(model))
280
+ return 'tablet';
281
+ return null;
282
+ }
283
+ /** Normalize a Timing Path value to an absolute filesystem path, or null if it isn't one. */
284
+ function toFilePath(value) {
285
+ let p = value;
286
+ if (/^file:\/\//i.test(p)) {
287
+ p = p.replace(/^file:\/\//i, '');
288
+ try {
289
+ p = decodeURI(p);
290
+ }
291
+ catch {
292
+ // keep the raw (still-encoded) path rather than dropping the file
293
+ }
294
+ }
295
+ else if (!p.startsWith('/')) {
296
+ return null;
297
+ }
298
+ const trimmed = p.trim();
299
+ return trimmed && trimmed !== '/' ? trimmed : null;
300
+ }
301
+ /**
302
+ * The most specific :MediaObject leaf a path's extension supports. The
303
+ * path-keyed entity IS the media object living there, typed as well as a
304
+ * path-only observer can tell; anything unrecognized (code, data, no extension)
305
+ * stays a plain MediaObject for a byte-reader to refine later.
306
+ */
307
+ const MEDIA_TYPE_BY_EXTENSION = {
308
+ ...leafType('ImageObject', 'png jpg jpeg gif webp heic heif svg tiff tif bmp ico'),
309
+ ...leafType('AudioObject', 'mp3 wav aac flac m4a ogg aiff wma'),
310
+ ...leafType('VideoObject', 'mp4 mov avi mkv webm m4v wmv flv'),
311
+ ...leafType('DocumentObject', 'pdf doc docx txt md markdown rtf pages key ppt pptx xls xlsx csv numbers epub odt tex'),
312
+ };
313
+ function leafType(type, extensions) {
314
+ return Object.fromEntries(extensions.split(' ').map(ext => [ext, type]));
315
+ }
316
+ function mediaTypeForPath(handle) {
317
+ const ext = path.extname(handle).slice(1).toLowerCase();
318
+ return MEDIA_TYPE_BY_EXTENSION[ext] ?? 'MediaObject';
319
+ }
320
+ /** Drop a trailing " - <app>" / " — <app>" suffix that just repeats the app name. */
321
+ function stripWindowTitle(title, appTitle) {
322
+ const trimmed = title.trim();
323
+ if (appTitle) {
324
+ for (const sep of [' - ', ' — ', ' – ']) {
325
+ const suffix = `${sep}${appTitle}`;
326
+ if (trimmed.endsWith(suffix))
327
+ return trimmed.slice(0, -suffix.length).trim();
328
+ }
329
+ }
330
+ return trimmed;
331
+ }
@@ -0,0 +1,25 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
+ import TimingCallTransformer from './TimingCallTransformer.js';
4
+ /**
5
+ * Timing.app's relay of Apple Call History — the historical supplement for the
6
+ * long tail Apple has pruned. Grouped under the `timing-app` source (it reads
7
+ * Timing's DB → `chronicle extract timing-app --type calls`), but its
8
+ * *records* emit `source: apple-call-history` keyed on the shared call UUID
9
+ * (`Event.origin_id`), so they fold with the Apple Call History plugin's
10
+ * calls. Direction is lost here, and named contacts carry no number (only the
11
+ * name).
12
+ */
13
+ export declare class TimingCallExtractor extends SqliteExtractor<typeof TimingCallExtractor> {
14
+ static source: string;
15
+ static description: string;
16
+ static delivery: "local";
17
+ static strategy: string;
18
+ static recordTypes: string[];
19
+ static default: boolean;
20
+ static defaultTransformer: typeof TimingCallTransformer;
21
+ static temporality: "event";
22
+ static schema: any;
23
+ extract(): AsyncGenerator<Record>;
24
+ }
25
+ //# sourceMappingURL=TimingCallExtractor.d.ts.map
@@ -0,0 +1,78 @@
1
+ import { normalizePhoneNumber } from '@chronicle.app/etl';
2
+ import { SqliteExtractor, iterateRows } from '@chronicle.app/etl-sqlite';
3
+ import { z } from 'zod';
4
+ import TimingCallTransformer from './TimingCallTransformer.js';
5
+ const DEFAULT_DB = `${process.env.HOME}/Library/Application Support/info.eurocomp.Timing2/SQLite.db`;
6
+ /**
7
+ * Timing.app's relay of Apple Call History — the historical supplement for the
8
+ * long tail Apple has pruned. Grouped under the `timing-app` source (it reads
9
+ * Timing's DB → `chronicle extract timing-app --type calls`), but its
10
+ * *records* emit `source: apple-call-history` keyed on the shared call UUID
11
+ * (`Event.origin_id`), so they fold with the Apple Call History plugin's
12
+ * calls. Direction is lost here, and named contacts carry no number (only the
13
+ * name).
14
+ */
15
+ export class TimingCallExtractor extends SqliteExtractor {
16
+ static source = 'timing-app';
17
+ static description = 'Phone and FaceTime calls (historical supplement)';
18
+ static delivery = 'local';
19
+ static strategy = 'app-db';
20
+ static recordTypes = ['calls'];
21
+ static default = false;
22
+ static defaultTransformer = TimingCallTransformer;
23
+ static temporality = 'event';
24
+ static schema = SqliteExtractor.schema.omit({ input: true }).extend({
25
+ input: z.string().describe("Path to Timing.app's SQLite.db").default(DEFAULT_DB),
26
+ });
27
+ async *extract() {
28
+ if (!this.db)
29
+ throw new Error('Database not initialized');
30
+ const conditions = [
31
+ "i.type = 'com.apple.CallHistory'",
32
+ 'e.deleted_at IS NULL',
33
+ 'e.origin_id IS NOT NULL',
34
+ ];
35
+ const params = [];
36
+ if (this.config.since) {
37
+ conditions.push('e.start_date > ?');
38
+ params.push(this.config.since.getTime() / 1000);
39
+ }
40
+ if (this.config.until) {
41
+ conditions.push('e.start_date < ?');
42
+ params.push(this.config.until.getTime() / 1000);
43
+ }
44
+ const limit = this.getEffectiveLimit();
45
+ let sql = `
46
+ SELECT e.origin_id AS uuid, e.start_date AS startSec, e.end_date AS endSec,
47
+ es.title AS contactTitle, es.event_source_type AS sourceType
48
+ FROM Event e
49
+ JOIN Integration i ON e.integration_id = i.id
50
+ LEFT JOIN EventSource es ON e.event_source_id = es.id
51
+ WHERE ${conditions.join(' AND ')}
52
+ ORDER BY e.start_date DESC`;
53
+ if (limit !== null) {
54
+ sql += ` LIMIT ?`;
55
+ params.push(limit);
56
+ }
57
+ for (const row of iterateRows(this.db.prepare(sql), ...params)) {
58
+ // The title is either a real handle (unnamed contact) or a display name.
59
+ const title = row.contactTitle || null;
60
+ let handle = null;
61
+ let contactName = null;
62
+ if (title) {
63
+ if (title.includes('@') || normalizePhoneNumber(title))
64
+ handle = title;
65
+ else
66
+ contactName = title;
67
+ }
68
+ yield this.createRecord({}, {
69
+ recordType: 'calls',
70
+ uuid: row.uuid,
71
+ startSec: row.startSec,
72
+ endSec: row.endSec ?? null,
73
+ handle,
74
+ contactName,
75
+ });
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,22 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ /**
4
+ * Timing's relay of Apple Call History — the historical supplement for the long
5
+ * tail Apple has pruned (82% of Timing calls predate Apple's retention). Same
6
+ * shape as an Apple call. The party:
7
+ * - unnamed contact (a raw handle) → an `apple-call-history` party (folds via handle),
8
+ * - named contact, resolvable → the `apple-contacts` party,
9
+ * - named contact, unresolvable → no party; Timing's label rides on a
10
+ * `sameAs`'d `timing-app` CallSession (a sourced fact, not a fabricated
11
+ * Person, not the canonical session's own name).
12
+ *
13
+ * Caveat: Timing records no direction, so a resolved party goes on `recipient`
14
+ * (the best available slot). For a call that ALSO exists in Apple, Apple is
15
+ * authoritative for direction; this adds coverage for the pruned tail.
16
+ */
17
+ export default class TimingCallTransformer extends ChronicleTransformer {
18
+ static source: string;
19
+ transform(record: Record): Promise<ActionAndChildren[]>;
20
+ private buildTimingCall;
21
+ }
22
+ //# sourceMappingURL=TimingCallTransformer.d.ts.map
@@ -0,0 +1,158 @@
1
+ import { ChronicleTransformer, normalizePhoneNumber } from '@chronicle.app/etl';
2
+ import { lookupContact, lookupContactByName } from '@chronicle.app/icloud';
3
+ // The call UUID is Apple's (relayed through Timing's Event.origin_id), so calls
4
+ // key on `apple-call-history` + UUID and fold with the Apple Call History plugin's calls.
5
+ const SOURCE = 'apple-call-history';
6
+ /**
7
+ * Timing's relay of Apple Call History — the historical supplement for the long
8
+ * tail Apple has pruned (82% of Timing calls predate Apple's retention). Same
9
+ * shape as an Apple call. The party:
10
+ * - unnamed contact (a raw handle) → an `apple-call-history` party (folds via handle),
11
+ * - named contact, resolvable → the `apple-contacts` party,
12
+ * - named contact, unresolvable → no party; Timing's label rides on a
13
+ * `sameAs`'d `timing-app` CallSession (a sourced fact, not a fabricated
14
+ * Person, not the canonical session's own name).
15
+ *
16
+ * Caveat: Timing records no direction, so a resolved party goes on `recipient`
17
+ * (the best available slot). For a call that ALSO exists in Apple, Apple is
18
+ * authoritative for direction; this adds coverage for the pruned tail.
19
+ */
20
+ export default class TimingCallTransformer extends ChronicleTransformer {
21
+ static source = SOURCE;
22
+ async transform(record) {
23
+ if (record.extraction.recordType === 'calls') {
24
+ return [this.buildTimingCall(record)];
25
+ }
26
+ return [];
27
+ }
28
+ buildTimingCall(record) {
29
+ const ctx = record.context;
30
+ const session = {
31
+ '@type': 'CallSession',
32
+ '@key': ['@type', 'source', 'sourceId'],
33
+ source: SOURCE,
34
+ sourceId: ctx.uuid,
35
+ };
36
+ if (ctx.handle) {
37
+ // A real calling handle → a proper party (folds via the handle).
38
+ const party = partyFromHandle(ctx.handle);
39
+ if (party)
40
+ session.recipient = [party];
41
+ }
42
+ else if (ctx.contactName) {
43
+ const party = partyFromContact(ctx.contactName);
44
+ if (party) {
45
+ session.recipient = [party];
46
+ }
47
+ else {
48
+ // Unresolvable name (ambiguous / no exact match): preserve Timing's label
49
+ // as a `sameAs`'d timing-app session node — a properly sourced fact, not a
50
+ // fabricated Person and not the canonical session's own name.
51
+ session.sameAs = [
52
+ {
53
+ '@type': 'CallSession',
54
+ '@key': ['@type', 'source', 'sourceId'],
55
+ source: 'timing-app',
56
+ sourceId: ctx.uuid,
57
+ name: ctx.contactName,
58
+ },
59
+ ];
60
+ }
61
+ }
62
+ return {
63
+ '@type': 'CallAction',
64
+ '@key': ['@type', 'source', 'sourceId'],
65
+ source: SOURCE,
66
+ sourceId: ctx.uuid,
67
+ startTime: secondsToISO(ctx.startSec),
68
+ endTime: secondsToISO(ctx.endSec ?? ctx.startSec),
69
+ result: session,
70
+ };
71
+ }
72
+ }
73
+ /**
74
+ * An `apple-call-history` party from a calling handle (like the Apple plugin). Identity
75
+ * keys on the handle alone (`['source','handle']`); `@type` is an `Agent`
76
+ * annotation — a bare number could be a person or an org.
77
+ */
78
+ function partyFromHandle(handle) {
79
+ const value = handle.trim();
80
+ if (!value)
81
+ return null;
82
+ const isEmail = value.includes('@');
83
+ const canonical = isEmail ? value.toLowerCase() : normalizePhoneNumber(value) || value;
84
+ const contact = lookupContact(canonical);
85
+ const party = {
86
+ '@type': 'Agent',
87
+ '@key': ['source', 'handle'],
88
+ source: 'apple-call-history',
89
+ handle: canonical,
90
+ };
91
+ if (contact?.fullName)
92
+ party.name = contact.fullName;
93
+ const sameAs = [
94
+ {
95
+ '@type': 'Agent',
96
+ '@key': ['source', 'handle'],
97
+ source: isEmail ? 'email' : 'phone',
98
+ handle: canonical,
99
+ },
100
+ ];
101
+ if (contact?.id)
102
+ sameAs.push(contactNode(contact));
103
+ party.sameAs = sameAs;
104
+ return party;
105
+ }
106
+ /**
107
+ * When Timing gives only a contact name (no number), the identity is the Address
108
+ * Book entry itself — `apple-contacts` primary, keyed on its stable id, with the
109
+ * contact's handles as `sameAs` (which is how it folds into the phone/email graph).
110
+ */
111
+ function partyFromContact(name) {
112
+ // It's a call, so break a same-name tie by the phone-bearing card.
113
+ const contact = lookupContactByName(name, { preferPhone: true });
114
+ if (!contact?.id)
115
+ return null; // no stable identity → don't guess
116
+ const sameAs = [];
117
+ for (const phone of contact.phoneNumbers) {
118
+ sameAs.push({
119
+ '@type': 'Agent',
120
+ '@key': ['source', 'handle'],
121
+ source: 'phone',
122
+ handle: normalizePhoneNumber(phone) || phone,
123
+ });
124
+ }
125
+ for (const email of contact.emails) {
126
+ sameAs.push({
127
+ '@type': 'Agent',
128
+ '@key': ['source', 'handle'],
129
+ source: 'email',
130
+ handle: email.toLowerCase(),
131
+ });
132
+ }
133
+ const party = contactNode(contact);
134
+ if (sameAs.length > 0)
135
+ party.sameAs = sameAs;
136
+ return party;
137
+ }
138
+ /**
139
+ * The Address Book entry, keyed on its portable id and typed from the card (a
140
+ * person, or an org-only card → Organization) — the one place the kind is known.
141
+ */
142
+ function contactNode(contact) {
143
+ const type = contact.firstName || contact.lastName
144
+ ? 'Person'
145
+ : contact.organization
146
+ ? 'Organization'
147
+ : 'Agent';
148
+ return {
149
+ '@type': type,
150
+ '@key': ['source', 'sourceId'],
151
+ source: 'apple-contacts',
152
+ sourceId: contact.id,
153
+ ...(contact.fullName ? { name: contact.fullName } : {}),
154
+ };
155
+ }
156
+ function secondsToISO(seconds) {
157
+ return new Date(seconds * 1000).toISOString();
158
+ }
@@ -0,0 +1,40 @@
1
+ import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
2
+ export declare const DEFAULT_DB: string;
3
+ export declare const DEFAULT_WINDOW_DAYS = 90;
4
+ export interface TimingDevice {
5
+ globalId: string;
6
+ name: string;
7
+ /** Real hardware MAC (Macs only); null for Screen-Time relayed devices. */
8
+ mac: string | null;
9
+ model: string | null;
10
+ }
11
+ export interface TimingProject {
12
+ id: string;
13
+ title: string;
14
+ }
15
+ /**
16
+ * Shared base for the extractors that read Timing.app's local SQLite: opens the
17
+ * DB read-only, pre-loads the small `Device`/`Project` lookups, and resolves the
18
+ * time window. `AppActivity` (6M+ rows) is streamed with a cursor by subclasses.
19
+ */
20
+ export declare abstract class TimingDbExtractor<SelfClass extends typeof SqliteExtractor = typeof SqliteExtractor> extends SqliteExtractor<SelfClass> {
21
+ static delivery: "local";
22
+ static strategy: string;
23
+ static temporality: "event";
24
+ static schema: any;
25
+ /**
26
+ * Lower bound (epoch seconds): explicit `since`, else a default window, else
27
+ * none. An unbounded `--limit 0` also drops the window — asking for "no cap"
28
+ * is a request for a full backfill, not the newest 90 days without a count cap.
29
+ */
30
+ protected sinceSeconds(): number | null;
31
+ protected untilSeconds(): number | null;
32
+ protected loadDevices(): Map<string, TimingDevice>;
33
+ protected loadProjects(): Map<string, any>;
34
+ /** A resolver that nests a project's full ancestry (leaf-first) for a row. */
35
+ protected projectChainResolver(): (id: string | null) => TimingProject[];
36
+ protected countWindow(table: string, col: string, extra?: string): number;
37
+ }
38
+ /** A project's ancestry, leaf-first: [self, parent, grandparent, ...]. */
39
+ export declare function resolveChain(projects: Map<string, any>, id: string | null): TimingProject[];
40
+ //# sourceMappingURL=TimingDbExtractor.d.ts.map