@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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/connectors/AppUsageExtractor.d.ts +20 -0
- package/dist/connectors/AppUsageExtractor.js +86 -0
- package/dist/connectors/TimeEntriesExtractor.d.ts +21 -0
- package/dist/connectors/TimeEntriesExtractor.js +73 -0
- package/dist/connectors/TimingAppTransformer.d.ts +48 -0
- package/dist/connectors/TimingAppTransformer.js +331 -0
- package/dist/connectors/TimingCallExtractor.d.ts +25 -0
- package/dist/connectors/TimingCallExtractor.js +78 -0
- package/dist/connectors/TimingCallTransformer.d.ts +22 -0
- package/dist/connectors/TimingCallTransformer.js +158 -0
- package/dist/connectors/TimingDbExtractor.d.ts +40 -0
- package/dist/connectors/TimingDbExtractor.js +121 -0
- package/dist/connectors/TimingDefaultExtractor.d.ts +25 -0
- package/dist/connectors/TimingDefaultExtractor.js +37 -0
- package/dist/connectors/TimingDefaultTransformer.d.ts +17 -0
- package/dist/connectors/TimingDefaultTransformer.js +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +10 -0
- package/package.json +82 -0
|
@@ -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
|