@chronicle.app/things-todo 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.
@@ -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,12 +9,34 @@ 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;
23
+ /**
24
+ * The default database lives in a folder macOS guards: say whether it's
25
+ * missing or refused before SQLite reports "unable to open database file".
26
+ */
27
+ setup(): Promise<void>;
14
28
  /** The owner's name from the local machine, used when `agentName` isn't set. */
15
29
  protected resolveAgentName(): string | undefined;
16
30
  private getStatusLabel;
17
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;
18
40
  private getEnrichedAreas;
19
41
  private getEnrichedProjects;
20
42
  private getTagHierarchies;
@@ -1,4 +1,5 @@
1
- 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';
2
3
  import { existsSync, readdirSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import { z } from 'zod';
@@ -12,11 +13,20 @@ import ThingsTodoTransformer from './ThingsTodoTransformer.js';
12
13
  * cannot be hardcoded. Glob the Group Containers directory for the
13
14
  * `ThingsData-*` folder and return the database inside it.
14
15
  */
16
+ const containerDir = `${process.env.HOME}/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac`;
15
17
  function resolveThingsDbPath() {
16
- const containerDir = `${process.env.HOME}/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac`;
17
18
  const dbSuffix = 'Things Database.thingsdatabase/main.sqlite';
19
+ // Listing the container needs Full Disk Access; without it, fall through to
20
+ // the conventional path and let setup() say what's wrong.
18
21
  if (existsSync(containerDir)) {
19
- const thingsDataDirs = readdirSync(containerDir).filter(name => name.startsWith('ThingsData-'));
22
+ let entries = [];
23
+ try {
24
+ entries = readdirSync(containerDir);
25
+ }
26
+ catch {
27
+ // Reported by setup().
28
+ }
29
+ const thingsDataDirs = entries.filter(name => name.startsWith('ThingsData-'));
20
30
  for (const dir of thingsDataDirs) {
21
31
  const dbPath = join(containerDir, dir, dbSuffix);
22
32
  if (existsSync(dbPath)) {
@@ -28,6 +38,16 @@ function resolveThingsDbPath() {
28
38
  // when Things is not installed.
29
39
  return join(containerDir, 'ThingsData', dbSuffix);
30
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
+ }
31
51
  export class ThingsTodoExtractor extends SqliteExtractor {
32
52
  static source = 'things-todo';
33
53
  static delivery = 'local';
@@ -40,6 +60,34 @@ export class ThingsTodoExtractor extends SqliteExtractor {
40
60
  static description = 'Tasks and projects';
41
61
  static recordTypes = ['tasks', 'projects'];
42
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
+ ];
43
91
  static schema = SqliteExtractor.schema.omit({ input: true }).extend({
44
92
  input: z
45
93
  .string()
@@ -51,6 +99,22 @@ export class ThingsTodoExtractor extends SqliteExtractor {
51
99
  .describe("Name of the task owner. Defaults to the OS account's full name"),
52
100
  });
53
101
  static defaultTransformer = ThingsTodoTransformer;
102
+ /**
103
+ * The default database lives in a folder macOS guards: say whether it's
104
+ * missing or refused before SQLite reports "unable to open database file".
105
+ */
106
+ async setup() {
107
+ const { input } = this.config;
108
+ if (input.startsWith(containerDir)) {
109
+ try {
110
+ readdirSync(containerDir);
111
+ }
112
+ catch (error) {
113
+ throw fileError(error, containerDir, 'Things library');
114
+ }
115
+ }
116
+ await super.setup();
117
+ }
54
118
  /** The owner's name from the local machine, used when `agentName` isn't set. */
55
119
  resolveAgentName() {
56
120
  return localAccountName();
@@ -72,17 +136,12 @@ export class ThingsTodoExtractor extends SqliteExtractor {
72
136
  throw new Error('Database not initialized');
73
137
  }
74
138
  const limit = this.getEffectiveLimit();
75
- const { conditions, values } = timeRangeConditions('task.userModificationDate', this.config, {
76
- sinceOp: '>=',
77
- untilOp: '<=',
78
- });
139
+ const { conditions, values } = this.taskFilter();
79
140
  let sql = `SELECT task.*, group_concat(DISTINCT tag.uuid) AS tag_ids,
80
141
  group_concat(DISTINCT tag.title) AS tag_titles FROM TMTask task
81
142
  LEFT JOIN TMTaskTag tagging ON task.uuid = tagging.tasks
82
143
  LEFT JOIN TMTag tag ON tagging.tags = tag.uuid
83
- WHERE task.type IN (0, 1, 2)`;
84
- if (conditions.length > 0)
85
- sql += ' AND ' + conditions.join(' AND ');
144
+ WHERE ${conditions.join(' AND ')}`;
86
145
  sql += ' GROUP BY task.uuid ORDER BY task.userModificationDate DESC, task.uuid';
87
146
  if (limit !== null) {
88
147
  sql += ' LIMIT ?';
@@ -169,6 +228,46 @@ export class ThingsTodoExtractor extends SqliteExtractor {
169
228
  });
170
229
  }
171
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
+ }
172
271
  async getEnrichedAreas(areaIds) {
173
272
  const enrichedAreas = new Map();
174
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
- '@key': ['@type', 'source', 'sourceId'],
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.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.3.0",
23
- "@chronicle.app/schema": "0.3.0",
24
- "@chronicle.app/tsconfig": "0.3.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.3.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
  }