@estruyf/worklog-cli 0.1.0 → 0.2.1
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 +102 -8
- package/dist/worklog.js +1294 -279
- package/package.json +2 -2
package/dist/worklog.js
CHANGED
|
@@ -101,6 +101,216 @@ function isTaskHeading(lines, index) {
|
|
|
101
101
|
return false;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// src/parser/blocks.ts
|
|
105
|
+
var ID_META2 = /^-\s+id\s*:\s*(.+?)\s*$/;
|
|
106
|
+
function findBlockRange(content, taskId) {
|
|
107
|
+
const lines = content.split(/\r?\n/);
|
|
108
|
+
let blockStart = -1;
|
|
109
|
+
let matched = false;
|
|
110
|
+
const ranges = [];
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
if (isTaskHeading(lines, i)) {
|
|
113
|
+
if (blockStart !== -1) {
|
|
114
|
+
ranges.push({ start: blockStart, end: i, matched });
|
|
115
|
+
}
|
|
116
|
+
blockStart = i;
|
|
117
|
+
matched = false;
|
|
118
|
+
} else if (blockStart !== -1) {
|
|
119
|
+
const m = ID_META2.exec(lines[i]);
|
|
120
|
+
if (m && m[1] === taskId) {
|
|
121
|
+
matched = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (blockStart !== -1) {
|
|
126
|
+
ranges.push({ start: blockStart, end: lines.length, matched });
|
|
127
|
+
}
|
|
128
|
+
const hit = ranges.find((r) => r.matched);
|
|
129
|
+
return hit ? { start: hit.start, end: hit.end } : void 0;
|
|
130
|
+
}
|
|
131
|
+
function replaceBlock(content, taskId, newBlock) {
|
|
132
|
+
const range = findBlockRange(content, taskId);
|
|
133
|
+
if (!range) {
|
|
134
|
+
return void 0;
|
|
135
|
+
}
|
|
136
|
+
const lines = content.split(/\r?\n/);
|
|
137
|
+
const original = lines.slice(range.start, range.end);
|
|
138
|
+
const hadTrailingBlank = original.length > 0 && original[original.length - 1].trim() === "";
|
|
139
|
+
const replacement = newBlock.split(/\r?\n/);
|
|
140
|
+
if (hadTrailingBlank) {
|
|
141
|
+
replacement.push("");
|
|
142
|
+
}
|
|
143
|
+
const next = [...lines.slice(0, range.start), ...replacement, ...lines.slice(range.end)];
|
|
144
|
+
return next.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
145
|
+
}
|
|
146
|
+
function extractBlock(content, taskId) {
|
|
147
|
+
const range = findBlockRange(content, taskId);
|
|
148
|
+
if (!range) {
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
151
|
+
const lines = content.split(/\r?\n/);
|
|
152
|
+
const blockLines = lines.slice(range.start, range.end);
|
|
153
|
+
while (blockLines.length && blockLines[blockLines.length - 1].trim() === "") {
|
|
154
|
+
blockLines.pop();
|
|
155
|
+
}
|
|
156
|
+
const before = lines.slice(0, range.start);
|
|
157
|
+
const after = lines.slice(range.end);
|
|
158
|
+
while (before.length && before[before.length - 1].trim() === "") {
|
|
159
|
+
before.pop();
|
|
160
|
+
}
|
|
161
|
+
const remainderLines = [...before];
|
|
162
|
+
if (before.length && after.some((l) => l.trim() !== "")) {
|
|
163
|
+
remainderLines.push("");
|
|
164
|
+
}
|
|
165
|
+
remainderLines.push(...after);
|
|
166
|
+
let remainder = remainderLines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "");
|
|
167
|
+
if (remainder) {
|
|
168
|
+
remainder += "\n";
|
|
169
|
+
}
|
|
170
|
+
return { block: blockLines.join("\n"), remainder, startLine: range.start };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/parser/meetingParser.ts
|
|
174
|
+
var H22 = /^##\s+(.*)$/;
|
|
175
|
+
var META2 = /^-\s+([A-Za-z][\w-]*)\s*:\s*(.*)$/;
|
|
176
|
+
var ACTIONS_HEADING = /^###\s+Action items\s*$/i;
|
|
177
|
+
var ACTION_ENTRY = /^-\s+\[([ xX])\]\s*(.*)$/;
|
|
178
|
+
var ACTION_TASK = /^(.*?)\s+→\s+(t_[A-Za-z0-9]+)$/;
|
|
179
|
+
var TIME = /^(\d{1,2}):(\d{2})$/;
|
|
180
|
+
var DURATION = /^(?:(\d+(?:\.\d+)?)\s*h)?\s*(?:(\d+)\s*m(?:in)?)?$/i;
|
|
181
|
+
function parseDuration(value) {
|
|
182
|
+
const text = value.trim();
|
|
183
|
+
if (/^\d+$/.test(text)) {
|
|
184
|
+
const n = Number(text);
|
|
185
|
+
return n > 0 ? n : void 0;
|
|
186
|
+
}
|
|
187
|
+
const m = DURATION.exec(text);
|
|
188
|
+
if (!m || !m[1] && !m[2]) {
|
|
189
|
+
return void 0;
|
|
190
|
+
}
|
|
191
|
+
const minutes = Math.round(Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0));
|
|
192
|
+
return minutes > 0 ? minutes : void 0;
|
|
193
|
+
}
|
|
194
|
+
function formatDuration(minutes) {
|
|
195
|
+
const h = Math.floor(minutes / 60);
|
|
196
|
+
const m = minutes % 60;
|
|
197
|
+
if (h === 0) {
|
|
198
|
+
return `${m}m`;
|
|
199
|
+
}
|
|
200
|
+
return m === 0 ? `${h}h` : `${h}h${m}m`;
|
|
201
|
+
}
|
|
202
|
+
function normalizeTime(value) {
|
|
203
|
+
const m = TIME.exec(value.trim());
|
|
204
|
+
if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) {
|
|
205
|
+
return void 0;
|
|
206
|
+
}
|
|
207
|
+
return `${m[1].padStart(2, "0")}:${m[2]}`;
|
|
208
|
+
}
|
|
209
|
+
function parseMeetingFile(content, sourceFile) {
|
|
210
|
+
const lines = content.split(/\r?\n/);
|
|
211
|
+
const starts = [];
|
|
212
|
+
for (let i = 0; i < lines.length; i++) {
|
|
213
|
+
if (isTaskHeading(lines, i)) {
|
|
214
|
+
starts.push(i);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const meetings = [];
|
|
218
|
+
for (let b = 0; b < starts.length; b++) {
|
|
219
|
+
const end = b + 1 < starts.length ? starts[b + 1] : lines.length;
|
|
220
|
+
const meeting = parseBlock(lines.slice(starts[b], end), sourceFile, starts[b]);
|
|
221
|
+
if (meeting.id) {
|
|
222
|
+
meetings.push(meeting);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return meetings;
|
|
226
|
+
}
|
|
227
|
+
function parseBlock(lines, sourceFile, line) {
|
|
228
|
+
const meeting = {
|
|
229
|
+
id: "",
|
|
230
|
+
title: (H22.exec(lines[0])?.[1] ?? "").trim(),
|
|
231
|
+
date: "",
|
|
232
|
+
people: [],
|
|
233
|
+
notes: "",
|
|
234
|
+
actions: [],
|
|
235
|
+
sourceFile,
|
|
236
|
+
sourceLine: line
|
|
237
|
+
};
|
|
238
|
+
let i = 1;
|
|
239
|
+
for (; i < lines.length; i++) {
|
|
240
|
+
const meta = META2.exec(lines[i]);
|
|
241
|
+
if (!meta) {
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
const value = meta[2].trim();
|
|
245
|
+
switch (meta[1].toLowerCase()) {
|
|
246
|
+
case "id":
|
|
247
|
+
meeting.id = value;
|
|
248
|
+
break;
|
|
249
|
+
case "date":
|
|
250
|
+
meeting.date = value;
|
|
251
|
+
break;
|
|
252
|
+
case "time":
|
|
253
|
+
meeting.time = normalizeTime(value) ?? meeting.time;
|
|
254
|
+
break;
|
|
255
|
+
case "duration":
|
|
256
|
+
meeting.duration = parseDuration(value) ?? meeting.duration;
|
|
257
|
+
break;
|
|
258
|
+
case "client":
|
|
259
|
+
meeting.clientId = value || void 0;
|
|
260
|
+
break;
|
|
261
|
+
case "people":
|
|
262
|
+
meeting.people = splitPeople(value);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const body = trimBlank(lines.slice(i));
|
|
267
|
+
const at = actionsStart(body);
|
|
268
|
+
if (at < 0) {
|
|
269
|
+
meeting.notes = body.join("\n");
|
|
270
|
+
} else {
|
|
271
|
+
meeting.notes = trimBlank(body.slice(0, at)).join("\n");
|
|
272
|
+
meeting.actions = body.slice(at + 1).filter((l) => l.trim() !== "").map(parseAction);
|
|
273
|
+
}
|
|
274
|
+
return meeting;
|
|
275
|
+
}
|
|
276
|
+
function actionsStart(body) {
|
|
277
|
+
for (let i = body.length - 1; i >= 0; i--) {
|
|
278
|
+
if (ACTIONS_HEADING.test(body[i])) {
|
|
279
|
+
return body.slice(i + 1).every((l) => l.trim() === "" || ACTION_ENTRY.test(l)) ? i : -1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return -1;
|
|
283
|
+
}
|
|
284
|
+
function parseAction(line) {
|
|
285
|
+
const m = ACTION_ENTRY.exec(line);
|
|
286
|
+
const done = m[1] !== " ";
|
|
287
|
+
const linked = ACTION_TASK.exec(m[2].trim());
|
|
288
|
+
return linked ? { text: linked[1].trim(), done, taskId: linked[2] } : { text: m[2].trim(), done };
|
|
289
|
+
}
|
|
290
|
+
function splitPeople(value) {
|
|
291
|
+
const seen = /* @__PURE__ */ new Set();
|
|
292
|
+
const out = [];
|
|
293
|
+
for (const raw of value.split(",")) {
|
|
294
|
+
const name = raw.trim();
|
|
295
|
+
if (name && !seen.has(name.toLowerCase())) {
|
|
296
|
+
seen.add(name.toLowerCase());
|
|
297
|
+
out.push(name);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
function trimBlank(lines) {
|
|
303
|
+
let start = 0;
|
|
304
|
+
let end = lines.length;
|
|
305
|
+
while (start < end && lines[start].trim() === "") {
|
|
306
|
+
start++;
|
|
307
|
+
}
|
|
308
|
+
while (end > start && lines[end - 1].trim() === "") {
|
|
309
|
+
end--;
|
|
310
|
+
}
|
|
311
|
+
return lines.slice(start, end);
|
|
312
|
+
}
|
|
313
|
+
|
|
104
314
|
// src/util/date.ts
|
|
105
315
|
function today() {
|
|
106
316
|
return toISODate(/* @__PURE__ */ new Date());
|
|
@@ -111,6 +321,9 @@ function toISODate(d) {
|
|
|
111
321
|
const day = String(d.getDate()).padStart(2, "0");
|
|
112
322
|
return `${y}-${m}-${day}`;
|
|
113
323
|
}
|
|
324
|
+
function monthOf(isoDate) {
|
|
325
|
+
return isoDate.slice(0, 7);
|
|
326
|
+
}
|
|
114
327
|
function nowStamp() {
|
|
115
328
|
const d = /* @__PURE__ */ new Date();
|
|
116
329
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
@@ -379,6 +592,10 @@ function firstOccurrence(rec, from) {
|
|
|
379
592
|
}
|
|
380
593
|
return start;
|
|
381
594
|
}
|
|
595
|
+
function nextDueAfterCompletion(rec, currentDue, completedDate) {
|
|
596
|
+
const base = rec.anchor === "completion" ? completedDate : currentDue ?? completedDate;
|
|
597
|
+
return nextOccurrence(rec, base);
|
|
598
|
+
}
|
|
382
599
|
function stepFromCompletion(rec, base) {
|
|
383
600
|
let next;
|
|
384
601
|
switch (rec.unit) {
|
|
@@ -474,8 +691,8 @@ function rollToWeekday(iso, weekdays) {
|
|
|
474
691
|
|
|
475
692
|
// src/parser/taskParser.ts
|
|
476
693
|
var H1 = /^#\s+(.*)$/;
|
|
477
|
-
var
|
|
478
|
-
var
|
|
694
|
+
var H23 = /^##\s+(.*)$/;
|
|
695
|
+
var META3 = /^-\s+([A-Za-z][\w-]*)\s*:\s*(.*)$/;
|
|
479
696
|
var NOTES_HEADING = /^###\s+Notes\s*$/;
|
|
480
697
|
var NOTE_ENTRY = /^-\s+(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\s+—\s+(.*)$/;
|
|
481
698
|
var PROMPTS_HEADING = /^###\s+Prompts\s*$/;
|
|
@@ -511,6 +728,7 @@ function parseTaskFile(content, sourceFile, clientId) {
|
|
|
511
728
|
lastDone: t.lastDone,
|
|
512
729
|
repeatOf: t.repeatOf,
|
|
513
730
|
workedOn: t.workedOn,
|
|
731
|
+
workedTime: t.workedTime,
|
|
514
732
|
tags: t.tags,
|
|
515
733
|
checklist,
|
|
516
734
|
prompts,
|
|
@@ -522,7 +740,7 @@ function parseTaskFile(content, sourceFile, clientId) {
|
|
|
522
740
|
};
|
|
523
741
|
for (let i = 0; i < lines.length; i++) {
|
|
524
742
|
const line = lines[i];
|
|
525
|
-
const h2 = isTaskHeading(lines, i) ?
|
|
743
|
+
const h2 = isTaskHeading(lines, i) ? H23.exec(line) : null;
|
|
526
744
|
if (h2) {
|
|
527
745
|
flush();
|
|
528
746
|
current = {
|
|
@@ -541,7 +759,7 @@ function parseTaskFile(content, sourceFile, clientId) {
|
|
|
541
759
|
continue;
|
|
542
760
|
}
|
|
543
761
|
if (inMeta) {
|
|
544
|
-
const meta =
|
|
762
|
+
const meta = META3.exec(line);
|
|
545
763
|
if (meta) {
|
|
546
764
|
applyMeta(current.task, meta[1].toLowerCase(), meta[2].trim());
|
|
547
765
|
continue;
|
|
@@ -648,7 +866,16 @@ function applyMeta(task, key, value) {
|
|
|
648
866
|
if (!value) {
|
|
649
867
|
break;
|
|
650
868
|
}
|
|
651
|
-
const dates =
|
|
869
|
+
const dates = [];
|
|
870
|
+
for (const token of value.split(/[\s,]+/).filter(Boolean)) {
|
|
871
|
+
const minutes = /[hm]/i.test(token) ? parseDuration(token) : void 0;
|
|
872
|
+
const date = dates[dates.length - 1];
|
|
873
|
+
if (minutes !== void 0 && date) {
|
|
874
|
+
task.workedTime = { ...task.workedTime, [date]: minutes };
|
|
875
|
+
} else {
|
|
876
|
+
dates.push(token);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
652
879
|
if (dates.length === 0) {
|
|
653
880
|
break;
|
|
654
881
|
}
|
|
@@ -844,7 +1071,8 @@ function serializeTask(task, clientId) {
|
|
|
844
1071
|
out.push(`- repeatOf: ${task.repeatOf}`);
|
|
845
1072
|
}
|
|
846
1073
|
for (const worked of task.workedOn ?? []) {
|
|
847
|
-
|
|
1074
|
+
const minutes = task.workedTime?.[worked];
|
|
1075
|
+
out.push(`- worked: ${worked}${minutes ? ` ${formatDuration(minutes)}` : ""}`);
|
|
848
1076
|
}
|
|
849
1077
|
if (task.tags && task.tags.length) {
|
|
850
1078
|
out.push(`- tags: ${task.tags.join(", ")}`);
|
|
@@ -896,6 +1124,13 @@ function parseWorklogFile(content, sourceFile) {
|
|
|
896
1124
|
}
|
|
897
1125
|
return entries;
|
|
898
1126
|
}
|
|
1127
|
+
function serializeWorklogEntry(entry) {
|
|
1128
|
+
const base = `- ${entry.date} ${entry.clientId} ${formatHours(entry.hours)}`;
|
|
1129
|
+
return entry.note ? `${base} \u2014 ${entry.note}` : base;
|
|
1130
|
+
}
|
|
1131
|
+
function formatHours(hours) {
|
|
1132
|
+
return Number.isInteger(hours) ? String(hours) : String(hours);
|
|
1133
|
+
}
|
|
899
1134
|
|
|
900
1135
|
// src/parser/dayNotes.ts
|
|
901
1136
|
var DAY_HEADING = /^##[ \t]+(\d{4}-\d{2}-\d{2})[ \t]*$/;
|
|
@@ -936,7 +1171,7 @@ function bodyOf(lines) {
|
|
|
936
1171
|
// src/parser/checklistParser.ts
|
|
937
1172
|
var ITEM = /^(\s*)([-*+])\s+\[([ xX])\]\s?(.*)$/;
|
|
938
1173
|
var H12 = /^#[ \t]+(.*)$/;
|
|
939
|
-
var
|
|
1174
|
+
var H24 = /^##[ \t]+(.*)$/;
|
|
940
1175
|
var LAST_RUN = /^-[ \t]+last run:[ \t]*(\d{4}-\d{2}-\d{2})[ \t]*$/;
|
|
941
1176
|
function parseChecklistFile(content, sourceFile, id) {
|
|
942
1177
|
const lines = content.split(/\r?\n/);
|
|
@@ -954,7 +1189,7 @@ function parseChecklistFile(content, sourceFile, id) {
|
|
|
954
1189
|
});
|
|
955
1190
|
continue;
|
|
956
1191
|
}
|
|
957
|
-
const h2 =
|
|
1192
|
+
const h2 = H24.exec(line);
|
|
958
1193
|
if (h2) {
|
|
959
1194
|
sections.push({ title: h2[1].trim(), line: i, items: [] });
|
|
960
1195
|
continue;
|
|
@@ -979,219 +1214,17 @@ function parseChecklistFile(content, sourceFile, id) {
|
|
|
979
1214
|
};
|
|
980
1215
|
}
|
|
981
1216
|
|
|
982
|
-
// src/
|
|
983
|
-
var
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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;
|
|
1217
|
+
// src/model/status.ts
|
|
1218
|
+
var DEFAULT_STATUSES = [
|
|
1219
|
+
{ id: "open", label: "Open" },
|
|
1220
|
+
{ id: "in-progress", label: "In progress" },
|
|
1221
|
+
{ id: "done", label: "Closed", terminal: true }
|
|
1222
|
+
];
|
|
1223
|
+
var MAX_STATUS_LABEL = 20;
|
|
1224
|
+
var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
1225
|
+
function parseStatusColor(color) {
|
|
1226
|
+
const value = typeof color === "string" ? color.trim() : "";
|
|
1227
|
+
return HEX_COLOR.test(value) ? value : void 0;
|
|
1195
1228
|
}
|
|
1196
1229
|
function normalizeStatuses(input) {
|
|
1197
1230
|
const defs = [];
|
|
@@ -1202,14 +1235,14 @@ function normalizeStatuses(input) {
|
|
|
1202
1235
|
}
|
|
1203
1236
|
const candidate = raw;
|
|
1204
1237
|
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
|
1205
|
-
const
|
|
1206
|
-
if (!id || !
|
|
1238
|
+
const label2 = typeof candidate.label === "string" ? candidate.label.trim() : "";
|
|
1239
|
+
if (!id || !label2 || seen.has(id)) {
|
|
1207
1240
|
continue;
|
|
1208
1241
|
}
|
|
1209
1242
|
seen.add(id);
|
|
1210
1243
|
defs.push({
|
|
1211
1244
|
id,
|
|
1212
|
-
label:
|
|
1245
|
+
label: label2.slice(0, MAX_STATUS_LABEL),
|
|
1213
1246
|
terminal: candidate.terminal === true || void 0,
|
|
1214
1247
|
color: parseStatusColor(candidate.color)
|
|
1215
1248
|
});
|
|
@@ -1230,6 +1263,12 @@ function normalizeStatuses(input) {
|
|
|
1230
1263
|
{ ...terminal, terminal: true }
|
|
1231
1264
|
];
|
|
1232
1265
|
}
|
|
1266
|
+
function terminalStatusId(statuses) {
|
|
1267
|
+
return (statuses.find((s) => s.terminal) ?? statuses[statuses.length - 1])?.id ?? "done";
|
|
1268
|
+
}
|
|
1269
|
+
function isTerminalStatus(statuses, id) {
|
|
1270
|
+
return statuses.find((s) => s.id === id)?.terminal === true;
|
|
1271
|
+
}
|
|
1233
1272
|
function openStatusId(statuses) {
|
|
1234
1273
|
return (statuses.find((s) => !s.terminal) ?? statuses[0])?.id ?? "open";
|
|
1235
1274
|
}
|
|
@@ -1400,8 +1439,8 @@ function parseLinks(value) {
|
|
|
1400
1439
|
return [];
|
|
1401
1440
|
}
|
|
1402
1441
|
return value.map((l) => typeof l === "string" ? { url: l } : l).filter((l) => !!l && typeof l.url === "string" && !!l.url.trim()).map((l) => {
|
|
1403
|
-
const
|
|
1404
|
-
return { url: l.url.trim(), ...
|
|
1442
|
+
const label2 = typeof l.label === "string" ? l.label.trim() : "";
|
|
1443
|
+
return { url: l.url.trim(), ...label2 ? { label: label2 } : {} };
|
|
1405
1444
|
});
|
|
1406
1445
|
}
|
|
1407
1446
|
function parseClientLinks(value) {
|
|
@@ -1590,11 +1629,72 @@ function dirName(path) {
|
|
|
1590
1629
|
return parts.pop() ?? "";
|
|
1591
1630
|
}
|
|
1592
1631
|
|
|
1632
|
+
// src/model/todos.ts
|
|
1633
|
+
var GENERAL_TODO_CLIENT_ID = "todos";
|
|
1634
|
+
var GENERAL_TODO_LABEL = "To-dos";
|
|
1635
|
+
var GENERAL_TODO_COLOR = "#64748b";
|
|
1636
|
+
function isGeneralTodoClientId(id) {
|
|
1637
|
+
return id === GENERAL_TODO_CLIENT_ID;
|
|
1638
|
+
}
|
|
1639
|
+
function generalTodoClient() {
|
|
1640
|
+
return { id: GENERAL_TODO_CLIENT_ID, name: GENERAL_TODO_LABEL, color: GENERAL_TODO_COLOR };
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1593
1643
|
// src/model/worklog.ts
|
|
1594
1644
|
var EVENT_WORKLOG_PREFIX = "event:";
|
|
1645
|
+
function eventWorklogClientId(eventType) {
|
|
1646
|
+
const normalized = eventType.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1647
|
+
if (!normalized) {
|
|
1648
|
+
throw new Error("Event type is required.");
|
|
1649
|
+
}
|
|
1650
|
+
return `${EVENT_WORKLOG_PREFIX}${normalized}`;
|
|
1651
|
+
}
|
|
1595
1652
|
function isEventWorklogClientId(clientId) {
|
|
1596
1653
|
return clientId.startsWith(EVENT_WORKLOG_PREFIX);
|
|
1597
1654
|
}
|
|
1655
|
+
function eventTypeFromClientId(clientId) {
|
|
1656
|
+
return isEventWorklogClientId(clientId) ? clientId.slice(EVENT_WORKLOG_PREFIX.length) : "";
|
|
1657
|
+
}
|
|
1658
|
+
function formatEventTypeLabel(eventType) {
|
|
1659
|
+
return eventType.split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
1660
|
+
}
|
|
1661
|
+
var EVENT_TYPES = [
|
|
1662
|
+
{ id: "vacation", label: "Vacation" },
|
|
1663
|
+
{ id: "public-holiday", label: "Public holiday" },
|
|
1664
|
+
{ id: "out-of-office", label: "Out of office" },
|
|
1665
|
+
{ id: "conference", label: "Conference" },
|
|
1666
|
+
{ id: "sick", label: "Sick day" },
|
|
1667
|
+
{ id: "other", label: "Other" }
|
|
1668
|
+
];
|
|
1669
|
+
function eventLabel(eventType) {
|
|
1670
|
+
return EVENT_TYPES.find((e) => e.id === eventType)?.label ?? formatEventTypeLabel(eventType);
|
|
1671
|
+
}
|
|
1672
|
+
function roundHours(n) {
|
|
1673
|
+
return Math.round(n * 100) / 100;
|
|
1674
|
+
}
|
|
1675
|
+
function resolveHours(amount, hoursPerDay) {
|
|
1676
|
+
if (amount === "full") {
|
|
1677
|
+
return hoursPerDay;
|
|
1678
|
+
}
|
|
1679
|
+
if (amount === "half") {
|
|
1680
|
+
return hoursPerDay / 2;
|
|
1681
|
+
}
|
|
1682
|
+
const hours = typeof amount === "number" ? amount : Number(amount.trim().replace(/h$/i, ""));
|
|
1683
|
+
return isFinite(hours) && hours >= 0 && String(amount).trim() !== "" ? roundHours(hours) : void 0;
|
|
1684
|
+
}
|
|
1685
|
+
function unloggableReason(clients, clientId) {
|
|
1686
|
+
if (isGeneralTodoClientId(clientId)) {
|
|
1687
|
+
return "The general to-do list belongs to no client, so it has no hours.";
|
|
1688
|
+
}
|
|
1689
|
+
const client = clients.find((c) => c.id === clientId);
|
|
1690
|
+
if (!client) {
|
|
1691
|
+
return `Unknown client "${clientId}".`;
|
|
1692
|
+
}
|
|
1693
|
+
if (client.archived) {
|
|
1694
|
+
return `"${client.name}" is archived; restore it in Settings to log time on it.`;
|
|
1695
|
+
}
|
|
1696
|
+
return void 0;
|
|
1697
|
+
}
|
|
1598
1698
|
|
|
1599
1699
|
// src/model/recurringTask.ts
|
|
1600
1700
|
function withSeededDue(task, from = today()) {
|
|
@@ -1718,22 +1818,14 @@ var BY_ID = new Map(PRIORITIES.map((p) => [p.id, p]));
|
|
|
1718
1818
|
function priorityDef(raw) {
|
|
1719
1819
|
return raw ? BY_ID.get(raw.trim().toLowerCase()) : void 0;
|
|
1720
1820
|
}
|
|
1821
|
+
function priorityRank(raw) {
|
|
1822
|
+
return (priorityDef(raw) ?? NORMAL).rank;
|
|
1823
|
+
}
|
|
1721
1824
|
function writablePriority(raw) {
|
|
1722
1825
|
const def = priorityDef(raw);
|
|
1723
1826
|
return def && def.id !== NORMAL_PRIORITY_ID ? def.id : void 0;
|
|
1724
1827
|
}
|
|
1725
1828
|
|
|
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
1829
|
// src/parser/ids.ts
|
|
1738
1830
|
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
1739
1831
|
function newTaskId(size = 6) {
|
|
@@ -1761,6 +1853,21 @@ async function appendTaskBlock(ws, client, block) {
|
|
|
1761
1853
|
if (existing === void 0 || existing.trim() === "") {
|
|
1762
1854
|
content = `# ${client.name}
|
|
1763
1855
|
|
|
1856
|
+
${block}
|
|
1857
|
+
`;
|
|
1858
|
+
} else {
|
|
1859
|
+
content = existing.replace(/\s+$/, "") + "\n\n" + block + "\n";
|
|
1860
|
+
}
|
|
1861
|
+
await writeText(path, content);
|
|
1862
|
+
}
|
|
1863
|
+
async function appendArchiveBlock(ws, clientId, clientName, month, block) {
|
|
1864
|
+
await ensureDir(`${ws.archiveDir}/${clientId}`);
|
|
1865
|
+
const path = ws.archiveFile(clientId, month);
|
|
1866
|
+
const existing = await readText(path);
|
|
1867
|
+
let content;
|
|
1868
|
+
if (existing === void 0 || existing.trim() === "") {
|
|
1869
|
+
content = `# ${clientName} \u2014 ${month}
|
|
1870
|
+
|
|
1764
1871
|
${block}
|
|
1765
1872
|
`;
|
|
1766
1873
|
} else {
|
|
@@ -1770,9 +1877,37 @@ ${block}
|
|
|
1770
1877
|
}
|
|
1771
1878
|
|
|
1772
1879
|
// src/services/taskOps.ts
|
|
1880
|
+
function collectDescendants(store, taskId) {
|
|
1881
|
+
const out = [];
|
|
1882
|
+
const visit = (id) => {
|
|
1883
|
+
for (const child of store.db.getChildren(id)) {
|
|
1884
|
+
out.push(child);
|
|
1885
|
+
visit(child.id);
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
visit(taskId);
|
|
1889
|
+
return out;
|
|
1890
|
+
}
|
|
1773
1891
|
function isClosed(task) {
|
|
1774
1892
|
return !!task.completed;
|
|
1775
1893
|
}
|
|
1894
|
+
async function setTaskStatus(store, taskId, statusId) {
|
|
1895
|
+
const task = store.db.getTask(taskId);
|
|
1896
|
+
if (!task) {
|
|
1897
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
1898
|
+
}
|
|
1899
|
+
const statuses = store.getConfig().statuses;
|
|
1900
|
+
const goingTerminal = isTerminalStatus(statuses, statusId);
|
|
1901
|
+
if (goingTerminal && !isClosed(task)) {
|
|
1902
|
+
return closeCascade(store, taskId, statusId, today());
|
|
1903
|
+
}
|
|
1904
|
+
if (!goingTerminal && isClosed(task)) {
|
|
1905
|
+
return reopenTask(store, taskId, statusId);
|
|
1906
|
+
}
|
|
1907
|
+
await updateInPlace(store, task, (t) => ({ ...t, status: statusId }));
|
|
1908
|
+
await store.rebuild("setStatus");
|
|
1909
|
+
return { ...task, status: statusId };
|
|
1910
|
+
}
|
|
1776
1911
|
async function toggleTaskWorkedOn(store, taskId, date) {
|
|
1777
1912
|
const task = store.db.getTask(taskId);
|
|
1778
1913
|
if (!task) {
|
|
@@ -1789,14 +1924,36 @@ async function toggleTaskWorkedOn(store, taskId, date) {
|
|
|
1789
1924
|
existing.delete(date);
|
|
1790
1925
|
}
|
|
1791
1926
|
const workedOn = [...existing].sort();
|
|
1927
|
+
const workedTime = marking ? task.workedTime : withoutDay(task.workedTime, date);
|
|
1792
1928
|
const wip = marking && !isClosed(task) ? inProgressStatusId(store.getConfig().statuses) : void 0;
|
|
1793
1929
|
await updateInPlace(store, task, (t) => ({
|
|
1794
1930
|
...t,
|
|
1795
1931
|
workedOn,
|
|
1932
|
+
workedTime,
|
|
1796
1933
|
status: wip ?? t.status
|
|
1797
1934
|
}));
|
|
1798
1935
|
await store.rebuild("toggleWorked");
|
|
1799
|
-
return { ...task, workedOn, status: wip ?? task.status };
|
|
1936
|
+
return { ...task, workedOn, workedTime, status: wip ?? task.status };
|
|
1937
|
+
}
|
|
1938
|
+
async function setTaskWorkedTime(store, taskId, date, minutes) {
|
|
1939
|
+
let task = store.db.getTask(taskId);
|
|
1940
|
+
if (!task) {
|
|
1941
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
1942
|
+
}
|
|
1943
|
+
if (minutes && !(task.workedOn ?? []).includes(date)) {
|
|
1944
|
+
task = await toggleTaskWorkedOn(store, taskId, date);
|
|
1945
|
+
}
|
|
1946
|
+
const workedTime = minutes ? { ...task.workedTime, [date]: minutes } : withoutDay(task.workedTime, date);
|
|
1947
|
+
await updateInPlace(store, task, (t) => ({ ...t, workedTime }));
|
|
1948
|
+
await store.rebuild("setWorkedTime");
|
|
1949
|
+
return { ...task, workedTime };
|
|
1950
|
+
}
|
|
1951
|
+
function withoutDay(time, date) {
|
|
1952
|
+
if (!time || !(date in time)) {
|
|
1953
|
+
return time;
|
|
1954
|
+
}
|
|
1955
|
+
const { [date]: _gone, ...rest } = time;
|
|
1956
|
+
return Object.keys(rest).length ? rest : void 0;
|
|
1800
1957
|
}
|
|
1801
1958
|
async function addTaskNote(store, taskId, text) {
|
|
1802
1959
|
const task = store.db.getTask(taskId);
|
|
@@ -1815,6 +1972,283 @@ async function addTaskNote(store, taskId, text) {
|
|
|
1815
1972
|
await store.rebuild("addNote");
|
|
1816
1973
|
return store.db.getTask(taskId) ?? task;
|
|
1817
1974
|
}
|
|
1975
|
+
async function deleteTaskNote(store, taskId, index) {
|
|
1976
|
+
const task = store.db.getTask(taskId);
|
|
1977
|
+
if (!task) {
|
|
1978
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
1979
|
+
}
|
|
1980
|
+
await updateInPlace(store, task, (t) => {
|
|
1981
|
+
const notes = [...t.notes ?? []];
|
|
1982
|
+
if (index >= 0 && index < notes.length) {
|
|
1983
|
+
notes.splice(index, 1);
|
|
1984
|
+
}
|
|
1985
|
+
return { ...t, notes };
|
|
1986
|
+
});
|
|
1987
|
+
await store.rebuild("deleteNote");
|
|
1988
|
+
return store.db.getTask(taskId) ?? task;
|
|
1989
|
+
}
|
|
1990
|
+
async function closeTaskById(store, taskId, completedDate = today()) {
|
|
1991
|
+
const terminal = terminalStatusId(store.getConfig().statuses);
|
|
1992
|
+
return closeCascade(store, taskId, terminal, completedDate);
|
|
1993
|
+
}
|
|
1994
|
+
async function closeCascade(store, taskId, statusId, completedDate, endSeries = false) {
|
|
1995
|
+
const root = store.db.getTask(taskId);
|
|
1996
|
+
if (!root) {
|
|
1997
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
1998
|
+
}
|
|
1999
|
+
const openDescendants = collectDescendants(store, taskId).filter(
|
|
2000
|
+
(t) => !isClosed(t)
|
|
2001
|
+
);
|
|
2002
|
+
for (const child of openDescendants) {
|
|
2003
|
+
await closeOne(store, child, statusId, completedDate, endSeries);
|
|
2004
|
+
}
|
|
2005
|
+
const closed = await closeOne(store, root, statusId, completedDate, endSeries);
|
|
2006
|
+
await store.rebuild("closeTask");
|
|
2007
|
+
return closed;
|
|
2008
|
+
}
|
|
2009
|
+
async function closeOne(store, task, statusId, completedDate, endSeries = false) {
|
|
2010
|
+
const clientId = task.clientIds[0];
|
|
2011
|
+
const clientName = store.db.getClients().find((c) => c.id === clientId)?.name ?? clientId;
|
|
2012
|
+
const clientUri = store.ws.clientFile(clientId);
|
|
2013
|
+
const content = await readText(clientUri);
|
|
2014
|
+
if (content === void 0) {
|
|
2015
|
+
throw new Error(`Could not read ${clientId}.md to close "${task.title}".`);
|
|
2016
|
+
}
|
|
2017
|
+
const extracted = extractBlock(content, task.id);
|
|
2018
|
+
if (!extracted) {
|
|
2019
|
+
throw new Error(`Could not locate task ${task.id} in ${clientId}.md.`);
|
|
2020
|
+
}
|
|
2021
|
+
const source = withSeededDue(
|
|
2022
|
+
parseTaskFile(extracted.block, clientUri, clientId).tasks[0] ?? task
|
|
2023
|
+
);
|
|
2024
|
+
if (source.repeat && !endSeries) {
|
|
2025
|
+
const nextDue = nextDueAfterCompletion(
|
|
2026
|
+
source.repeat,
|
|
2027
|
+
source.due,
|
|
2028
|
+
completedDate
|
|
2029
|
+
);
|
|
2030
|
+
if (nextDue) {
|
|
2031
|
+
return rollOccurrence(store, {
|
|
2032
|
+
source,
|
|
2033
|
+
clientId,
|
|
2034
|
+
clientName,
|
|
2035
|
+
clientUri,
|
|
2036
|
+
content,
|
|
2037
|
+
statusId,
|
|
2038
|
+
completedDate,
|
|
2039
|
+
nextDue
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
const closed = {
|
|
2044
|
+
...source,
|
|
2045
|
+
status: statusId,
|
|
2046
|
+
completed: completedDate
|
|
2047
|
+
};
|
|
2048
|
+
await writeText(clientUri, extracted.remainder);
|
|
2049
|
+
await appendArchiveBlock(
|
|
2050
|
+
store.ws,
|
|
2051
|
+
clientId,
|
|
2052
|
+
clientName,
|
|
2053
|
+
monthOf(closed.completed),
|
|
2054
|
+
serializeTask(closed, clientId)
|
|
2055
|
+
);
|
|
2056
|
+
return closed;
|
|
2057
|
+
}
|
|
2058
|
+
async function rollOccurrence(store, args) {
|
|
2059
|
+
const { source, clientId, clientName, clientUri, content } = args;
|
|
2060
|
+
const snapshot = {
|
|
2061
|
+
...source,
|
|
2062
|
+
id: newTaskId(),
|
|
2063
|
+
status: args.statusId,
|
|
2064
|
+
completed: args.completedDate,
|
|
2065
|
+
repeat: void 0,
|
|
2066
|
+
lastDone: void 0,
|
|
2067
|
+
repeatOf: source.id
|
|
2068
|
+
};
|
|
2069
|
+
const live = {
|
|
2070
|
+
...source,
|
|
2071
|
+
status: openStatusId(store.getConfig().statuses),
|
|
2072
|
+
due: args.nextDue,
|
|
2073
|
+
lastDone: args.completedDate,
|
|
2074
|
+
completed: void 0,
|
|
2075
|
+
notes: void 0,
|
|
2076
|
+
// The queue is the plan and comes along; the prompts that ran belong to the
|
|
2077
|
+
// occurrence that ran them and stay on its snapshot, or a weekly task would
|
|
2078
|
+
// carry every prompt it ever ran into every future week.
|
|
2079
|
+
prompts: queuedPrompts(source.prompts),
|
|
2080
|
+
// Same reasoning one step further: the steps are the plan for the series and
|
|
2081
|
+
// come along, but unticked — the ticks belong to the occurrence that earned
|
|
2082
|
+
// them and stay on its snapshot.
|
|
2083
|
+
checklist: untickedChecklist(source.checklist),
|
|
2084
|
+
workedOn: void 0,
|
|
2085
|
+
workedTime: void 0
|
|
2086
|
+
};
|
|
2087
|
+
const replaced = replaceBlock(
|
|
2088
|
+
content,
|
|
2089
|
+
source.id,
|
|
2090
|
+
serializeTask(live, clientId)
|
|
2091
|
+
);
|
|
2092
|
+
if (replaced === void 0) {
|
|
2093
|
+
throw new Error(`Could not locate task ${source.id} to roll forward.`);
|
|
2094
|
+
}
|
|
2095
|
+
await writeText(clientUri, replaced.replace(/\s*$/, "") + "\n");
|
|
2096
|
+
await appendArchiveBlock(
|
|
2097
|
+
store.ws,
|
|
2098
|
+
clientId,
|
|
2099
|
+
clientName,
|
|
2100
|
+
monthOf(args.completedDate),
|
|
2101
|
+
serializeTask(snapshot, clientId)
|
|
2102
|
+
);
|
|
2103
|
+
return live;
|
|
2104
|
+
}
|
|
2105
|
+
function queuedPrompts(prompts) {
|
|
2106
|
+
const queued = (prompts ?? []).filter((p) => !p.ran);
|
|
2107
|
+
return queued.length ? queued : void 0;
|
|
2108
|
+
}
|
|
2109
|
+
function untickedChecklist(checklist) {
|
|
2110
|
+
return checklist?.length ? checklist.map((i) => ({ ...i, done: false })) : void 0;
|
|
2111
|
+
}
|
|
2112
|
+
async function reopenTask(store, taskId, statusId) {
|
|
2113
|
+
const task = store.db.getTask(taskId);
|
|
2114
|
+
if (!task) {
|
|
2115
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
2116
|
+
}
|
|
2117
|
+
if (task.repeatOf) {
|
|
2118
|
+
const template = store.db.getTask(task.repeatOf);
|
|
2119
|
+
if (template?.repeat) {
|
|
2120
|
+
return undoOccurrence(store, task, template, statusId);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
const clientId = task.clientIds[0];
|
|
2124
|
+
const client = store.db.getClients().find((c) => c.id === clientId);
|
|
2125
|
+
const archiveUri = task.sourceFile;
|
|
2126
|
+
const content = await readText(archiveUri);
|
|
2127
|
+
if (content === void 0) {
|
|
2128
|
+
throw new Error(`Could not read the archive file for "${task.title}".`);
|
|
2129
|
+
}
|
|
2130
|
+
const extracted = extractBlock(content, task.id);
|
|
2131
|
+
if (!extracted) {
|
|
2132
|
+
throw new Error(`Could not locate task ${task.id} in the archive.`);
|
|
2133
|
+
}
|
|
2134
|
+
const parsed = parseTaskFile(extracted.block, archiveUri, clientId).tasks[0];
|
|
2135
|
+
const reopened = {
|
|
2136
|
+
...parsed ?? task,
|
|
2137
|
+
status: statusId,
|
|
2138
|
+
completed: void 0
|
|
2139
|
+
};
|
|
2140
|
+
await writeText(archiveUri, extracted.remainder);
|
|
2141
|
+
await appendTaskBlock(
|
|
2142
|
+
store.ws,
|
|
2143
|
+
client ?? { id: clientId, name: clientId },
|
|
2144
|
+
serializeTask(reopened, clientId)
|
|
2145
|
+
);
|
|
2146
|
+
await store.rebuild("reopenTask");
|
|
2147
|
+
return reopened;
|
|
2148
|
+
}
|
|
2149
|
+
async function undoOccurrence(store, snapshot, template, statusId) {
|
|
2150
|
+
const archiveUri = snapshot.sourceFile;
|
|
2151
|
+
const archiveContent = await readText(archiveUri);
|
|
2152
|
+
if (archiveContent === void 0) {
|
|
2153
|
+
throw new Error(`Could not read the archive file for "${snapshot.title}".`);
|
|
2154
|
+
}
|
|
2155
|
+
const extracted = extractBlock(archiveContent, snapshot.id);
|
|
2156
|
+
if (!extracted) {
|
|
2157
|
+
throw new Error(`Could not locate occurrence ${snapshot.id} in the archive.`);
|
|
2158
|
+
}
|
|
2159
|
+
const previous = store.db.occurrencesOf(template.id).find((o) => o.id !== snapshot.id && o.completed);
|
|
2160
|
+
await writeText(archiveUri, extracted.remainder);
|
|
2161
|
+
await updateInPlace(store, template, (t) => ({
|
|
2162
|
+
...t,
|
|
2163
|
+
status: statusId,
|
|
2164
|
+
due: snapshot.due,
|
|
2165
|
+
lastDone: previous?.completed,
|
|
2166
|
+
notes: snapshot.notes,
|
|
2167
|
+
prompts: snapshot.prompts,
|
|
2168
|
+
checklist: snapshot.checklist,
|
|
2169
|
+
workedOn: snapshot.workedOn,
|
|
2170
|
+
workedTime: snapshot.workedTime
|
|
2171
|
+
}));
|
|
2172
|
+
await store.rebuild("undoOccurrence");
|
|
2173
|
+
return store.db.getTask(template.id) ?? template;
|
|
2174
|
+
}
|
|
2175
|
+
async function setTaskRecurrence(store, taskId, repeat) {
|
|
2176
|
+
const task = store.db.getTask(taskId);
|
|
2177
|
+
if (!task) {
|
|
2178
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
2179
|
+
}
|
|
2180
|
+
if (task.completed) {
|
|
2181
|
+
throw new Error("Only open tasks can repeat.");
|
|
2182
|
+
}
|
|
2183
|
+
await updateInPlace(
|
|
2184
|
+
store,
|
|
2185
|
+
task,
|
|
2186
|
+
(t) => withSeededDue({
|
|
2187
|
+
...t,
|
|
2188
|
+
repeat,
|
|
2189
|
+
lastDone: repeat ? t.lastDone : void 0
|
|
2190
|
+
})
|
|
2191
|
+
);
|
|
2192
|
+
await store.rebuild("setRecurrence");
|
|
2193
|
+
return store.db.getTask(taskId) ?? task;
|
|
2194
|
+
}
|
|
2195
|
+
async function updateTask(store, taskId, fields) {
|
|
2196
|
+
const task = store.db.getTask(taskId);
|
|
2197
|
+
if (!task) {
|
|
2198
|
+
throw new Error(`Task ${taskId} not found.`);
|
|
2199
|
+
}
|
|
2200
|
+
const current = task.clientIds[0];
|
|
2201
|
+
const target = fields.clientId && fields.clientId.trim() ? fields.clientId : current;
|
|
2202
|
+
const apply = (t) => withSeededDue({
|
|
2203
|
+
...t,
|
|
2204
|
+
title: fields.title !== void 0 ? fields.title.trim() || t.title : t.title,
|
|
2205
|
+
// Passing the field at all is a decision about priority, so an unrecognised
|
|
2206
|
+
// value clears it rather than leaving the old one standing.
|
|
2207
|
+
priority: fields.priority !== void 0 ? writablePriority(fields.priority) : t.priority,
|
|
2208
|
+
parentId: fields.parentId !== void 0 ? fields.parentId || void 0 : t.parentId,
|
|
2209
|
+
links: fields.links !== void 0 ? parseLinks(fields.links) : t.links,
|
|
2210
|
+
description: fields.description !== void 0 ? fields.description.trim() || void 0 : t.description,
|
|
2211
|
+
due: fields.due !== void 0 ? fields.due.trim() || void 0 : t.due,
|
|
2212
|
+
tags: fields.tags !== void 0 ? fields.tags.map((tag) => tag.trim()).filter(Boolean) : t.tags,
|
|
2213
|
+
repeat: fields.repeat !== void 0 ? fields.repeat ?? void 0 : t.repeat,
|
|
2214
|
+
// Dropping the rule drops the series' bookkeeping with it.
|
|
2215
|
+
lastDone: fields.repeat === null ? void 0 : t.lastDone
|
|
2216
|
+
});
|
|
2217
|
+
if (target === current) {
|
|
2218
|
+
await updateInPlace(store, task, apply);
|
|
2219
|
+
} else {
|
|
2220
|
+
const uri = task.sourceFile;
|
|
2221
|
+
const content = await readText(uri);
|
|
2222
|
+
if (content === void 0) {
|
|
2223
|
+
throw new Error(`Could not read ${task.sourceFile}.`);
|
|
2224
|
+
}
|
|
2225
|
+
const extracted = extractBlock(content, task.id);
|
|
2226
|
+
if (!extracted) {
|
|
2227
|
+
throw new Error(`Could not locate task ${task.id}.`);
|
|
2228
|
+
}
|
|
2229
|
+
const parsed = parseTaskFile(extracted.block, uri, current).tasks[0] ?? task;
|
|
2230
|
+
const moved = apply({ ...parsed, clientIds: [target] });
|
|
2231
|
+
await writeText(uri, extracted.remainder);
|
|
2232
|
+
if (moved.completed) {
|
|
2233
|
+
const name = store.db.getClients().find((c) => c.id === target)?.name ?? target;
|
|
2234
|
+
await appendArchiveBlock(
|
|
2235
|
+
store.ws,
|
|
2236
|
+
target,
|
|
2237
|
+
name,
|
|
2238
|
+
monthOf(moved.completed),
|
|
2239
|
+
serializeTask(moved, target)
|
|
2240
|
+
);
|
|
2241
|
+
} else {
|
|
2242
|
+
const client = store.db.getClients().find((c) => c.id === target) ?? {
|
|
2243
|
+
id: target,
|
|
2244
|
+
name: target
|
|
2245
|
+
};
|
|
2246
|
+
await appendTaskBlock(store.ws, client, serializeTask(moved, target));
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
await store.rebuild("updateTask");
|
|
2250
|
+
return store.db.getTask(taskId) ?? task;
|
|
2251
|
+
}
|
|
1818
2252
|
async function updateInPlace(store, task, mutate) {
|
|
1819
2253
|
const uri = task.sourceFile;
|
|
1820
2254
|
const content = await readText(uri);
|
|
@@ -1892,12 +2326,16 @@ async function logWork(store, input) {
|
|
|
1892
2326
|
if (markedWorked) {
|
|
1893
2327
|
task = await toggleTaskWorkedOn(store, task.id, date);
|
|
1894
2328
|
}
|
|
2329
|
+
const timeSet = input.minutes === null ? !!task.workedTime?.[date] : !!input.minutes && task.workedTime?.[date] !== input.minutes;
|
|
2330
|
+
if (timeSet) {
|
|
2331
|
+
task = await setTaskWorkedTime(store, task.id, date, input.minutes ?? void 0);
|
|
2332
|
+
}
|
|
1895
2333
|
const note = input.note?.trim();
|
|
1896
2334
|
const noteAdded = !!note && !(task.notes ?? []).some((n) => n.timestamp.startsWith(today()) && n.text === note);
|
|
1897
2335
|
if (noteAdded) {
|
|
1898
2336
|
task = await addTaskNote(store, task.id, note);
|
|
1899
2337
|
}
|
|
1900
|
-
return { task: store.db.getTask(task.id) ?? task, created, markedWorked, noteAdded };
|
|
2338
|
+
return { task: store.db.getTask(task.id) ?? task, created, markedWorked, timeSet, noteAdded };
|
|
1901
2339
|
}
|
|
1902
2340
|
function findTask(store, input) {
|
|
1903
2341
|
if (input.taskId) {
|
|
@@ -1917,22 +2355,374 @@ function normalizeUrl(url) {
|
|
|
1917
2355
|
return url.trim().replace(/\/+$/, "");
|
|
1918
2356
|
}
|
|
1919
2357
|
|
|
2358
|
+
// src/services/editTask.ts
|
|
2359
|
+
function requireTask(store, ref, { includeClosed = false } = {}) {
|
|
2360
|
+
if (ref.taskId) {
|
|
2361
|
+
const byId = store.db.getTask(ref.taskId);
|
|
2362
|
+
if (!byId) {
|
|
2363
|
+
throw new Error(`Task ${ref.taskId} not found.`);
|
|
2364
|
+
}
|
|
2365
|
+
return byId;
|
|
2366
|
+
}
|
|
2367
|
+
if (!ref.link && !ref.title) {
|
|
2368
|
+
throw new Error("Name the task with an id, a link or a title.");
|
|
2369
|
+
}
|
|
2370
|
+
const scoped = store.db.getAllTasks().filter((t) => !ref.clientId || t.clientIds.includes(ref.clientId));
|
|
2371
|
+
const link = ref.link ? normalizeUrl(ref.link) : void 0;
|
|
2372
|
+
const title = ref.title?.trim().toLowerCase();
|
|
2373
|
+
const matches = scoped.filter(
|
|
2374
|
+
(t) => link ? t.links.some((l) => normalizeUrl(l.url) === link) : t.title.trim().toLowerCase() === title
|
|
2375
|
+
);
|
|
2376
|
+
const named = link ? `linked to ${ref.link}` : `called "${ref.title}"`;
|
|
2377
|
+
const open = matches.filter((t) => !t.completed);
|
|
2378
|
+
if (open.length > 1) {
|
|
2379
|
+
const list2 = open.map((t) => `${t.id} (${t.sourceFile})`).join(", ");
|
|
2380
|
+
throw new Error(`Several open tasks are ${named}: ${list2}. Pass --id, or --client to narrow it down.`);
|
|
2381
|
+
}
|
|
2382
|
+
if (open.length === 1) {
|
|
2383
|
+
return open[0];
|
|
2384
|
+
}
|
|
2385
|
+
const closed = matches.filter((t) => t.completed).sort((a, b) => (b.completed ?? "").localeCompare(a.completed ?? ""))[0];
|
|
2386
|
+
if (includeClosed && closed) {
|
|
2387
|
+
return closed;
|
|
2388
|
+
}
|
|
2389
|
+
throw new Error(`No open task is ${named}${ref.clientId ? ` for client "${ref.clientId}"` : ""}.`);
|
|
2390
|
+
}
|
|
2391
|
+
async function editTask(store, ref, edit) {
|
|
2392
|
+
let task = requireTask(store, ref);
|
|
2393
|
+
const fields = {};
|
|
2394
|
+
const changed = [];
|
|
2395
|
+
const title = edit.title?.trim();
|
|
2396
|
+
if (title && title !== task.title) {
|
|
2397
|
+
fields.title = title;
|
|
2398
|
+
changed.push("title");
|
|
2399
|
+
}
|
|
2400
|
+
if (edit.due !== void 0 && (edit.due ?? void 0) !== task.due) {
|
|
2401
|
+
fields.due = edit.due ?? "";
|
|
2402
|
+
changed.push("due");
|
|
2403
|
+
}
|
|
2404
|
+
if (edit.priority !== void 0 && writablePriority(edit.priority) !== writablePriority(task.priority)) {
|
|
2405
|
+
fields.priority = edit.priority;
|
|
2406
|
+
changed.push("priority");
|
|
2407
|
+
}
|
|
2408
|
+
if (edit.description !== void 0 && (edit.description.trim() || void 0) !== task.description) {
|
|
2409
|
+
fields.description = edit.description;
|
|
2410
|
+
changed.push("description");
|
|
2411
|
+
}
|
|
2412
|
+
if (edit.addTags?.length || edit.removeTags?.length) {
|
|
2413
|
+
const current = task.tags ?? [];
|
|
2414
|
+
const remove = new Set((edit.removeTags ?? []).map((t) => t.trim().toLowerCase()));
|
|
2415
|
+
const kept = current.filter((t) => !remove.has(t.toLowerCase()));
|
|
2416
|
+
const added = (edit.addTags ?? []).map((t) => t.trim()).filter((t) => t && !kept.some((k) => k.toLowerCase() === t.toLowerCase()));
|
|
2417
|
+
const tags = [...kept, ...added];
|
|
2418
|
+
if (tags.join("\n") !== current.join("\n")) {
|
|
2419
|
+
fields.tags = tags;
|
|
2420
|
+
changed.push("tags");
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
if (edit.parentId !== void 0 && (edit.parentId ?? void 0) !== task.parentId) {
|
|
2424
|
+
if (edit.parentId) {
|
|
2425
|
+
requireParent(store, edit.parentId, task.id);
|
|
2426
|
+
}
|
|
2427
|
+
fields.parentId = edit.parentId ?? "";
|
|
2428
|
+
changed.push("parent");
|
|
2429
|
+
}
|
|
2430
|
+
if (Object.keys(fields).length > 0) {
|
|
2431
|
+
task = await updateTask(store, task.id, fields);
|
|
2432
|
+
}
|
|
2433
|
+
if (edit.repeat !== void 0) {
|
|
2434
|
+
const next = edit.repeat === null ? void 0 : { ...readRecurrence(edit.repeat), anchor: task.repeat?.anchor ?? "schedule", until: task.repeat?.until };
|
|
2435
|
+
if ((next && formatRecurrence(next)) !== (task.repeat && formatRecurrence(task.repeat))) {
|
|
2436
|
+
task = await setTaskRecurrence(store, task.id, next);
|
|
2437
|
+
changed.push("repeat");
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
if (edit.status !== void 0 && edit.status !== task.status) {
|
|
2441
|
+
const statuses = store.getConfig().statuses;
|
|
2442
|
+
if (!statuses.some((s) => s.id === edit.status)) {
|
|
2443
|
+
throw new Error(`Unknown status "${edit.status}". Known: ${statuses.map((s) => s.id).join(", ")}.`);
|
|
2444
|
+
}
|
|
2445
|
+
task = await setTaskStatus(store, task.id, edit.status);
|
|
2446
|
+
changed.push("status");
|
|
2447
|
+
}
|
|
2448
|
+
return { task: store.db.getTask(task.id) ?? task, changed };
|
|
2449
|
+
}
|
|
2450
|
+
async function closeTask(store, ref) {
|
|
2451
|
+
const task = requireTask(store, ref, { includeClosed: true });
|
|
2452
|
+
if (task.completed || task.repeat && task.lastDone === today()) {
|
|
2453
|
+
return { task, closed: false };
|
|
2454
|
+
}
|
|
2455
|
+
const closed = await closeTaskById(store, task.id);
|
|
2456
|
+
return { task: store.db.getTask(task.id) ?? closed, closed: true };
|
|
2457
|
+
}
|
|
2458
|
+
async function unlogWork(store, ref, input) {
|
|
2459
|
+
let task = requireTask(store, ref, { includeClosed: true });
|
|
2460
|
+
const unmarked = !!input.date && (task.workedOn ?? []).includes(input.date);
|
|
2461
|
+
if (input.date && unmarked) {
|
|
2462
|
+
task = await toggleTaskWorkedOn(store, task.id, input.date);
|
|
2463
|
+
}
|
|
2464
|
+
const note = input.note?.trim();
|
|
2465
|
+
const index = note ? (task.notes ?? []).map((n) => n.text).lastIndexOf(note) : -1;
|
|
2466
|
+
if (index >= 0) {
|
|
2467
|
+
task = await deleteTaskNote(store, task.id, index);
|
|
2468
|
+
}
|
|
2469
|
+
return { task: store.db.getTask(task.id) ?? task, unmarked, noteRemoved: index >= 0 };
|
|
2470
|
+
}
|
|
2471
|
+
function readRecurrence(text) {
|
|
2472
|
+
const rule = parseRecurrence(text);
|
|
2473
|
+
if (!rule) {
|
|
2474
|
+
throw new Error(
|
|
2475
|
+
`Can't read the repeat "${text}". Use daily, weekdays, every 3 days, weekly on mon,thu, monthly on 15, monthly on last or yearly on 03-14.`
|
|
2476
|
+
);
|
|
2477
|
+
}
|
|
2478
|
+
return rule;
|
|
2479
|
+
}
|
|
2480
|
+
function requireParent(store, parentId, childId) {
|
|
2481
|
+
const parent = store.db.getTask(parentId);
|
|
2482
|
+
if (!parent) {
|
|
2483
|
+
throw new Error(`Parent task ${parentId} not found.`);
|
|
2484
|
+
}
|
|
2485
|
+
for (let p = parent; p; p = p.parentId ? store.db.getTask(p.parentId) : void 0) {
|
|
2486
|
+
if (p.id === childId) {
|
|
2487
|
+
throw new Error(`Task ${childId} can't go under ${parentId}: that is the task itself or one of its subtasks.`);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
return parent;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// src/services/addTask.ts
|
|
2494
|
+
async function addTask(store, input) {
|
|
2495
|
+
const existing = findTask(store, input);
|
|
2496
|
+
if (existing) {
|
|
2497
|
+
return { task: existing, created: false };
|
|
2498
|
+
}
|
|
2499
|
+
if (input.parentId) {
|
|
2500
|
+
requireParent(store, input.parentId);
|
|
2501
|
+
}
|
|
2502
|
+
const repeat = input.repeat ? readRecurrence(input.repeat) : void 0;
|
|
2503
|
+
const task = await createTask(store, {
|
|
2504
|
+
clientId: input.clientId,
|
|
2505
|
+
title: input.title,
|
|
2506
|
+
links: input.link ? [input.link] : void 0,
|
|
2507
|
+
due: input.due,
|
|
2508
|
+
priority: input.priority,
|
|
2509
|
+
description: input.description,
|
|
2510
|
+
tags: input.tags,
|
|
2511
|
+
parentId: input.parentId,
|
|
2512
|
+
repeat
|
|
2513
|
+
});
|
|
2514
|
+
return { task: store.db.getTask(task.id) ?? task, created: true };
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// src/services/listTasks.ts
|
|
2518
|
+
function listTasks(store, filter = {}) {
|
|
2519
|
+
const tags = (filter.tags ?? []).map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
2520
|
+
return store.db.getAllTasks().filter((t) => !t.completed).filter((t) => !filter.clientId || t.clientIds.includes(filter.clientId)).filter((t) => !filter.dueBy || !!t.due && t.due <= filter.dueBy).filter((t) => tags.every((tag) => (t.tags ?? []).some((x) => x.toLowerCase() === tag))).sort(
|
|
2521
|
+
(a, b) => (a.due ?? "\uFFFF").localeCompare(b.due ?? "\uFFFF") || priorityRank(a.priority) - priorityRank(b.priority) || (a.created ?? "").localeCompare(b.created ?? "")
|
|
2522
|
+
);
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/services/worklog.ts
|
|
2526
|
+
function withEntry(existing, month, date, clientId, hours, note) {
|
|
2527
|
+
const line = serializeWorklogEntry({ date, clientId, hours, note });
|
|
2528
|
+
if (existing === void 0 || existing.trim() === "") {
|
|
2529
|
+
return `# Worklog ${month}
|
|
2530
|
+
|
|
2531
|
+
${line}
|
|
2532
|
+
`;
|
|
2533
|
+
}
|
|
2534
|
+
const lines = existing.split(/\r?\n/);
|
|
2535
|
+
const prefix = `- ${date} ${clientId} `;
|
|
2536
|
+
const idx = lines.findIndex((l) => l.startsWith(prefix));
|
|
2537
|
+
if (idx >= 0) {
|
|
2538
|
+
lines[idx] = line;
|
|
2539
|
+
} else {
|
|
2540
|
+
let insertAt = lines.length;
|
|
2541
|
+
while (insertAt > 0 && lines[insertAt - 1].trim() === "") {
|
|
2542
|
+
insertAt--;
|
|
2543
|
+
}
|
|
2544
|
+
lines.splice(insertAt, 0, line);
|
|
2545
|
+
}
|
|
2546
|
+
return lines.join("\n").replace(/\s+$/, "") + "\n";
|
|
2547
|
+
}
|
|
2548
|
+
async function setWorklog(store, date, clientId, hours, note) {
|
|
2549
|
+
if (!(hours > 0)) {
|
|
2550
|
+
return removeWorklog(store, date, clientId);
|
|
2551
|
+
}
|
|
2552
|
+
const month = monthOf(date);
|
|
2553
|
+
const uri = store.ws.worklogFile(month);
|
|
2554
|
+
await ensureDir(store.ws.worklogDir);
|
|
2555
|
+
const existing = await readText(uri);
|
|
2556
|
+
await writeText(uri, withEntry(existing, month, date, clientId, hours, note));
|
|
2557
|
+
await store.rebuild("setWorklog");
|
|
2558
|
+
}
|
|
2559
|
+
async function removeWorklog(store, date, clientId) {
|
|
2560
|
+
const uri = store.ws.worklogFile(monthOf(date));
|
|
2561
|
+
const existing = await readText(uri);
|
|
2562
|
+
if (existing === void 0) {
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
const prefix = `- ${date} ${clientId} `;
|
|
2566
|
+
const lines = existing.split(/\r?\n/).filter((l) => !l.startsWith(prefix));
|
|
2567
|
+
await writeText(uri, lines.join("\n").replace(/\s+$/, "") + "\n");
|
|
2568
|
+
await store.rebuild("removeWorklog");
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// src/services/logHours.ts
|
|
2572
|
+
async function logHours(store, input) {
|
|
2573
|
+
const config = store.getConfig();
|
|
2574
|
+
const date = input.date ?? today();
|
|
2575
|
+
if (!!input.clientId === !!input.event) {
|
|
2576
|
+
throw new Error("Name either a client or a day event.");
|
|
2577
|
+
}
|
|
2578
|
+
let target;
|
|
2579
|
+
if (input.event) {
|
|
2580
|
+
target = eventWorklogClientId(input.event);
|
|
2581
|
+
if (!EVENT_TYPES.some((e) => e.id === eventTypeFromClientId(target))) {
|
|
2582
|
+
throw new Error(`Unknown day event "${input.event}". Known: ${EVENT_TYPES.map((e) => e.id).join(", ")}.`);
|
|
2583
|
+
}
|
|
2584
|
+
} else {
|
|
2585
|
+
target = input.clientId;
|
|
2586
|
+
const reason = unloggableReason(store.db.getClients(), target);
|
|
2587
|
+
if (reason) {
|
|
2588
|
+
throw new Error(reason);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
const hours = resolveHours(input.hours, config.hoursPerDay);
|
|
2592
|
+
if (hours === void 0) {
|
|
2593
|
+
throw new Error(`Hours must be full, half or a number, got "${input.hours}".`);
|
|
2594
|
+
}
|
|
2595
|
+
const previous = dayEntries(store, date).find((e) => e.clientId === target);
|
|
2596
|
+
const note = input.note !== void 0 ? input.note.trim() || void 0 : previous?.note;
|
|
2597
|
+
const changed = hours > 0 ? previous?.hours !== hours || previous?.note !== note : !!previous;
|
|
2598
|
+
if (changed) {
|
|
2599
|
+
await setWorklog(store, date, target, hours, note);
|
|
2600
|
+
}
|
|
2601
|
+
return {
|
|
2602
|
+
target,
|
|
2603
|
+
date,
|
|
2604
|
+
hours,
|
|
2605
|
+
previous,
|
|
2606
|
+
changed,
|
|
2607
|
+
dayTotal: roundHours(dayEntries(store, date).reduce((sum, e) => sum + e.hours, 0)),
|
|
2608
|
+
hoursPerDay: config.hoursPerDay
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
function dayEntries(store, date) {
|
|
2612
|
+
return store.db.getAllWorklog().filter((e) => e.date === date);
|
|
2613
|
+
}
|
|
2614
|
+
function targetLabel(store, target) {
|
|
2615
|
+
if (isEventWorklogClientId(target)) {
|
|
2616
|
+
return eventLabel(eventTypeFromClientId(target));
|
|
2617
|
+
}
|
|
2618
|
+
return store.db.getClients().find((c) => c.id === target)?.name ?? target;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
1920
2621
|
// cli/worklog.ts
|
|
1921
2622
|
var TEXT_PATH = /\.(md|json)$|\.worklog$/;
|
|
1922
2623
|
var PUSH_ATTEMPTS = 3;
|
|
1923
|
-
var
|
|
2624
|
+
var NONE = "none";
|
|
2625
|
+
var USAGE = `Usage: worklog <command> [options]
|
|
2626
|
+
|
|
2627
|
+
log Work that was done: marks the day worked on a task (creating the task
|
|
2628
|
+
when it's new), records the time spent and adds the note.
|
|
2629
|
+
unlog Undoes a log: takes a day worked off a task, removes a note.
|
|
2630
|
+
add Something still to do: creates an open task. No day is marked worked.
|
|
2631
|
+
Without --client it goes on the general to-do list.
|
|
2632
|
+
update Changes an existing task: due date, priority, description, ...
|
|
2633
|
+
done Closes a task. A recurring one rolls on to its next occurrence.
|
|
2634
|
+
list Prints the open tasks. Read-only.
|
|
2635
|
+
hours Sets the hours worked for a client (or a day event) on a day. Without
|
|
2636
|
+
--hours it prints what's logged that day.
|
|
2637
|
+
|
|
2638
|
+
All but list, and hours without --hours, commit and push to your timesheet repo.
|
|
1924
2639
|
|
|
1925
|
-
|
|
1926
|
-
|
|
2640
|
+
Every command:
|
|
2641
|
+
--repo <path> timesheet clone (default: $WORKLOG_REPO)
|
|
2642
|
+
--client <id> client id from .worklog/config.json; "todos" is the
|
|
2643
|
+
general to-do list
|
|
1927
2644
|
|
|
1928
|
-
|
|
1929
|
-
--
|
|
1930
|
-
--link <url>
|
|
1931
|
-
--
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
--
|
|
1935
|
-
--
|
|
2645
|
+
Naming a task (log, unlog, update, done):
|
|
2646
|
+
--id <t_xxxxxx> this exact task
|
|
2647
|
+
--link <url> the task linked to this issue/ticket url
|
|
2648
|
+
--title <title> the open task with this title
|
|
2649
|
+
|
|
2650
|
+
log --client <id> --title <title>:
|
|
2651
|
+
--note <text> progress note added to the task
|
|
2652
|
+
--date <date> day worked, YYYY-MM-DD (default: today)
|
|
2653
|
+
--time <duration> time spent on the task that day: 1h, 45m, 1h30m, or
|
|
2654
|
+
none to clear it. Sets the day's time, it doesn't add.
|
|
2655
|
+
A record only: billed hours stay with "hours"
|
|
2656
|
+
|
|
2657
|
+
unlog (at least one of):
|
|
2658
|
+
--date <date> take this day worked off the task, with its time
|
|
2659
|
+
--note <text> remove the note with exactly this text
|
|
2660
|
+
|
|
2661
|
+
add --title <title>:
|
|
2662
|
+
--link <url> issue/ticket url
|
|
2663
|
+
--due <date> day the task is due, YYYY-MM-DD
|
|
2664
|
+
--priority <level> ${PRIORITIES.map((p) => p.id).join(", ")}
|
|
2665
|
+
--description <text> what the task is about
|
|
2666
|
+
--tag <tag> repeat for several tags
|
|
2667
|
+
--parent <t_xxxxxx> make it a subtask of this task
|
|
2668
|
+
--repeat <rule> daily, weekdays, every 3 days, weekly on mon,thu,
|
|
2669
|
+
monthly on 15, monthly on last, yearly on 03-14
|
|
2670
|
+
|
|
2671
|
+
update:
|
|
2672
|
+
--rename <title> new title
|
|
2673
|
+
--due <date|none> new due date, or none to clear it
|
|
2674
|
+
--priority <level> new priority; normal clears it
|
|
2675
|
+
--description <text> new description; "" clears it
|
|
2676
|
+
--status <id> a status from .worklog/config.json
|
|
2677
|
+
--tag <tag> add a tag (repeatable)
|
|
2678
|
+
--untag <tag> remove a tag (repeatable)
|
|
2679
|
+
--parent <id|none> move it under this task, or none to make it top-level
|
|
2680
|
+
--repeat <rule|none> a new repeat rule (as for add), or none to stop it
|
|
2681
|
+
|
|
2682
|
+
hours --client <id> | --event <type>:
|
|
2683
|
+
--hours <amount> full, half, or a number of hours; 0 removes the entry.
|
|
2684
|
+
Sets the day's total for that client, it doesn't add
|
|
2685
|
+
--date <date> the day, YYYY-MM-DD (default: today)
|
|
2686
|
+
--note <text> what the time went to (default: keep the note there)
|
|
2687
|
+
--event <type> ${EVENT_TYPES.map((e) => e.id).join(", ")}
|
|
2688
|
+
|
|
2689
|
+
list:
|
|
2690
|
+
--due-by <date> only tasks due on or before this day
|
|
2691
|
+
--tag <tag> only tasks with this tag; repeat it for tasks with all
|
|
2692
|
+
--json print JSON instead of one line per task, with the
|
|
2693
|
+
days worked and the minutes per day
|
|
2694
|
+
|
|
2695
|
+
log, unlog, add, update, done, hours:
|
|
2696
|
+
--dry-run show the change, commit nothing`;
|
|
2697
|
+
var OPTIONS = {
|
|
2698
|
+
log: ["client", "id", "link", "title", "note", "date", "time", "dry-run"],
|
|
2699
|
+
unlog: ["client", "id", "link", "title", "date", "note", "dry-run"],
|
|
2700
|
+
add: ["client", "link", "title", "due", "priority", "description", "tag", "parent", "repeat", "dry-run"],
|
|
2701
|
+
update: [
|
|
2702
|
+
"client",
|
|
2703
|
+
"id",
|
|
2704
|
+
"link",
|
|
2705
|
+
"title",
|
|
2706
|
+
"rename",
|
|
2707
|
+
"due",
|
|
2708
|
+
"priority",
|
|
2709
|
+
"description",
|
|
2710
|
+
"status",
|
|
2711
|
+
"tag",
|
|
2712
|
+
"untag",
|
|
2713
|
+
"parent",
|
|
2714
|
+
"repeat",
|
|
2715
|
+
"dry-run"
|
|
2716
|
+
],
|
|
2717
|
+
done: ["client", "id", "link", "title", "dry-run"],
|
|
2718
|
+
list: ["client", "due-by", "tag", "json"],
|
|
2719
|
+
hours: ["client", "event", "hours", "date", "note", "dry-run"]
|
|
2720
|
+
};
|
|
2721
|
+
var HINTS = {
|
|
2722
|
+
date: `A to-do's date is --due; --date is the day worked, for "worklog log" and "worklog hours".`,
|
|
2723
|
+
time: `Time on a task is "worklog log --time"; a client's hours for the day are "worklog hours --hours".`,
|
|
2724
|
+
due: "The day worked is --date; --due is when a to-do is due."
|
|
2725
|
+
};
|
|
1936
2726
|
async function main() {
|
|
1937
2727
|
const { values, positionals } = parseArgs({
|
|
1938
2728
|
allowPositionals: true,
|
|
@@ -1943,6 +2733,20 @@ async function main() {
|
|
|
1943
2733
|
id: { type: "string" },
|
|
1944
2734
|
note: { type: "string" },
|
|
1945
2735
|
date: { type: "string" },
|
|
2736
|
+
time: { type: "string" },
|
|
2737
|
+
due: { type: "string" },
|
|
2738
|
+
"due-by": { type: "string" },
|
|
2739
|
+
priority: { type: "string" },
|
|
2740
|
+
description: { type: "string" },
|
|
2741
|
+
rename: { type: "string" },
|
|
2742
|
+
status: { type: "string" },
|
|
2743
|
+
tag: { type: "string", multiple: true },
|
|
2744
|
+
untag: { type: "string", multiple: true },
|
|
2745
|
+
parent: { type: "string" },
|
|
2746
|
+
repeat: { type: "string" },
|
|
2747
|
+
event: { type: "string" },
|
|
2748
|
+
hours: { type: "string" },
|
|
2749
|
+
json: { type: "boolean" },
|
|
1946
2750
|
repo: { type: "string" },
|
|
1947
2751
|
"dry-run": { type: "boolean" },
|
|
1948
2752
|
help: { type: "boolean", short: "h" }
|
|
@@ -1952,35 +2756,140 @@ async function main() {
|
|
|
1952
2756
|
console.log(USAGE);
|
|
1953
2757
|
return;
|
|
1954
2758
|
}
|
|
1955
|
-
|
|
2759
|
+
const command = positionals[0];
|
|
2760
|
+
if (!(command in OPTIONS) || positionals.length > 1) {
|
|
1956
2761
|
throw new Error(`Unknown command "${positionals.join(" ")}".
|
|
1957
2762
|
|
|
1958
2763
|
${USAGE}`);
|
|
1959
2764
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
2765
|
+
const allowed = /* @__PURE__ */ new Set([...OPTIONS[command], "repo", "help"]);
|
|
2766
|
+
const misplaced = Object.keys(values).find((o) => !allowed.has(o));
|
|
2767
|
+
if (misplaced) {
|
|
2768
|
+
const hint = HINTS[misplaced] ? ` ${HINTS[misplaced]}` : "";
|
|
2769
|
+
throw new Error(`"worklog ${command}" has no --${misplaced} option.${hint}
|
|
1962
2770
|
|
|
1963
2771
|
${USAGE}`);
|
|
1964
2772
|
}
|
|
1965
|
-
|
|
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
|
-
};
|
|
2773
|
+
validate(values);
|
|
1976
2774
|
const repo = values.repo ?? process.env.WORKLOG_REPO;
|
|
1977
2775
|
if (!repo) {
|
|
1978
2776
|
throw new Error("Point --repo or $WORKLOG_REPO at a clone of your timesheet repo.");
|
|
1979
2777
|
}
|
|
1980
|
-
|
|
2778
|
+
if (command === "list") {
|
|
2779
|
+
return list(repo, values);
|
|
2780
|
+
}
|
|
2781
|
+
if (command === "hours" && values.hours === void 0) {
|
|
2782
|
+
return showDay(repo, values.date ?? today());
|
|
2783
|
+
}
|
|
2784
|
+
return run(repo, toEdit(command, values), !!values["dry-run"]);
|
|
1981
2785
|
}
|
|
1982
|
-
|
|
1983
|
-
|
|
2786
|
+
function validate(values) {
|
|
2787
|
+
for (const option of ["date", "due", "due-by"]) {
|
|
2788
|
+
const value = values[option];
|
|
2789
|
+
if (typeof value === "string" && !(option === "due" && value === NONE) && !isValidISODate(value)) {
|
|
2790
|
+
throw new Error(`--${option} must be YYYY-MM-DD, got "${value}".`);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
const time = values.time;
|
|
2794
|
+
if (typeof time === "string" && time !== NONE && !(/[hm]/i.test(time) && parseDuration(time))) {
|
|
2795
|
+
throw new Error(`--time must be a duration like 1h, 45m or 1h30m, or none, got "${time}".`);
|
|
2796
|
+
}
|
|
2797
|
+
const priority = values.priority;
|
|
2798
|
+
if (typeof priority === "string" && !PRIORITIES.some((p) => p.id === priority.trim().toLowerCase())) {
|
|
2799
|
+
throw new Error(`--priority must be one of ${PRIORITIES.map((p) => p.id).join(", ")}, got "${priority}".`);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
function toEdit(command, values) {
|
|
2803
|
+
const str = (key) => values[key];
|
|
2804
|
+
const list2 = (key) => values[key];
|
|
2805
|
+
const ref = { taskId: str("id"), link: str("link"), title: str("title"), clientId: str("client") };
|
|
2806
|
+
const orNull = (value) => value === NONE ? null : value;
|
|
2807
|
+
switch (command) {
|
|
2808
|
+
case "log": {
|
|
2809
|
+
const clientId = str("client");
|
|
2810
|
+
const title = str("title");
|
|
2811
|
+
if (!clientId || !title) {
|
|
2812
|
+
throw new Error(`--client and --title are required.
|
|
2813
|
+
|
|
2814
|
+
${USAGE}`);
|
|
2815
|
+
}
|
|
2816
|
+
const time = str("time");
|
|
2817
|
+
const input = {
|
|
2818
|
+
clientId,
|
|
2819
|
+
title,
|
|
2820
|
+
taskId: ref.taskId,
|
|
2821
|
+
link: ref.link,
|
|
2822
|
+
note: str("note"),
|
|
2823
|
+
date: str("date"),
|
|
2824
|
+
minutes: time === NONE ? null : time ? parseDuration(time) : void 0
|
|
2825
|
+
};
|
|
2826
|
+
return async (store) => describeLog(await logWork(store, input), input);
|
|
2827
|
+
}
|
|
2828
|
+
case "unlog": {
|
|
2829
|
+
requireRef(ref);
|
|
2830
|
+
const input = { date: str("date"), note: str("note") };
|
|
2831
|
+
if (!input.date && !input.note) {
|
|
2832
|
+
throw new Error("Nothing to take off: pass --date, --note or both.");
|
|
2833
|
+
}
|
|
2834
|
+
return async (store) => describeUnlog(await unlogWork(store, ref, input), input);
|
|
2835
|
+
}
|
|
2836
|
+
case "add": {
|
|
2837
|
+
const title = str("title");
|
|
2838
|
+
if (!title) {
|
|
2839
|
+
throw new Error(`--title is required.
|
|
2840
|
+
|
|
2841
|
+
${USAGE}`);
|
|
2842
|
+
}
|
|
2843
|
+
const input = {
|
|
2844
|
+
clientId: str("client") ?? GENERAL_TODO_CLIENT_ID,
|
|
2845
|
+
title,
|
|
2846
|
+
link: ref.link,
|
|
2847
|
+
due: str("due"),
|
|
2848
|
+
priority: str("priority"),
|
|
2849
|
+
description: str("description"),
|
|
2850
|
+
tags: list2("tag"),
|
|
2851
|
+
parentId: str("parent"),
|
|
2852
|
+
repeat: str("repeat")
|
|
2853
|
+
};
|
|
2854
|
+
return async (store) => describeAdd(await addTask(store, input));
|
|
2855
|
+
}
|
|
2856
|
+
case "update": {
|
|
2857
|
+
requireRef(ref);
|
|
2858
|
+
const edit = {
|
|
2859
|
+
title: str("rename"),
|
|
2860
|
+
due: orNull(str("due")),
|
|
2861
|
+
priority: str("priority"),
|
|
2862
|
+
description: str("description"),
|
|
2863
|
+
status: str("status"),
|
|
2864
|
+
addTags: list2("tag"),
|
|
2865
|
+
removeTags: list2("untag"),
|
|
2866
|
+
parentId: orNull(str("parent")),
|
|
2867
|
+
repeat: orNull(str("repeat"))
|
|
2868
|
+
};
|
|
2869
|
+
if (Object.values(edit).every((v) => v === void 0)) {
|
|
2870
|
+
throw new Error(`Nothing to change: pass at least one of --rename, --due, --priority, --description, --status, --tag, --untag, --parent, --repeat.`);
|
|
2871
|
+
}
|
|
2872
|
+
return async (store) => describeUpdate(await editTask(store, ref, edit));
|
|
2873
|
+
}
|
|
2874
|
+
case "hours": {
|
|
2875
|
+
const input = { clientId: str("client"), event: str("event"), hours: str("hours") ?? "", note: str("note"), date: str("date") };
|
|
2876
|
+
return async (store) => describeHours(store, await logHours(store, input));
|
|
2877
|
+
}
|
|
2878
|
+
default: {
|
|
2879
|
+
requireRef(ref);
|
|
2880
|
+
return async (store) => describeDone(await closeTask(store, ref));
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
function requireRef(ref) {
|
|
2885
|
+
if (!ref.taskId && !ref.link && !ref.title) {
|
|
2886
|
+
throw new Error(`Name the task with --id, --link or --title.
|
|
2887
|
+
|
|
2888
|
+
${USAGE}`);
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
async function run(repo, edit, dryRun) {
|
|
2892
|
+
const git = gitIn(repo);
|
|
1984
2893
|
if (git("status", "--porcelain", "--untracked-files=no")) {
|
|
1985
2894
|
throw new Error(`${repo} has uncommitted changes; commit or discard them first.`);
|
|
1986
2895
|
}
|
|
@@ -1990,39 +2899,106 @@ async function run(repo, input, dryRun) {
|
|
|
1990
2899
|
}
|
|
1991
2900
|
git("merge", "--ff-only", "--quiet", "@{u}");
|
|
1992
2901
|
for (let attempt = 1; ; attempt++) {
|
|
1993
|
-
const {
|
|
2902
|
+
const { line, changed } = await applyToClone(repo, edit);
|
|
1994
2903
|
if (changed.length === 0) {
|
|
1995
|
-
console.log(
|
|
2904
|
+
console.log(`${line} \u2014 nothing new to write`);
|
|
1996
2905
|
return;
|
|
1997
2906
|
}
|
|
1998
2907
|
if (dryRun) {
|
|
1999
2908
|
git("add", "--intent-to-add", "--", ...changed);
|
|
2000
2909
|
console.log(git("--no-pager", "diff", "--", ...changed));
|
|
2001
2910
|
git("reset", "--quiet", "--hard", "HEAD");
|
|
2002
|
-
console.log(
|
|
2911
|
+
console.log(`${line} \u2014 dry run, nothing committed`);
|
|
2003
2912
|
return;
|
|
2004
2913
|
}
|
|
2005
2914
|
git("add", "--", ...changed);
|
|
2006
2915
|
git("commit", "--quiet", "-m", `chore: worklog sync ${today()}`);
|
|
2007
2916
|
try {
|
|
2008
2917
|
git("push", "--quiet");
|
|
2009
|
-
console.log(
|
|
2918
|
+
console.log(`${line} \u2014 pushed ${git("rev-parse", "--short", "HEAD")}`);
|
|
2010
2919
|
return;
|
|
2011
2920
|
} catch (err) {
|
|
2012
2921
|
git("reset", "--quiet", "--hard", "@{u}");
|
|
2013
2922
|
const raced = /rejected|fetch first|non-fast-forward/.test(String(err.stderr));
|
|
2014
2923
|
if (!raced) {
|
|
2015
|
-
throw new Error("Push failed; nothing was
|
|
2924
|
+
throw new Error("Push failed; nothing was written.", { cause: err });
|
|
2016
2925
|
}
|
|
2017
2926
|
if (attempt >= PUSH_ATTEMPTS) {
|
|
2018
|
-
throw new Error(`Push rejected ${PUSH_ATTEMPTS} times; nothing was
|
|
2927
|
+
throw new Error(`Push rejected ${PUSH_ATTEMPTS} times; nothing was written.`, { cause: err });
|
|
2019
2928
|
}
|
|
2020
2929
|
git("fetch", "--quiet");
|
|
2021
2930
|
git("reset", "--quiet", "--hard", "@{u}");
|
|
2022
2931
|
}
|
|
2023
2932
|
}
|
|
2024
2933
|
}
|
|
2025
|
-
|
|
2934
|
+
function catchUp(repo) {
|
|
2935
|
+
const git = gitIn(repo);
|
|
2936
|
+
try {
|
|
2937
|
+
git("fetch", "--quiet");
|
|
2938
|
+
if (git("status", "--porcelain", "--untracked-files=no") || git("rev-list", "--count", "@{u}..HEAD") !== "0") {
|
|
2939
|
+
console.error(`${repo} has local changes; reading it as it is, without the latest from the remote.`);
|
|
2940
|
+
} else {
|
|
2941
|
+
git("merge", "--ff-only", "--quiet", "@{u}");
|
|
2942
|
+
}
|
|
2943
|
+
} catch {
|
|
2944
|
+
console.error("Could not fetch the remote; reading the clone as it is.");
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
async function list(repo, values) {
|
|
2948
|
+
catchUp(repo);
|
|
2949
|
+
const { store } = await loadClone(repo);
|
|
2950
|
+
const tasks = listTasks(store, {
|
|
2951
|
+
clientId: values.client,
|
|
2952
|
+
dueBy: values["due-by"],
|
|
2953
|
+
tags: values.tag
|
|
2954
|
+
});
|
|
2955
|
+
if (values.json) {
|
|
2956
|
+
console.log(JSON.stringify(tasks.map(toJson), null, 2));
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
if (tasks.length === 0) {
|
|
2960
|
+
console.log("No open tasks match.");
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
for (const t of tasks) {
|
|
2964
|
+
console.log(
|
|
2965
|
+
[t.id, t.due ?? "-", t.priority ?? "-", t.clientIds[0], t.status, t.title].join(" ")
|
|
2966
|
+
);
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
async function showDay(repo, date) {
|
|
2970
|
+
catchUp(repo);
|
|
2971
|
+
const { store } = await loadClone(repo);
|
|
2972
|
+
const entries = dayEntries(store, date);
|
|
2973
|
+
const total = entries.reduce((sum, e) => sum + e.hours, 0);
|
|
2974
|
+
console.log(`${date}: ${total}h logged of ${store.getConfig().hoursPerDay}h`);
|
|
2975
|
+
for (const e of entries) {
|
|
2976
|
+
console.log(["", e.clientId, `${e.hours}h`, targetLabel(store, e.clientId), e.note ?? ""].join(" ").trimEnd());
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
function toJson(t) {
|
|
2980
|
+
return {
|
|
2981
|
+
id: t.id,
|
|
2982
|
+
title: t.title,
|
|
2983
|
+
client: t.clientIds[0],
|
|
2984
|
+
status: t.status,
|
|
2985
|
+
priority: t.priority ?? "normal",
|
|
2986
|
+
due: t.due ?? null,
|
|
2987
|
+
tags: t.tags ?? [],
|
|
2988
|
+
links: t.links.map((l) => l.url),
|
|
2989
|
+
parent: t.parentId ?? null,
|
|
2990
|
+
repeats: !!t.repeat,
|
|
2991
|
+
worked: t.workedOn ?? [],
|
|
2992
|
+
minutes: t.workedTime ?? {},
|
|
2993
|
+
created: t.created ?? null,
|
|
2994
|
+
description: t.description ?? null,
|
|
2995
|
+
file: t.sourceFile
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
function gitIn(repo) {
|
|
2999
|
+
return (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
3000
|
+
}
|
|
3001
|
+
async function loadClone(repo) {
|
|
2026
3002
|
const fm = new FileMap();
|
|
2027
3003
|
for (const path of listFiles(repo)) {
|
|
2028
3004
|
if (isWorklogPath(path) && TEXT_PATH.test(path)) {
|
|
@@ -2033,24 +3009,63 @@ async function applyToClone(repo, input) {
|
|
|
2033
3009
|
mountFileMap(fm);
|
|
2034
3010
|
const store = new Store();
|
|
2035
3011
|
await store.rebuild("load");
|
|
2036
|
-
|
|
2037
|
-
|
|
3012
|
+
return { store, fm };
|
|
3013
|
+
}
|
|
3014
|
+
async function applyToClone(repo, edit) {
|
|
3015
|
+
const { store, fm } = await loadClone(repo);
|
|
3016
|
+
const before = new Map(fm.text);
|
|
3017
|
+
const line = await edit(store);
|
|
3018
|
+
const changed = [...fm.dirty].filter((path) => fm.text.has(path) && fm.text.get(path) !== before.get(path));
|
|
2038
3019
|
for (const path of changed) {
|
|
2039
3020
|
mkdirSync(dirname(join(repo, path)), { recursive: true });
|
|
2040
3021
|
writeFileSync(join(repo, path), fm.text.get(path) ?? "");
|
|
2041
3022
|
}
|
|
2042
|
-
return {
|
|
3023
|
+
return { line, changed };
|
|
2043
3024
|
}
|
|
2044
3025
|
function listFiles(repo) {
|
|
2045
3026
|
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
3027
|
}
|
|
2047
|
-
function
|
|
3028
|
+
function label(task) {
|
|
3029
|
+
return `${task.id} "${task.title}" (${task.sourceFile})`;
|
|
3030
|
+
}
|
|
3031
|
+
function describeLog({ task, created, markedWorked, timeSet, noteAdded }, input) {
|
|
2048
3032
|
const what = [
|
|
2049
3033
|
created ? "created" : "found",
|
|
2050
3034
|
markedWorked ? `marked worked ${input.date ?? today()}` : void 0,
|
|
3035
|
+
timeSet ? input.minutes ? `time ${formatDuration(input.minutes)}` : "time cleared" : void 0,
|
|
2051
3036
|
noteAdded ? "note added" : void 0
|
|
2052
3037
|
].filter(Boolean);
|
|
2053
|
-
return `${
|
|
3038
|
+
return `${label(task)}: ${what.join(", ")}`;
|
|
3039
|
+
}
|
|
3040
|
+
function describeUnlog({ task, unmarked, noteRemoved }, input) {
|
|
3041
|
+
const what = [
|
|
3042
|
+
input.date ? unmarked ? `took ${input.date} off` : `${input.date} wasn't marked worked` : void 0,
|
|
3043
|
+
input.note ? noteRemoved ? "note removed" : "no note with that text" : void 0
|
|
3044
|
+
].filter(Boolean);
|
|
3045
|
+
return `${label(task)}: ${what.join(", ")}`;
|
|
3046
|
+
}
|
|
3047
|
+
function describeAdd({ task, created }) {
|
|
3048
|
+
const what = [
|
|
3049
|
+
created ? "created" : "already exists, left as is",
|
|
3050
|
+
task.due ? `due ${task.due}` : void 0,
|
|
3051
|
+
task.priority ? `priority ${task.priority}` : void 0,
|
|
3052
|
+
task.repeat ? `repeats ${formatRecurrence(task.repeat)}` : void 0
|
|
3053
|
+
].filter(Boolean);
|
|
3054
|
+
return `${label(task)}: ${what.join(", ")}`;
|
|
3055
|
+
}
|
|
3056
|
+
function describeUpdate({ task, changed }) {
|
|
3057
|
+
return `${label(task)}: ${changed.length ? `changed ${changed.join(", ")}` : "already up to date"}`;
|
|
3058
|
+
}
|
|
3059
|
+
function describeHours(store, r) {
|
|
3060
|
+
const was = !r.previous ? "new" : r.previous.hours === r.hours && r.changed ? "note updated" : `was ${r.previous.hours}h`;
|
|
3061
|
+
const what = r.hours > 0 ? `${r.hours}h (${was})` : r.previous ? `removed (was ${r.previous.hours}h)` : "nothing logged";
|
|
3062
|
+
return `${targetLabel(store, r.target)} on ${r.date}: ${what}; day total ${r.dayTotal}h of ${r.hoursPerDay}h`;
|
|
3063
|
+
}
|
|
3064
|
+
function describeDone({ task, closed }) {
|
|
3065
|
+
if (!closed) {
|
|
3066
|
+
return `${label(task)}: ${task.completed ? `already closed on ${task.completed}` : `already done today, next due ${task.due}`}`;
|
|
3067
|
+
}
|
|
3068
|
+
return `${label(task)}: ${task.completed ? "closed" : `done, next due ${task.due}`}`;
|
|
2054
3069
|
}
|
|
2055
3070
|
main().catch((err) => {
|
|
2056
3071
|
console.error(err instanceof Error ? err.message : String(err));
|