@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.
Files changed (3) hide show
  1. package/README.md +102 -8
  2. package/dist/worklog.js +1294 -279
  3. 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 H22 = /^##\s+(.*)$/;
478
- var META2 = /^-\s+([A-Za-z][\w-]*)\s*:\s*(.*)$/;
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) ? H22.exec(line) : null;
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 = META2.exec(line);
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 = value.split(/[\s,]+/).map((d) => d.trim()).filter(Boolean);
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
- out.push(`- worked: ${worked}`);
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 H23 = /^##[ \t]+(.*)$/;
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 = H23.exec(line);
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/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;
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 label = typeof candidate.label === "string" ? candidate.label.trim() : "";
1206
- if (!id || !label || seen.has(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: label.slice(0, MAX_STATUS_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 label = typeof l.label === "string" ? l.label.trim() : "";
1404
- return { url: l.url.trim(), ...label ? { label } : {} };
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 USAGE = `Usage: worklog log --client <id> --title <title> [options]
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
- 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.
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
- --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`;
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
- if (positionals[0] !== "log" || positionals.length > 1) {
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
- if (!values.client || !values.title) {
1961
- throw new Error(`--client and --title are required.
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
- 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
- };
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
- return run(repo, input, !!values["dry-run"]);
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
- async function run(repo, input, dryRun) {
1983
- const git = (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
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 { result, changed } = await applyToClone(repo, input);
2902
+ const { line, changed } = await applyToClone(repo, edit);
1994
2903
  if (changed.length === 0) {
1995
- console.log(describe(result, input, "nothing new to 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(describe(result, input, "dry run, nothing committed"));
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(describe(result, input, `pushed ${git("rev-parse", "--short", "HEAD")}`));
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 logged.", { cause: err });
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 logged.`, { cause: err });
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
- async function applyToClone(repo, input) {
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
- const result = await logWork(store, input);
2037
- const changed = [...fm.dirty].filter((path) => fm.text.has(path));
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 { result, changed };
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 describe({ task, created, markedWorked, noteAdded }, input, outcome) {
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 `${task.id} "${task.title}" (${task.sourceFile}): ${what.join(", ")} \u2014 ${outcome}`;
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));