@chronicle.app/things-todo 0.1.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 +7 -0
- package/dist/connectors/ThingsTodoExtractor.d.ts +20 -0
- package/dist/connectors/ThingsTodoExtractor.js +304 -0
- package/dist/connectors/ThingsTodoTransformer.d.ts +13 -0
- package/dist/connectors/ThingsTodoTransformer.js +166 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +55 -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,7 @@
|
|
|
1
|
+
# @chronicle.app/things-todo
|
|
2
|
+
|
|
3
|
+
Read Things 3 tasks/projects with `ThingsTodoExtractor`, then map task lifecycle events with `ThingsTodoTransformer`. The default path locates the installed Things database; pass `input` to use a consistent backup or fixture. Optional `agentName` labels the source-local task owner.
|
|
4
|
+
|
|
5
|
+
Uses read-only `node:sqlite`. `since`/`until` are inclusive modification-date bounds; `limit: 0` is unlimited. Records are newest-modified first. Raw project/heading records enrich task relationships; the transformer emits task actions (plan, update, complete, cancel, delete), not standalone project actions. Source timestamps remain event timestamps; no store annotations are generated.
|
|
6
|
+
|
|
7
|
+
Use the ETL Runner or call setup/extract/teardown directly, always tearing down in a finally block. This package has no CLI dependency. The tests build synthetic Things tables without accessing an installed application.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
|
|
3
|
+
import ThingsTodoTransformer from './ThingsTodoTransformer.js';
|
|
4
|
+
export declare class ThingsTodoExtractor extends SqliteExtractor<typeof ThingsTodoExtractor> {
|
|
5
|
+
static source: string;
|
|
6
|
+
static delivery: "local";
|
|
7
|
+
static strategy: string;
|
|
8
|
+
static temporality: "snapshot";
|
|
9
|
+
static description: string;
|
|
10
|
+
static recordTypes: string[];
|
|
11
|
+
static default: boolean;
|
|
12
|
+
static schema: any;
|
|
13
|
+
static defaultTransformer: typeof ThingsTodoTransformer;
|
|
14
|
+
private getStatusLabel;
|
|
15
|
+
extract(): AsyncGenerator<Record>;
|
|
16
|
+
private getEnrichedAreas;
|
|
17
|
+
private getEnrichedProjects;
|
|
18
|
+
private getTagHierarchies;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=ThingsTodoExtractor.d.ts.map
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { SqliteExtractor, timeRangeConditions } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import ThingsTodoTransformer from './ThingsTodoTransformer.js';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the Things 3 SQLite database path.
|
|
8
|
+
*
|
|
9
|
+
* Things stores its data under a per-install directory whose name is
|
|
10
|
+
* `ThingsData-XXXXX` (the suffix is unique to each installation), so the path
|
|
11
|
+
* cannot be hardcoded. Glob the Group Containers directory for the
|
|
12
|
+
* `ThingsData-*` folder and return the database inside it.
|
|
13
|
+
*/
|
|
14
|
+
function resolveThingsDbPath() {
|
|
15
|
+
const containerDir = `${process.env.HOME}/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac`;
|
|
16
|
+
const dbSuffix = 'Things Database.thingsdatabase/main.sqlite';
|
|
17
|
+
if (existsSync(containerDir)) {
|
|
18
|
+
const thingsDataDirs = readdirSync(containerDir).filter(name => name.startsWith('ThingsData-'));
|
|
19
|
+
for (const dir of thingsDataDirs) {
|
|
20
|
+
const dbPath = join(containerDir, dir, dbSuffix);
|
|
21
|
+
if (existsSync(dbPath)) {
|
|
22
|
+
return dbPath;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Fall back to the conventional location so the error message is meaningful
|
|
27
|
+
// when Things is not installed.
|
|
28
|
+
return join(containerDir, 'ThingsData', dbSuffix);
|
|
29
|
+
}
|
|
30
|
+
export class ThingsTodoExtractor extends SqliteExtractor {
|
|
31
|
+
static source = 'things-todo';
|
|
32
|
+
static delivery = 'local';
|
|
33
|
+
static strategy = 'app-db';
|
|
34
|
+
// Re-reads current task state, so attributes (name, status) are sighted at
|
|
35
|
+
// read time — not back-dated to the creation/modification timestamps the
|
|
36
|
+
// actions carry. Lets a rename supersede cleanly instead of colliding with the
|
|
37
|
+
// old name at the creation instant. See Extractor.temporality.
|
|
38
|
+
static temporality = 'snapshot';
|
|
39
|
+
static description = 'Tasks and projects';
|
|
40
|
+
static recordTypes = ['tasks', 'projects'];
|
|
41
|
+
static default = true;
|
|
42
|
+
static schema = SqliteExtractor.schema.omit({ input: true }).extend({
|
|
43
|
+
input: z
|
|
44
|
+
.string()
|
|
45
|
+
.describe('The db file to read from')
|
|
46
|
+
.default(() => resolveThingsDbPath()),
|
|
47
|
+
agentName: z.string().optional().describe('Name of the task owner, when known'),
|
|
48
|
+
});
|
|
49
|
+
static defaultTransformer = ThingsTodoTransformer;
|
|
50
|
+
getStatusLabel(status) {
|
|
51
|
+
switch (status) {
|
|
52
|
+
case 0:
|
|
53
|
+
return 'open';
|
|
54
|
+
case 2:
|
|
55
|
+
return 'cancelled';
|
|
56
|
+
case 3:
|
|
57
|
+
return 'completed';
|
|
58
|
+
default:
|
|
59
|
+
return 'unknown';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async *extract() {
|
|
63
|
+
if (!this.db) {
|
|
64
|
+
throw new Error('Database not initialized');
|
|
65
|
+
}
|
|
66
|
+
const limit = this.getEffectiveLimit();
|
|
67
|
+
const { conditions, values } = timeRangeConditions('task.userModificationDate', this.config, {
|
|
68
|
+
sinceOp: '>=',
|
|
69
|
+
untilOp: '<=',
|
|
70
|
+
});
|
|
71
|
+
let sql = `SELECT task.*, group_concat(DISTINCT tag.uuid) AS tag_ids,
|
|
72
|
+
group_concat(DISTINCT tag.title) AS tag_titles FROM TMTask task
|
|
73
|
+
LEFT JOIN TMTaskTag tagging ON task.uuid = tagging.tasks
|
|
74
|
+
LEFT JOIN TMTag tag ON tagging.tags = tag.uuid
|
|
75
|
+
WHERE task.type IN (0, 1, 2)`;
|
|
76
|
+
if (conditions.length > 0)
|
|
77
|
+
sql += ' AND ' + conditions.join(' AND ');
|
|
78
|
+
sql += ' GROUP BY task.uuid ORDER BY task.userModificationDate DESC, task.uuid';
|
|
79
|
+
if (limit !== null) {
|
|
80
|
+
sql += ' LIMIT ?';
|
|
81
|
+
values.push(limit);
|
|
82
|
+
}
|
|
83
|
+
const rows = this.db.prepare(sql).all(...values);
|
|
84
|
+
// Get enriched areas and projects with nested data
|
|
85
|
+
const areaIds = [...new Set(rows.map(row => row.area))].filter(Boolean);
|
|
86
|
+
const enrichedAreas = await this.getEnrichedAreas(areaIds);
|
|
87
|
+
// Get both direct projects and headings (which can also be projects)
|
|
88
|
+
const directProjectIds = [...new Set(rows.map(row => row.project))].filter(Boolean);
|
|
89
|
+
const headingIds = [...new Set(rows.map(row => row.heading))].filter(Boolean);
|
|
90
|
+
const allProjectIds = [...new Set([...directProjectIds, ...headingIds])];
|
|
91
|
+
const enrichedProjects = this.getEnrichedProjects(allProjectIds);
|
|
92
|
+
// Get tag hierarchies for all tags used
|
|
93
|
+
const allTagIds = rows
|
|
94
|
+
.map(row => row.tag_ids)
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(',')
|
|
97
|
+
.split(',')
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
const tagHierarchies = this.getTagHierarchies([...new Set(allTagIds)]);
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
const recordType = row.type === 1 || row.type === 2 ? 'projects' : 'tasks';
|
|
102
|
+
// Convert timestamps to dates
|
|
103
|
+
const timeline = {
|
|
104
|
+
createdAt: row.creationDate ? new Date(row.creationDate * 1000) : null,
|
|
105
|
+
startedAt: row.startDate ? new Date(row.startDate * 1000) : null,
|
|
106
|
+
completedAt: row.stopDate ? new Date(row.stopDate * 1000) : null,
|
|
107
|
+
status: this.getStatusLabel(row.status),
|
|
108
|
+
lastModifiedAt: row.userModificationDate ? new Date(row.userModificationDate * 1000) : null,
|
|
109
|
+
};
|
|
110
|
+
// Build enriched tags array with hierarchies
|
|
111
|
+
const taskTagIds = row.tag_ids ? row.tag_ids.split(',') : [];
|
|
112
|
+
const taskTagTitles = row.tag_titles ? row.tag_titles.split(',') : [];
|
|
113
|
+
const enrichedTags = taskTagIds.map((tagId, index) => {
|
|
114
|
+
const hierarchy = tagHierarchies.get(tagId);
|
|
115
|
+
return {
|
|
116
|
+
uuid: tagId,
|
|
117
|
+
title: taskTagTitles[index] || null,
|
|
118
|
+
shortcut: hierarchy?.shortcut || null,
|
|
119
|
+
hierarchy: hierarchy
|
|
120
|
+
? {
|
|
121
|
+
parent: hierarchy.parent,
|
|
122
|
+
children: hierarchy.children,
|
|
123
|
+
path: hierarchy.path,
|
|
124
|
+
level: hierarchy.level,
|
|
125
|
+
}
|
|
126
|
+
: null,
|
|
127
|
+
lastUsedAt: hierarchy?.lastUsedAt || null,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
// Get user's real name for Person object deduplication
|
|
131
|
+
const realName = this.config.agentName;
|
|
132
|
+
// Determine the project relationship
|
|
133
|
+
let projectRelation = null;
|
|
134
|
+
let headingRelation = null;
|
|
135
|
+
// If task has a direct project, use that
|
|
136
|
+
if (row.project) {
|
|
137
|
+
projectRelation = enrichedProjects.get(row.project);
|
|
138
|
+
}
|
|
139
|
+
// If task has a heading, the heading might belong to a project
|
|
140
|
+
else if (row.heading) {
|
|
141
|
+
const heading = enrichedProjects.get(row.heading);
|
|
142
|
+
if (heading) {
|
|
143
|
+
headingRelation = {
|
|
144
|
+
uuid: heading.uuid,
|
|
145
|
+
title: heading.title,
|
|
146
|
+
type: heading.type,
|
|
147
|
+
};
|
|
148
|
+
// If the heading belongs to a project, use that project
|
|
149
|
+
if (heading.project) {
|
|
150
|
+
projectRelation = enrichedProjects.get(heading.project);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
yield this.createRecord(row, {
|
|
155
|
+
recordType,
|
|
156
|
+
timeline,
|
|
157
|
+
project: projectRelation,
|
|
158
|
+
heading: headingRelation,
|
|
159
|
+
area: row.area ? enrichedAreas.get(row.area) || null : null,
|
|
160
|
+
tags: enrichedTags,
|
|
161
|
+
agent: {
|
|
162
|
+
name: realName, // Just the real name for Person [@type, title, source] deduplication
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async getEnrichedAreas(areaIds) {
|
|
168
|
+
const enrichedAreas = new Map();
|
|
169
|
+
if (areaIds.length === 0)
|
|
170
|
+
return enrichedAreas;
|
|
171
|
+
const areas = this.db.prepare(`SELECT area.*,
|
|
172
|
+
group_concat(DISTINCT area_tag.tags) AS area_tag_ids,
|
|
173
|
+
group_concat(DISTINCT tag.title) AS area_tag_titles,
|
|
174
|
+
COUNT(DISTINCT task.uuid) AS task_count,
|
|
175
|
+
COUNT(DISTINCT project.uuid) AS project_count
|
|
176
|
+
FROM TMArea area
|
|
177
|
+
LEFT JOIN TMAreaTag area_tag ON area.uuid = area_tag.areas
|
|
178
|
+
LEFT JOIN TMTag tag ON area_tag.tags = tag.uuid
|
|
179
|
+
LEFT JOIN TMTask task ON area.uuid = task.area AND task.type IN (0, 1)
|
|
180
|
+
LEFT JOIN TMTask project ON area.uuid = project.area AND project.type = 2
|
|
181
|
+
WHERE area.uuid IN (${areaIds.map(() => '?').join(',')}) GROUP BY area.uuid
|
|
182
|
+
`).all(...areaIds);
|
|
183
|
+
for (const area of areas) {
|
|
184
|
+
const areaData = area;
|
|
185
|
+
const areaTagIds = areaData.area_tag_ids ? areaData.area_tag_ids.split(',') : [];
|
|
186
|
+
const areaTagTitles = areaData.area_tag_titles ? areaData.area_tag_titles.split(',') : [];
|
|
187
|
+
const tags = areaTagIds.map((id, index) => ({
|
|
188
|
+
uuid: id,
|
|
189
|
+
title: areaTagTitles[index] || null,
|
|
190
|
+
}));
|
|
191
|
+
enrichedAreas.set(areaData.uuid, {
|
|
192
|
+
...areaData,
|
|
193
|
+
tags,
|
|
194
|
+
metrics: {
|
|
195
|
+
taskCount: Number.parseInt(areaData.task_count) || 0,
|
|
196
|
+
projectCount: Number.parseInt(areaData.project_count) || 0,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return enrichedAreas;
|
|
201
|
+
}
|
|
202
|
+
getEnrichedProjects(projectIds) {
|
|
203
|
+
const enrichedProjects = new Map();
|
|
204
|
+
if (projectIds.length === 0)
|
|
205
|
+
return enrichedProjects;
|
|
206
|
+
const query = `
|
|
207
|
+
SELECT
|
|
208
|
+
project.*,
|
|
209
|
+
area.title as area_title,
|
|
210
|
+
area.uuid as area_uuid,
|
|
211
|
+
COUNT(DISTINCT task.uuid) as task_count,
|
|
212
|
+
COUNT(DISTINCT completed_task.uuid) as completed_task_count
|
|
213
|
+
FROM TMTask project
|
|
214
|
+
LEFT JOIN TMArea area ON project.area = area.uuid
|
|
215
|
+
LEFT JOIN TMTask task ON (project.uuid = task.project OR project.uuid = task.heading) AND task.type IN (0, 1)
|
|
216
|
+
LEFT JOIN TMTask completed_task ON (project.uuid = completed_task.project OR project.uuid = completed_task.heading) AND completed_task.type IN (0, 1) AND completed_task.status = 3
|
|
217
|
+
WHERE project.uuid IN (${projectIds.map(() => '?').join(',')}) AND project.type IN (1, 2)
|
|
218
|
+
GROUP BY project.uuid
|
|
219
|
+
`;
|
|
220
|
+
const projects = this.db.prepare(query).all(...projectIds);
|
|
221
|
+
for (const project of projects) {
|
|
222
|
+
const projectData = project;
|
|
223
|
+
enrichedProjects.set(projectData.uuid, {
|
|
224
|
+
...projectData,
|
|
225
|
+
area: projectData.area_uuid
|
|
226
|
+
? {
|
|
227
|
+
uuid: projectData.area_uuid,
|
|
228
|
+
title: projectData.area_title,
|
|
229
|
+
}
|
|
230
|
+
: null,
|
|
231
|
+
metrics: {
|
|
232
|
+
totalTasks: Number.parseInt(projectData.task_count) || 0,
|
|
233
|
+
completedTasks: Number.parseInt(projectData.completed_task_count) || 0,
|
|
234
|
+
progress: projectData.task_count > 0
|
|
235
|
+
? projectData.completed_task_count / projectData.task_count
|
|
236
|
+
: 0,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return enrichedProjects;
|
|
241
|
+
}
|
|
242
|
+
getTagHierarchies(tagIds) {
|
|
243
|
+
const tagHierarchies = new Map();
|
|
244
|
+
if (tagIds.length === 0)
|
|
245
|
+
return tagHierarchies;
|
|
246
|
+
// Get all tags with their parent relationships
|
|
247
|
+
const query = `
|
|
248
|
+
SELECT
|
|
249
|
+
tag.*,
|
|
250
|
+
parent.title as parent_title,
|
|
251
|
+
group_concat(DISTINCT child.uuid) as child_ids,
|
|
252
|
+
group_concat(DISTINCT child.title) as child_titles
|
|
253
|
+
FROM TMTag tag
|
|
254
|
+
LEFT JOIN TMTag parent ON tag.parent = parent.uuid
|
|
255
|
+
LEFT JOIN TMTag child ON tag.uuid = child.parent
|
|
256
|
+
WHERE tag.uuid IN (${tagIds.map(() => '?').join(',')})
|
|
257
|
+
GROUP BY tag.uuid
|
|
258
|
+
ORDER BY tag.title
|
|
259
|
+
`;
|
|
260
|
+
const tags = this.db.prepare(query).all(...tagIds);
|
|
261
|
+
// Build hierarchical paths
|
|
262
|
+
const buildPath = (tagId, visited = new Set()) => {
|
|
263
|
+
if (visited.has(tagId))
|
|
264
|
+
return ''; // Prevent cycles
|
|
265
|
+
visited.add(tagId);
|
|
266
|
+
const tag = tags.find((t) => t.uuid === tagId);
|
|
267
|
+
if (!tag)
|
|
268
|
+
return '';
|
|
269
|
+
const tagData = tag;
|
|
270
|
+
if (tagData.parent) {
|
|
271
|
+
const parentPath = buildPath(tagData.parent, visited);
|
|
272
|
+
return parentPath ? `${parentPath} > ${tagData.title}` : tagData.title;
|
|
273
|
+
}
|
|
274
|
+
return tagData.title;
|
|
275
|
+
};
|
|
276
|
+
for (const tag of tags) {
|
|
277
|
+
const tagData = tag;
|
|
278
|
+
const childIds = tagData.child_ids ? tagData.child_ids.split(',') : [];
|
|
279
|
+
const childTitles = tagData.child_titles ? tagData.child_titles.split(',') : [];
|
|
280
|
+
const children = childIds.map((id, index) => ({
|
|
281
|
+
uuid: id,
|
|
282
|
+
title: childTitles[index] || null,
|
|
283
|
+
}));
|
|
284
|
+
const path = buildPath(tagData.uuid);
|
|
285
|
+
const level = path.split(' > ').length - 1;
|
|
286
|
+
tagHierarchies.set(tagData.uuid, {
|
|
287
|
+
uuid: tagData.uuid,
|
|
288
|
+
title: tagData.title,
|
|
289
|
+
shortcut: tagData.shortcut,
|
|
290
|
+
lastUsedAt: tagData.usedDate ? new Date(tagData.usedDate * 1000) : null,
|
|
291
|
+
parent: tagData.parent
|
|
292
|
+
? {
|
|
293
|
+
uuid: tagData.parent,
|
|
294
|
+
title: tagData.parent_title,
|
|
295
|
+
}
|
|
296
|
+
: null,
|
|
297
|
+
children,
|
|
298
|
+
path,
|
|
299
|
+
level,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return tagHierarchies;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
export default class ThingsTodoTransformer extends ChronicleTransformer {
|
|
4
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
5
|
+
private buildTaskActions;
|
|
6
|
+
private buildAction;
|
|
7
|
+
private buildUser;
|
|
8
|
+
private buildTask;
|
|
9
|
+
private buildProject;
|
|
10
|
+
private buildHeading;
|
|
11
|
+
private buildArea;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=ThingsTodoTransformer.d.ts.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ChronicleTransformer } from '@chronicle.app/etl';
|
|
2
|
+
export default class ThingsTodoTransformer extends ChronicleTransformer {
|
|
3
|
+
async transform(record) {
|
|
4
|
+
const actions = [];
|
|
5
|
+
if (record.extraction.recordType === 'tasks') {
|
|
6
|
+
actions.push(...this.buildTaskActions(record));
|
|
7
|
+
}
|
|
8
|
+
return actions;
|
|
9
|
+
}
|
|
10
|
+
buildTaskActions(record) {
|
|
11
|
+
const actions = [];
|
|
12
|
+
const task = this.buildTask({
|
|
13
|
+
task: record.data,
|
|
14
|
+
project: record.context.project,
|
|
15
|
+
area: record.context.area,
|
|
16
|
+
heading: record.context.heading,
|
|
17
|
+
tags: record.context.tags,
|
|
18
|
+
});
|
|
19
|
+
const user = this.buildUser(record.context.agent.name);
|
|
20
|
+
// Always create the initial PlanAction with creation timestamp
|
|
21
|
+
actions.push(this.buildAction({
|
|
22
|
+
data: record.data,
|
|
23
|
+
timestamp: record.data.creationDate,
|
|
24
|
+
type: 'PlanAction',
|
|
25
|
+
agentObj: user,
|
|
26
|
+
taskObj: task,
|
|
27
|
+
}));
|
|
28
|
+
// If task was edited (modification date differs significantly from creation), create UpdateAction
|
|
29
|
+
const timeDifferenceSeconds = Math.abs(record.data.userModificationDate - record.data.creationDate);
|
|
30
|
+
const wasEdited = timeDifferenceSeconds > 60; // More than 60 seconds difference indicates an edit
|
|
31
|
+
// Completing/cancelling/trashing a task bumps userModificationDate, which would
|
|
32
|
+
// otherwise emit a redundant UpdateAction alongside the terminal action. Skip the
|
|
33
|
+
// update when the modification just reflects that terminal event.
|
|
34
|
+
const terminalTimestamp = record.data.status == 3 || record.data.status == 2
|
|
35
|
+
? record.data.stopDate
|
|
36
|
+
: record.data.trashed == 1
|
|
37
|
+
? record.data.userModificationDate
|
|
38
|
+
: undefined;
|
|
39
|
+
const modificationIsTerminal = terminalTimestamp !== undefined &&
|
|
40
|
+
Math.abs(record.data.userModificationDate - terminalTimestamp) <= 60;
|
|
41
|
+
if (wasEdited && !modificationIsTerminal) {
|
|
42
|
+
const updateAction = {
|
|
43
|
+
'@type': 'UpdateAction',
|
|
44
|
+
timestamp: new Date(record.data.userModificationDate * 1000),
|
|
45
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
46
|
+
source: 'things-todo',
|
|
47
|
+
sourceId: record.data.uuid,
|
|
48
|
+
agent: user,
|
|
49
|
+
object: task,
|
|
50
|
+
};
|
|
51
|
+
actions.push(updateAction);
|
|
52
|
+
}
|
|
53
|
+
if (record.data.status == 3) {
|
|
54
|
+
actions.push(this.buildAction({
|
|
55
|
+
data: record.data,
|
|
56
|
+
timestamp: record.data.stopDate,
|
|
57
|
+
type: 'CompleteAction',
|
|
58
|
+
agentObj: user,
|
|
59
|
+
taskObj: task,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
else if (record.data.status == 2) {
|
|
63
|
+
actions.push(this.buildAction({
|
|
64
|
+
data: record.data,
|
|
65
|
+
timestamp: record.data.stopDate,
|
|
66
|
+
type: 'CancelAction',
|
|
67
|
+
agentObj: user,
|
|
68
|
+
taskObj: task,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
if (record.data.trashed == 1) {
|
|
72
|
+
actions.push(this.buildAction({
|
|
73
|
+
data: record.data,
|
|
74
|
+
timestamp: record.data.userModificationDate,
|
|
75
|
+
type: 'DeleteAction',
|
|
76
|
+
agentObj: user,
|
|
77
|
+
taskObj: task,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
return actions;
|
|
81
|
+
}
|
|
82
|
+
buildAction({ data, timestamp, type, agentObj, taskObj, }) {
|
|
83
|
+
return {
|
|
84
|
+
'@type': type,
|
|
85
|
+
timestamp: new Date(timestamp * 1000),
|
|
86
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
87
|
+
source: 'things-todo',
|
|
88
|
+
sourceId: data.uuid,
|
|
89
|
+
agent: agentObj,
|
|
90
|
+
object: taskObj,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
buildUser(realName) {
|
|
94
|
+
return {
|
|
95
|
+
'@type': 'Agent',
|
|
96
|
+
'@key': ['@type', 'source'],
|
|
97
|
+
source: 'things-todo',
|
|
98
|
+
name: realName,
|
|
99
|
+
sameAs: ['@me'],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
buildTask({ task, project, area, heading, tags, }) {
|
|
103
|
+
const obj = {
|
|
104
|
+
'@type': 'Task',
|
|
105
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
106
|
+
source: 'things-todo',
|
|
107
|
+
sourceId: task.uuid,
|
|
108
|
+
name: task.title,
|
|
109
|
+
};
|
|
110
|
+
if (task.notes) {
|
|
111
|
+
obj.description = task.notes;
|
|
112
|
+
}
|
|
113
|
+
const isPartOf = [];
|
|
114
|
+
if (project) {
|
|
115
|
+
isPartOf.push(this.buildProject(project));
|
|
116
|
+
}
|
|
117
|
+
if (heading) {
|
|
118
|
+
isPartOf.push(this.buildHeading(heading)); // Headings are organizational sections
|
|
119
|
+
}
|
|
120
|
+
if (area) {
|
|
121
|
+
isPartOf.push(this.buildArea(area));
|
|
122
|
+
}
|
|
123
|
+
if (isPartOf.length > 0) {
|
|
124
|
+
obj.isPartOf = isPartOf;
|
|
125
|
+
}
|
|
126
|
+
// Add tags as 'about' entities (representing what the task is about)
|
|
127
|
+
if (tags && tags.length > 0) {
|
|
128
|
+
obj.about = tags.map(tag => ({
|
|
129
|
+
'@type': 'Tag',
|
|
130
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
131
|
+
source: 'things-todo',
|
|
132
|
+
sourceId: tag.uuid,
|
|
133
|
+
handle: tag.title,
|
|
134
|
+
name: tag.title,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
return obj;
|
|
138
|
+
}
|
|
139
|
+
buildProject(project) {
|
|
140
|
+
return {
|
|
141
|
+
'@type': 'Project',
|
|
142
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
143
|
+
source: 'things-todo',
|
|
144
|
+
sourceId: project.uuid,
|
|
145
|
+
name: project.title,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
buildHeading(heading) {
|
|
149
|
+
return {
|
|
150
|
+
'@type': 'Collection',
|
|
151
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
152
|
+
source: 'things-todo',
|
|
153
|
+
sourceId: heading.uuid,
|
|
154
|
+
name: heading.title,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
buildArea(area) {
|
|
158
|
+
return {
|
|
159
|
+
'@type': 'Collection',
|
|
160
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
161
|
+
source: 'things-todo',
|
|
162
|
+
sourceId: area.uuid,
|
|
163
|
+
name: area.title,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronicle.app/things-todo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**/*.js",
|
|
8
|
+
"dist/**/*.d.ts"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -b tsconfig.lib.json",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
13
|
+
"lint": "eslint src --ext .ts,.js",
|
|
14
|
+
"test": "node --test src/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "Andrew Louis",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"description": "Chronicle things-todo extraction plugin",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@chronicle.app/tsconfig": "0.1.0",
|
|
23
|
+
"@types/node": "^22.10.1",
|
|
24
|
+
"typescript": "^5.7.2"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://chronicle.app",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/chronicle-app/chronicle.git",
|
|
30
|
+
"directory": "plugins/things-todo"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@chronicle.app/etl": "0.1.0",
|
|
38
|
+
"@chronicle.app/schema": "0.1.0",
|
|
39
|
+
"zod": "^3.24.1",
|
|
40
|
+
"@chronicle.app/etl-sqlite": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22.13.0"
|
|
44
|
+
},
|
|
45
|
+
"exports": {
|
|
46
|
+
".": {
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"import": "./dist/index.js"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"chronicle": {
|
|
52
|
+
"plugin": true
|
|
53
|
+
},
|
|
54
|
+
"oclif": {}
|
|
55
|
+
}
|