@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
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,86 @@
|
|
|
1
|
+
# @chronicle.app/timing-app
|
|
2
|
+
|
|
3
|
+
Chronicle plugin that extracts app/screen usage, manually-logged time entries,
|
|
4
|
+
and relayed phone/FaceTime call history from
|
|
5
|
+
[Timing.app](https://timingapp.com)'s local SQLite database.
|
|
6
|
+
|
|
7
|
+
## Source
|
|
8
|
+
|
|
9
|
+
`~/Library/Application Support/info.eurocomp.Timing2/SQLite.db` (opened read-only).
|
|
10
|
+
Timestamps are Unix epoch seconds.
|
|
11
|
+
|
|
12
|
+
Timing is partly a relay: Mac `AppActivity`/`TaskActivity`/`Project` are native,
|
|
13
|
+
while iOS app usage rides in `AppActivity` via Apple Screen Time, and Apple Call
|
|
14
|
+
History rides in `Event`.
|
|
15
|
+
|
|
16
|
+
## Extraction types
|
|
17
|
+
|
|
18
|
+
Bare `chronicle extract timing-app` runs the default extractor — a
|
|
19
|
+
reverse-chronological merge of the three focused streams below (so `--limit 1`
|
|
20
|
+
is the single most-recent record across all of them). Each stream is also
|
|
21
|
+
selectable on its own:
|
|
22
|
+
|
|
23
|
+
| `--type` | Table | Emits |
|
|
24
|
+
| ---------------- | ------------------------------ | --------------------------------- |
|
|
25
|
+
| (none, default) | all three, merged newest-first | — |
|
|
26
|
+
| `app-activities` | `AppActivity` | `ExecuteAction` + `DeviceSession` |
|
|
27
|
+
| `time-entries` | `TaskActivity` | `ExperienceAction` + `Session` |
|
|
28
|
+
| `calls` | `Event` (call relay) | `CallAction` + `CallSession` |
|
|
29
|
+
|
|
30
|
+
## Mapping
|
|
31
|
+
|
|
32
|
+
- **App usage → `ExecuteAction` + `DeviceSession`.** The action carries the
|
|
33
|
+
measured span (`startTime`/`endTime`), `agent: @me`, `object`: the
|
|
34
|
+
`SoftwareApplication`, `instrument`: the `Device`, `result`: the `DeviceSession`.
|
|
35
|
+
The session carries `isPartOf` → Project and, for the on-screen resource, a
|
|
36
|
+
URL-keyed web `Entity` or a realm-scoped filesystem entry as its `subject`.
|
|
37
|
+
A filesystem entry is keyed by path within the machine's name-keyed `:Realm`
|
|
38
|
+
(`source: hostname`, via `inRealm`) — not globally, since a path isn't unique.
|
|
39
|
+
A trailing-slash path is a `:Directory` and lands on `workingDirectory` (the
|
|
40
|
+
terminal CWD / Finder folder the block happened in); any other path IS the
|
|
41
|
+
media object living there, typed by extension as far as a path-only source can
|
|
42
|
+
tell (`ImageObject`/`AudioObject`/`VideoObject`/`DocumentObject`, else a plain
|
|
43
|
+
`MediaObject` a byte-reader may refine later). The window title, when it adds
|
|
44
|
+
anything beyond the subject's name, holds the session's `name`.
|
|
45
|
+
- **Time entry → `ExperienceAction` + `Session`.** A manually-logged block of
|
|
46
|
+
real time (Timing's "time entries"; each belongs to a project). The action
|
|
47
|
+
carries the real occurrence (`startTime`/`endTime`, not `scheduledTime`); the
|
|
48
|
+
lean `Session` object carries `isPartOf` → Project, `name` (title), and `description` (notes).
|
|
49
|
+
- **Project ancestry** is nested via `isPartOf` on the session (leaf → parent →
|
|
50
|
+
…), not emitted as standalone records.
|
|
51
|
+
- **Call relay (`--type calls`).** Timing relays Apple Call History; this plugin
|
|
52
|
+
reads that DB. It emits `CallAction`/`CallSession` under `source: apple-call-history`
|
|
53
|
+
- the shared call UUID, so it folds with the Apple Call History plugin's richer
|
|
54
|
+
Apple records — supplying the long tail Apple prunes. Direction is lost and
|
|
55
|
+
named contacts carry no number, so these are thin (a name, no participant).
|
|
56
|
+
|
|
57
|
+
## Identity
|
|
58
|
+
|
|
59
|
+
- App: bundle id (`source: apple-bundle-id`) where present, else `timing-app` +
|
|
60
|
+
executable/title. Never keyed on the local `Application.id`.
|
|
61
|
+
- Device: `timing-app` + `Device.globalID`, with the hardware MAC linked via
|
|
62
|
+
`sameAs` (`source: mac-address`) on Macs.
|
|
63
|
+
- `@me`: Timing has no account of its own, so the self node is just the `@me` tag
|
|
64
|
+
sourced to `timing-app` (no assumed name); the token merges it with the
|
|
65
|
+
subject's named identities from other sources.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Default merge, recent window (~90 days), newest-first
|
|
71
|
+
chronicle extract timing-app
|
|
72
|
+
|
|
73
|
+
# Full history
|
|
74
|
+
chronicle extract timing-app --all --limit 0
|
|
75
|
+
|
|
76
|
+
# A single focused stream
|
|
77
|
+
chronicle extract timing-app --type app-activities
|
|
78
|
+
chronicle extract timing-app --type time-entries
|
|
79
|
+
chronicle extract timing-app --type calls
|
|
80
|
+
|
|
81
|
+
# Another copy of the database
|
|
82
|
+
chronicle extract timing-app --input /path/to/SQLite.db
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Tests use a synthetic Timing `SQLite.db` and a synthetic AddressBook under a
|
|
86
|
+
temporary `HOME`, so they never read the host's Timing data or contacts.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import TimingAppTransformer from './TimingAppTransformer.js';
|
|
3
|
+
import { TimingDbExtractor } from './TimingDbExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* Automatic app/screen usage from Timing's `AppActivity` table (6M+ rows),
|
|
6
|
+
* streamed newest-first with a cursor. Each row → an `ExecuteAction` with a
|
|
7
|
+
* `DeviceSession` result.
|
|
8
|
+
*/
|
|
9
|
+
export declare class AppUsageExtractor extends TimingDbExtractor<typeof AppUsageExtractor> {
|
|
10
|
+
static source: string;
|
|
11
|
+
static description: string;
|
|
12
|
+
static recordTypes: string[];
|
|
13
|
+
static default: boolean;
|
|
14
|
+
static defaultTransformer: typeof TimingAppTransformer;
|
|
15
|
+
extract(): AsyncGenerator<Record>;
|
|
16
|
+
private rows;
|
|
17
|
+
private buildRecord;
|
|
18
|
+
determineCount(): Promise<number | null>;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=AppUsageExtractor.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { iterateRows } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import TimingAppTransformer from './TimingAppTransformer.js';
|
|
3
|
+
import { TimingDbExtractor } from './TimingDbExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* Automatic app/screen usage from Timing's `AppActivity` table (6M+ rows),
|
|
6
|
+
* streamed newest-first with a cursor. Each row → an `ExecuteAction` with a
|
|
7
|
+
* `DeviceSession` result.
|
|
8
|
+
*/
|
|
9
|
+
export class AppUsageExtractor extends TimingDbExtractor {
|
|
10
|
+
static source = 'timing-app';
|
|
11
|
+
static description = 'Automatic app and screen usage';
|
|
12
|
+
static recordTypes = ['app-activities'];
|
|
13
|
+
static default = false;
|
|
14
|
+
static defaultTransformer = TimingAppTransformer;
|
|
15
|
+
async *extract() {
|
|
16
|
+
if (!this.db)
|
|
17
|
+
throw new Error('Database not initialized');
|
|
18
|
+
const projectChain = this.projectChainResolver();
|
|
19
|
+
const devices = this.loadDevices();
|
|
20
|
+
for (const row of this.rows()) {
|
|
21
|
+
yield this.buildRecord(row, devices, projectChain);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
rows() {
|
|
25
|
+
const since = this.sinceSeconds();
|
|
26
|
+
const until = this.untilSeconds();
|
|
27
|
+
const conditions = ['a.isDeleted = 0'];
|
|
28
|
+
const params = [];
|
|
29
|
+
if (since !== null) {
|
|
30
|
+
conditions.push('a.startDate > ?');
|
|
31
|
+
params.push(since);
|
|
32
|
+
}
|
|
33
|
+
if (until !== null) {
|
|
34
|
+
conditions.push('a.startDate < ?');
|
|
35
|
+
params.push(until);
|
|
36
|
+
}
|
|
37
|
+
const limit = this.getEffectiveLimit();
|
|
38
|
+
let sql = `
|
|
39
|
+
SELECT CAST(a.id AS TEXT) AS id, a.startDate, a.endDate,
|
|
40
|
+
CAST(a.localDeviceID AS TEXT) AS deviceLocalId,
|
|
41
|
+
app.bundleIdentifier AS bundleIdentifier, app.executable AS executable,
|
|
42
|
+
app.title AS appTitle,
|
|
43
|
+
CAST(a.projectID AS TEXT) AS projectId,
|
|
44
|
+
t.stringValue AS windowTitle, p.stringValue AS pathString
|
|
45
|
+
FROM AppActivity a
|
|
46
|
+
LEFT JOIN Application app ON a.applicationID = app.id
|
|
47
|
+
LEFT JOIN Title t ON a.titleID = t.id
|
|
48
|
+
LEFT JOIN Path p ON a.pathID = p.id
|
|
49
|
+
WHERE ${conditions.join(' AND ')}
|
|
50
|
+
ORDER BY a.startDate DESC`;
|
|
51
|
+
if (limit !== null) {
|
|
52
|
+
sql += ` LIMIT ?`;
|
|
53
|
+
params.push(limit);
|
|
54
|
+
}
|
|
55
|
+
return iterateRows(this.db.prepare(sql), ...params);
|
|
56
|
+
}
|
|
57
|
+
buildRecord(row, devices, projectChain) {
|
|
58
|
+
return this.createRecord({}, {
|
|
59
|
+
recordType: 'app-activities',
|
|
60
|
+
id: row.id,
|
|
61
|
+
startDate: row.startDate,
|
|
62
|
+
endDate: row.endDate,
|
|
63
|
+
app: {
|
|
64
|
+
bundleIdentifier: row.bundleIdentifier || null,
|
|
65
|
+
executable: row.executable || null,
|
|
66
|
+
title: row.appTitle || null,
|
|
67
|
+
},
|
|
68
|
+
device: devices.get(row.deviceLocalId) ?? null,
|
|
69
|
+
projectChain: projectChain(row.projectId),
|
|
70
|
+
windowTitle: row.windowTitle || null,
|
|
71
|
+
pathString: row.pathString || null,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async determineCount() {
|
|
75
|
+
if (!this.db)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
const total = this.countWindow('AppActivity', 'startDate');
|
|
79
|
+
const limit = this.getEffectiveLimit();
|
|
80
|
+
return limit === null ? total : Math.min(limit, total);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import TimingAppTransformer from './TimingAppTransformer.js';
|
|
3
|
+
import { TimingDbExtractor } from './TimingDbExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* Manually-logged time entries from Timing's `TaskActivity` table. Timing calls
|
|
6
|
+
* these "time entries" (each belongs to a project). Small and high-signal —
|
|
7
|
+
* deliberately recorded, unlike the automatic app usage. Each → an
|
|
8
|
+
* `ExperienceAction` carrying a lean `Session`.
|
|
9
|
+
*/
|
|
10
|
+
export declare class TimeEntriesExtractor extends TimingDbExtractor<typeof TimeEntriesExtractor> {
|
|
11
|
+
static source: string;
|
|
12
|
+
static description: string;
|
|
13
|
+
static recordTypes: string[];
|
|
14
|
+
static default: boolean;
|
|
15
|
+
static defaultTransformer: typeof TimingAppTransformer;
|
|
16
|
+
extract(): AsyncGenerator<Record>;
|
|
17
|
+
private rows;
|
|
18
|
+
private buildRecord;
|
|
19
|
+
determineCount(): Promise<number | null>;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=TimeEntriesExtractor.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { iterateRows } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import TimingAppTransformer from './TimingAppTransformer.js';
|
|
3
|
+
import { TimingDbExtractor } from './TimingDbExtractor.js';
|
|
4
|
+
/**
|
|
5
|
+
* Manually-logged time entries from Timing's `TaskActivity` table. Timing calls
|
|
6
|
+
* these "time entries" (each belongs to a project). Small and high-signal —
|
|
7
|
+
* deliberately recorded, unlike the automatic app usage. Each → an
|
|
8
|
+
* `ExperienceAction` carrying a lean `Session`.
|
|
9
|
+
*/
|
|
10
|
+
export class TimeEntriesExtractor extends TimingDbExtractor {
|
|
11
|
+
static source = 'timing-app';
|
|
12
|
+
static description = 'Manually logged time entries';
|
|
13
|
+
static recordTypes = ['time-entries'];
|
|
14
|
+
static default = false;
|
|
15
|
+
static defaultTransformer = TimingAppTransformer;
|
|
16
|
+
async *extract() {
|
|
17
|
+
if (!this.db)
|
|
18
|
+
throw new Error('Database not initialized');
|
|
19
|
+
const projectChain = this.projectChainResolver();
|
|
20
|
+
for (const row of this.rows()) {
|
|
21
|
+
yield this.buildRecord(row, projectChain);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
rows() {
|
|
25
|
+
const since = this.sinceSeconds();
|
|
26
|
+
const until = this.untilSeconds();
|
|
27
|
+
const conditions = ['isDeleted = 0', 'isRunning = 0'];
|
|
28
|
+
const params = [];
|
|
29
|
+
if (since !== null) {
|
|
30
|
+
conditions.push('startDate > ?');
|
|
31
|
+
params.push(since);
|
|
32
|
+
}
|
|
33
|
+
if (until !== null) {
|
|
34
|
+
conditions.push('startDate < ?');
|
|
35
|
+
params.push(until);
|
|
36
|
+
}
|
|
37
|
+
const limit = this.getEffectiveLimit();
|
|
38
|
+
let sql = `
|
|
39
|
+
SELECT CAST(id AS TEXT) AS id, startDate, endDate,
|
|
40
|
+
CAST(projectID AS TEXT) AS projectId, title, notes
|
|
41
|
+
FROM TaskActivity
|
|
42
|
+
WHERE ${conditions.join(' AND ')}
|
|
43
|
+
ORDER BY startDate DESC`;
|
|
44
|
+
if (limit !== null) {
|
|
45
|
+
sql += ` LIMIT ?`;
|
|
46
|
+
params.push(limit);
|
|
47
|
+
}
|
|
48
|
+
return iterateRows(this.db.prepare(sql), ...params);
|
|
49
|
+
}
|
|
50
|
+
buildRecord(row, projectChain) {
|
|
51
|
+
return this.createRecord({}, {
|
|
52
|
+
recordType: 'time-entries',
|
|
53
|
+
id: row.id,
|
|
54
|
+
startDate: row.startDate,
|
|
55
|
+
endDate: row.endDate,
|
|
56
|
+
title: row.title || null,
|
|
57
|
+
notes: row.notes || null,
|
|
58
|
+
projectChain: projectChain(row.projectId),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async determineCount() {
|
|
62
|
+
if (!this.db)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const total = this.countWindow('TaskActivity', 'startDate', ' AND isRunning = 0');
|
|
66
|
+
const limit = this.getEffectiveLimit();
|
|
67
|
+
return limit === null ? total : Math.min(limit, total);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
export default class TimingAppTransformer extends ChronicleTransformer {
|
|
4
|
+
static source: string;
|
|
5
|
+
private meAgent?;
|
|
6
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
7
|
+
/**
|
|
8
|
+
* The account owner, tagged `@me`. Timing has no account of its own (and it is
|
|
9
|
+
* not an iCloud identity), so we don't assume a name — the node is just the
|
|
10
|
+
* `@me` tag sourced to Timing, keyed on `['@type','source']`. The `@me` token
|
|
11
|
+
* merges it with the subject's named identities from other sources.
|
|
12
|
+
*/
|
|
13
|
+
private me;
|
|
14
|
+
/**
|
|
15
|
+
* A Project node with its ancestry nested via isPartOf. `chain` is leaf-first
|
|
16
|
+
* ([self, parent, ...]); returns the leaf node, or null when unattributed.
|
|
17
|
+
*
|
|
18
|
+
* Each node asserts `isPartOf` as the complete parent set as of this read, so
|
|
19
|
+
* a project reparented (or promoted to root) in Timing closes its old parent
|
|
20
|
+
* edge instead of accumulating both.
|
|
21
|
+
*/
|
|
22
|
+
private buildProjectChain;
|
|
23
|
+
private buildAppActivity;
|
|
24
|
+
/** The on-screen resource: a URL-keyed web page, or a realm-scoped filesystem entry. */
|
|
25
|
+
private buildResource;
|
|
26
|
+
/**
|
|
27
|
+
* The logical machine as a name-keyed `:Realm` — a file's `inRealm` scope and
|
|
28
|
+
* the device's `inRealm`. Keyed on the normalized name (Timing's device name →
|
|
29
|
+
* `pat-mbp`), so it folds with the same machine seen by the shell plugin, unifies
|
|
30
|
+
* across sources, and survives the hardware migrating.
|
|
31
|
+
*/
|
|
32
|
+
private buildRealm;
|
|
33
|
+
/**
|
|
34
|
+
* The physical device — the action's `instrument`. Keyed on Timing's stable
|
|
35
|
+
* device id (with the hardware MAC linked via `sameAs`), so distinct hardware
|
|
36
|
+
* stays distinct: two MAC addresses are two devices, never merged. `inRealm`
|
|
37
|
+
* links it to the logical machine (`realm`) it runs on.
|
|
38
|
+
*/
|
|
39
|
+
private buildDevice;
|
|
40
|
+
private buildApplication;
|
|
41
|
+
/**
|
|
42
|
+
* A manually-logged time entry. It's real lived activity (not a plan), so it's
|
|
43
|
+
* an `ExperienceAction` carrying the real occurrence, with the produced `Session`
|
|
44
|
+
* as its `result` — symmetric with app usage (`ExecuteAction` → `DeviceSession`).
|
|
45
|
+
*/
|
|
46
|
+
private buildTimeEntry;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=TimingAppTransformer.d.ts.map
|