Skip to content

Commit 255be7f

Browse files
kaufmanhenryclaude
andauthored
v2.0.3 fix: decode XML-plist recurrence rules, hide templates from someday (#2)
* fix: decode XML-plist recurrence rules and hide templates from someday Current Things writes the recurrence rule as an XML plist (fu=NSCalendarUnit, fa=interval, of=weekday/day), not the binary-plist format the decoder sniffed for, so every repeat read as UNKNOWN. Rewrite the decoder to parse it. Repeating-task templates are stored with start=2 (same as Someday) but the app hides them; somedayTasks and the stats someday count now exclude rows with a recurrence rule so templates stop masquerading as Someday tasks. rt1_nextInstanceStartDate is a bit-packed calendar date, not Unix seconds — decode it with thingsDateToIso/formatThingsShortDate instead of the never-true >= 1e9 guard. Fixture emits real recurrence XML plus a Someday-template row; regression tests cover the someday exclusion, frequency decode, and next-instance date. * chore: bump version and changelog (v2.0.3) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0f81092 commit 255be7f

9 files changed

Lines changed: 183 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# Changelog
22

3+
## 2.0.3
4+
5+
Fix recurring tasks across `someday` and `repeating`. Two bugs, both from the same
6+
wrong-format assumption as 2.0.2: the code expected an older recurrence encoding
7+
that current Things no longer writes.
8+
9+
- **Repeating-task templates leaked into `someday`.** Things stores a repeating
10+
to-do's template with `start = 2` (the same value as Someday) but hides it from
11+
the Someday list, surfacing only the generated instances. `someday` (and the
12+
`stats` someday count) listed every template, so a bank of recurring chores and
13+
calls showed up as phantom Someday tasks. Both now exclude rows that carry a
14+
recurrence rule, matching the app.
15+
- **Recurrence rules never decoded.** `repeating` reported every task as
16+
`freq: UNKNOWN` because the decoder sniffed for binary-plist `frequency` /
17+
`interval` keys, but current Things writes an **XML plist** (`fu` =
18+
NSCalendarUnit unit, `fa` = interval, `of` = weekday / day-of-month). Rewrote the
19+
decoder to parse it, so cadences read correctly (`every week`, `every 2 weeks`,
20+
`every month`, `every 2 days`).
21+
- **`repeating` next-instance was always null.** `rt1_nextInstanceStartDate` is a
22+
bit-packed calendar date (like `startDate`), not Unix seconds, but the code gated
23+
it on `>= 1000000000`, which packed dates never satisfy. Now decoded with
24+
`thingsDateToIso` / `formatThingsShortDate`.
25+
26+
Fixture rebuilt to emit real recurrence-rule XML plus a Someday-template row;
27+
regression tests added for the someday exclusion, frequency decoding, and
28+
next-instance date.
29+
330
## 2.0.2
431

532
Fix date decoding across every list and stat. The previous releases misread the

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "thingsctl",
3-
"version": "2.0.2",
3+
"version": "2.0.3",
44
"description": "A full-featured command-line interface and MCP server for Things 3 on macOS.",
55
"main": "src/cli.js",
66
"type": "commonjs",

src/commands/repeating.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const queries = require('../lib/queries');
55
const { colors } = require('../lib/format');
66
const { taskToJson } = require('../lib/format');
77
const { decodeRecurrenceRule, describe } = require('../lib/rrule');
8-
const { formatShortDate, unixToDate } = require('../lib/dates');
8+
const { formatThingsShortDate, thingsDateToIso } = require('../lib/dates');
99
const { pickFilters, filterSchema, outputSchema } = require('./_filters');
1010

1111
function _line(t, decode) {
@@ -14,8 +14,10 @@ function _line(t, decode) {
1414
const rule = decodeRecurrenceRule(t.rt1_recurrenceRule);
1515
line += ` ${colors.dim('(' + describe(rule) + ')')}`;
1616
}
17-
if (t.rt1_nextInstanceStartDate >= 1000000000) {
18-
const next = formatShortDate(t.rt1_nextInstanceStartDate);
17+
// rt1_nextInstanceStartDate is a bit-packed Things calendar date, NOT Unix
18+
// seconds — decode it the same way as startDate/deadline.
19+
if (t.rt1_nextInstanceStartDate > 0) {
20+
const next = formatThingsShortDate(t.rt1_nextInstanceStartDate);
1921
if (next) line += ` ${colors.blue('next ' + next)}`;
2022
}
2123
const tags = t.tagList ? t.tagList.split(',').filter(Boolean) : [];
@@ -29,8 +31,8 @@ function run(opts = {}) {
2931
return tasks.map((t) => ({
3032
...taskToJson(t),
3133
recurrence: decodeRecurrenceRule(t.rt1_recurrenceRule),
32-
nextInstance: t.rt1_nextInstanceStartDate >= 1000000000
33-
? unixToDate(t.rt1_nextInstanceStartDate).toISOString() : null,
34+
nextInstance: t.rt1_nextInstanceStartDate > 0
35+
? thingsDateToIso(t.rt1_nextInstanceStartDate) : null,
3436
}));
3537
}
3638
if (opts.ids) return tasks.map((t) => t.uuid);

src/commands/stats.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ function _gather(database) {
2525
SELECT COUNT(*) as count FROM TMTask
2626
WHERE status=0 AND trashed=0 AND type=0 AND start=2 AND todayIndex<=0
2727
AND startDate IS NULL
28+
AND rt1_recurrenceRule IS NULL
2829
`),
2930
upcoming: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0 AND startDate IS NOT NULL AND startDate >= ? AND todayIndex<=0`, tomorrow),
3031
evening: q(`SELECT COUNT(*) as count FROM TMTask WHERE status=0 AND trashed=0 AND type=0 AND startBucket=1 AND todayIndex<=0`),

src/lib/queries.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,15 @@ function anytimeTasks(db, filters = {}) {
9999

100100
function somedayTasks(db, filters = {}) {
101101
const f = _filterClauses(filters);
102+
// Repeating-task templates are stored with start=2 (the same value as
103+
// Someday) but Things hides them from the Someday list — only their
104+
// generated instances show (in Today/Upcoming). Exclude any row that carries
105+
// a recurrence rule so templates don't masquerade as Someday tasks.
102106
return db.prepare(`
103107
SELECT ${SELECT_TASK_FIELDS}
104108
${BASE} AND t.start = 2 AND t.todayIndex <= 0
105109
AND t.startDate IS NULL
110+
AND t.rt1_recurrenceRule IS NULL
106111
${f.sql}
107112
ORDER BY t."index" ASC
108113
`).all(...f.params);

src/lib/rrule.js

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,65 @@
11
'use strict';
22

3-
// Best-effort decode of TMTask.rt1_recurrenceRule (a binary plist that
4-
// archives an EKRecurrenceRule). We don't ship a full plist parser; instead
5-
// we sniff the canonical bytes that always appear in the buffer.
3+
// Decode TMTask.rt1_recurrenceRule. In current Things 3 builds this is an
4+
// XML *property list* (not a binary plist) archiving the repeat rule. It uses
5+
// short keys:
66
//
7-
// Returns { freq, interval, weekdays, raw } where freq ∈
8-
// 'DAILY'|'WEEKLY'|'MONTHLY'|'YEARLY'|'UNKNOWN'.
7+
// fu frequency unit — an NSCalendarUnit bit value:
8+
// 4=year, 8=month, 16=day, 256=weekday(week),
9+
// 512=weekday-ordinal (nth weekday of month)
10+
// fa frequency amount — the interval ("every N units")
11+
// of array of "on" specifiers, each a dict with either
12+
// wd=<1..7> (weekday, 1=Sun) or dy=<1..31> (day of month)
13+
// tp schedule type, ed/ia/sr anchor + end dates (not needed for cadence)
14+
//
15+
// Returns { freq, interval, weekday?, dayOfMonth? } where freq ∈
16+
// 'DAILY'|'WEEKLY'|'MONTHLY'|'YEARLY'|'UNKNOWN'. Anything we can't parse
17+
// degrades to { freq: 'UNKNOWN', interval: 1 }.
18+
19+
function _toXml(buf) {
20+
if (buf == null) return null;
21+
if (Buffer.isBuffer(buf)) return buf.length ? buf.toString('utf8') : null;
22+
const s = String(buf);
23+
return s.length ? s : null;
24+
}
25+
26+
function _int(xml, key) {
27+
const m = xml.match(new RegExp(`<key>${key}</key>\\s*<integer>(-?\\d+)</integer>`));
28+
return m ? parseInt(m[1], 10) : null;
29+
}
930

10-
const FREQ = { 0: 'DAILY', 1: 'WEEKLY', 2: 'MONTHLY', 3: 'YEARLY' };
31+
// First weekday/day-of-month inside the <key>of</key> array, if any.
32+
function _ofInt(xml, subkey) {
33+
const idx = xml.indexOf('<key>of</key>');
34+
if (idx < 0) return null;
35+
const m = xml.slice(idx).match(new RegExp(`<key>${subkey}</key>\\s*<integer>(-?\\d+)</integer>`));
36+
return m ? parseInt(m[1], 10) : null;
37+
}
38+
39+
function _freqFromUnit(fu) {
40+
if (fu == null) return 'UNKNOWN';
41+
if (fu & 4) return 'YEARLY'; // NSCalendarUnitYear
42+
if (fu & 8) return 'MONTHLY'; // NSCalendarUnitMonth
43+
if (fu & 512) return 'MONTHLY'; // NSCalendarUnitWeekdayOrdinal (nth weekday)
44+
if (fu & 256) return 'WEEKLY'; // NSCalendarUnitWeekday
45+
if (fu & 16) return 'DAILY'; // NSCalendarUnitDay
46+
return 'UNKNOWN';
47+
}
1148

1249
function decodeRecurrenceRule(buf) {
13-
if (!buf || !Buffer.isBuffer(buf)) return { freq: 'UNKNOWN', interval: 1 };
14-
15-
let freq = 'UNKNOWN';
16-
let interval = 1;
17-
18-
// Look for "frequency" / "interval" UTF-8 keys, then read the byte after the
19-
// associated kCFNumberSInt32Type marker. This is heuristic but stable across
20-
// recent Things versions.
21-
const text = buf.toString('binary');
22-
23-
const fIdx = text.indexOf('frequency');
24-
if (fIdx >= 0) {
25-
// After 'frequency' (9 bytes) comes a few bytes of plist metadata, then
26-
// the integer value as a single byte for small numbers.
27-
for (let off = fIdx + 9; off < Math.min(fIdx + 24, buf.length); off++) {
28-
const b = buf[off];
29-
if (b >= 0 && b <= 3) { freq = FREQ[b]; break; }
30-
}
31-
}
32-
33-
const iIdx = text.indexOf('interval');
34-
if (iIdx >= 0) {
35-
for (let off = iIdx + 8; off < Math.min(iIdx + 24, buf.length); off++) {
36-
const b = buf[off];
37-
if (b >= 1 && b <= 60) { interval = b; break; }
38-
}
39-
}
40-
41-
return { freq, interval };
50+
const xml = _toXml(buf);
51+
if (!xml) return { freq: 'UNKNOWN', interval: 1 };
52+
53+
const freq = _freqFromUnit(_int(xml, 'fu'));
54+
const fa = _int(xml, 'fa');
55+
const interval = fa && fa > 0 ? fa : 1;
56+
57+
const rule = { freq, interval };
58+
const wd = _ofInt(xml, 'wd');
59+
if (wd != null) rule.weekday = wd; // 1=Sun … 7=Sat
60+
const dy = _ofInt(xml, 'dy');
61+
if (dy != null) rule.dayOfMonth = dy;
62+
return rule;
4263
}
4364

4465
function describe(rule) {

test/fixtures/build.js

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ const PACK_DAYS = (offset) => {
3434
};
3535
const PACKED_TODAY = PACK(TODAY_MIDNIGHT);
3636

37+
// Real Things recurrence rule: an XML plist with fu (NSCalendarUnit: 4=year,
38+
// 8=month, 16=day, 256=weekday), fa (interval), and an `of` array carrying a
39+
// weekday (wd) or day-of-month (dy). Mirrors what current Things 3 writes.
40+
const RRULE = ({ fu, fa = 1, wd, dy }) => {
41+
const of =
42+
wd != null ? `<dict><key>wd</key><integer>${wd}</integer></dict>`
43+
: dy != null ? `<dict><key>dy</key><integer>${dy}</integer></dict>`
44+
: '';
45+
const xml =
46+
`<?xml version="1.0" encoding="UTF-8"?>\n<plist version="1.0"><dict>` +
47+
`<key>fa</key><integer>${fa}</integer>` +
48+
`<key>fu</key><integer>${fu}</integer>` +
49+
`<key>of</key><array>${of}</array>` +
50+
`<key>tp</key><integer>1</integer>` +
51+
`</dict></plist>`;
52+
return Buffer.from(xml, 'utf8');
53+
};
54+
3755
function build() {
3856
if (fs.existsSync(OUT)) fs.unlinkSync(OUT);
3957
const db = new Database(OUT);
@@ -90,7 +108,16 @@ function build() {
90108
insertTask.run(
91109
't-template-1', 0, 0, 'Weekly review template', null, 2, null, 0, null, 1, 900, 'a-work', null, null,
92110
NOW_UNIX - 86400 * 60, NOW_UNIX - 86400 * 30, null, 0, 0,
93-
Buffer.from('frequency\x00\x01interval\x00\x01'), PACK_DAYS(7)
111+
RRULE({ fu: 256, fa: 1, wd: 2 }), PACK_DAYS(7)
112+
);
113+
114+
// Recurrence template in Someday with todayIndex=0 — exactly how the real DB
115+
// stores a repeating to-do. Regression guard for the "templates leak into
116+
// Someday" bug: it must be excluded from `someday` but listed by `repeating`.
117+
insertTask.run(
118+
't-template-someday', 0, 0, 'Biweekly 1:1 recurring', null, 2, null, 0, null, 2, 0, 'a-work', null, null,
119+
NOW_UNIX - 86400 * 60, NOW_UNIX - 86400 * 30, null, 0, 0,
120+
RRULE({ fu: 256, fa: 2, wd: 4 }), PACK_DAYS(3)
94121
);
95122

96123
// Inbox task
@@ -137,11 +164,12 @@ function build() {
137164
NOW_UNIX - 86400, NOW_UNIX - 3600, null, 0, 0, null, 0
138165
);
139166

140-
// Repeating task with a stub recurrence rule blob
167+
// Repeating task (daily) — anchored in Anytime, with a real recurrence rule
168+
// and a packed next-instance date.
141169
insertTask.run(
142170
't-repeat-1', 0, 0, 'Standup recurring', null, 1, null, 0, null, 1, 0, 'a-work', null, null,
143171
NOW_UNIX - 86400 * 30, NOW_UNIX - 86400, null, 0, 0,
144-
Buffer.from('frequency\x00\x01interval\x00\x01'), PACK_DAYS(1)
172+
RRULE({ fu: 16, fa: 1 }), PACK_DAYS(1)
145173
);
146174

147175
// Completed task (logbook) — stopDate is Unix seconds

test/integration/lists.test.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ describe('list commands against fixture', () => {
3131
expect(out.map((t) => t.title)).toEqual(['Learn Mandarin']);
3232
});
3333

34+
test('someday excludes repeating-task templates (start=2 + recurrence rule)', () => {
35+
const out = someday.run({ json: true });
36+
expect(out.find((t) => t.title === 'Biweekly 1:1 recurring')).toBeUndefined();
37+
});
38+
3439
test('upcoming lists future-scheduled tasks', () => {
3540
const out = upcoming.run({ json: true });
3641
expect(out.map((t) => t.title)).toEqual(['Future thing']);
@@ -53,7 +58,23 @@ describe('list commands against fixture', () => {
5358

5459
test('repeating lists tasks with rt1_recurrenceRule', () => {
5560
const out = repeating.run({ json: true });
56-
expect(out.map((t) => t.title).sort()).toEqual(['Standup recurring', 'Weekly review template']);
61+
expect(out.map((t) => t.title).sort()).toEqual([
62+
'Biweekly 1:1 recurring', 'Standup recurring', 'Weekly review template',
63+
]);
64+
});
65+
66+
test('repeating decodes frequency and interval from the recurrence rule', () => {
67+
const byTitle = Object.fromEntries(repeating.run({ json: true }).map((t) => [t.title, t]));
68+
expect(byTitle['Standup recurring'].recurrence).toMatchObject({ freq: 'DAILY', interval: 1 });
69+
expect(byTitle['Weekly review template'].recurrence).toMatchObject({ freq: 'WEEKLY', interval: 1 });
70+
expect(byTitle['Biweekly 1:1 recurring'].recurrence).toMatchObject({ freq: 'WEEKLY', interval: 2 });
71+
});
72+
73+
test('repeating decodes nextInstance as a real calendar date (not Unix-epoch)', () => {
74+
const standup = repeating.run({ json: true }).find((t) => t.title === 'Standup recurring');
75+
expect(standup.nextInstance).not.toBeNull();
76+
expect(Number.isNaN(Date.parse(standup.nextInstance))).toBe(false);
77+
expect(new Date(standup.nextInstance).getFullYear()).toBeGreaterThan(2000);
5778
});
5879

5980
test('projects includes the seeded project with task count', () => {

test/unit/rrule.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,45 @@
22

33
const { decodeRecurrenceRule, describe: describeRule } = require('../../src/lib/rrule');
44

5+
// Minimal real-format Things recurrence plist.
6+
const plist = ({ fu, fa, of = '' }) =>
7+
Buffer.from(
8+
`<?xml version="1.0" encoding="UTF-8"?>\n<plist version="1.0"><dict>` +
9+
`<key>fa</key><integer>${fa}</integer>` +
10+
`<key>fu</key><integer>${fu}</integer>` +
11+
`<key>of</key><array>${of}</array>` +
12+
`</dict></plist>`,
13+
'utf8'
14+
);
15+
516
describe('decodeRecurrenceRule', () => {
617
test('returns UNKNOWN for empty buffer', () => {
718
expect(decodeRecurrenceRule(null)).toEqual({ freq: 'UNKNOWN', interval: 1 });
819
expect(decodeRecurrenceRule(Buffer.alloc(0))).toEqual({ freq: 'UNKNOWN', interval: 1 });
920
});
1021

22+
test('decodes daily / weekly / monthly / yearly from fu (NSCalendarUnit)', () => {
23+
expect(decodeRecurrenceRule(plist({ fu: 16, fa: 1 }))).toMatchObject({ freq: 'DAILY', interval: 1 });
24+
expect(decodeRecurrenceRule(plist({ fu: 256, fa: 1 }))).toMatchObject({ freq: 'WEEKLY', interval: 1 });
25+
expect(decodeRecurrenceRule(plist({ fu: 8, fa: 1 }))).toMatchObject({ freq: 'MONTHLY', interval: 1 });
26+
expect(decodeRecurrenceRule(plist({ fu: 4, fa: 1 }))).toMatchObject({ freq: 'YEARLY', interval: 1 });
27+
});
28+
29+
test('reads the interval from fa', () => {
30+
expect(decodeRecurrenceRule(plist({ fu: 256, fa: 2 }))).toMatchObject({ freq: 'WEEKLY', interval: 2 });
31+
});
32+
33+
test('extracts weekday and day-of-month from the `of` array', () => {
34+
const weekly = decodeRecurrenceRule(plist({ fu: 256, fa: 1, of: '<dict><key>wd</key><integer>6</integer></dict>' }));
35+
expect(weekly.weekday).toBe(6);
36+
const monthly = decodeRecurrenceRule(plist({ fu: 8, fa: 1, of: '<dict><key>dy</key><integer>18</integer></dict>' }));
37+
expect(monthly.dayOfMonth).toBe(18);
38+
});
39+
40+
test('accepts a string rule as well as a Buffer', () => {
41+
expect(decodeRecurrenceRule(plist({ fu: 16, fa: 1 }).toString('utf8'))).toMatchObject({ freq: 'DAILY' });
42+
});
43+
1144
test('describes UNKNOWN as "repeats"', () => {
1245
expect(describeRule({ freq: 'UNKNOWN', interval: 1 })).toBe('repeats');
1346
});

0 commit comments

Comments
 (0)