@estruyf/worklog-cli 0.0.0-stage → 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/README.md +36 -2
- package/dist/worklog.js +2058 -0
- package/package.json +27 -4
package/dist/worklog.js
ADDED
|
@@ -0,0 +1,2058 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// cli/worklog.ts
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
// src/db/memoryDb.ts
|
|
10
|
+
var MemoryDb = class {
|
|
11
|
+
clients = [];
|
|
12
|
+
tasks = [];
|
|
13
|
+
worklog = [];
|
|
14
|
+
dayNotes = [];
|
|
15
|
+
checklists = [];
|
|
16
|
+
meetings = [];
|
|
17
|
+
tasksById = /* @__PURE__ */ new Map();
|
|
18
|
+
notesByDate = /* @__PURE__ */ new Map();
|
|
19
|
+
/** Replace all cached records (called by the indexer after a full parse). */
|
|
20
|
+
load(data) {
|
|
21
|
+
this.clients = [...data.clients].sort((a, b) => a.name.localeCompare(b.name));
|
|
22
|
+
this.tasks = data.tasks.filter((t) => t.id);
|
|
23
|
+
this.worklog = data.worklog;
|
|
24
|
+
this.dayNotes = data.dayNotes;
|
|
25
|
+
this.checklists = data.checklists;
|
|
26
|
+
this.meetings = data.meetings;
|
|
27
|
+
this.tasksById = new Map(this.tasks.map((t) => [t.id, t]));
|
|
28
|
+
this.notesByDate = new Map(this.dayNotes.map((n) => [n.date, n]));
|
|
29
|
+
}
|
|
30
|
+
reset() {
|
|
31
|
+
this.load({ clients: [], tasks: [], worklog: [], dayNotes: [], checklists: [], meetings: [] });
|
|
32
|
+
}
|
|
33
|
+
getClients() {
|
|
34
|
+
return this.clients;
|
|
35
|
+
}
|
|
36
|
+
getTask(id) {
|
|
37
|
+
return this.tasksById.get(id);
|
|
38
|
+
}
|
|
39
|
+
/** Direct children of a task. */
|
|
40
|
+
getChildren(parentId) {
|
|
41
|
+
return this.tasks.filter((t) => t.parentId === parentId).sort(byCreatedThenTitle);
|
|
42
|
+
}
|
|
43
|
+
/** Active (not-yet-closed) tasks for a client — closed once it has a completed date. */
|
|
44
|
+
openTasksForClient(clientId) {
|
|
45
|
+
return this.tasks.filter((t) => t.clientIds.includes(clientId) && !t.completed).sort(byCreatedThenTitle);
|
|
46
|
+
}
|
|
47
|
+
/** Archived occurrences of a recurring task, most recently completed first. */
|
|
48
|
+
occurrencesOf(templateId) {
|
|
49
|
+
return this.tasks.filter((t) => t.repeatOf === templateId).sort((a, b) => (b.completed ?? "").localeCompare(a.completed ?? ""));
|
|
50
|
+
}
|
|
51
|
+
/** Every task (open + done) — used to build the app state. */
|
|
52
|
+
getAllTasks() {
|
|
53
|
+
return [...this.tasks].sort(byCreatedThenTitle);
|
|
54
|
+
}
|
|
55
|
+
/** Every worklog entry — used to build the app state. */
|
|
56
|
+
getAllWorklog() {
|
|
57
|
+
return [...this.worklog].sort((a, b) => a.date.localeCompare(b.date) || a.clientId.localeCompare(b.clientId));
|
|
58
|
+
}
|
|
59
|
+
/** The note for one day, if it has one. */
|
|
60
|
+
getDayNote(date) {
|
|
61
|
+
return this.notesByDate.get(date);
|
|
62
|
+
}
|
|
63
|
+
/** Every day note, oldest first — used to build the app state. */
|
|
64
|
+
getAllDayNotes() {
|
|
65
|
+
return [...this.dayNotes].sort((a, b) => a.date.localeCompare(b.date));
|
|
66
|
+
}
|
|
67
|
+
/** Every checklist, by name — the order they are offered in. */
|
|
68
|
+
getAllChecklists() {
|
|
69
|
+
return [...this.checklists].sort((a, b) => a.name.localeCompare(b.name));
|
|
70
|
+
}
|
|
71
|
+
getMeeting(id) {
|
|
72
|
+
return this.meetings.find((m) => m.id === id);
|
|
73
|
+
}
|
|
74
|
+
/** Every meeting, most recent first — the order every list of them is read in. */
|
|
75
|
+
getAllMeetings() {
|
|
76
|
+
return [...this.meetings].sort(
|
|
77
|
+
(a, b) => b.date.localeCompare(a.date) || (b.time ?? "").localeCompare(a.time ?? "") || a.title.localeCompare(b.title)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
function byCreatedThenTitle(a, b) {
|
|
82
|
+
return (a.created ?? "").localeCompare(b.created ?? "") || a.title.localeCompare(b.title);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/parser/taskHeading.ts
|
|
86
|
+
var H2 = /^##\s+/;
|
|
87
|
+
var META = /^-\s+[A-Za-z][\w-]*\s*:/;
|
|
88
|
+
var ID_META = /^-\s+id\s*:\s*(.+?)\s*$/;
|
|
89
|
+
function isTaskHeading(lines, index) {
|
|
90
|
+
if (!H2.test(lines[index])) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
for (let i = index + 1; i < lines.length; i++) {
|
|
94
|
+
if (ID_META.test(lines[i])) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (!META.test(lines[i])) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/util/date.ts
|
|
105
|
+
function today() {
|
|
106
|
+
return toISODate(/* @__PURE__ */ new Date());
|
|
107
|
+
}
|
|
108
|
+
function toISODate(d) {
|
|
109
|
+
const y = d.getFullYear();
|
|
110
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
111
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
112
|
+
return `${y}-${m}-${day}`;
|
|
113
|
+
}
|
|
114
|
+
function nowStamp() {
|
|
115
|
+
const d = /* @__PURE__ */ new Date();
|
|
116
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
117
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
118
|
+
return `${toISODate(d)} ${hh}:${mm}`;
|
|
119
|
+
}
|
|
120
|
+
function parseISODate(iso) {
|
|
121
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso.trim());
|
|
122
|
+
if (!match) {
|
|
123
|
+
return void 0;
|
|
124
|
+
}
|
|
125
|
+
const y = Number(match[1]);
|
|
126
|
+
const m = Number(match[2]);
|
|
127
|
+
const d = Number(match[3]);
|
|
128
|
+
if (m < 1 || m > 12 || d < 1 || d > daysInMonth(y, m)) {
|
|
129
|
+
return void 0;
|
|
130
|
+
}
|
|
131
|
+
return { y, m, d };
|
|
132
|
+
}
|
|
133
|
+
function isValidISODate(iso) {
|
|
134
|
+
return parseISODate(iso) !== void 0;
|
|
135
|
+
}
|
|
136
|
+
function formatParts(p) {
|
|
137
|
+
return `${String(p.y).padStart(4, "0")}-${String(p.m).padStart(2, "0")}-${String(p.d).padStart(2, "0")}`;
|
|
138
|
+
}
|
|
139
|
+
function daysInMonth(y, m) {
|
|
140
|
+
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
141
|
+
}
|
|
142
|
+
function weekdayOf(iso) {
|
|
143
|
+
const p = parseISODate(iso);
|
|
144
|
+
if (!p) {
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
return new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay();
|
|
148
|
+
}
|
|
149
|
+
function daysSinceEpoch(iso) {
|
|
150
|
+
const p = parseISODate(iso);
|
|
151
|
+
if (!p) {
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
return Math.round(Date.UTC(p.y, p.m - 1, p.d) / 864e5);
|
|
155
|
+
}
|
|
156
|
+
function addDays(iso, n) {
|
|
157
|
+
const p = parseISODate(iso);
|
|
158
|
+
if (!p) {
|
|
159
|
+
return iso;
|
|
160
|
+
}
|
|
161
|
+
const d = new Date(Date.UTC(p.y, p.m - 1, p.d + n));
|
|
162
|
+
return formatParts({ y: d.getUTCFullYear(), m: d.getUTCMonth() + 1, d: d.getUTCDate() });
|
|
163
|
+
}
|
|
164
|
+
function addMonths(iso, n) {
|
|
165
|
+
const p = parseISODate(iso);
|
|
166
|
+
if (!p) {
|
|
167
|
+
return iso;
|
|
168
|
+
}
|
|
169
|
+
const total = p.y * 12 + (p.m - 1) + n;
|
|
170
|
+
const y = Math.floor(total / 12);
|
|
171
|
+
const m = total % 12 + 1;
|
|
172
|
+
return formatParts({ y, m, d: Math.min(p.d, daysInMonth(y, m)) });
|
|
173
|
+
}
|
|
174
|
+
function addYears(iso, n) {
|
|
175
|
+
return addMonths(iso, n * 12);
|
|
176
|
+
}
|
|
177
|
+
function withDayOfMonth(iso, day) {
|
|
178
|
+
const p = parseISODate(iso);
|
|
179
|
+
if (!p) {
|
|
180
|
+
return iso;
|
|
181
|
+
}
|
|
182
|
+
const max = daysInMonth(p.y, p.m);
|
|
183
|
+
return formatParts({ y: p.y, m: p.m, d: day === "last" ? max : Math.min(day, max) });
|
|
184
|
+
}
|
|
185
|
+
function weekIndexOf(iso) {
|
|
186
|
+
return Math.floor((daysSinceEpoch(iso) + 4) / 7);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/model/recurrence.ts
|
|
190
|
+
var WEEKDAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
191
|
+
var WEEKDAY_ALIASES = {
|
|
192
|
+
sun: 0,
|
|
193
|
+
sunday: 0,
|
|
194
|
+
mon: 1,
|
|
195
|
+
monday: 1,
|
|
196
|
+
tue: 2,
|
|
197
|
+
tues: 2,
|
|
198
|
+
tuesday: 2,
|
|
199
|
+
wed: 3,
|
|
200
|
+
weds: 3,
|
|
201
|
+
wednesday: 3,
|
|
202
|
+
thu: 4,
|
|
203
|
+
thur: 4,
|
|
204
|
+
thurs: 4,
|
|
205
|
+
thursday: 4,
|
|
206
|
+
fri: 5,
|
|
207
|
+
friday: 5,
|
|
208
|
+
sat: 6,
|
|
209
|
+
saturday: 6
|
|
210
|
+
};
|
|
211
|
+
var BUSINESS_DAYS = [1, 2, 3, 4, 5];
|
|
212
|
+
var MAX_STEPS = 800;
|
|
213
|
+
var HEAD_SUGAR = {
|
|
214
|
+
daily: { unit: "day", interval: 1 },
|
|
215
|
+
weekly: { unit: "week", interval: 1 },
|
|
216
|
+
monthly: { unit: "month", interval: 1 },
|
|
217
|
+
yearly: { unit: "year", interval: 1 },
|
|
218
|
+
annually: { unit: "year", interval: 1 },
|
|
219
|
+
weekdays: { unit: "week", interval: 1, weekdays: BUSINESS_DAYS }
|
|
220
|
+
};
|
|
221
|
+
var UNIT_WORDS = {
|
|
222
|
+
day: "day",
|
|
223
|
+
days: "day",
|
|
224
|
+
week: "week",
|
|
225
|
+
weeks: "week",
|
|
226
|
+
month: "month",
|
|
227
|
+
months: "month",
|
|
228
|
+
year: "year",
|
|
229
|
+
years: "year"
|
|
230
|
+
};
|
|
231
|
+
function parseRecurrence(input) {
|
|
232
|
+
const text = input.trim().toLowerCase();
|
|
233
|
+
if (!text) {
|
|
234
|
+
return void 0;
|
|
235
|
+
}
|
|
236
|
+
const [headText, specText] = splitOn(text);
|
|
237
|
+
const head = parseHead(headText);
|
|
238
|
+
if (!head) {
|
|
239
|
+
return void 0;
|
|
240
|
+
}
|
|
241
|
+
const rec = {
|
|
242
|
+
unit: head.unit,
|
|
243
|
+
interval: head.interval,
|
|
244
|
+
weekdays: head.weekdays,
|
|
245
|
+
anchor: "schedule"
|
|
246
|
+
};
|
|
247
|
+
if (specText !== void 0) {
|
|
248
|
+
if (!applySpec(rec, specText)) {
|
|
249
|
+
return void 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return rec;
|
|
253
|
+
}
|
|
254
|
+
function splitOn(text) {
|
|
255
|
+
const m = /^(.*?)\s+on\s+(.+)$/.exec(text);
|
|
256
|
+
return m ? [m[1].trim(), m[2].trim()] : [text, void 0];
|
|
257
|
+
}
|
|
258
|
+
function parseHead(text) {
|
|
259
|
+
const sugar = HEAD_SUGAR[text];
|
|
260
|
+
if (sugar) {
|
|
261
|
+
return { ...sugar };
|
|
262
|
+
}
|
|
263
|
+
const m = /^every\s+(?:(\d+)\s+)?([a-z]+)$/.exec(text);
|
|
264
|
+
if (!m) {
|
|
265
|
+
return void 0;
|
|
266
|
+
}
|
|
267
|
+
const unit = UNIT_WORDS[m[2]];
|
|
268
|
+
if (!unit) {
|
|
269
|
+
return void 0;
|
|
270
|
+
}
|
|
271
|
+
const interval = m[1] === void 0 ? 1 : Number(m[1]);
|
|
272
|
+
if (!Number.isInteger(interval) || interval < 1 || interval > 1e3) {
|
|
273
|
+
return void 0;
|
|
274
|
+
}
|
|
275
|
+
return { unit, interval };
|
|
276
|
+
}
|
|
277
|
+
function applySpec(rec, spec) {
|
|
278
|
+
switch (rec.unit) {
|
|
279
|
+
case "week": {
|
|
280
|
+
const days = spec.split(/[\s,]+/).filter(Boolean).map((name) => WEEKDAY_ALIASES[name]);
|
|
281
|
+
if (!days.length || days.some((d) => d === void 0)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
rec.weekdays = [...new Set(days)].sort((a, b) => a - b);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
case "month": {
|
|
288
|
+
const day = parseMonthDay(spec);
|
|
289
|
+
if (day === void 0) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
rec.monthDay = day;
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
case "year": {
|
|
296
|
+
const m = /^(\d{1,2})-(\d{1,2}|last)$/.exec(spec);
|
|
297
|
+
if (!m) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
const month = Number(m[1]);
|
|
301
|
+
const day = parseMonthDay(m[2]);
|
|
302
|
+
if (month < 1 || month > 12 || day === void 0) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
rec.month = month;
|
|
306
|
+
rec.monthDay = day;
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
default:
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function parseMonthDay(spec) {
|
|
314
|
+
if (spec === "last") {
|
|
315
|
+
return "last";
|
|
316
|
+
}
|
|
317
|
+
if (!/^\d{1,2}$/.test(spec)) {
|
|
318
|
+
return void 0;
|
|
319
|
+
}
|
|
320
|
+
const n = Number(spec);
|
|
321
|
+
return n >= 1 && n <= 31 ? n : void 0;
|
|
322
|
+
}
|
|
323
|
+
function formatRecurrence(rec) {
|
|
324
|
+
const head = formatHead(rec);
|
|
325
|
+
const spec = formatSpec(rec);
|
|
326
|
+
return spec ? `${head} on ${spec}` : head;
|
|
327
|
+
}
|
|
328
|
+
function formatHead(rec) {
|
|
329
|
+
if (rec.interval === 1) {
|
|
330
|
+
if (rec.unit === "week" && isBusinessWeek(rec.weekdays)) {
|
|
331
|
+
return "weekdays";
|
|
332
|
+
}
|
|
333
|
+
return { day: "daily", week: "weekly", month: "monthly", year: "yearly" }[rec.unit];
|
|
334
|
+
}
|
|
335
|
+
return `every ${rec.interval} ${rec.unit}s`;
|
|
336
|
+
}
|
|
337
|
+
function formatSpec(rec) {
|
|
338
|
+
switch (rec.unit) {
|
|
339
|
+
case "week":
|
|
340
|
+
if (!rec.weekdays?.length || rec.interval === 1 && isBusinessWeek(rec.weekdays)) {
|
|
341
|
+
return void 0;
|
|
342
|
+
}
|
|
343
|
+
return rec.weekdays.map((d) => WEEKDAY_NAMES[d]).join(",");
|
|
344
|
+
case "month":
|
|
345
|
+
return rec.monthDay === void 0 ? void 0 : String(rec.monthDay);
|
|
346
|
+
case "year":
|
|
347
|
+
if (rec.month === void 0 || rec.monthDay === void 0) {
|
|
348
|
+
return void 0;
|
|
349
|
+
}
|
|
350
|
+
return `${String(rec.month).padStart(2, "0")}-${rec.monthDay === "last" ? "last" : String(rec.monthDay).padStart(2, "0")}`;
|
|
351
|
+
default:
|
|
352
|
+
return void 0;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function isBusinessWeek(weekdays) {
|
|
356
|
+
return !!weekdays && weekdays.length === 5 && BUSINESS_DAYS.every((d) => weekdays.includes(d));
|
|
357
|
+
}
|
|
358
|
+
function nextOccurrence(rec, base) {
|
|
359
|
+
if (!isValidISODate(base)) {
|
|
360
|
+
return void 0;
|
|
361
|
+
}
|
|
362
|
+
const next = rec.anchor === "completion" ? stepFromCompletion(rec, base) : stepInSeries(rec, base);
|
|
363
|
+
if (!next) {
|
|
364
|
+
return void 0;
|
|
365
|
+
}
|
|
366
|
+
if (rec.until && next > rec.until) {
|
|
367
|
+
return void 0;
|
|
368
|
+
}
|
|
369
|
+
return next;
|
|
370
|
+
}
|
|
371
|
+
function firstOccurrence(rec, from) {
|
|
372
|
+
if (!isValidISODate(from)) {
|
|
373
|
+
return void 0;
|
|
374
|
+
}
|
|
375
|
+
const namesDays = !!rec.weekdays?.length || rec.monthDay !== void 0 || rec.month !== void 0;
|
|
376
|
+
const start = namesDays ? nextOccurrence({ ...rec, interval: 1, anchor: "schedule", until: void 0 }, addDays(from, -1)) : from;
|
|
377
|
+
if (!start || rec.until && start > rec.until) {
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
380
|
+
return start;
|
|
381
|
+
}
|
|
382
|
+
function stepFromCompletion(rec, base) {
|
|
383
|
+
let next;
|
|
384
|
+
switch (rec.unit) {
|
|
385
|
+
case "day":
|
|
386
|
+
next = addDays(base, rec.interval);
|
|
387
|
+
break;
|
|
388
|
+
case "week":
|
|
389
|
+
next = addDays(base, 7 * rec.interval);
|
|
390
|
+
break;
|
|
391
|
+
case "month":
|
|
392
|
+
next = addMonths(base, rec.interval);
|
|
393
|
+
break;
|
|
394
|
+
case "year":
|
|
395
|
+
next = addYears(base, rec.interval);
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
if (rec.monthDay !== void 0) {
|
|
399
|
+
next = withDayOfMonth(next, rec.monthDay);
|
|
400
|
+
}
|
|
401
|
+
if (rec.weekdays?.length) {
|
|
402
|
+
next = rollToWeekday(next, rec.weekdays);
|
|
403
|
+
}
|
|
404
|
+
return next > base ? next : void 0;
|
|
405
|
+
}
|
|
406
|
+
function stepInSeries(rec, base) {
|
|
407
|
+
switch (rec.unit) {
|
|
408
|
+
case "day":
|
|
409
|
+
return addDays(base, rec.interval);
|
|
410
|
+
case "week":
|
|
411
|
+
return nextWeekly(rec, base);
|
|
412
|
+
case "month":
|
|
413
|
+
return nextMonthly(rec, base);
|
|
414
|
+
case "year":
|
|
415
|
+
return nextYearly(rec, base);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function nextWeekly(rec, base) {
|
|
419
|
+
if (!rec.weekdays?.length) {
|
|
420
|
+
return addDays(base, 7 * rec.interval);
|
|
421
|
+
}
|
|
422
|
+
const baseWeek = weekIndexOf(base);
|
|
423
|
+
for (let i = 1; i <= MAX_STEPS; i++) {
|
|
424
|
+
const cand = addDays(base, i);
|
|
425
|
+
if (!rec.weekdays.includes(weekdayOf(cand))) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if ((weekIndexOf(cand) - baseWeek) % rec.interval === 0) {
|
|
429
|
+
return cand;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return void 0;
|
|
433
|
+
}
|
|
434
|
+
function nextMonthly(rec, base) {
|
|
435
|
+
const parts = parseISODate(base);
|
|
436
|
+
if (!parts) {
|
|
437
|
+
return void 0;
|
|
438
|
+
}
|
|
439
|
+
const day = rec.monthDay ?? parts.d;
|
|
440
|
+
const firstOfMonth = withDayOfMonth(base, 1);
|
|
441
|
+
for (let k = 0; k <= MAX_STEPS; k++) {
|
|
442
|
+
const cand = withDayOfMonth(addMonths(firstOfMonth, k * rec.interval), day);
|
|
443
|
+
if (cand > base) {
|
|
444
|
+
return cand;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return void 0;
|
|
448
|
+
}
|
|
449
|
+
function nextYearly(rec, base) {
|
|
450
|
+
const parts = parseISODate(base);
|
|
451
|
+
if (!parts) {
|
|
452
|
+
return void 0;
|
|
453
|
+
}
|
|
454
|
+
const month = rec.month ?? parts.m;
|
|
455
|
+
const day = rec.monthDay ?? parts.d;
|
|
456
|
+
for (let k = 0; k <= MAX_STEPS; k++) {
|
|
457
|
+
const year = parts.y + k * rec.interval;
|
|
458
|
+
const cand = withDayOfMonth(`${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`, day);
|
|
459
|
+
if (cand > base) {
|
|
460
|
+
return cand;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return void 0;
|
|
464
|
+
}
|
|
465
|
+
function rollToWeekday(iso, weekdays) {
|
|
466
|
+
for (let i = 0; i < 7; i++) {
|
|
467
|
+
const cand = addDays(iso, i);
|
|
468
|
+
if (weekdays.includes(weekdayOf(cand))) {
|
|
469
|
+
return cand;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return iso;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/parser/taskParser.ts
|
|
476
|
+
var H1 = /^#\s+(.*)$/;
|
|
477
|
+
var H22 = /^##\s+(.*)$/;
|
|
478
|
+
var META2 = /^-\s+([A-Za-z][\w-]*)\s*:\s*(.*)$/;
|
|
479
|
+
var NOTES_HEADING = /^###\s+Notes\s*$/;
|
|
480
|
+
var NOTE_ENTRY = /^-\s+(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\s+—\s+(.*)$/;
|
|
481
|
+
var PROMPTS_HEADING = /^###\s+Prompts\s*$/;
|
|
482
|
+
var PROMPT_ENTRY = /^-\s+\[([ xX])\]\s*(.*)$/;
|
|
483
|
+
var PROMPT_RAN = /^(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\s+—\s+(.*)$/;
|
|
484
|
+
var CHECKLIST_HEADING = /^###\s+Checklist\s*$/;
|
|
485
|
+
var CHECKLIST_ENTRY = /^-\s+\[([ xX])\]\s*(.*)$/;
|
|
486
|
+
function parseTaskFile(content, sourceFile, clientId) {
|
|
487
|
+
const lines = content.split(/\r?\n/);
|
|
488
|
+
const result = { tasks: [] };
|
|
489
|
+
let current;
|
|
490
|
+
let inMeta = false;
|
|
491
|
+
const flush = () => {
|
|
492
|
+
if (!current) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const t = current.task;
|
|
496
|
+
const { description, checklist, prompts, notes } = splitBody(current.descLines);
|
|
497
|
+
result.tasks.push({
|
|
498
|
+
id: t.id ?? "",
|
|
499
|
+
title: (t.title ?? "").trim(),
|
|
500
|
+
description,
|
|
501
|
+
status: t.status ?? "open",
|
|
502
|
+
priority: t.priority,
|
|
503
|
+
parentId: t.parentId,
|
|
504
|
+
clientIds: t.clientIds && t.clientIds.length ? t.clientIds : [clientId],
|
|
505
|
+
links: t.links,
|
|
506
|
+
attachments: t.attachments,
|
|
507
|
+
created: t.created,
|
|
508
|
+
due: t.due,
|
|
509
|
+
completed: t.completed,
|
|
510
|
+
repeat: buildRecurrence(t.repeatMeta),
|
|
511
|
+
lastDone: t.lastDone,
|
|
512
|
+
repeatOf: t.repeatOf,
|
|
513
|
+
workedOn: t.workedOn,
|
|
514
|
+
tags: t.tags,
|
|
515
|
+
checklist,
|
|
516
|
+
prompts,
|
|
517
|
+
notes,
|
|
518
|
+
sourceFile,
|
|
519
|
+
sourceLine: current.line
|
|
520
|
+
});
|
|
521
|
+
current = void 0;
|
|
522
|
+
};
|
|
523
|
+
for (let i = 0; i < lines.length; i++) {
|
|
524
|
+
const line = lines[i];
|
|
525
|
+
const h2 = isTaskHeading(lines, i) ? H22.exec(line) : null;
|
|
526
|
+
if (h2) {
|
|
527
|
+
flush();
|
|
528
|
+
current = {
|
|
529
|
+
task: { title: h2[1], links: [], clientIds: [] },
|
|
530
|
+
descLines: [],
|
|
531
|
+
line: i
|
|
532
|
+
};
|
|
533
|
+
inMeta = true;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (!current) {
|
|
537
|
+
const h1 = H1.exec(line);
|
|
538
|
+
if (h1 && result.displayName === void 0) {
|
|
539
|
+
result.displayName = h1[1].trim();
|
|
540
|
+
}
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (inMeta) {
|
|
544
|
+
const meta = META2.exec(line);
|
|
545
|
+
if (meta) {
|
|
546
|
+
applyMeta(current.task, meta[1].toLowerCase(), meta[2].trim());
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
inMeta = false;
|
|
550
|
+
if (line.trim() === "") {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
current.descLines.push(line);
|
|
555
|
+
}
|
|
556
|
+
flush();
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
function buildRecurrence(meta) {
|
|
560
|
+
if (!meta?.expr) {
|
|
561
|
+
return void 0;
|
|
562
|
+
}
|
|
563
|
+
const rec = parseRecurrence(meta.expr);
|
|
564
|
+
if (!rec) {
|
|
565
|
+
return void 0;
|
|
566
|
+
}
|
|
567
|
+
const anchor = parseAnchor(meta.from);
|
|
568
|
+
if (anchor) {
|
|
569
|
+
rec.anchor = anchor;
|
|
570
|
+
}
|
|
571
|
+
if (meta.until) {
|
|
572
|
+
rec.until = meta.until;
|
|
573
|
+
}
|
|
574
|
+
return rec;
|
|
575
|
+
}
|
|
576
|
+
function parseAnchor(value) {
|
|
577
|
+
switch (value?.trim().toLowerCase()) {
|
|
578
|
+
case "completion":
|
|
579
|
+
case "completed":
|
|
580
|
+
case "done":
|
|
581
|
+
return "completion";
|
|
582
|
+
case "schedule":
|
|
583
|
+
case "due":
|
|
584
|
+
return "schedule";
|
|
585
|
+
default:
|
|
586
|
+
return void 0;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
function repeatMeta(task) {
|
|
590
|
+
return task.repeatMeta ??= {};
|
|
591
|
+
}
|
|
592
|
+
function applyMeta(task, key, value) {
|
|
593
|
+
switch (key) {
|
|
594
|
+
case "id":
|
|
595
|
+
task.id = value;
|
|
596
|
+
break;
|
|
597
|
+
case "status":
|
|
598
|
+
task.status = value || "open";
|
|
599
|
+
break;
|
|
600
|
+
case "priority":
|
|
601
|
+
task.priority = value || void 0;
|
|
602
|
+
break;
|
|
603
|
+
case "parent":
|
|
604
|
+
task.parentId = value || void 0;
|
|
605
|
+
break;
|
|
606
|
+
case "link": {
|
|
607
|
+
const link = parseLink(value);
|
|
608
|
+
if (link) {
|
|
609
|
+
task.links.push(link);
|
|
610
|
+
}
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
case "attachment":
|
|
614
|
+
if (value) {
|
|
615
|
+
(task.attachments ??= []).push(value);
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
case "created":
|
|
619
|
+
task.created = value || void 0;
|
|
620
|
+
break;
|
|
621
|
+
case "due":
|
|
622
|
+
task.due = value || void 0;
|
|
623
|
+
break;
|
|
624
|
+
case "completed":
|
|
625
|
+
task.completed = value || void 0;
|
|
626
|
+
break;
|
|
627
|
+
case "repeat":
|
|
628
|
+
repeatMeta(task).expr = value || void 0;
|
|
629
|
+
break;
|
|
630
|
+
case "repeatfrom":
|
|
631
|
+
repeatMeta(task).from = value || void 0;
|
|
632
|
+
break;
|
|
633
|
+
case "repeatuntil":
|
|
634
|
+
repeatMeta(task).until = value || void 0;
|
|
635
|
+
break;
|
|
636
|
+
case "lastdone":
|
|
637
|
+
task.lastDone = value || void 0;
|
|
638
|
+
break;
|
|
639
|
+
case "repeatof":
|
|
640
|
+
task.repeatOf = value || void 0;
|
|
641
|
+
break;
|
|
642
|
+
case "tags": {
|
|
643
|
+
const tags = value.split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
|
|
644
|
+
task.tags = tags.length ? tags : void 0;
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
case "worked": {
|
|
648
|
+
if (!value) {
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
const dates = value.split(/[\s,]+/).map((d) => d.trim()).filter(Boolean);
|
|
652
|
+
if (dates.length === 0) {
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
const all = /* @__PURE__ */ new Set([...task.workedOn ?? [], ...dates]);
|
|
656
|
+
task.workedOn = [...all].sort();
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
case "client":
|
|
660
|
+
task.clientIds = value.split(/[,\s]+/).filter(Boolean);
|
|
661
|
+
break;
|
|
662
|
+
default:
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function parseLink(value) {
|
|
667
|
+
const trimmed = value.trim();
|
|
668
|
+
if (!trimmed) {
|
|
669
|
+
return void 0;
|
|
670
|
+
}
|
|
671
|
+
const spaceIdx = trimmed.search(/\s/);
|
|
672
|
+
if (spaceIdx === -1) {
|
|
673
|
+
return { url: trimmed };
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
url: trimmed.slice(0, spaceIdx),
|
|
677
|
+
label: trimmed.slice(spaceIdx + 1).trim() || void 0
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function splitBody(lines) {
|
|
681
|
+
const marks = [];
|
|
682
|
+
lines.forEach((line, at) => {
|
|
683
|
+
if (PROMPTS_HEADING.test(line)) {
|
|
684
|
+
marks.push({ at, kind: "prompts" });
|
|
685
|
+
} else if (NOTES_HEADING.test(line)) {
|
|
686
|
+
marks.push({ at, kind: "notes" });
|
|
687
|
+
} else if (CHECKLIST_HEADING.test(line)) {
|
|
688
|
+
marks.push({ at, kind: "checklist" });
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
if (marks.length === 0) {
|
|
692
|
+
const description2 = lines.join("\n").trim();
|
|
693
|
+
return { description: description2 || void 0 };
|
|
694
|
+
}
|
|
695
|
+
const description = lines.slice(0, marks[0].at).join("\n").trim();
|
|
696
|
+
const checklist = [];
|
|
697
|
+
const prompts = [];
|
|
698
|
+
const notes = [];
|
|
699
|
+
marks.forEach((mark, i) => {
|
|
700
|
+
const body = lines.slice(mark.at + 1, marks[i + 1]?.at ?? lines.length);
|
|
701
|
+
if (mark.kind === "prompts") {
|
|
702
|
+
prompts.push(...parsePrompts(body));
|
|
703
|
+
} else if (mark.kind === "checklist") {
|
|
704
|
+
checklist.push(...parseChecklist(body));
|
|
705
|
+
} else {
|
|
706
|
+
notes.push(...parseNotes(body));
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
return {
|
|
710
|
+
description: description || void 0,
|
|
711
|
+
checklist: checklist.length ? checklist : void 0,
|
|
712
|
+
prompts: prompts.length ? prompts : void 0,
|
|
713
|
+
notes: notes.length ? notes : void 0
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
function parseChecklist(lines) {
|
|
717
|
+
const items = [];
|
|
718
|
+
for (const line of lines) {
|
|
719
|
+
const m = CHECKLIST_ENTRY.exec(line);
|
|
720
|
+
const text = m?.[2].trim();
|
|
721
|
+
if (m && text) {
|
|
722
|
+
items.push({ text, done: m[1].toLowerCase() === "x" });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return items;
|
|
726
|
+
}
|
|
727
|
+
function parsePrompts(lines) {
|
|
728
|
+
const prompts = [];
|
|
729
|
+
let current;
|
|
730
|
+
const flush = () => {
|
|
731
|
+
if (current) {
|
|
732
|
+
prompts.push({
|
|
733
|
+
title: current.title.trim(),
|
|
734
|
+
text: current.text.join("\n").trim(),
|
|
735
|
+
ran: current.ran || void 0,
|
|
736
|
+
ranAt: current.ranAt
|
|
737
|
+
});
|
|
738
|
+
current = void 0;
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
for (const line of lines) {
|
|
742
|
+
const m = PROMPT_ENTRY.exec(line);
|
|
743
|
+
if (m) {
|
|
744
|
+
flush();
|
|
745
|
+
const ran = m[1].toLowerCase() === "x";
|
|
746
|
+
const stamped = ran ? PROMPT_RAN.exec(m[2]) : null;
|
|
747
|
+
current = {
|
|
748
|
+
title: stamped ? stamped[2] : m[2],
|
|
749
|
+
ran,
|
|
750
|
+
ranAt: stamped ? stamped[1].replace("T", " ") : void 0,
|
|
751
|
+
text: []
|
|
752
|
+
};
|
|
753
|
+
} else if (current) {
|
|
754
|
+
current.text.push(line.replace(/^ {1,2}/, ""));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
flush();
|
|
758
|
+
return prompts;
|
|
759
|
+
}
|
|
760
|
+
function parseNotes(lines) {
|
|
761
|
+
const notes = [];
|
|
762
|
+
let current;
|
|
763
|
+
const flush = () => {
|
|
764
|
+
if (current) {
|
|
765
|
+
notes.push({
|
|
766
|
+
timestamp: current.timestamp,
|
|
767
|
+
text: current.text.join("\n").trim()
|
|
768
|
+
});
|
|
769
|
+
current = void 0;
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
for (const line of lines) {
|
|
773
|
+
const m = NOTE_ENTRY.exec(line);
|
|
774
|
+
if (m) {
|
|
775
|
+
flush();
|
|
776
|
+
current = { timestamp: m[1].replace("T", " "), text: [m[2]] };
|
|
777
|
+
} else if (current) {
|
|
778
|
+
current.text.push(line.replace(/^ {1,2}/, ""));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
flush();
|
|
782
|
+
return notes;
|
|
783
|
+
}
|
|
784
|
+
function serializePrompt(prompt) {
|
|
785
|
+
const stamp = prompt.ran && prompt.ranAt ? `${prompt.ranAt} \u2014 ` : "";
|
|
786
|
+
const head = `- [${prompt.ran ? "x" : " "}] ${stamp}${prompt.title}`;
|
|
787
|
+
if (!prompt.text.trim()) {
|
|
788
|
+
return head;
|
|
789
|
+
}
|
|
790
|
+
const body = prompt.text.split(/\r?\n/).map((l) => l.trim() ? ` ${l}` : "");
|
|
791
|
+
return [head, ...body].join("\n");
|
|
792
|
+
}
|
|
793
|
+
function serializeChecklistItem(item) {
|
|
794
|
+
return `- [${item.done ? "x" : " "}] ${item.text}`;
|
|
795
|
+
}
|
|
796
|
+
function serializeNote(note) {
|
|
797
|
+
const [first = "", ...rest] = note.text.split(/\r?\n/);
|
|
798
|
+
const head = `- ${note.timestamp} \u2014 ${first}`;
|
|
799
|
+
const cont = rest.map((l) => l.trim() ? ` ${l}` : "");
|
|
800
|
+
return [head, ...cont].join("\n");
|
|
801
|
+
}
|
|
802
|
+
function serializeTask(task, clientId) {
|
|
803
|
+
const out = [];
|
|
804
|
+
out.push(`## ${task.title}`);
|
|
805
|
+
out.push(`- id: ${task.id}`);
|
|
806
|
+
out.push(`- status: ${task.status}`);
|
|
807
|
+
if (task.priority) {
|
|
808
|
+
out.push(`- priority: ${task.priority}`);
|
|
809
|
+
}
|
|
810
|
+
if (task.parentId) {
|
|
811
|
+
out.push(`- parent: ${task.parentId}`);
|
|
812
|
+
}
|
|
813
|
+
if (clientId && (task.clientIds.length > 1 || task.clientIds.length === 1 && task.clientIds[0] !== clientId)) {
|
|
814
|
+
out.push(`- client: ${task.clientIds.join(" ")}`);
|
|
815
|
+
}
|
|
816
|
+
for (const link of task.links) {
|
|
817
|
+
out.push(`- link: ${link.label ? `${link.url} ${link.label}` : link.url}`);
|
|
818
|
+
}
|
|
819
|
+
for (const ref of task.attachments ?? []) {
|
|
820
|
+
out.push(`- attachment: ${ref}`);
|
|
821
|
+
}
|
|
822
|
+
if (task.created) {
|
|
823
|
+
out.push(`- created: ${task.created}`);
|
|
824
|
+
}
|
|
825
|
+
if (task.due) {
|
|
826
|
+
out.push(`- due: ${task.due}`);
|
|
827
|
+
}
|
|
828
|
+
if (task.completed) {
|
|
829
|
+
out.push(`- completed: ${task.completed}`);
|
|
830
|
+
}
|
|
831
|
+
if (task.repeat) {
|
|
832
|
+
out.push(`- repeat: ${formatRecurrence(task.repeat)}`);
|
|
833
|
+
if (task.repeat.anchor === "completion") {
|
|
834
|
+
out.push(`- repeatFrom: completion`);
|
|
835
|
+
}
|
|
836
|
+
if (task.repeat.until) {
|
|
837
|
+
out.push(`- repeatUntil: ${task.repeat.until}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (task.lastDone) {
|
|
841
|
+
out.push(`- lastDone: ${task.lastDone}`);
|
|
842
|
+
}
|
|
843
|
+
if (task.repeatOf) {
|
|
844
|
+
out.push(`- repeatOf: ${task.repeatOf}`);
|
|
845
|
+
}
|
|
846
|
+
for (const worked of task.workedOn ?? []) {
|
|
847
|
+
out.push(`- worked: ${worked}`);
|
|
848
|
+
}
|
|
849
|
+
if (task.tags && task.tags.length) {
|
|
850
|
+
out.push(`- tags: ${task.tags.join(", ")}`);
|
|
851
|
+
}
|
|
852
|
+
const body = [];
|
|
853
|
+
if (task.description && task.description.trim()) {
|
|
854
|
+
body.push(task.description.trim());
|
|
855
|
+
}
|
|
856
|
+
if (task.checklist && task.checklist.length) {
|
|
857
|
+
body.push(["### Checklist", ...task.checklist.map(serializeChecklistItem)].join("\n"));
|
|
858
|
+
}
|
|
859
|
+
if (task.prompts && task.prompts.length) {
|
|
860
|
+
body.push(`### Prompts
|
|
861
|
+
${task.prompts.map(serializePrompt).join("\n\n")}`);
|
|
862
|
+
}
|
|
863
|
+
if (task.notes && task.notes.length) {
|
|
864
|
+
body.push(["### Notes", ...task.notes.map(serializeNote)].join("\n"));
|
|
865
|
+
}
|
|
866
|
+
if (body.length) {
|
|
867
|
+
out.push("");
|
|
868
|
+
out.push(body.join("\n\n"));
|
|
869
|
+
}
|
|
870
|
+
return out.join("\n");
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/parser/worklogParser.ts
|
|
874
|
+
var LINE = /^-\s+(\d{4}-\d{2}-\d{2})\s+(\S+)\s+([\d.]+)\s*(?:[—–]|-{1,2}|:)?\s*(.*)$/;
|
|
875
|
+
function parseWorklogFile(content, sourceFile) {
|
|
876
|
+
const lines = content.split(/\r?\n/);
|
|
877
|
+
const entries = [];
|
|
878
|
+
for (let i = 0; i < lines.length; i++) {
|
|
879
|
+
const m = LINE.exec(lines[i].trim());
|
|
880
|
+
if (!m) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const hours = Number(m[3]);
|
|
884
|
+
if (!isFinite(hours)) {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
const note = m[4]?.trim();
|
|
888
|
+
entries.push({
|
|
889
|
+
date: m[1],
|
|
890
|
+
clientId: m[2],
|
|
891
|
+
hours,
|
|
892
|
+
note: note ? note : void 0,
|
|
893
|
+
sourceFile,
|
|
894
|
+
sourceLine: i
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
return entries;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// src/parser/dayNotes.ts
|
|
901
|
+
var DAY_HEADING = /^##[ \t]+(\d{4}-\d{2}-\d{2})[ \t]*$/;
|
|
902
|
+
function splitDayNoteBlocks(content) {
|
|
903
|
+
const lines = content.split(/\r?\n/);
|
|
904
|
+
const starts = [];
|
|
905
|
+
for (let i = 0; i < lines.length; i++) {
|
|
906
|
+
if (DAY_HEADING.test(lines[i])) {
|
|
907
|
+
starts.push(i);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const header = lines.slice(0, starts.length ? starts[0] : lines.length).join("\n").replace(/\s+$/, "");
|
|
911
|
+
const blocks = [];
|
|
912
|
+
for (let b = 0; b < starts.length; b++) {
|
|
913
|
+
const end = b + 1 < starts.length ? starts[b + 1] : lines.length;
|
|
914
|
+
const date = DAY_HEADING.exec(lines[starts[b]])[1];
|
|
915
|
+
blocks.push({ date, text: bodyOf(lines.slice(starts[b] + 1, end)), line: starts[b] });
|
|
916
|
+
}
|
|
917
|
+
return { header, blocks };
|
|
918
|
+
}
|
|
919
|
+
function parseDayNotesFile(content, sourceFile) {
|
|
920
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
921
|
+
for (const b of splitDayNoteBlocks(content).blocks) {
|
|
922
|
+
if (b.text !== "") {
|
|
923
|
+
byDate.set(b.date, { date: b.date, body: b.text, sourceFile, sourceLine: b.line });
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return [...byDate.values()];
|
|
927
|
+
}
|
|
928
|
+
function bodyOf(lines) {
|
|
929
|
+
let start = 0;
|
|
930
|
+
while (start < lines.length && lines[start].trim() === "") {
|
|
931
|
+
start++;
|
|
932
|
+
}
|
|
933
|
+
return lines.slice(start).join("\n").replace(/\s+$/, "");
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/parser/checklistParser.ts
|
|
937
|
+
var ITEM = /^(\s*)([-*+])\s+\[([ xX])\]\s?(.*)$/;
|
|
938
|
+
var H12 = /^#[ \t]+(.*)$/;
|
|
939
|
+
var H23 = /^##[ \t]+(.*)$/;
|
|
940
|
+
var LAST_RUN = /^-[ \t]+last run:[ \t]*(\d{4}-\d{2}-\d{2})[ \t]*$/;
|
|
941
|
+
function parseChecklistFile(content, sourceFile, id) {
|
|
942
|
+
const lines = content.split(/\r?\n/);
|
|
943
|
+
let name = "";
|
|
944
|
+
let lastRun;
|
|
945
|
+
const sections = [{ items: [] }];
|
|
946
|
+
for (let i = 0; i < lines.length; i++) {
|
|
947
|
+
const line = lines[i];
|
|
948
|
+
const item = ITEM.exec(line);
|
|
949
|
+
if (item) {
|
|
950
|
+
sections[sections.length - 1].items.push({
|
|
951
|
+
text: item[4].trim(),
|
|
952
|
+
done: item[3] !== " ",
|
|
953
|
+
line: i
|
|
954
|
+
});
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
const h2 = H23.exec(line);
|
|
958
|
+
if (h2) {
|
|
959
|
+
sections.push({ title: h2[1].trim(), line: i, items: [] });
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const h1 = H12.exec(line);
|
|
963
|
+
if (h1 && !name) {
|
|
964
|
+
name = h1[1].trim();
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const run2 = LAST_RUN.exec(line);
|
|
968
|
+
if (run2 && !lastRun) {
|
|
969
|
+
lastRun = run2[1];
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const kept = sections.filter((s, i) => i > 0 || s.items.length > 0);
|
|
973
|
+
return {
|
|
974
|
+
id,
|
|
975
|
+
name: name || id,
|
|
976
|
+
lastRun,
|
|
977
|
+
sections: kept.length ? kept : [sections[0]],
|
|
978
|
+
sourceFile
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// src/parser/blocks.ts
|
|
983
|
+
var ID_META2 = /^-\s+id\s*:\s*(.+?)\s*$/;
|
|
984
|
+
function findBlockRange(content, taskId) {
|
|
985
|
+
const lines = content.split(/\r?\n/);
|
|
986
|
+
let blockStart = -1;
|
|
987
|
+
let matched = false;
|
|
988
|
+
const ranges = [];
|
|
989
|
+
for (let i = 0; i < lines.length; i++) {
|
|
990
|
+
if (isTaskHeading(lines, i)) {
|
|
991
|
+
if (blockStart !== -1) {
|
|
992
|
+
ranges.push({ start: blockStart, end: i, matched });
|
|
993
|
+
}
|
|
994
|
+
blockStart = i;
|
|
995
|
+
matched = false;
|
|
996
|
+
} else if (blockStart !== -1) {
|
|
997
|
+
const m = ID_META2.exec(lines[i]);
|
|
998
|
+
if (m && m[1] === taskId) {
|
|
999
|
+
matched = true;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (blockStart !== -1) {
|
|
1004
|
+
ranges.push({ start: blockStart, end: lines.length, matched });
|
|
1005
|
+
}
|
|
1006
|
+
const hit = ranges.find((r) => r.matched);
|
|
1007
|
+
return hit ? { start: hit.start, end: hit.end } : void 0;
|
|
1008
|
+
}
|
|
1009
|
+
function replaceBlock(content, taskId, newBlock) {
|
|
1010
|
+
const range = findBlockRange(content, taskId);
|
|
1011
|
+
if (!range) {
|
|
1012
|
+
return void 0;
|
|
1013
|
+
}
|
|
1014
|
+
const lines = content.split(/\r?\n/);
|
|
1015
|
+
const original = lines.slice(range.start, range.end);
|
|
1016
|
+
const hadTrailingBlank = original.length > 0 && original[original.length - 1].trim() === "";
|
|
1017
|
+
const replacement = newBlock.split(/\r?\n/);
|
|
1018
|
+
if (hadTrailingBlank) {
|
|
1019
|
+
replacement.push("");
|
|
1020
|
+
}
|
|
1021
|
+
const next = [...lines.slice(0, range.start), ...replacement, ...lines.slice(range.end)];
|
|
1022
|
+
return next.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
1023
|
+
}
|
|
1024
|
+
function extractBlock(content, taskId) {
|
|
1025
|
+
const range = findBlockRange(content, taskId);
|
|
1026
|
+
if (!range) {
|
|
1027
|
+
return void 0;
|
|
1028
|
+
}
|
|
1029
|
+
const lines = content.split(/\r?\n/);
|
|
1030
|
+
const blockLines = lines.slice(range.start, range.end);
|
|
1031
|
+
while (blockLines.length && blockLines[blockLines.length - 1].trim() === "") {
|
|
1032
|
+
blockLines.pop();
|
|
1033
|
+
}
|
|
1034
|
+
const before = lines.slice(0, range.start);
|
|
1035
|
+
const after = lines.slice(range.end);
|
|
1036
|
+
while (before.length && before[before.length - 1].trim() === "") {
|
|
1037
|
+
before.pop();
|
|
1038
|
+
}
|
|
1039
|
+
const remainderLines = [...before];
|
|
1040
|
+
if (before.length && after.some((l) => l.trim() !== "")) {
|
|
1041
|
+
remainderLines.push("");
|
|
1042
|
+
}
|
|
1043
|
+
remainderLines.push(...after);
|
|
1044
|
+
let remainder = remainderLines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "");
|
|
1045
|
+
if (remainder) {
|
|
1046
|
+
remainder += "\n";
|
|
1047
|
+
}
|
|
1048
|
+
return { block: blockLines.join("\n"), remainder, startLine: range.start };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// src/parser/meetingParser.ts
|
|
1052
|
+
var H24 = /^##\s+(.*)$/;
|
|
1053
|
+
var META3 = /^-\s+([A-Za-z][\w-]*)\s*:\s*(.*)$/;
|
|
1054
|
+
var ACTIONS_HEADING = /^###\s+Action items\s*$/i;
|
|
1055
|
+
var ACTION_ENTRY = /^-\s+\[([ xX])\]\s*(.*)$/;
|
|
1056
|
+
var ACTION_TASK = /^(.*?)\s+→\s+(t_[A-Za-z0-9]+)$/;
|
|
1057
|
+
var TIME = /^(\d{1,2}):(\d{2})$/;
|
|
1058
|
+
var DURATION = /^(?:(\d+(?:\.\d+)?)\s*h)?\s*(?:(\d+)\s*m(?:in)?)?$/i;
|
|
1059
|
+
function parseDuration(value) {
|
|
1060
|
+
const text = value.trim();
|
|
1061
|
+
if (/^\d+$/.test(text)) {
|
|
1062
|
+
const n = Number(text);
|
|
1063
|
+
return n > 0 ? n : void 0;
|
|
1064
|
+
}
|
|
1065
|
+
const m = DURATION.exec(text);
|
|
1066
|
+
if (!m || !m[1] && !m[2]) {
|
|
1067
|
+
return void 0;
|
|
1068
|
+
}
|
|
1069
|
+
const minutes = Math.round(Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0));
|
|
1070
|
+
return minutes > 0 ? minutes : void 0;
|
|
1071
|
+
}
|
|
1072
|
+
function normalizeTime(value) {
|
|
1073
|
+
const m = TIME.exec(value.trim());
|
|
1074
|
+
if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) {
|
|
1075
|
+
return void 0;
|
|
1076
|
+
}
|
|
1077
|
+
return `${m[1].padStart(2, "0")}:${m[2]}`;
|
|
1078
|
+
}
|
|
1079
|
+
function parseMeetingFile(content, sourceFile) {
|
|
1080
|
+
const lines = content.split(/\r?\n/);
|
|
1081
|
+
const starts = [];
|
|
1082
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1083
|
+
if (isTaskHeading(lines, i)) {
|
|
1084
|
+
starts.push(i);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const meetings = [];
|
|
1088
|
+
for (let b = 0; b < starts.length; b++) {
|
|
1089
|
+
const end = b + 1 < starts.length ? starts[b + 1] : lines.length;
|
|
1090
|
+
const meeting = parseBlock(lines.slice(starts[b], end), sourceFile, starts[b]);
|
|
1091
|
+
if (meeting.id) {
|
|
1092
|
+
meetings.push(meeting);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return meetings;
|
|
1096
|
+
}
|
|
1097
|
+
function parseBlock(lines, sourceFile, line) {
|
|
1098
|
+
const meeting = {
|
|
1099
|
+
id: "",
|
|
1100
|
+
title: (H24.exec(lines[0])?.[1] ?? "").trim(),
|
|
1101
|
+
date: "",
|
|
1102
|
+
people: [],
|
|
1103
|
+
notes: "",
|
|
1104
|
+
actions: [],
|
|
1105
|
+
sourceFile,
|
|
1106
|
+
sourceLine: line
|
|
1107
|
+
};
|
|
1108
|
+
let i = 1;
|
|
1109
|
+
for (; i < lines.length; i++) {
|
|
1110
|
+
const meta = META3.exec(lines[i]);
|
|
1111
|
+
if (!meta) {
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
const value = meta[2].trim();
|
|
1115
|
+
switch (meta[1].toLowerCase()) {
|
|
1116
|
+
case "id":
|
|
1117
|
+
meeting.id = value;
|
|
1118
|
+
break;
|
|
1119
|
+
case "date":
|
|
1120
|
+
meeting.date = value;
|
|
1121
|
+
break;
|
|
1122
|
+
case "time":
|
|
1123
|
+
meeting.time = normalizeTime(value) ?? meeting.time;
|
|
1124
|
+
break;
|
|
1125
|
+
case "duration":
|
|
1126
|
+
meeting.duration = parseDuration(value) ?? meeting.duration;
|
|
1127
|
+
break;
|
|
1128
|
+
case "client":
|
|
1129
|
+
meeting.clientId = value || void 0;
|
|
1130
|
+
break;
|
|
1131
|
+
case "people":
|
|
1132
|
+
meeting.people = splitPeople(value);
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const body = trimBlank(lines.slice(i));
|
|
1137
|
+
const at = actionsStart(body);
|
|
1138
|
+
if (at < 0) {
|
|
1139
|
+
meeting.notes = body.join("\n");
|
|
1140
|
+
} else {
|
|
1141
|
+
meeting.notes = trimBlank(body.slice(0, at)).join("\n");
|
|
1142
|
+
meeting.actions = body.slice(at + 1).filter((l) => l.trim() !== "").map(parseAction);
|
|
1143
|
+
}
|
|
1144
|
+
return meeting;
|
|
1145
|
+
}
|
|
1146
|
+
function actionsStart(body) {
|
|
1147
|
+
for (let i = body.length - 1; i >= 0; i--) {
|
|
1148
|
+
if (ACTIONS_HEADING.test(body[i])) {
|
|
1149
|
+
return body.slice(i + 1).every((l) => l.trim() === "" || ACTION_ENTRY.test(l)) ? i : -1;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return -1;
|
|
1153
|
+
}
|
|
1154
|
+
function parseAction(line) {
|
|
1155
|
+
const m = ACTION_ENTRY.exec(line);
|
|
1156
|
+
const done = m[1] !== " ";
|
|
1157
|
+
const linked = ACTION_TASK.exec(m[2].trim());
|
|
1158
|
+
return linked ? { text: linked[1].trim(), done, taskId: linked[2] } : { text: m[2].trim(), done };
|
|
1159
|
+
}
|
|
1160
|
+
function splitPeople(value) {
|
|
1161
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1162
|
+
const out = [];
|
|
1163
|
+
for (const raw of value.split(",")) {
|
|
1164
|
+
const name = raw.trim();
|
|
1165
|
+
if (name && !seen.has(name.toLowerCase())) {
|
|
1166
|
+
seen.add(name.toLowerCase());
|
|
1167
|
+
out.push(name);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return out;
|
|
1171
|
+
}
|
|
1172
|
+
function trimBlank(lines) {
|
|
1173
|
+
let start = 0;
|
|
1174
|
+
let end = lines.length;
|
|
1175
|
+
while (start < end && lines[start].trim() === "") {
|
|
1176
|
+
start++;
|
|
1177
|
+
}
|
|
1178
|
+
while (end > start && lines[end - 1].trim() === "") {
|
|
1179
|
+
end--;
|
|
1180
|
+
}
|
|
1181
|
+
return lines.slice(start, end);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// src/model/status.ts
|
|
1185
|
+
var DEFAULT_STATUSES = [
|
|
1186
|
+
{ id: "open", label: "Open" },
|
|
1187
|
+
{ id: "in-progress", label: "In progress" },
|
|
1188
|
+
{ id: "done", label: "Closed", terminal: true }
|
|
1189
|
+
];
|
|
1190
|
+
var MAX_STATUS_LABEL = 20;
|
|
1191
|
+
var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
1192
|
+
function parseStatusColor(color) {
|
|
1193
|
+
const value = typeof color === "string" ? color.trim() : "";
|
|
1194
|
+
return HEX_COLOR.test(value) ? value : void 0;
|
|
1195
|
+
}
|
|
1196
|
+
function normalizeStatuses(input) {
|
|
1197
|
+
const defs = [];
|
|
1198
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1199
|
+
for (const raw of Array.isArray(input) ? input : []) {
|
|
1200
|
+
if (!raw || typeof raw !== "object") {
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
const candidate = raw;
|
|
1204
|
+
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
|
1205
|
+
const label = typeof candidate.label === "string" ? candidate.label.trim() : "";
|
|
1206
|
+
if (!id || !label || seen.has(id)) {
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
seen.add(id);
|
|
1210
|
+
defs.push({
|
|
1211
|
+
id,
|
|
1212
|
+
label: label.slice(0, MAX_STATUS_LABEL),
|
|
1213
|
+
terminal: candidate.terminal === true || void 0,
|
|
1214
|
+
color: parseStatusColor(candidate.color)
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
if (defs.length === 0) {
|
|
1218
|
+
return DEFAULT_STATUSES.map((s) => ({ ...s }));
|
|
1219
|
+
}
|
|
1220
|
+
const flagged = defs.findIndex((s) => s.terminal);
|
|
1221
|
+
const terminalIndex = flagged >= 0 ? flagged : defs.length - 1;
|
|
1222
|
+
const terminal = defs[terminalIndex];
|
|
1223
|
+
const working = defs.filter((_, i) => i !== terminalIndex);
|
|
1224
|
+
if (working.length === 0) {
|
|
1225
|
+
const fallback = DEFAULT_STATUSES.find((s) => !s.terminal && s.id !== terminal.id) ?? { id: "open", label: "Open" };
|
|
1226
|
+
working.push({ id: fallback.id, label: fallback.label });
|
|
1227
|
+
}
|
|
1228
|
+
return [
|
|
1229
|
+
...working.map((s) => ({ ...s, terminal: void 0 })),
|
|
1230
|
+
{ ...terminal, terminal: true }
|
|
1231
|
+
];
|
|
1232
|
+
}
|
|
1233
|
+
function openStatusId(statuses) {
|
|
1234
|
+
return (statuses.find((s) => !s.terminal) ?? statuses[0])?.id ?? "open";
|
|
1235
|
+
}
|
|
1236
|
+
function inProgressStatusId(statuses) {
|
|
1237
|
+
const explicit = statuses.find((s) => s.id === "in-progress");
|
|
1238
|
+
if (explicit) {
|
|
1239
|
+
return explicit.id;
|
|
1240
|
+
}
|
|
1241
|
+
return statuses.filter((s) => !s.terminal)[1]?.id;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// src/model/aiAgents.ts
|
|
1245
|
+
var COMMAND_EXECUTOR_ID = "eliostruyf.execcommand";
|
|
1246
|
+
var COMMAND_EXECUTOR_URL = `https://marketplace.visualstudio.com/items?itemName=${COMMAND_EXECUTOR_ID}`;
|
|
1247
|
+
var AI_AGENTS = [
|
|
1248
|
+
{
|
|
1249
|
+
id: "copilot",
|
|
1250
|
+
label: "GitHub Copilot",
|
|
1251
|
+
description: "Opens Copilot Chat in VS Code with the task in the input box.",
|
|
1252
|
+
command: "workbench.action.chat.open",
|
|
1253
|
+
// `isPartialQuery` is what leaves the prompt sitting in the input rather than
|
|
1254
|
+
// sending it. Without it the link decides what the agent works on; with it,
|
|
1255
|
+
// you do.
|
|
1256
|
+
args: (prompt) => [{ query: prompt, isPartialQuery: true }]
|
|
1257
|
+
},
|
|
1258
|
+
// Appended rather than slotted in, for the same reason as AUTO_SYNC_EVENTS:
|
|
1259
|
+
// `parseAiAgents` orders a saved array by this list, so inserting in the middle
|
|
1260
|
+
// rewrites every existing config on its next save for no reason.
|
|
1261
|
+
{
|
|
1262
|
+
id: "claude",
|
|
1263
|
+
label: "Claude Code",
|
|
1264
|
+
description: "Opens a Claude Code tab in VS Code with the task in its prompt box.",
|
|
1265
|
+
command: "claude-vscode.editor.open",
|
|
1266
|
+
// `(sessionId, prompt, viewColumn)`. The first parameter is left out on
|
|
1267
|
+
// purpose: it resumes an existing session, and passing anything there would
|
|
1268
|
+
// have the prompt read as a session key. The third picks a column, which is a
|
|
1269
|
+
// choice the editor makes better than we can.
|
|
1270
|
+
//
|
|
1271
|
+
// The prompt lands in the tab's input rather than being sent — the webview
|
|
1272
|
+
// reads it once and calls `setInputText`. That is the same bargain as
|
|
1273
|
+
// Copilot's `isPartialQuery`, and the reason this beats the terminal command,
|
|
1274
|
+
// which would run what it was handed.
|
|
1275
|
+
args: (prompt) => [void 0, prompt]
|
|
1276
|
+
}
|
|
1277
|
+
];
|
|
1278
|
+
function parseAiAgents(value) {
|
|
1279
|
+
if (!Array.isArray(value)) {
|
|
1280
|
+
return [];
|
|
1281
|
+
}
|
|
1282
|
+
const wanted = new Set(value);
|
|
1283
|
+
return AI_AGENTS.filter((a) => wanted.has(a.id)).map((a) => a.id);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// src/model/syncEvents.ts
|
|
1287
|
+
var AUTO_SYNC_EVENTS = [
|
|
1288
|
+
{
|
|
1289
|
+
id: "taskCreated",
|
|
1290
|
+
label: "A task or to-do is created",
|
|
1291
|
+
description: "A new task, subtask or general to-do."
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
id: "taskStatus",
|
|
1295
|
+
label: "A task changes state",
|
|
1296
|
+
description: "Status changed, marked done or worked on, reopened, completion date changed."
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
id: "taskEdited",
|
|
1300
|
+
label: "A task is edited or deleted",
|
|
1301
|
+
description: "Title, due date, links, tags, description, task notes, checklist, attachments, recurrence, parent \u2014 or a deletion."
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
id: "timeLogged",
|
|
1305
|
+
label: "Time is logged",
|
|
1306
|
+
description: "An entry in the day view\u2019s log form is added, changed or removed."
|
|
1307
|
+
},
|
|
1308
|
+
{
|
|
1309
|
+
id: "settings",
|
|
1310
|
+
label: "Clients, statuses or settings change",
|
|
1311
|
+
description: "A client or task status is added, edited, reordered or removed, or these settings are saved."
|
|
1312
|
+
},
|
|
1313
|
+
// Appended, not slotted in beside the other edit events: `parseAutoSyncEvents`
|
|
1314
|
+
// orders a saved array by this list, so an insertion in the middle rewrites
|
|
1315
|
+
// every existing config's `events` on its next save for no reason.
|
|
1316
|
+
{
|
|
1317
|
+
id: "dayNote",
|
|
1318
|
+
label: "A day note is saved",
|
|
1319
|
+
description: "The freeform Markdown written on a day, saved or cleared from the day view."
|
|
1320
|
+
},
|
|
1321
|
+
// Its own event rather than part of `taskEdited`: a prompt queue is an optional
|
|
1322
|
+
// block (`features.prompts`), and someone who writes prompts for an agent
|
|
1323
|
+
// running elsewhere wants them on the branch sooner than the rest of the task.
|
|
1324
|
+
{
|
|
1325
|
+
id: "prompt",
|
|
1326
|
+
label: "A prompt is written or run",
|
|
1327
|
+
description: "A prompt in a task\u2019s queue is added, edited, ticked off as run, or removed."
|
|
1328
|
+
},
|
|
1329
|
+
// Appended for the reason above. Its own event rather than part of any of the
|
|
1330
|
+
// task ones: a list is not a task, and the thing you want on the branch fast is
|
|
1331
|
+
// a list being ticked through on a phone that is about to lose signal.
|
|
1332
|
+
{
|
|
1333
|
+
id: "list",
|
|
1334
|
+
label: "A list changes",
|
|
1335
|
+
description: "An item on a reusable checklist is ticked, added, edited, moved or removed \u2014 or a list is created, renamed, duplicated, deleted or started again."
|
|
1336
|
+
},
|
|
1337
|
+
// Appended for the reason above. Its own event: the notes of a meeting that
|
|
1338
|
+
// just ended are what you want on the branch before the laptop lid closes.
|
|
1339
|
+
{
|
|
1340
|
+
id: "meeting",
|
|
1341
|
+
label: "Meeting notes change",
|
|
1342
|
+
description: "A meeting is started, edited or deleted \u2014 its notes, people, action items or times."
|
|
1343
|
+
}
|
|
1344
|
+
];
|
|
1345
|
+
function parseAutoSyncEvents(value) {
|
|
1346
|
+
if (!Array.isArray(value)) {
|
|
1347
|
+
return [];
|
|
1348
|
+
}
|
|
1349
|
+
const wanted = new Set(value);
|
|
1350
|
+
return AUTO_SYNC_EVENTS.filter((e) => wanted.has(e.id)).map((e) => e.id);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// src/model/taskSort.ts
|
|
1354
|
+
var TASK_SORT_KEYS = ["created", "due", "priority", "title", "status"];
|
|
1355
|
+
var DEFAULT_TASK_SORT = { key: "created", dir: "asc" };
|
|
1356
|
+
function normalizeTaskSort(value) {
|
|
1357
|
+
if (!value || typeof value !== "object") {
|
|
1358
|
+
return { ...DEFAULT_TASK_SORT };
|
|
1359
|
+
}
|
|
1360
|
+
const candidate = value;
|
|
1361
|
+
return {
|
|
1362
|
+
key: TASK_SORT_KEYS.includes(candidate.key) ? candidate.key : DEFAULT_TASK_SORT.key,
|
|
1363
|
+
dir: candidate.dir === "desc" ? "desc" : "asc"
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// src/model/codeTheme.ts
|
|
1368
|
+
var CODE_THEMES = ["system", "light", "dark"];
|
|
1369
|
+
var DEFAULT_CODE_THEME = "system";
|
|
1370
|
+
function normalizeCodeTheme(value) {
|
|
1371
|
+
return CODE_THEMES.includes(value) ? value : DEFAULT_CODE_THEME;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// src/workspace/paths.ts
|
|
1375
|
+
var DEFAULT_HOURS_PER_DAY = 8;
|
|
1376
|
+
var DEFAULT_WEEK_START = 0;
|
|
1377
|
+
var DEFAULT_TODOS_PER_PAGE = 5;
|
|
1378
|
+
var DEFAULT_AUTO_SYNC = { enabled: false, delayMinutes: 5, events: [] };
|
|
1379
|
+
var DEFAULT_FEATURES = { attachments: true, prompts: true, checklist: true, lists: true, meetings: true };
|
|
1380
|
+
function parseFeatures(value) {
|
|
1381
|
+
const raw = value ?? {};
|
|
1382
|
+
return {
|
|
1383
|
+
attachments: raw.attachments !== false,
|
|
1384
|
+
prompts: raw.prompts !== false,
|
|
1385
|
+
checklist: raw.checklist !== false,
|
|
1386
|
+
lists: raw.lists !== false,
|
|
1387
|
+
meetings: raw.meetings !== false
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
function parseTodosPerPage(value) {
|
|
1391
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : DEFAULT_TODOS_PER_PAGE;
|
|
1392
|
+
}
|
|
1393
|
+
function parseAutoSync(value) {
|
|
1394
|
+
const raw = value ?? {};
|
|
1395
|
+
const delay = typeof raw.delayMinutes === "number" && Number.isFinite(raw.delayMinutes) && raw.delayMinutes >= 1 ? raw.delayMinutes : DEFAULT_AUTO_SYNC.delayMinutes;
|
|
1396
|
+
return { enabled: raw.enabled === true, delayMinutes: delay, events: parseAutoSyncEvents(raw.events) };
|
|
1397
|
+
}
|
|
1398
|
+
function parseLinks(value) {
|
|
1399
|
+
if (!Array.isArray(value)) {
|
|
1400
|
+
return [];
|
|
1401
|
+
}
|
|
1402
|
+
return value.map((l) => typeof l === "string" ? { url: l } : l).filter((l) => !!l && typeof l.url === "string" && !!l.url.trim()).map((l) => {
|
|
1403
|
+
const label = typeof l.label === "string" ? l.label.trim() : "";
|
|
1404
|
+
return { url: l.url.trim(), ...label ? { label } : {} };
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
function parseClientLinks(value) {
|
|
1408
|
+
const links = parseLinks(value);
|
|
1409
|
+
return links.length ? links : void 0;
|
|
1410
|
+
}
|
|
1411
|
+
var DAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
1412
|
+
function parseWeekStart(value) {
|
|
1413
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 6) {
|
|
1414
|
+
return value;
|
|
1415
|
+
}
|
|
1416
|
+
if (typeof value === "string") {
|
|
1417
|
+
const idx = DAY_NAMES.indexOf(value.trim().toLowerCase());
|
|
1418
|
+
if (idx >= 0) {
|
|
1419
|
+
return idx;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return DEFAULT_WEEK_START;
|
|
1423
|
+
}
|
|
1424
|
+
var FileMap = class {
|
|
1425
|
+
/** repo-relative path -> UTF-8 text (markdown, json). */
|
|
1426
|
+
text = /* @__PURE__ */ new Map();
|
|
1427
|
+
/** repo-relative path -> bytes (images under assets/). */
|
|
1428
|
+
binary = /* @__PURE__ */ new Map();
|
|
1429
|
+
/** Paths written since load; the host commits exactly these. */
|
|
1430
|
+
dirty = /* @__PURE__ */ new Set();
|
|
1431
|
+
/** Dirty paths that were removed rather than written; they commit as tree
|
|
1432
|
+
* deletions instead of blobs. */
|
|
1433
|
+
deleted = /* @__PURE__ */ new Set();
|
|
1434
|
+
/** Paths the branch is known to hold — everything loaded, plus everything a
|
|
1435
|
+
* successful sync wrote. A delete only has to reach GitHub for those. */
|
|
1436
|
+
remote = /* @__PURE__ */ new Set();
|
|
1437
|
+
/** Blob SHA per path as loaded from GitHub, to skip unchanged files on reload. */
|
|
1438
|
+
baseSha = /* @__PURE__ */ new Map();
|
|
1439
|
+
/** Text of each path as the branch last had it — the version the local edits
|
|
1440
|
+
* started from. It's the common ancestor the sync merge needs to tell "I
|
|
1441
|
+
* changed this" from "the other instance changed this" (see data/merge). Set
|
|
1442
|
+
* on load and updated on every successful push. */
|
|
1443
|
+
baseText = /* @__PURE__ */ new Map();
|
|
1444
|
+
markDirty(path) {
|
|
1445
|
+
this.dirty.add(path);
|
|
1446
|
+
}
|
|
1447
|
+
clearDirty() {
|
|
1448
|
+
this.dirty.clear();
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
var mounted;
|
|
1452
|
+
function mountFileMap(map) {
|
|
1453
|
+
mounted = map;
|
|
1454
|
+
}
|
|
1455
|
+
function fileMap() {
|
|
1456
|
+
if (!mounted) {
|
|
1457
|
+
throw new Error("No repository is mounted.");
|
|
1458
|
+
}
|
|
1459
|
+
return mounted;
|
|
1460
|
+
}
|
|
1461
|
+
async function readText(path) {
|
|
1462
|
+
return fileMap().text.get(path);
|
|
1463
|
+
}
|
|
1464
|
+
async function writeText(path, text) {
|
|
1465
|
+
const fm = fileMap();
|
|
1466
|
+
fm.deleted.delete(path);
|
|
1467
|
+
fm.text.set(path, text);
|
|
1468
|
+
fm.markDirty(path);
|
|
1469
|
+
}
|
|
1470
|
+
async function ensureDir(_path) {
|
|
1471
|
+
}
|
|
1472
|
+
function defaultConfig() {
|
|
1473
|
+
return {
|
|
1474
|
+
hoursPerDay: DEFAULT_HOURS_PER_DAY,
|
|
1475
|
+
weekStart: DEFAULT_WEEK_START,
|
|
1476
|
+
todosPerPage: DEFAULT_TODOS_PER_PAGE,
|
|
1477
|
+
defaultTaskSort: { ...DEFAULT_TASK_SORT },
|
|
1478
|
+
codeTheme: DEFAULT_CODE_THEME,
|
|
1479
|
+
clients: [],
|
|
1480
|
+
// Copied, not shared: the caller may edit the result, and the status editor
|
|
1481
|
+
// splices this very array when config.json is missing.
|
|
1482
|
+
statuses: DEFAULT_STATUSES.map((s) => ({ ...s })),
|
|
1483
|
+
autoSync: parseAutoSync(void 0),
|
|
1484
|
+
features: { ...DEFAULT_FEATURES },
|
|
1485
|
+
aiAgents: []
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
var Workspace = class {
|
|
1489
|
+
get configDir() {
|
|
1490
|
+
return ".worklog";
|
|
1491
|
+
}
|
|
1492
|
+
get configFile() {
|
|
1493
|
+
return ".worklog/config.json";
|
|
1494
|
+
}
|
|
1495
|
+
get clientsDir() {
|
|
1496
|
+
return "clients";
|
|
1497
|
+
}
|
|
1498
|
+
get archiveDir() {
|
|
1499
|
+
return "archive";
|
|
1500
|
+
}
|
|
1501
|
+
get worklogDir() {
|
|
1502
|
+
return "worklog";
|
|
1503
|
+
}
|
|
1504
|
+
get notesDir() {
|
|
1505
|
+
return "notes";
|
|
1506
|
+
}
|
|
1507
|
+
get listsDir() {
|
|
1508
|
+
return "lists";
|
|
1509
|
+
}
|
|
1510
|
+
get meetingsDir() {
|
|
1511
|
+
return "meetings";
|
|
1512
|
+
}
|
|
1513
|
+
get assetsDir() {
|
|
1514
|
+
return "assets";
|
|
1515
|
+
}
|
|
1516
|
+
get appFile() {
|
|
1517
|
+
return "worklog.worklog";
|
|
1518
|
+
}
|
|
1519
|
+
clientFile(clientId) {
|
|
1520
|
+
return `clients/${clientId}.md`;
|
|
1521
|
+
}
|
|
1522
|
+
archiveFile(clientId, month) {
|
|
1523
|
+
return `archive/${clientId}/${month}.md`;
|
|
1524
|
+
}
|
|
1525
|
+
worklogFile(month) {
|
|
1526
|
+
return `worklog/${month}.md`;
|
|
1527
|
+
}
|
|
1528
|
+
notesFile(month) {
|
|
1529
|
+
return `notes/${month}.md`;
|
|
1530
|
+
}
|
|
1531
|
+
listFile(listId) {
|
|
1532
|
+
return `lists/${listId}.md`;
|
|
1533
|
+
}
|
|
1534
|
+
meetingsFile(month) {
|
|
1535
|
+
return `meetings/${month}.md`;
|
|
1536
|
+
}
|
|
1537
|
+
async loadConfig() {
|
|
1538
|
+
const raw = await readText(this.configFile);
|
|
1539
|
+
if (raw === void 0) {
|
|
1540
|
+
return defaultConfig();
|
|
1541
|
+
}
|
|
1542
|
+
try {
|
|
1543
|
+
const parsed = JSON.parse(raw);
|
|
1544
|
+
return {
|
|
1545
|
+
hoursPerDay: parsed.hoursPerDay && parsed.hoursPerDay > 0 ? parsed.hoursPerDay : DEFAULT_HOURS_PER_DAY,
|
|
1546
|
+
weekStart: parseWeekStart(parsed.weekStart),
|
|
1547
|
+
todosPerPage: parseTodosPerPage(parsed.todosPerPage),
|
|
1548
|
+
// Absent in every repo written before this setting existed, so the
|
|
1549
|
+
// fallback is the order those repos have always been shown in.
|
|
1550
|
+
defaultTaskSort: normalizeTaskSort(parsed.defaultTaskSort),
|
|
1551
|
+
// Absent in every repo written before this setting existed, and absent
|
|
1552
|
+
// reads as "follow the OS" — see normalizeCodeTheme.
|
|
1553
|
+
codeTheme: normalizeCodeTheme(parsed.codeTheme),
|
|
1554
|
+
// `archived`, `description` and `links` are normalized to value-or-absent
|
|
1555
|
+
// so they never round-trip a stray value into config.json (JSON.stringify
|
|
1556
|
+
// drops the undefined).
|
|
1557
|
+
clients: Array.isArray(parsed.clients) ? parsed.clients.filter((c) => !!c && !!c.id).map((c) => ({
|
|
1558
|
+
...c,
|
|
1559
|
+
description: typeof c.description === "string" && c.description.trim() ? c.description : void 0,
|
|
1560
|
+
links: parseClientLinks(c.links),
|
|
1561
|
+
archived: c.archived === true ? true : void 0
|
|
1562
|
+
})) : [],
|
|
1563
|
+
// Normalized rather than trusted: a hand-edited list with no closing
|
|
1564
|
+
// status, two of them, or duplicate ids has to read as *something*, and
|
|
1565
|
+
// every caller assumes exactly one terminal status sitting last.
|
|
1566
|
+
statuses: normalizeStatuses(parsed.statuses),
|
|
1567
|
+
autoSync: parseAutoSync(parsed.autoSync),
|
|
1568
|
+
// Absent means both on, which is what every repo written before these
|
|
1569
|
+
// switches existed expects.
|
|
1570
|
+
features: parseFeatures(parsed.features),
|
|
1571
|
+
// Absent in every repo written before this setting existed, and absent is
|
|
1572
|
+
// the same answer as "none switched on" — no agent is offered by default.
|
|
1573
|
+
aiAgents: parseAiAgents(parsed.aiAgents)
|
|
1574
|
+
};
|
|
1575
|
+
} catch {
|
|
1576
|
+
return defaultConfig();
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
async saveConfig(config) {
|
|
1580
|
+
await writeText(this.configFile, JSON.stringify(config, null, 2) + "\n");
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
function stem(path) {
|
|
1584
|
+
const base = path.split("/").pop() ?? path;
|
|
1585
|
+
return base.replace(/\.[^.]+$/, "");
|
|
1586
|
+
}
|
|
1587
|
+
function dirName(path) {
|
|
1588
|
+
const parts = path.split("/");
|
|
1589
|
+
parts.pop();
|
|
1590
|
+
return parts.pop() ?? "";
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// src/model/worklog.ts
|
|
1594
|
+
var EVENT_WORKLOG_PREFIX = "event:";
|
|
1595
|
+
function isEventWorklogClientId(clientId) {
|
|
1596
|
+
return clientId.startsWith(EVENT_WORKLOG_PREFIX);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// src/model/recurringTask.ts
|
|
1600
|
+
function withSeededDue(task, from = today()) {
|
|
1601
|
+
if (!task.repeat || task.due || task.completed) {
|
|
1602
|
+
return task;
|
|
1603
|
+
}
|
|
1604
|
+
const start = firstOccurrence(task.repeat, from);
|
|
1605
|
+
return start ? { ...task, due: start } : task;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// src/workspace/indexer.ts
|
|
1609
|
+
async function rebuild(db, ws) {
|
|
1610
|
+
const config = await ws.loadConfig();
|
|
1611
|
+
const fm = fileMap();
|
|
1612
|
+
const tasks = [];
|
|
1613
|
+
const worklog = [];
|
|
1614
|
+
const dayNotes = [];
|
|
1615
|
+
const checklists = [];
|
|
1616
|
+
const meetings = [];
|
|
1617
|
+
for (const [path, text] of fm.text) {
|
|
1618
|
+
if (/^clients\/[^/]+\.md$/.test(path)) {
|
|
1619
|
+
tasks.push(...parseTaskFile(text, path, stem(path)).tasks);
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
if (/^archive\/[^/]+\/[^/]+\.md$/.test(path)) {
|
|
1623
|
+
tasks.push(...parseTaskFile(text, path, dirName(path)).tasks);
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
if (/^worklog\/[^/]+\.md$/.test(path)) {
|
|
1627
|
+
worklog.push(...parseWorklogFile(text, path));
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
if (/^notes\/[^/]+\.md$/.test(path)) {
|
|
1631
|
+
dayNotes.push(...parseDayNotesFile(text, path));
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
if (/^lists\/[^/]+\.md$/.test(path)) {
|
|
1635
|
+
checklists.push(parseChecklistFile(text, path, stem(path)));
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
if (/^meetings\/[^/]+\.md$/.test(path)) {
|
|
1639
|
+
meetings.push(...parseMeetingFile(text, path));
|
|
1640
|
+
continue;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
const seeded = tasks.map((t) => withSeededDue(t));
|
|
1644
|
+
const clients = mergeClients(config.clients, seeded, worklog);
|
|
1645
|
+
db.reset();
|
|
1646
|
+
db.load({ clients, tasks: seeded, worklog, dayNotes, checklists, meetings });
|
|
1647
|
+
return {
|
|
1648
|
+
clients: clients.length,
|
|
1649
|
+
tasks: seeded.length,
|
|
1650
|
+
worklog: worklog.length,
|
|
1651
|
+
dayNotes: dayNotes.length,
|
|
1652
|
+
checklists: checklists.length,
|
|
1653
|
+
meetings: meetings.length
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
function mergeClients(configured, tasks, worklog) {
|
|
1657
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1658
|
+
for (const c of configured) {
|
|
1659
|
+
byId.set(c.id, c);
|
|
1660
|
+
}
|
|
1661
|
+
const ensure = (id) => {
|
|
1662
|
+
if (id && !byId.has(id)) {
|
|
1663
|
+
byId.set(id, { id, name: id });
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
for (const t of tasks) {
|
|
1667
|
+
t.clientIds.forEach(ensure);
|
|
1668
|
+
}
|
|
1669
|
+
for (const w of worklog) {
|
|
1670
|
+
if (!isEventWorklogClientId(w.clientId)) {
|
|
1671
|
+
ensure(w.clientId);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
return [...byId.values()];
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// src/store.ts
|
|
1678
|
+
var Store = class {
|
|
1679
|
+
db = new MemoryDb();
|
|
1680
|
+
ws = new Workspace();
|
|
1681
|
+
config;
|
|
1682
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1683
|
+
/** Rebuild from the mounted file map (called once after load, then after each edit). */
|
|
1684
|
+
async rebuild(reason) {
|
|
1685
|
+
this.config = await this.ws.loadConfig();
|
|
1686
|
+
await rebuild(this.db, this.ws);
|
|
1687
|
+
this.emitChange(reason);
|
|
1688
|
+
}
|
|
1689
|
+
getConfig() {
|
|
1690
|
+
return this.config;
|
|
1691
|
+
}
|
|
1692
|
+
onDidChange(listener) {
|
|
1693
|
+
this.listeners.add(listener);
|
|
1694
|
+
return () => this.listeners.delete(listener);
|
|
1695
|
+
}
|
|
1696
|
+
emitChange(reason) {
|
|
1697
|
+
for (const l of this.listeners) {
|
|
1698
|
+
l(reason);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
// src/server/github.ts
|
|
1704
|
+
function isWorklogPath(path) {
|
|
1705
|
+
return path === ".worklog/config.json" || path === "worklog.worklog" || /^clients\/[^/]+\.md$/.test(path) || /^archive\/[^/]+\/[^/]+\.md$/.test(path) || /^worklog\/[^/]+\.md$/.test(path) || /^notes\/[^/]+\.md$/.test(path) || /^lists\/[^/]+\.md$/.test(path) || /^meetings\/[^/]+\.md$/.test(path) || /^assets\/[^/]+$/.test(path);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// src/model/priority.ts
|
|
1709
|
+
var NORMAL_PRIORITY_ID = "normal";
|
|
1710
|
+
var NORMAL = { id: NORMAL_PRIORITY_ID, label: "Normal", rank: 2 };
|
|
1711
|
+
var PRIORITIES = [
|
|
1712
|
+
{ id: "urgent", label: "Urgent", rank: 0 },
|
|
1713
|
+
{ id: "high", label: "High", rank: 1 },
|
|
1714
|
+
NORMAL,
|
|
1715
|
+
{ id: "low", label: "Low", rank: 3 }
|
|
1716
|
+
];
|
|
1717
|
+
var BY_ID = new Map(PRIORITIES.map((p) => [p.id, p]));
|
|
1718
|
+
function priorityDef(raw) {
|
|
1719
|
+
return raw ? BY_ID.get(raw.trim().toLowerCase()) : void 0;
|
|
1720
|
+
}
|
|
1721
|
+
function writablePriority(raw) {
|
|
1722
|
+
const def = priorityDef(raw);
|
|
1723
|
+
return def && def.id !== NORMAL_PRIORITY_ID ? def.id : void 0;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// src/model/todos.ts
|
|
1727
|
+
var GENERAL_TODO_CLIENT_ID = "todos";
|
|
1728
|
+
var GENERAL_TODO_LABEL = "To-dos";
|
|
1729
|
+
var GENERAL_TODO_COLOR = "#64748b";
|
|
1730
|
+
function isGeneralTodoClientId(id) {
|
|
1731
|
+
return id === GENERAL_TODO_CLIENT_ID;
|
|
1732
|
+
}
|
|
1733
|
+
function generalTodoClient() {
|
|
1734
|
+
return { id: GENERAL_TODO_CLIENT_ID, name: GENERAL_TODO_LABEL, color: GENERAL_TODO_COLOR };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// src/parser/ids.ts
|
|
1738
|
+
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
1739
|
+
function newTaskId(size = 6) {
|
|
1740
|
+
return "t_" + randomToken(size);
|
|
1741
|
+
}
|
|
1742
|
+
function randomToken(size) {
|
|
1743
|
+
const bytes = new Uint8Array(size);
|
|
1744
|
+
crypto.getRandomValues(bytes);
|
|
1745
|
+
let out = "";
|
|
1746
|
+
for (let i = 0; i < size; i++) {
|
|
1747
|
+
out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
1748
|
+
}
|
|
1749
|
+
return out;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// src/services/assets.ts
|
|
1753
|
+
var MAX_BYTES = 10 * 1024 * 1024;
|
|
1754
|
+
|
|
1755
|
+
// src/commands/shared.ts
|
|
1756
|
+
async function appendTaskBlock(ws, client, block) {
|
|
1757
|
+
await ensureDir(ws.clientsDir);
|
|
1758
|
+
const path = ws.clientFile(client.id);
|
|
1759
|
+
const existing = await readText(path);
|
|
1760
|
+
let content;
|
|
1761
|
+
if (existing === void 0 || existing.trim() === "") {
|
|
1762
|
+
content = `# ${client.name}
|
|
1763
|
+
|
|
1764
|
+
${block}
|
|
1765
|
+
`;
|
|
1766
|
+
} else {
|
|
1767
|
+
content = existing.replace(/\s+$/, "") + "\n\n" + block + "\n";
|
|
1768
|
+
}
|
|
1769
|
+
await writeText(path, content);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/services/taskOps.ts
|
|
1773
|
+
function isClosed(task) {
|
|
1774
|
+
return !!task.completed;
|
|
1775
|
+
}
|
|
1776
|
+
async function toggleTaskWorkedOn(store, taskId, date) {
|
|
1777
|
+
const task = store.db.getTask(taskId);
|
|
1778
|
+
if (!task) {
|
|
1779
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
1780
|
+
}
|
|
1781
|
+
if (isGeneralTodoClientId(task.clientIds[0])) {
|
|
1782
|
+
throw new Error("To-dos don't track worked-on days \u2014 they're open or closed.");
|
|
1783
|
+
}
|
|
1784
|
+
const existing = new Set(task.workedOn ?? []);
|
|
1785
|
+
const marking = !existing.has(date);
|
|
1786
|
+
if (marking) {
|
|
1787
|
+
existing.add(date);
|
|
1788
|
+
} else {
|
|
1789
|
+
existing.delete(date);
|
|
1790
|
+
}
|
|
1791
|
+
const workedOn = [...existing].sort();
|
|
1792
|
+
const wip = marking && !isClosed(task) ? inProgressStatusId(store.getConfig().statuses) : void 0;
|
|
1793
|
+
await updateInPlace(store, task, (t) => ({
|
|
1794
|
+
...t,
|
|
1795
|
+
workedOn,
|
|
1796
|
+
status: wip ?? t.status
|
|
1797
|
+
}));
|
|
1798
|
+
await store.rebuild("toggleWorked");
|
|
1799
|
+
return { ...task, workedOn, status: wip ?? task.status };
|
|
1800
|
+
}
|
|
1801
|
+
async function addTaskNote(store, taskId, text) {
|
|
1802
|
+
const task = store.db.getTask(taskId);
|
|
1803
|
+
if (!task) {
|
|
1804
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
1805
|
+
}
|
|
1806
|
+
const trimmed = text.trim();
|
|
1807
|
+
if (!trimmed) {
|
|
1808
|
+
throw new Error("Cannot add an empty note.");
|
|
1809
|
+
}
|
|
1810
|
+
const note = { timestamp: nowStamp(), text: trimmed };
|
|
1811
|
+
await updateInPlace(store, task, (t) => ({
|
|
1812
|
+
...t,
|
|
1813
|
+
notes: [...t.notes ?? [], note]
|
|
1814
|
+
}));
|
|
1815
|
+
await store.rebuild("addNote");
|
|
1816
|
+
return store.db.getTask(taskId) ?? task;
|
|
1817
|
+
}
|
|
1818
|
+
async function updateInPlace(store, task, mutate) {
|
|
1819
|
+
const uri = task.sourceFile;
|
|
1820
|
+
const content = await readText(uri);
|
|
1821
|
+
if (content === void 0) {
|
|
1822
|
+
throw new Error(`Could not read ${task.sourceFile}.`);
|
|
1823
|
+
}
|
|
1824
|
+
const parsed = parseTaskFile(
|
|
1825
|
+
extractBlockText(content, task.id),
|
|
1826
|
+
uri,
|
|
1827
|
+
task.clientIds[0]
|
|
1828
|
+
).tasks[0] ?? task;
|
|
1829
|
+
const next = mutate({ ...parsed, clientIds: task.clientIds });
|
|
1830
|
+
const replaced = replaceBlock(
|
|
1831
|
+
content,
|
|
1832
|
+
task.id,
|
|
1833
|
+
serializeTask(next, task.clientIds[0])
|
|
1834
|
+
);
|
|
1835
|
+
if (replaced === void 0) {
|
|
1836
|
+
throw new Error(`Could not locate task ${task.id} to update.`);
|
|
1837
|
+
}
|
|
1838
|
+
await writeText(uri, replaced.replace(/\s*$/, "") + "\n");
|
|
1839
|
+
}
|
|
1840
|
+
function extractBlockText(content, taskId) {
|
|
1841
|
+
return extractBlock(content, taskId)?.block ?? "";
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// src/services/tasks.ts
|
|
1845
|
+
async function createTask(store, input) {
|
|
1846
|
+
const title = input.title.trim();
|
|
1847
|
+
if (!title) {
|
|
1848
|
+
throw new Error("A task title is required.");
|
|
1849
|
+
}
|
|
1850
|
+
const client = store.db.getClients().find((c) => c.id === input.clientId) ?? (isGeneralTodoClientId(input.clientId) ? generalTodoClient() : void 0);
|
|
1851
|
+
if (!client) {
|
|
1852
|
+
throw new Error(`Unknown client "${input.clientId}".`);
|
|
1853
|
+
}
|
|
1854
|
+
const task = {
|
|
1855
|
+
id: newTaskId(),
|
|
1856
|
+
title,
|
|
1857
|
+
description: input.description?.trim() || void 0,
|
|
1858
|
+
// The configured first working status, not the literal 'open': the list is
|
|
1859
|
+
// the user's, and a renamed or removed first status would otherwise mint
|
|
1860
|
+
// tasks in a status nothing knows about.
|
|
1861
|
+
status: openStatusId(store.getConfig().statuses),
|
|
1862
|
+
priority: writablePriority(input.priority),
|
|
1863
|
+
parentId: input.parentId || void 0,
|
|
1864
|
+
clientIds: [client.id],
|
|
1865
|
+
links: parseLinks(input.links),
|
|
1866
|
+
created: today(),
|
|
1867
|
+
due: input.due?.trim() || void 0,
|
|
1868
|
+
repeat: input.repeat,
|
|
1869
|
+
tags: (input.tags ?? []).map((t) => t.trim()).filter(Boolean),
|
|
1870
|
+
sourceFile: "",
|
|
1871
|
+
sourceLine: 0
|
|
1872
|
+
};
|
|
1873
|
+
const seeded = withSeededDue(task);
|
|
1874
|
+
await appendTaskBlock(store.ws, client, serializeTask(seeded, client.id));
|
|
1875
|
+
await store.rebuild("addTask");
|
|
1876
|
+
return seeded;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// src/services/logWork.ts
|
|
1880
|
+
async function logWork(store, input) {
|
|
1881
|
+
const date = input.date ?? today();
|
|
1882
|
+
let task = findTask(store, input);
|
|
1883
|
+
const created = !task;
|
|
1884
|
+
if (!task) {
|
|
1885
|
+
task = await createTask(store, {
|
|
1886
|
+
clientId: input.clientId,
|
|
1887
|
+
title: input.title,
|
|
1888
|
+
links: input.link ? [input.link] : void 0
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
const markedWorked = !(task.workedOn ?? []).includes(date);
|
|
1892
|
+
if (markedWorked) {
|
|
1893
|
+
task = await toggleTaskWorkedOn(store, task.id, date);
|
|
1894
|
+
}
|
|
1895
|
+
const note = input.note?.trim();
|
|
1896
|
+
const noteAdded = !!note && !(task.notes ?? []).some((n) => n.timestamp.startsWith(today()) && n.text === note);
|
|
1897
|
+
if (noteAdded) {
|
|
1898
|
+
task = await addTaskNote(store, task.id, note);
|
|
1899
|
+
}
|
|
1900
|
+
return { task: store.db.getTask(task.id) ?? task, created, markedWorked, noteAdded };
|
|
1901
|
+
}
|
|
1902
|
+
function findTask(store, input) {
|
|
1903
|
+
if (input.taskId) {
|
|
1904
|
+
const byId = store.db.getTask(input.taskId);
|
|
1905
|
+
if (!byId) {
|
|
1906
|
+
throw new Error(`Task ${input.taskId} not found.`);
|
|
1907
|
+
}
|
|
1908
|
+
return byId;
|
|
1909
|
+
}
|
|
1910
|
+
const all = store.db.getAllTasks().filter((t) => t.clientIds.includes(input.clientId));
|
|
1911
|
+
const link = input.link ? normalizeUrl(input.link) : void 0;
|
|
1912
|
+
const byLink = link ? all.find((t) => t.links.some((l) => normalizeUrl(l.url) === link)) : void 0;
|
|
1913
|
+
const title = input.title.trim().toLowerCase();
|
|
1914
|
+
return byLink ?? all.find((t) => !t.completed && t.title.trim().toLowerCase() === title);
|
|
1915
|
+
}
|
|
1916
|
+
function normalizeUrl(url) {
|
|
1917
|
+
return url.trim().replace(/\/+$/, "");
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// cli/worklog.ts
|
|
1921
|
+
var TEXT_PATH = /\.(md|json)$|\.worklog$/;
|
|
1922
|
+
var PUSH_ATTEMPTS = 3;
|
|
1923
|
+
var USAGE = `Usage: worklog log --client <id> --title <title> [options]
|
|
1924
|
+
|
|
1925
|
+
Marks the day worked on a task in your timesheet repo (creating the task when
|
|
1926
|
+
it's new), adds the note, then commits and pushes.
|
|
1927
|
+
|
|
1928
|
+
--client <id> client id from .worklog/config.json (required)
|
|
1929
|
+
--title <title> task title; used to create the task, or to find an open one (required)
|
|
1930
|
+
--link <url> issue/ticket url \u2014 finds the task again on later runs
|
|
1931
|
+
--id <t_xxxxxx> log on this exact task
|
|
1932
|
+
--note <text> progress note added to the task
|
|
1933
|
+
--date <date> day worked, YYYY-MM-DD (default: today)
|
|
1934
|
+
--repo <path> timesheet clone (default: $WORKLOG_REPO)
|
|
1935
|
+
--dry-run show the change, commit nothing`;
|
|
1936
|
+
async function main() {
|
|
1937
|
+
const { values, positionals } = parseArgs({
|
|
1938
|
+
allowPositionals: true,
|
|
1939
|
+
options: {
|
|
1940
|
+
client: { type: "string" },
|
|
1941
|
+
title: { type: "string" },
|
|
1942
|
+
link: { type: "string" },
|
|
1943
|
+
id: { type: "string" },
|
|
1944
|
+
note: { type: "string" },
|
|
1945
|
+
date: { type: "string" },
|
|
1946
|
+
repo: { type: "string" },
|
|
1947
|
+
"dry-run": { type: "boolean" },
|
|
1948
|
+
help: { type: "boolean", short: "h" }
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
if (values.help || positionals.length === 0) {
|
|
1952
|
+
console.log(USAGE);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (positionals[0] !== "log" || positionals.length > 1) {
|
|
1956
|
+
throw new Error(`Unknown command "${positionals.join(" ")}".
|
|
1957
|
+
|
|
1958
|
+
${USAGE}`);
|
|
1959
|
+
}
|
|
1960
|
+
if (!values.client || !values.title) {
|
|
1961
|
+
throw new Error(`--client and --title are required.
|
|
1962
|
+
|
|
1963
|
+
${USAGE}`);
|
|
1964
|
+
}
|
|
1965
|
+
if (values.date && !isValidISODate(values.date)) {
|
|
1966
|
+
throw new Error(`--date must be YYYY-MM-DD, got "${values.date}".`);
|
|
1967
|
+
}
|
|
1968
|
+
const input = {
|
|
1969
|
+
clientId: values.client,
|
|
1970
|
+
title: values.title,
|
|
1971
|
+
taskId: values.id,
|
|
1972
|
+
link: values.link,
|
|
1973
|
+
note: values.note,
|
|
1974
|
+
date: values.date
|
|
1975
|
+
};
|
|
1976
|
+
const repo = values.repo ?? process.env.WORKLOG_REPO;
|
|
1977
|
+
if (!repo) {
|
|
1978
|
+
throw new Error("Point --repo or $WORKLOG_REPO at a clone of your timesheet repo.");
|
|
1979
|
+
}
|
|
1980
|
+
return run(repo, input, !!values["dry-run"]);
|
|
1981
|
+
}
|
|
1982
|
+
async function run(repo, input, dryRun) {
|
|
1983
|
+
const git = (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
1984
|
+
if (git("status", "--porcelain", "--untracked-files=no")) {
|
|
1985
|
+
throw new Error(`${repo} has uncommitted changes; commit or discard them first.`);
|
|
1986
|
+
}
|
|
1987
|
+
git("fetch", "--quiet");
|
|
1988
|
+
if (git("rev-list", "--count", "@{u}..HEAD") !== "0") {
|
|
1989
|
+
throw new Error(`${repo} has unpushed commits; push them first.`);
|
|
1990
|
+
}
|
|
1991
|
+
git("merge", "--ff-only", "--quiet", "@{u}");
|
|
1992
|
+
for (let attempt = 1; ; attempt++) {
|
|
1993
|
+
const { result, changed } = await applyToClone(repo, input);
|
|
1994
|
+
if (changed.length === 0) {
|
|
1995
|
+
console.log(describe(result, input, "nothing new to log"));
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
if (dryRun) {
|
|
1999
|
+
git("add", "--intent-to-add", "--", ...changed);
|
|
2000
|
+
console.log(git("--no-pager", "diff", "--", ...changed));
|
|
2001
|
+
git("reset", "--quiet", "--hard", "HEAD");
|
|
2002
|
+
console.log(describe(result, input, "dry run, nothing committed"));
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
git("add", "--", ...changed);
|
|
2006
|
+
git("commit", "--quiet", "-m", `chore: worklog sync ${today()}`);
|
|
2007
|
+
try {
|
|
2008
|
+
git("push", "--quiet");
|
|
2009
|
+
console.log(describe(result, input, `pushed ${git("rev-parse", "--short", "HEAD")}`));
|
|
2010
|
+
return;
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
git("reset", "--quiet", "--hard", "@{u}");
|
|
2013
|
+
const raced = /rejected|fetch first|non-fast-forward/.test(String(err.stderr));
|
|
2014
|
+
if (!raced) {
|
|
2015
|
+
throw new Error("Push failed; nothing was logged.", { cause: err });
|
|
2016
|
+
}
|
|
2017
|
+
if (attempt >= PUSH_ATTEMPTS) {
|
|
2018
|
+
throw new Error(`Push rejected ${PUSH_ATTEMPTS} times; nothing was logged.`, { cause: err });
|
|
2019
|
+
}
|
|
2020
|
+
git("fetch", "--quiet");
|
|
2021
|
+
git("reset", "--quiet", "--hard", "@{u}");
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
async function applyToClone(repo, input) {
|
|
2026
|
+
const fm = new FileMap();
|
|
2027
|
+
for (const path of listFiles(repo)) {
|
|
2028
|
+
if (isWorklogPath(path) && TEXT_PATH.test(path)) {
|
|
2029
|
+
fm.text.set(path, readFileSync(join(repo, path), "utf8"));
|
|
2030
|
+
fm.remote.add(path);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
mountFileMap(fm);
|
|
2034
|
+
const store = new Store();
|
|
2035
|
+
await store.rebuild("load");
|
|
2036
|
+
const result = await logWork(store, input);
|
|
2037
|
+
const changed = [...fm.dirty].filter((path) => fm.text.has(path));
|
|
2038
|
+
for (const path of changed) {
|
|
2039
|
+
mkdirSync(dirname(join(repo, path)), { recursive: true });
|
|
2040
|
+
writeFileSync(join(repo, path), fm.text.get(path) ?? "");
|
|
2041
|
+
}
|
|
2042
|
+
return { result, changed };
|
|
2043
|
+
}
|
|
2044
|
+
function listFiles(repo) {
|
|
2045
|
+
return readdirSync(repo, { recursive: true, withFileTypes: true }).filter((e) => e.isFile()).map((e) => relative(repo, join(e.parentPath, e.name)).split(sep).join("/")).filter((path) => !path.startsWith(".git/"));
|
|
2046
|
+
}
|
|
2047
|
+
function describe({ task, created, markedWorked, noteAdded }, input, outcome) {
|
|
2048
|
+
const what = [
|
|
2049
|
+
created ? "created" : "found",
|
|
2050
|
+
markedWorked ? `marked worked ${input.date ?? today()}` : void 0,
|
|
2051
|
+
noteAdded ? "note added" : void 0
|
|
2052
|
+
].filter(Boolean);
|
|
2053
|
+
return `${task.id} "${task.title}" (${task.sourceFile}): ${what.join(", ")} \u2014 ${outcome}`;
|
|
2054
|
+
}
|
|
2055
|
+
main().catch((err) => {
|
|
2056
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2057
|
+
process.exit(1);
|
|
2058
|
+
});
|