@chronicle.app/timing-app 0.3.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 +1 -1
- package/dist/connectors/AppUsageExtractor.d.ts +6 -1
- package/dist/connectors/AppUsageExtractor.js +20 -14
- package/dist/connectors/TimeEntriesExtractor.d.ts +5 -1
- package/dist/connectors/TimeEntriesExtractor.js +20 -14
- package/dist/connectors/TimingAppTransformer.js +6 -21
- package/dist/connectors/TimingCallExtractor.d.ts +5 -1
- package/dist/connectors/TimingCallExtractor.js +38 -15
- package/dist/connectors/TimingCallTransformer.js +4 -3
- package/dist/connectors/TimingDbExtractor.d.ts +28 -1
- package/dist/connectors/TimingDbExtractor.js +36 -3
- package/package.json +12 -7
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ selectable on its own:
|
|
|
44
44
|
anything beyond the subject's name, holds the session's `name`.
|
|
45
45
|
- **Time entry → `ExperienceAction` + `Session`.** A manually-logged block of
|
|
46
46
|
real time (Timing's "time entries"; each belongs to a project). The action
|
|
47
|
-
carries the real occurrence (`startTime`/`endTime`, not `
|
|
47
|
+
carries the real occurrence (`startTime`/`endTime`, not `scheduledStart`/`scheduledEnd`); the
|
|
48
48
|
lean `Session` object carries `isPartOf` → Project, `name` (title), and `description` (notes).
|
|
49
49
|
- **Project ancestry** is nested via `isPartOf` on the session (leaf → parent →
|
|
50
50
|
…), not emitted as standalone records.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Record } from '@chronicle.app/etl';
|
|
1
|
+
import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
|
|
2
2
|
import TimingAppTransformer from './TimingAppTransformer.js';
|
|
3
3
|
import { TimingDbExtractor } from './TimingDbExtractor.js';
|
|
4
4
|
/**
|
|
@@ -16,5 +16,10 @@ export declare class AppUsageExtractor extends TimingDbExtractor<typeof AppUsage
|
|
|
16
16
|
private rows;
|
|
17
17
|
private buildRecord;
|
|
18
18
|
determineCount(): Promise<number | null>;
|
|
19
|
+
/**
|
|
20
|
+
* The activities an uncapped run reads, when, and the time they span. The
|
|
21
|
+
* run's joins are left out: each is to a primary key, so it keeps every row.
|
|
22
|
+
*/
|
|
23
|
+
survey(options?: SurveyOptions): Promise<Survey | null>;
|
|
19
24
|
}
|
|
20
25
|
//# sourceMappingURL=AppUsageExtractor.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { iterateRows } from '@chronicle.app/etl-sqlite';
|
|
1
|
+
import { getRow, iterateRows } from '@chronicle.app/etl-sqlite';
|
|
2
2
|
import TimingAppTransformer from './TimingAppTransformer.js';
|
|
3
|
-
import { TimingDbExtractor } from './TimingDbExtractor.js';
|
|
3
|
+
import { TimingDbExtractor, startsByMonth, timeSurvey, } from './TimingDbExtractor.js';
|
|
4
4
|
/**
|
|
5
5
|
* Automatic app/screen usage from Timing's `AppActivity` table (6M+ rows),
|
|
6
6
|
* streamed newest-first with a cursor. Each row → an `ExecuteAction` with a
|
|
@@ -22,18 +22,7 @@ export class AppUsageExtractor extends TimingDbExtractor {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
rows() {
|
|
25
|
-
const
|
|
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
|
-
}
|
|
25
|
+
const { conditions, params } = this.windowFilter('a');
|
|
37
26
|
const limit = this.getEffectiveLimit();
|
|
38
27
|
let sql = `
|
|
39
28
|
SELECT CAST(a.id AS TEXT) AS id, a.startDate, a.endDate,
|
|
@@ -83,4 +72,21 @@ export class AppUsageExtractor extends TimingDbExtractor {
|
|
|
83
72
|
return null;
|
|
84
73
|
}
|
|
85
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* The activities an uncapped run reads, when, and the time they span. The
|
|
77
|
+
* run's joins are left out: each is to a primary key, so it keeps every row.
|
|
78
|
+
*/
|
|
79
|
+
async survey(options = {}) {
|
|
80
|
+
if (!this.db)
|
|
81
|
+
return null;
|
|
82
|
+
const { conditions, params } = this.windowFilter('a', this.sinceSeconds(null));
|
|
83
|
+
const rows = `FROM AppActivity a WHERE ${conditions.join(' AND ')}`;
|
|
84
|
+
const row = getRow(this.db.prepare(`SELECT COUNT(*) AS n, MIN(a.startDate) AS first, MAX(a.startDate) AS last,
|
|
85
|
+
COALESCE(SUM(a.endDate - a.startDate), 0) AS seconds
|
|
86
|
+
${rows}`), ...params);
|
|
87
|
+
const byMonth = options.byMonth
|
|
88
|
+
? startsByMonth(this.db, 'a.startDate', rows, params)
|
|
89
|
+
: undefined;
|
|
90
|
+
return { 'app-activities': timeSurvey(row, 'activities', byMonth) };
|
|
91
|
+
}
|
|
86
92
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Record } from '@chronicle.app/etl';
|
|
1
|
+
import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
|
|
2
2
|
import TimingAppTransformer from './TimingAppTransformer.js';
|
|
3
3
|
import { TimingDbExtractor } from './TimingDbExtractor.js';
|
|
4
4
|
/**
|
|
@@ -17,5 +17,9 @@ export declare class TimeEntriesExtractor extends TimingDbExtractor<typeof TimeE
|
|
|
17
17
|
private rows;
|
|
18
18
|
private buildRecord;
|
|
19
19
|
determineCount(): Promise<number | null>;
|
|
20
|
+
/** The entries an uncapped run reads, when, and the time they span. */
|
|
21
|
+
survey(options?: SurveyOptions): Promise<Survey | null>;
|
|
22
|
+
/** Finished entries within the window. */
|
|
23
|
+
private entryFilter;
|
|
20
24
|
}
|
|
21
25
|
//# sourceMappingURL=TimeEntriesExtractor.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { iterateRows } from '@chronicle.app/etl-sqlite';
|
|
1
|
+
import { getRow, iterateRows } from '@chronicle.app/etl-sqlite';
|
|
2
2
|
import TimingAppTransformer from './TimingAppTransformer.js';
|
|
3
|
-
import { TimingDbExtractor } from './TimingDbExtractor.js';
|
|
3
|
+
import { TimingDbExtractor, startsByMonth, timeSurvey, } from './TimingDbExtractor.js';
|
|
4
4
|
/**
|
|
5
5
|
* Manually-logged time entries from Timing's `TaskActivity` table. Timing calls
|
|
6
6
|
* these "time entries" (each belongs to a project). Small and high-signal —
|
|
@@ -22,18 +22,7 @@ export class TimeEntriesExtractor extends TimingDbExtractor {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
rows() {
|
|
25
|
-
const
|
|
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
|
-
}
|
|
25
|
+
const { conditions, params } = this.entryFilter();
|
|
37
26
|
const limit = this.getEffectiveLimit();
|
|
38
27
|
let sql = `
|
|
39
28
|
SELECT CAST(id AS TEXT) AS id, startDate, endDate,
|
|
@@ -70,4 +59,21 @@ export class TimeEntriesExtractor extends TimingDbExtractor {
|
|
|
70
59
|
return null;
|
|
71
60
|
}
|
|
72
61
|
}
|
|
62
|
+
/** The entries an uncapped run reads, when, and the time they span. */
|
|
63
|
+
async survey(options = {}) {
|
|
64
|
+
if (!this.db)
|
|
65
|
+
return null;
|
|
66
|
+
const { conditions, params } = this.entryFilter(this.sinceSeconds(null));
|
|
67
|
+
const rows = `FROM TaskActivity WHERE ${conditions.join(' AND ')}`;
|
|
68
|
+
const row = getRow(this.db.prepare(`SELECT COUNT(*) AS n, MIN(startDate) AS first, MAX(startDate) AS last,
|
|
69
|
+
COALESCE(SUM(endDate - startDate), 0) AS seconds
|
|
70
|
+
${rows}`), ...params);
|
|
71
|
+
const byMonth = options.byMonth ? startsByMonth(this.db, 'startDate', rows, params) : undefined;
|
|
72
|
+
return { 'time-entries': timeSurvey(row, 'entries', byMonth) };
|
|
73
|
+
}
|
|
74
|
+
/** Finished entries within the window. */
|
|
75
|
+
entryFilter(since = this.sinceSeconds()) {
|
|
76
|
+
const { conditions, params } = this.windowFilter('TaskActivity', since);
|
|
77
|
+
return { conditions: [...conditions, 'TaskActivity.isRunning = 0'], params };
|
|
78
|
+
}
|
|
73
79
|
}
|
|
@@ -96,9 +96,9 @@ export default class TimingAppTransformer extends ChronicleTransformer {
|
|
|
96
96
|
}
|
|
97
97
|
const action = {
|
|
98
98
|
'@type': 'ExecuteAction',
|
|
99
|
-
|
|
99
|
+
// Keyed by the session it made, which has the activity's ID.
|
|
100
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
100
101
|
source: SOURCE,
|
|
101
|
-
sourceId: ctx.id,
|
|
102
102
|
startTime: secondsToISO(ctx.startDate),
|
|
103
103
|
endTime: secondsToISO(ctx.endDate),
|
|
104
104
|
agent: this.me(),
|
|
@@ -108,7 +108,7 @@ export default class TimingAppTransformer extends ChronicleTransformer {
|
|
|
108
108
|
if (app)
|
|
109
109
|
action.object = app;
|
|
110
110
|
if (device)
|
|
111
|
-
action.instrument = device;
|
|
111
|
+
action.instrument = [device];
|
|
112
112
|
return action;
|
|
113
113
|
}
|
|
114
114
|
/** The on-screen resource: a URL-keyed web page, or a realm-scoped filesystem entry. */
|
|
@@ -190,10 +190,7 @@ export default class TimingAppTransformer extends ChronicleTransformer {
|
|
|
190
190
|
sourceId: device.globalId,
|
|
191
191
|
name: device.name,
|
|
192
192
|
};
|
|
193
|
-
//
|
|
194
|
-
const category = deviceCategory(device.model);
|
|
195
|
-
if (category)
|
|
196
|
-
node.category = [category];
|
|
193
|
+
// Its make and model, such as `Mac15,3`, says what kind of device it is.
|
|
197
194
|
if (device.model)
|
|
198
195
|
node.model = device.model;
|
|
199
196
|
if (device.mac) {
|
|
@@ -254,9 +251,9 @@ export default class TimingAppTransformer extends ChronicleTransformer {
|
|
|
254
251
|
session.isPartOf = [project];
|
|
255
252
|
const action = {
|
|
256
253
|
'@type': 'ExperienceAction',
|
|
257
|
-
|
|
254
|
+
// Keyed by the session it made, which has the time entry's ID.
|
|
255
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
258
256
|
source: SOURCE,
|
|
259
|
-
sourceId: ctx.id,
|
|
260
257
|
startTime: secondsToISO(ctx.startDate),
|
|
261
258
|
endTime: secondsToISO(ctx.endDate),
|
|
262
259
|
agent: this.me(),
|
|
@@ -268,18 +265,6 @@ export default class TimingAppTransformer extends ChronicleTransformer {
|
|
|
268
265
|
function secondsToISO(seconds) {
|
|
269
266
|
return new Date(seconds * 1000).toISOString();
|
|
270
267
|
}
|
|
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
268
|
/** Normalize a Timing Path value to an absolute filesystem path, or null if it isn't one. */
|
|
284
269
|
function toFilePath(value) {
|
|
285
270
|
let p = value;
|
|
@@ -1,4 +1,4 @@
|
|
|
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
3
|
import TimingCallTransformer from './TimingCallTransformer.js';
|
|
4
4
|
/**
|
|
@@ -21,5 +21,9 @@ export declare class TimingCallExtractor extends SqliteExtractor<typeof TimingCa
|
|
|
21
21
|
static temporality: "event";
|
|
22
22
|
static schema: any;
|
|
23
23
|
extract(): AsyncGenerator<Record>;
|
|
24
|
+
/** The calls a run reads, when, and the time they span; a call with no end spans none. */
|
|
25
|
+
survey(options?: SurveyOptions): Promise<Survey | null>;
|
|
26
|
+
/** Call History events, not deleted, that start after since and before until. */
|
|
27
|
+
private callFilter;
|
|
24
28
|
}
|
|
25
29
|
//# sourceMappingURL=TimingCallExtractor.d.ts.map
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { normalizePhoneNumber } from '@chronicle.app/etl';
|
|
2
|
-
import { SqliteExtractor, iterateRows } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import { SqliteExtractor, getRow, iterateRows } from '@chronicle.app/etl-sqlite';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import TimingCallTransformer from './TimingCallTransformer.js';
|
|
5
|
+
import { startsByMonth, timeSurvey } from './TimingDbExtractor.js';
|
|
5
6
|
const DEFAULT_DB = `${process.env.HOME}/Library/Application Support/info.eurocomp.Timing2/SQLite.db`;
|
|
6
7
|
/**
|
|
7
8
|
* Timing.app's relay of Apple Call History — the historical supplement for the
|
|
@@ -27,20 +28,7 @@ export class TimingCallExtractor extends SqliteExtractor {
|
|
|
27
28
|
async *extract() {
|
|
28
29
|
if (!this.db)
|
|
29
30
|
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
|
-
}
|
|
31
|
+
const { conditions, params } = this.callFilter();
|
|
44
32
|
const limit = this.getEffectiveLimit();
|
|
45
33
|
let sql = `
|
|
46
34
|
SELECT e.origin_id AS uuid, e.start_date AS startSec, e.end_date AS endSec,
|
|
@@ -75,4 +63,39 @@ export class TimingCallExtractor extends SqliteExtractor {
|
|
|
75
63
|
});
|
|
76
64
|
}
|
|
77
65
|
}
|
|
66
|
+
/** The calls a run reads, when, and the time they span; a call with no end spans none. */
|
|
67
|
+
async survey(options = {}) {
|
|
68
|
+
if (!this.db)
|
|
69
|
+
return null;
|
|
70
|
+
const { conditions, params } = this.callFilter();
|
|
71
|
+
const rows = `FROM Event e
|
|
72
|
+
JOIN Integration i ON e.integration_id = i.id
|
|
73
|
+
LEFT JOIN EventSource es ON e.event_source_id = es.id
|
|
74
|
+
WHERE ${conditions.join(' AND ')}`;
|
|
75
|
+
const row = getRow(this.db.prepare(`SELECT COUNT(*) AS n, MIN(e.start_date) AS first, MAX(e.start_date) AS last,
|
|
76
|
+
COALESCE(SUM(e.end_date - e.start_date), 0) AS seconds
|
|
77
|
+
${rows}`), ...params);
|
|
78
|
+
const byMonth = options.byMonth
|
|
79
|
+
? startsByMonth(this.db, 'e.start_date', rows, params)
|
|
80
|
+
: undefined;
|
|
81
|
+
return { calls: timeSurvey(row, 'calls', byMonth) };
|
|
82
|
+
}
|
|
83
|
+
/** Call History events, not deleted, that start after since and before until. */
|
|
84
|
+
callFilter() {
|
|
85
|
+
const conditions = [
|
|
86
|
+
"i.type = 'com.apple.CallHistory'",
|
|
87
|
+
'e.deleted_at IS NULL',
|
|
88
|
+
'e.origin_id IS NOT NULL',
|
|
89
|
+
];
|
|
90
|
+
const params = [];
|
|
91
|
+
if (this.config.since) {
|
|
92
|
+
conditions.push('e.start_date > ?');
|
|
93
|
+
params.push(this.config.since.getTime() / 1000);
|
|
94
|
+
}
|
|
95
|
+
if (this.config.until) {
|
|
96
|
+
conditions.push('e.start_date < ?');
|
|
97
|
+
params.push(this.config.until.getTime() / 1000);
|
|
98
|
+
}
|
|
99
|
+
return { conditions, params };
|
|
100
|
+
}
|
|
78
101
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChronicleTransformer, normalizePhoneNumber } from '@chronicle.app/etl';
|
|
2
|
-
import { lookupContact, lookupContactByName } from '@chronicle.app/
|
|
2
|
+
import { lookupContact, lookupContactByName } from '@chronicle.app/apple';
|
|
3
3
|
// The call UUID is Apple's (relayed through Timing's Event.origin_id), so calls
|
|
4
4
|
// key on `apple-call-history` + UUID and fold with the Apple Call History plugin's calls.
|
|
5
5
|
const SOURCE = 'apple-call-history';
|
|
@@ -61,9 +61,10 @@ export default class TimingCallTransformer extends ChronicleTransformer {
|
|
|
61
61
|
}
|
|
62
62
|
return {
|
|
63
63
|
'@type': 'CallAction',
|
|
64
|
-
|
|
64
|
+
// Keyed by its session, as the apple-call-history plugin keys a call, so
|
|
65
|
+
// the two fold into one call.
|
|
66
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
65
67
|
source: SOURCE,
|
|
66
|
-
sourceId: ctx.uuid,
|
|
67
68
|
startTime: secondsToISO(ctx.startSec),
|
|
68
69
|
endTime: secondsToISO(ctx.endSec ?? ctx.startSec),
|
|
69
70
|
result: session,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { KindSurvey } from '@chronicle.app/etl';
|
|
1
2
|
import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
|
|
3
|
+
import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
|
|
2
4
|
export declare const DEFAULT_DB: string;
|
|
3
5
|
export declare const DEFAULT_WINDOW_DAYS = 90;
|
|
4
6
|
export interface TimingDevice {
|
|
@@ -27,14 +29,39 @@ export declare abstract class TimingDbExtractor<SelfClass extends typeof SqliteE
|
|
|
27
29
|
* none. An unbounded `--limit 0` also drops the window — asking for "no cap"
|
|
28
30
|
* is a request for a full backfill, not the newest 90 days without a count cap.
|
|
29
31
|
*/
|
|
30
|
-
protected sinceSeconds(): number | null;
|
|
32
|
+
protected sinceSeconds(limit?: number | null): number | null;
|
|
31
33
|
protected untilSeconds(): number | null;
|
|
34
|
+
/** Rows of `alias` not deleted that start after `since` and before `until`. */
|
|
35
|
+
protected windowFilter(alias: string, since?: number | null): {
|
|
36
|
+
conditions: string[];
|
|
37
|
+
params: number[];
|
|
38
|
+
};
|
|
32
39
|
protected loadDevices(): Map<string, TimingDevice>;
|
|
33
40
|
protected loadProjects(): Map<string, any>;
|
|
34
41
|
/** A resolver that nests a project's full ancestry (leaf-first) for a row. */
|
|
35
42
|
protected projectChainResolver(): (id: string | null) => TimingProject[];
|
|
36
43
|
protected countWindow(table: string, col: string, extra?: string): number;
|
|
37
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* One aggregate over a kind's rows: how many, their first and last start in
|
|
47
|
+
* epoch seconds, and the seconds they span.
|
|
48
|
+
*/
|
|
49
|
+
export interface SpanRow {
|
|
50
|
+
n: number;
|
|
51
|
+
first: number | null;
|
|
52
|
+
last: number | null;
|
|
53
|
+
seconds: number;
|
|
54
|
+
}
|
|
55
|
+
/** A kind's survey from its aggregate, with its months when asked. */
|
|
56
|
+
export declare function timeSurvey(row: SpanRow, label: string, byMonth?: KindSurvey['byMonth']): KindSurvey;
|
|
57
|
+
/**
|
|
58
|
+
* How many of a kind's rows start in each month, over the FROM and WHERE of
|
|
59
|
+
* its aggregate.
|
|
60
|
+
* @param start A trusted SQL expression for the start, in epoch seconds
|
|
61
|
+
*/
|
|
62
|
+
export declare function startsByMonth(db: DatabaseSync, start: string, rows: string, params: SQLInputValue[]): {
|
|
63
|
+
[month: string]: number;
|
|
64
|
+
};
|
|
38
65
|
/** A project's ancestry, leaf-first: [self, parent, grandparent, ...]. */
|
|
39
66
|
export declare function resolveChain(projects: Map<string, any>, id: string | null): TimingProject[];
|
|
40
67
|
//# sourceMappingURL=TimingDbExtractor.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
|
|
1
|
+
import { SqliteExtractor, allRows, countsByMonth, monthSql } from '@chronicle.app/etl-sqlite';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
export const DEFAULT_DB = `${process.env.HOME}/Library/Application Support/info.eurocomp.Timing2/SQLite.db`;
|
|
4
4
|
export const DEFAULT_WINDOW_DAYS = 90;
|
|
@@ -23,16 +23,31 @@ export class TimingDbExtractor extends SqliteExtractor {
|
|
|
23
23
|
* none. An unbounded `--limit 0` also drops the window — asking for "no cap"
|
|
24
24
|
* is a request for a full backfill, not the newest 90 days without a count cap.
|
|
25
25
|
*/
|
|
26
|
-
sinceSeconds() {
|
|
26
|
+
sinceSeconds(limit = this.getEffectiveLimit()) {
|
|
27
27
|
if (this.config.since)
|
|
28
28
|
return this.config.since.getTime() / 1000;
|
|
29
|
-
if (this.config.all ||
|
|
29
|
+
if (this.config.all || limit === null)
|
|
30
30
|
return null;
|
|
31
31
|
return (Date.now() - DEFAULT_WINDOW_DAYS * 86_400_000) / 1000;
|
|
32
32
|
}
|
|
33
33
|
untilSeconds() {
|
|
34
34
|
return this.config.until ? this.config.until.getTime() / 1000 : null;
|
|
35
35
|
}
|
|
36
|
+
/** Rows of `alias` not deleted that start after `since` and before `until`. */
|
|
37
|
+
windowFilter(alias, since = this.sinceSeconds()) {
|
|
38
|
+
const until = this.untilSeconds();
|
|
39
|
+
const conditions = [`${alias}.isDeleted = 0`];
|
|
40
|
+
const params = [];
|
|
41
|
+
if (since !== null) {
|
|
42
|
+
conditions.push(`${alias}.startDate > ?`);
|
|
43
|
+
params.push(since);
|
|
44
|
+
}
|
|
45
|
+
if (until !== null) {
|
|
46
|
+
conditions.push(`${alias}.startDate < ?`);
|
|
47
|
+
params.push(until);
|
|
48
|
+
}
|
|
49
|
+
return { conditions, params };
|
|
50
|
+
}
|
|
36
51
|
loadDevices() {
|
|
37
52
|
const rows = this.db.prepare(`SELECT CAST(localID AS TEXT) AS localId, CAST(globalID AS TEXT) AS globalId,
|
|
38
53
|
macAddress, displayName, property_bag AS propertyBag
|
|
@@ -93,6 +108,24 @@ export class TimingDbExtractor extends SqliteExtractor {
|
|
|
93
108
|
return this.db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get(...params).n;
|
|
94
109
|
}
|
|
95
110
|
}
|
|
111
|
+
/** A kind's survey from its aggregate, with its months when asked. */
|
|
112
|
+
export function timeSurvey(row, label, byMonth) {
|
|
113
|
+
return {
|
|
114
|
+
count: row.n,
|
|
115
|
+
...(row.first !== null && { earliest: new Date(row.first * 1000) }),
|
|
116
|
+
...(row.last !== null && { latest: new Date(row.last * 1000) }),
|
|
117
|
+
...(byMonth && { byMonth }),
|
|
118
|
+
measures: [{ key: 'duration', label, value: row.seconds, unit: 'seconds' }],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* How many of a kind's rows start in each month, over the FROM and WHERE of
|
|
123
|
+
* its aggregate.
|
|
124
|
+
* @param start A trusted SQL expression for the start, in epoch seconds
|
|
125
|
+
*/
|
|
126
|
+
export function startsByMonth(db, start, rows, params) {
|
|
127
|
+
return countsByMonth(allRows(db.prepare(`SELECT ${monthSql(start)} AS month, COUNT(*) AS n ${rows} GROUP BY month`), ...params));
|
|
128
|
+
}
|
|
96
129
|
/** A project's ancestry, leaf-first: [self, parent, grandparent, ...]. */
|
|
97
130
|
export function resolveChain(projects, id) {
|
|
98
131
|
const chain = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/timing-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"type": "module",
|
|
25
25
|
"description": "Chronicle plugin for extracting app usage, projects, and time blocks from Timing.app",
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@chronicle.app/etl": "0.
|
|
28
|
-
"@chronicle.app/schema": "0.
|
|
29
|
-
"@chronicle.app/tsconfig": "0.
|
|
27
|
+
"@chronicle.app/etl": "0.5.0",
|
|
28
|
+
"@chronicle.app/schema": "0.5.0",
|
|
29
|
+
"@chronicle.app/tsconfig": "0.5.0",
|
|
30
30
|
"@types/node": "^22.10.1",
|
|
31
31
|
"typescript": "^5.7.2"
|
|
32
32
|
},
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"registry": "https://registry.npmjs.org/"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@chronicle.app/etl-sqlite": "0.
|
|
45
|
-
"@chronicle.app/
|
|
44
|
+
"@chronicle.app/etl-sqlite": "0.5.0",
|
|
45
|
+
"@chronicle.app/apple": "0.5.0",
|
|
46
46
|
"zod": "^3.24.1"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
@@ -70,7 +70,12 @@
|
|
|
70
70
|
"time-entries",
|
|
71
71
|
"calls"
|
|
72
72
|
],
|
|
73
|
-
"default": true
|
|
73
|
+
"default": true,
|
|
74
|
+
"locations": [
|
|
75
|
+
{
|
|
76
|
+
"path": "~/Library/Application Support/info.eurocomp.Timing2/SQLite.db"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
74
79
|
}
|
|
75
80
|
},
|
|
76
81
|
"platforms": [
|