@chronicle.app/things-todo 0.4.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.
|
@@ -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 ThingsTodoTransformer from './ThingsTodoTransformer.js';
|
|
4
4
|
export declare class ThingsTodoExtractor extends SqliteExtractor<typeof ThingsTodoExtractor> {
|
|
@@ -9,6 +9,15 @@ export declare class ThingsTodoExtractor extends SqliteExtractor<typeof ThingsTo
|
|
|
9
9
|
static description: string;
|
|
10
10
|
static recordTypes: string[];
|
|
11
11
|
static default: boolean;
|
|
12
|
+
static databases: {
|
|
13
|
+
tables: {
|
|
14
|
+
TMTask: string[];
|
|
15
|
+
TMTaskTag: string[];
|
|
16
|
+
TMTag: string[];
|
|
17
|
+
TMArea: string[];
|
|
18
|
+
TMAreaTag: string[];
|
|
19
|
+
};
|
|
20
|
+
}[];
|
|
12
21
|
static schema: any;
|
|
13
22
|
static defaultTransformer: typeof ThingsTodoTransformer;
|
|
14
23
|
/**
|
|
@@ -20,6 +29,14 @@ export declare class ThingsTodoExtractor extends SqliteExtractor<typeof ThingsTo
|
|
|
20
29
|
protected resolveAgentName(): string | undefined;
|
|
21
30
|
private getStatusLabel;
|
|
22
31
|
extract(): AsyncGenerator<Record>;
|
|
32
|
+
/**
|
|
33
|
+
* The tasks and projects a run reads, by when each was created, and how
|
|
34
|
+
* many of the tasks are completed. A run groups its tag joins back to one
|
|
35
|
+
* row per task, and `uuid` is the key, so counting tasks counts its rows.
|
|
36
|
+
*/
|
|
37
|
+
survey(options?: SurveyOptions): Promise<Survey | null>;
|
|
38
|
+
/** Tasks, projects and headings last modified within since and until, inclusive. */
|
|
39
|
+
private taskFilter;
|
|
23
40
|
private getEnrichedAreas;
|
|
24
41
|
private getEnrichedProjects;
|
|
25
42
|
private getTagHierarchies;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { fileError } from '@chronicle.app/etl';
|
|
2
|
-
import { SqliteExtractor, timeRangeConditions } from '@chronicle.app/etl-sqlite';
|
|
1
|
+
import { fileError, } from '@chronicle.app/etl';
|
|
2
|
+
import { SqliteExtractor, allRows, countsByMonth, monthSql, timeRangeConditions, } from '@chronicle.app/etl-sqlite';
|
|
3
3
|
import { existsSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
@@ -38,6 +38,16 @@ function resolveThingsDbPath() {
|
|
|
38
38
|
// when Things is not installed.
|
|
39
39
|
return join(containerDir, 'ThingsData', dbSuffix);
|
|
40
40
|
}
|
|
41
|
+
/** A kind's count and the first and last creation of its records, when there are any. */
|
|
42
|
+
function created(row) {
|
|
43
|
+
if (!row)
|
|
44
|
+
return { count: 0 };
|
|
45
|
+
return {
|
|
46
|
+
count: row.n,
|
|
47
|
+
...(row.first !== null && { earliest: new Date(row.first * 1000) }),
|
|
48
|
+
...(row.last !== null && { latest: new Date(row.last * 1000) }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
41
51
|
export class ThingsTodoExtractor extends SqliteExtractor {
|
|
42
52
|
static source = 'things-todo';
|
|
43
53
|
static delivery = 'local';
|
|
@@ -50,6 +60,34 @@ export class ThingsTodoExtractor extends SqliteExtractor {
|
|
|
50
60
|
static description = 'Tasks and projects';
|
|
51
61
|
static recordTypes = ['tasks', 'projects'];
|
|
52
62
|
static default = true;
|
|
63
|
+
// What extract() and survey() read, which a Things main.sqlite must have.
|
|
64
|
+
// Those marked `?` come only through `task.*` and `tag.*`, and a run does
|
|
65
|
+
// without them.
|
|
66
|
+
static databases = [
|
|
67
|
+
{
|
|
68
|
+
tables: {
|
|
69
|
+
TMTask: [
|
|
70
|
+
'uuid',
|
|
71
|
+
'title',
|
|
72
|
+
'type',
|
|
73
|
+
'status',
|
|
74
|
+
'creationDate',
|
|
75
|
+
'startDate?',
|
|
76
|
+
'stopDate',
|
|
77
|
+
'userModificationDate',
|
|
78
|
+
'project',
|
|
79
|
+
'heading',
|
|
80
|
+
'area',
|
|
81
|
+
'notes?',
|
|
82
|
+
'trashed?',
|
|
83
|
+
],
|
|
84
|
+
TMTaskTag: ['tasks', 'tags'],
|
|
85
|
+
TMTag: ['uuid', 'title', 'parent', 'shortcut?', 'usedDate?'],
|
|
86
|
+
TMArea: ['uuid', 'title'],
|
|
87
|
+
TMAreaTag: ['areas', 'tags'],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
];
|
|
53
91
|
static schema = SqliteExtractor.schema.omit({ input: true }).extend({
|
|
54
92
|
input: z
|
|
55
93
|
.string()
|
|
@@ -98,17 +136,12 @@ export class ThingsTodoExtractor extends SqliteExtractor {
|
|
|
98
136
|
throw new Error('Database not initialized');
|
|
99
137
|
}
|
|
100
138
|
const limit = this.getEffectiveLimit();
|
|
101
|
-
const { conditions, values } =
|
|
102
|
-
sinceOp: '>=',
|
|
103
|
-
untilOp: '<=',
|
|
104
|
-
});
|
|
139
|
+
const { conditions, values } = this.taskFilter();
|
|
105
140
|
let sql = `SELECT task.*, group_concat(DISTINCT tag.uuid) AS tag_ids,
|
|
106
141
|
group_concat(DISTINCT tag.title) AS tag_titles FROM TMTask task
|
|
107
142
|
LEFT JOIN TMTaskTag tagging ON task.uuid = tagging.tasks
|
|
108
143
|
LEFT JOIN TMTag tag ON tagging.tags = tag.uuid
|
|
109
|
-
WHERE
|
|
110
|
-
if (conditions.length > 0)
|
|
111
|
-
sql += ' AND ' + conditions.join(' AND ');
|
|
144
|
+
WHERE ${conditions.join(' AND ')}`;
|
|
112
145
|
sql += ' GROUP BY task.uuid ORDER BY task.userModificationDate DESC, task.uuid';
|
|
113
146
|
if (limit !== null) {
|
|
114
147
|
sql += ' LIMIT ?';
|
|
@@ -195,6 +228,46 @@ export class ThingsTodoExtractor extends SqliteExtractor {
|
|
|
195
228
|
});
|
|
196
229
|
}
|
|
197
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* The tasks and projects a run reads, by when each was created, and how
|
|
233
|
+
* many of the tasks are completed. A run groups its tag joins back to one
|
|
234
|
+
* row per task, and `uuid` is the key, so counting tasks counts its rows.
|
|
235
|
+
*/
|
|
236
|
+
async survey(options = {}) {
|
|
237
|
+
if (!this.db)
|
|
238
|
+
return null;
|
|
239
|
+
const { conditions, values } = this.taskFilter();
|
|
240
|
+
const kind = `CASE WHEN task.type IN (1, 2) THEN 'projects' ELSE 'tasks' END`;
|
|
241
|
+
const from = `FROM TMTask task WHERE ${conditions.join(' AND ')}`;
|
|
242
|
+
const rows = allRows(this.db.prepare(`SELECT ${kind} AS kind,
|
|
243
|
+
COUNT(*) AS n, MIN(task.creationDate) AS first, MAX(task.creationDate) AS last,
|
|
244
|
+
COUNT(*) FILTER (WHERE task.status = 3) AS completed
|
|
245
|
+
${from} GROUP BY kind`), ...values);
|
|
246
|
+
const months = options.byMonth &&
|
|
247
|
+
allRows(this.db.prepare(`SELECT ${kind} AS kind, ${monthSql('task.creationDate')} AS month, COUNT(*) AS n
|
|
248
|
+
${from} GROUP BY kind, month`), ...values);
|
|
249
|
+
// A kind's survey, with its months when asked.
|
|
250
|
+
const ofKind = (name) => ({
|
|
251
|
+
...created(rows.find(row => row.kind === name)),
|
|
252
|
+
...(months && { byMonth: countsByMonth(months.filter(row => row.kind === name)) }),
|
|
253
|
+
});
|
|
254
|
+
const tasks = rows.find(row => row.kind === 'tasks');
|
|
255
|
+
return {
|
|
256
|
+
tasks: {
|
|
257
|
+
...ofKind('tasks'),
|
|
258
|
+
measures: [{ key: 'completed', label: 'completed', value: tasks?.completed ?? 0 }],
|
|
259
|
+
},
|
|
260
|
+
projects: ofKind('projects'),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** Tasks, projects and headings last modified within since and until, inclusive. */
|
|
264
|
+
taskFilter() {
|
|
265
|
+
const { conditions, values } = timeRangeConditions('task.userModificationDate', this.config, {
|
|
266
|
+
sinceOp: '>=',
|
|
267
|
+
untilOp: '<=',
|
|
268
|
+
});
|
|
269
|
+
return { conditions: ['task.type IN (0, 1, 2)', ...conditions], values };
|
|
270
|
+
}
|
|
198
271
|
async getEnrichedAreas(areaIds) {
|
|
199
272
|
const enrichedAreas = new Map();
|
|
200
273
|
if (areaIds.length === 0)
|
|
@@ -42,9 +42,9 @@ export default class ThingsTodoTransformer extends ChronicleTransformer {
|
|
|
42
42
|
const updateAction = {
|
|
43
43
|
'@type': 'UpdateAction',
|
|
44
44
|
timestamp: new Date(record.data.userModificationDate * 1000),
|
|
45
|
-
|
|
45
|
+
// Keyed by the task: Things keeps only its latest edit.
|
|
46
|
+
'@key': ['@type', 'source', 'object.sourceId'],
|
|
46
47
|
source: 'things-todo',
|
|
47
|
-
sourceId: record.data.uuid,
|
|
48
48
|
agent: user,
|
|
49
49
|
object: task,
|
|
50
50
|
};
|
|
@@ -80,12 +80,13 @@ export default class ThingsTodoTransformer extends ChronicleTransformer {
|
|
|
80
80
|
return actions;
|
|
81
81
|
}
|
|
82
82
|
buildAction({ data, timestamp, type, agentObj, taskObj, }) {
|
|
83
|
+
// A task is planned, completed, cancelled, or deleted once, so the task
|
|
84
|
+
// identifies the action.
|
|
83
85
|
return {
|
|
84
86
|
'@type': type,
|
|
85
87
|
timestamp: new Date(timestamp * 1000),
|
|
86
|
-
'@key': ['@type', 'source', 'sourceId'],
|
|
88
|
+
'@key': ['@type', 'source', 'object.sourceId'],
|
|
87
89
|
source: 'things-todo',
|
|
88
|
-
sourceId: data.uuid,
|
|
89
90
|
agent: agentObj,
|
|
90
91
|
object: taskObj,
|
|
91
92
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/things-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"type": "module",
|
|
20
20
|
"description": "Chronicle things-todo extraction plugin",
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@chronicle.app/etl": "0.
|
|
23
|
-
"@chronicle.app/schema": "0.
|
|
24
|
-
"@chronicle.app/tsconfig": "0.
|
|
22
|
+
"@chronicle.app/etl": "0.5.0",
|
|
23
|
+
"@chronicle.app/schema": "0.5.0",
|
|
24
|
+
"@chronicle.app/tsconfig": "0.5.0",
|
|
25
25
|
"@types/node": "^22.10.1",
|
|
26
26
|
"typescript": "^5.7.2"
|
|
27
27
|
},
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"registry": "https://registry.npmjs.org/"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@chronicle.app/etl-sqlite": "0.
|
|
39
|
+
"@chronicle.app/etl-sqlite": "0.5.0",
|
|
40
40
|
"zod": "^3.24.1"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
@@ -63,11 +63,59 @@
|
|
|
63
63
|
"tasks",
|
|
64
64
|
"projects"
|
|
65
65
|
],
|
|
66
|
-
"default": true
|
|
66
|
+
"default": true,
|
|
67
|
+
"locations": [
|
|
68
|
+
{
|
|
69
|
+
"path": "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData*/Things Database.thingsdatabase/main.sqlite"
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"databases": [
|
|
73
|
+
{
|
|
74
|
+
"tables": {
|
|
75
|
+
"TMTask": [
|
|
76
|
+
"uuid",
|
|
77
|
+
"title",
|
|
78
|
+
"type",
|
|
79
|
+
"status",
|
|
80
|
+
"creationDate",
|
|
81
|
+
"startDate?",
|
|
82
|
+
"stopDate",
|
|
83
|
+
"userModificationDate",
|
|
84
|
+
"project",
|
|
85
|
+
"heading",
|
|
86
|
+
"area",
|
|
87
|
+
"notes?",
|
|
88
|
+
"trashed?"
|
|
89
|
+
],
|
|
90
|
+
"TMTaskTag": [
|
|
91
|
+
"tasks",
|
|
92
|
+
"tags"
|
|
93
|
+
],
|
|
94
|
+
"TMTag": [
|
|
95
|
+
"uuid",
|
|
96
|
+
"title",
|
|
97
|
+
"parent",
|
|
98
|
+
"shortcut?",
|
|
99
|
+
"usedDate?"
|
|
100
|
+
],
|
|
101
|
+
"TMArea": [
|
|
102
|
+
"uuid",
|
|
103
|
+
"title"
|
|
104
|
+
],
|
|
105
|
+
"TMAreaTag": [
|
|
106
|
+
"areas",
|
|
107
|
+
"tags"
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
]
|
|
67
112
|
}
|
|
68
113
|
},
|
|
69
114
|
"platforms": [
|
|
70
115
|
"darwin"
|
|
116
|
+
],
|
|
117
|
+
"requires": [
|
|
118
|
+
"full-disk-access"
|
|
71
119
|
]
|
|
72
120
|
}
|
|
73
121
|
}
|