@estruyf/worklog-cli 0.2.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 +18 -2
  2. package/dist/worklog.js +395 -231
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -34,11 +34,25 @@ Each command refuses options it doesn't have, so a to-do's due date can never en
34
34
  | `--id` | Log on this exact task id instead. |
35
35
  | `--note` | Progress note added under the task's `### Notes`. |
36
36
  | `--date` | The day worked, `YYYY-MM-DD`. Defaults to today. |
37
+ | `--time` | Time spent on the task that day: `1h`, `45m`, `1h30m`, or `none` to clear it. Sets the day's time, the same as the app's Time field. |
38
+
39
+ `--time` is a record on the task only. The hours you bill a client for stay with [`hours`](#hours--time-worked).
37
40
 
38
41
  The task is matched by `--id`, then `--link`, then an **open task of the same title** for that
39
42
  client — so a task you typed into the app yourself is picked up rather than duplicated. With no match
40
43
  it is created.
41
44
 
45
+ ## `unlog` — take a log back off
46
+
47
+ For a log that went on the wrong task or day. Name the task like `update`, then pass what to remove:
48
+
49
+ | Option | Meaning |
50
+ | --- | --- |
51
+ | `--date` | Take this day worked off the task, with its time. |
52
+ | `--note` | Remove the note with exactly this text. |
53
+
54
+ Each option removes only what it names. The task itself and its status stay as they are.
55
+
42
56
  ## `add` — a to-do
43
57
 
44
58
  Creates an **open task**: nothing is marked worked and no note is written.
@@ -52,6 +66,7 @@ Creates an **open task**: nothing is marked worked and no note is written.
52
66
  | `--description` | What the task is about, written under its fields. |
53
67
  | `--tag` | A tag. Repeat it for several. |
54
68
  | `--parent` | A task id, making the new task its subtask. |
69
+ | `--repeat` | A repeat rule, as the app's `- repeat:` line takes it: `weekly on mon`, `monthly on last`, … Without `--due`, the first occurrence is the due date. |
55
70
  | `--link` | Issue or ticket url. |
56
71
 
57
72
  An open task with the same link or title is left as it is rather than duplicated.
@@ -70,6 +85,7 @@ several clients have one), then pass only what changes:
70
85
  | `--status` | A status id from `.worklog/config.json`. A closing one closes the task. |
71
86
  | `--tag` / `--untag` | Add or remove a tag. Repeatable. |
72
87
  | `--parent` | Move it under this task id, or `none` to make it top-level. |
88
+ | `--repeat` | A new repeat rule, or `none` to stop it repeating. Its `repeatFrom` and `repeatUntil` are kept. |
73
89
 
74
90
  A title that matches open tasks of several clients is refused, with their ids, rather than guessed.
75
91
 
@@ -87,8 +103,8 @@ due, priority, client, status, title), soonest due first.
87
103
  | --- | --- |
88
104
  | `--client` | Only this client's tasks; `todos` for the general to-do list. |
89
105
  | `--due-by` | Only tasks due on or before this day. |
90
- | `--tag` | Only tasks with this tag. |
91
- | `--json` | Print JSON instead, with descriptions, tags and links. |
106
+ | `--tag` | Only tasks with this tag. Repeat it for tasks with all of them. |
107
+ | `--json` | Print JSON instead, with descriptions, tags, links, the days worked and the minutes per day. |
92
108
 
93
109
  ## `hours` — time worked
94
110
 
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());
@@ -481,8 +691,8 @@ function rollToWeekday(iso, weekdays) {
481
691
 
482
692
  // src/parser/taskParser.ts
483
693
  var H1 = /^#\s+(.*)$/;
484
- var H22 = /^##\s+(.*)$/;
485
- var META2 = /^-\s+([A-Za-z][\w-]*)\s*:\s*(.*)$/;
694
+ var H23 = /^##\s+(.*)$/;
695
+ var META3 = /^-\s+([A-Za-z][\w-]*)\s*:\s*(.*)$/;
486
696
  var NOTES_HEADING = /^###\s+Notes\s*$/;
487
697
  var NOTE_ENTRY = /^-\s+(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\s+—\s+(.*)$/;
488
698
  var PROMPTS_HEADING = /^###\s+Prompts\s*$/;
@@ -518,6 +728,7 @@ function parseTaskFile(content, sourceFile, clientId) {
518
728
  lastDone: t.lastDone,
519
729
  repeatOf: t.repeatOf,
520
730
  workedOn: t.workedOn,
731
+ workedTime: t.workedTime,
521
732
  tags: t.tags,
522
733
  checklist,
523
734
  prompts,
@@ -529,7 +740,7 @@ function parseTaskFile(content, sourceFile, clientId) {
529
740
  };
530
741
  for (let i = 0; i < lines.length; i++) {
531
742
  const line = lines[i];
532
- const h2 = isTaskHeading(lines, i) ? H22.exec(line) : null;
743
+ const h2 = isTaskHeading(lines, i) ? H23.exec(line) : null;
533
744
  if (h2) {
534
745
  flush();
535
746
  current = {
@@ -548,7 +759,7 @@ function parseTaskFile(content, sourceFile, clientId) {
548
759
  continue;
549
760
  }
550
761
  if (inMeta) {
551
- const meta = META2.exec(line);
762
+ const meta = META3.exec(line);
552
763
  if (meta) {
553
764
  applyMeta(current.task, meta[1].toLowerCase(), meta[2].trim());
554
765
  continue;
@@ -655,7 +866,16 @@ function applyMeta(task, key, value) {
655
866
  if (!value) {
656
867
  break;
657
868
  }
658
- 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
+ }
659
879
  if (dates.length === 0) {
660
880
  break;
661
881
  }
@@ -851,7 +1071,8 @@ function serializeTask(task, clientId) {
851
1071
  out.push(`- repeatOf: ${task.repeatOf}`);
852
1072
  }
853
1073
  for (const worked of task.workedOn ?? []) {
854
- out.push(`- worked: ${worked}`);
1074
+ const minutes = task.workedTime?.[worked];
1075
+ out.push(`- worked: ${worked}${minutes ? ` ${formatDuration(minutes)}` : ""}`);
855
1076
  }
856
1077
  if (task.tags && task.tags.length) {
857
1078
  out.push(`- tags: ${task.tags.join(", ")}`);
@@ -950,7 +1171,7 @@ function bodyOf(lines) {
950
1171
  // src/parser/checklistParser.ts
951
1172
  var ITEM = /^(\s*)([-*+])\s+\[([ xX])\]\s?(.*)$/;
952
1173
  var H12 = /^#[ \t]+(.*)$/;
953
- var H23 = /^##[ \t]+(.*)$/;
1174
+ var H24 = /^##[ \t]+(.*)$/;
954
1175
  var LAST_RUN = /^-[ \t]+last run:[ \t]*(\d{4}-\d{2}-\d{2})[ \t]*$/;
955
1176
  function parseChecklistFile(content, sourceFile, id) {
956
1177
  const lines = content.split(/\r?\n/);
@@ -968,7 +1189,7 @@ function parseChecklistFile(content, sourceFile, id) {
968
1189
  });
969
1190
  continue;
970
1191
  }
971
- const h2 = H23.exec(line);
1192
+ const h2 = H24.exec(line);
972
1193
  if (h2) {
973
1194
  sections.push({ title: h2[1].trim(), line: i, items: [] });
974
1195
  continue;
@@ -993,208 +1214,6 @@ function parseChecklistFile(content, sourceFile, id) {
993
1214
  };
994
1215
  }
995
1216
 
996
- // src/parser/blocks.ts
997
- var ID_META2 = /^-\s+id\s*:\s*(.+?)\s*$/;
998
- function findBlockRange(content, taskId) {
999
- const lines = content.split(/\r?\n/);
1000
- let blockStart = -1;
1001
- let matched = false;
1002
- const ranges = [];
1003
- for (let i = 0; i < lines.length; i++) {
1004
- if (isTaskHeading(lines, i)) {
1005
- if (blockStart !== -1) {
1006
- ranges.push({ start: blockStart, end: i, matched });
1007
- }
1008
- blockStart = i;
1009
- matched = false;
1010
- } else if (blockStart !== -1) {
1011
- const m = ID_META2.exec(lines[i]);
1012
- if (m && m[1] === taskId) {
1013
- matched = true;
1014
- }
1015
- }
1016
- }
1017
- if (blockStart !== -1) {
1018
- ranges.push({ start: blockStart, end: lines.length, matched });
1019
- }
1020
- const hit = ranges.find((r) => r.matched);
1021
- return hit ? { start: hit.start, end: hit.end } : void 0;
1022
- }
1023
- function replaceBlock(content, taskId, newBlock) {
1024
- const range = findBlockRange(content, taskId);
1025
- if (!range) {
1026
- return void 0;
1027
- }
1028
- const lines = content.split(/\r?\n/);
1029
- const original = lines.slice(range.start, range.end);
1030
- const hadTrailingBlank = original.length > 0 && original[original.length - 1].trim() === "";
1031
- const replacement = newBlock.split(/\r?\n/);
1032
- if (hadTrailingBlank) {
1033
- replacement.push("");
1034
- }
1035
- const next = [...lines.slice(0, range.start), ...replacement, ...lines.slice(range.end)];
1036
- return next.join("\n").replace(/\n{3,}/g, "\n\n");
1037
- }
1038
- function extractBlock(content, taskId) {
1039
- const range = findBlockRange(content, taskId);
1040
- if (!range) {
1041
- return void 0;
1042
- }
1043
- const lines = content.split(/\r?\n/);
1044
- const blockLines = lines.slice(range.start, range.end);
1045
- while (blockLines.length && blockLines[blockLines.length - 1].trim() === "") {
1046
- blockLines.pop();
1047
- }
1048
- const before = lines.slice(0, range.start);
1049
- const after = lines.slice(range.end);
1050
- while (before.length && before[before.length - 1].trim() === "") {
1051
- before.pop();
1052
- }
1053
- const remainderLines = [...before];
1054
- if (before.length && after.some((l) => l.trim() !== "")) {
1055
- remainderLines.push("");
1056
- }
1057
- remainderLines.push(...after);
1058
- let remainder = remainderLines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "");
1059
- if (remainder) {
1060
- remainder += "\n";
1061
- }
1062
- return { block: blockLines.join("\n"), remainder, startLine: range.start };
1063
- }
1064
-
1065
- // src/parser/meetingParser.ts
1066
- var H24 = /^##\s+(.*)$/;
1067
- var META3 = /^-\s+([A-Za-z][\w-]*)\s*:\s*(.*)$/;
1068
- var ACTIONS_HEADING = /^###\s+Action items\s*$/i;
1069
- var ACTION_ENTRY = /^-\s+\[([ xX])\]\s*(.*)$/;
1070
- var ACTION_TASK = /^(.*?)\s+→\s+(t_[A-Za-z0-9]+)$/;
1071
- var TIME = /^(\d{1,2}):(\d{2})$/;
1072
- var DURATION = /^(?:(\d+(?:\.\d+)?)\s*h)?\s*(?:(\d+)\s*m(?:in)?)?$/i;
1073
- function parseDuration(value) {
1074
- const text = value.trim();
1075
- if (/^\d+$/.test(text)) {
1076
- const n = Number(text);
1077
- return n > 0 ? n : void 0;
1078
- }
1079
- const m = DURATION.exec(text);
1080
- if (!m || !m[1] && !m[2]) {
1081
- return void 0;
1082
- }
1083
- const minutes = Math.round(Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0));
1084
- return minutes > 0 ? minutes : void 0;
1085
- }
1086
- function normalizeTime(value) {
1087
- const m = TIME.exec(value.trim());
1088
- if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) {
1089
- return void 0;
1090
- }
1091
- return `${m[1].padStart(2, "0")}:${m[2]}`;
1092
- }
1093
- function parseMeetingFile(content, sourceFile) {
1094
- const lines = content.split(/\r?\n/);
1095
- const starts = [];
1096
- for (let i = 0; i < lines.length; i++) {
1097
- if (isTaskHeading(lines, i)) {
1098
- starts.push(i);
1099
- }
1100
- }
1101
- const meetings = [];
1102
- for (let b = 0; b < starts.length; b++) {
1103
- const end = b + 1 < starts.length ? starts[b + 1] : lines.length;
1104
- const meeting = parseBlock(lines.slice(starts[b], end), sourceFile, starts[b]);
1105
- if (meeting.id) {
1106
- meetings.push(meeting);
1107
- }
1108
- }
1109
- return meetings;
1110
- }
1111
- function parseBlock(lines, sourceFile, line) {
1112
- const meeting = {
1113
- id: "",
1114
- title: (H24.exec(lines[0])?.[1] ?? "").trim(),
1115
- date: "",
1116
- people: [],
1117
- notes: "",
1118
- actions: [],
1119
- sourceFile,
1120
- sourceLine: line
1121
- };
1122
- let i = 1;
1123
- for (; i < lines.length; i++) {
1124
- const meta = META3.exec(lines[i]);
1125
- if (!meta) {
1126
- break;
1127
- }
1128
- const value = meta[2].trim();
1129
- switch (meta[1].toLowerCase()) {
1130
- case "id":
1131
- meeting.id = value;
1132
- break;
1133
- case "date":
1134
- meeting.date = value;
1135
- break;
1136
- case "time":
1137
- meeting.time = normalizeTime(value) ?? meeting.time;
1138
- break;
1139
- case "duration":
1140
- meeting.duration = parseDuration(value) ?? meeting.duration;
1141
- break;
1142
- case "client":
1143
- meeting.clientId = value || void 0;
1144
- break;
1145
- case "people":
1146
- meeting.people = splitPeople(value);
1147
- break;
1148
- }
1149
- }
1150
- const body = trimBlank(lines.slice(i));
1151
- const at = actionsStart(body);
1152
- if (at < 0) {
1153
- meeting.notes = body.join("\n");
1154
- } else {
1155
- meeting.notes = trimBlank(body.slice(0, at)).join("\n");
1156
- meeting.actions = body.slice(at + 1).filter((l) => l.trim() !== "").map(parseAction);
1157
- }
1158
- return meeting;
1159
- }
1160
- function actionsStart(body) {
1161
- for (let i = body.length - 1; i >= 0; i--) {
1162
- if (ACTIONS_HEADING.test(body[i])) {
1163
- return body.slice(i + 1).every((l) => l.trim() === "" || ACTION_ENTRY.test(l)) ? i : -1;
1164
- }
1165
- }
1166
- return -1;
1167
- }
1168
- function parseAction(line) {
1169
- const m = ACTION_ENTRY.exec(line);
1170
- const done = m[1] !== " ";
1171
- const linked = ACTION_TASK.exec(m[2].trim());
1172
- return linked ? { text: linked[1].trim(), done, taskId: linked[2] } : { text: m[2].trim(), done };
1173
- }
1174
- function splitPeople(value) {
1175
- const seen = /* @__PURE__ */ new Set();
1176
- const out = [];
1177
- for (const raw of value.split(",")) {
1178
- const name = raw.trim();
1179
- if (name && !seen.has(name.toLowerCase())) {
1180
- seen.add(name.toLowerCase());
1181
- out.push(name);
1182
- }
1183
- }
1184
- return out;
1185
- }
1186
- function trimBlank(lines) {
1187
- let start = 0;
1188
- let end = lines.length;
1189
- while (start < end && lines[start].trim() === "") {
1190
- start++;
1191
- }
1192
- while (end > start && lines[end - 1].trim() === "") {
1193
- end--;
1194
- }
1195
- return lines.slice(start, end);
1196
- }
1197
-
1198
1217
  // src/model/status.ts
1199
1218
  var DEFAULT_STATUSES = [
1200
1219
  { id: "open", label: "Open" },
@@ -1905,14 +1924,36 @@ async function toggleTaskWorkedOn(store, taskId, date) {
1905
1924
  existing.delete(date);
1906
1925
  }
1907
1926
  const workedOn = [...existing].sort();
1927
+ const workedTime = marking ? task.workedTime : withoutDay(task.workedTime, date);
1908
1928
  const wip = marking && !isClosed(task) ? inProgressStatusId(store.getConfig().statuses) : void 0;
1909
1929
  await updateInPlace(store, task, (t) => ({
1910
1930
  ...t,
1911
1931
  workedOn,
1932
+ workedTime,
1912
1933
  status: wip ?? t.status
1913
1934
  }));
1914
1935
  await store.rebuild("toggleWorked");
1915
- 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;
1916
1957
  }
1917
1958
  async function addTaskNote(store, taskId, text) {
1918
1959
  const task = store.db.getTask(taskId);
@@ -1931,6 +1972,21 @@ async function addTaskNote(store, taskId, text) {
1931
1972
  await store.rebuild("addNote");
1932
1973
  return store.db.getTask(taskId) ?? task;
1933
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
+ }
1934
1990
  async function closeTaskById(store, taskId, completedDate = today()) {
1935
1991
  const terminal = terminalStatusId(store.getConfig().statuses);
1936
1992
  return closeCascade(store, taskId, terminal, completedDate);
@@ -2025,7 +2081,8 @@ async function rollOccurrence(store, args) {
2025
2081
  // come along, but unticked — the ticks belong to the occurrence that earned
2026
2082
  // them and stay on its snapshot.
2027
2083
  checklist: untickedChecklist(source.checklist),
2028
- workedOn: void 0
2084
+ workedOn: void 0,
2085
+ workedTime: void 0
2029
2086
  };
2030
2087
  const replaced = replaceBlock(
2031
2088
  content,
@@ -2109,11 +2166,32 @@ async function undoOccurrence(store, snapshot, template, statusId) {
2109
2166
  notes: snapshot.notes,
2110
2167
  prompts: snapshot.prompts,
2111
2168
  checklist: snapshot.checklist,
2112
- workedOn: snapshot.workedOn
2169
+ workedOn: snapshot.workedOn,
2170
+ workedTime: snapshot.workedTime
2113
2171
  }));
2114
2172
  await store.rebuild("undoOccurrence");
2115
2173
  return store.db.getTask(template.id) ?? template;
2116
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
+ }
2117
2195
  async function updateTask(store, taskId, fields) {
2118
2196
  const task = store.db.getTask(taskId);
2119
2197
  if (!task) {
@@ -2248,12 +2326,16 @@ async function logWork(store, input) {
2248
2326
  if (markedWorked) {
2249
2327
  task = await toggleTaskWorkedOn(store, task.id, date);
2250
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
+ }
2251
2333
  const note = input.note?.trim();
2252
2334
  const noteAdded = !!note && !(task.notes ?? []).some((n) => n.timestamp.startsWith(today()) && n.text === note);
2253
2335
  if (noteAdded) {
2254
2336
  task = await addTaskNote(store, task.id, note);
2255
2337
  }
2256
- return { task: store.db.getTask(task.id) ?? task, created, markedWorked, noteAdded };
2338
+ return { task: store.db.getTask(task.id) ?? task, created, markedWorked, timeSet, noteAdded };
2257
2339
  }
2258
2340
  function findTask(store, input) {
2259
2341
  if (input.taskId) {
@@ -2348,6 +2430,13 @@ async function editTask(store, ref, edit) {
2348
2430
  if (Object.keys(fields).length > 0) {
2349
2431
  task = await updateTask(store, task.id, fields);
2350
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
+ }
2351
2440
  if (edit.status !== void 0 && edit.status !== task.status) {
2352
2441
  const statuses = store.getConfig().statuses;
2353
2442
  if (!statuses.some((s) => s.id === edit.status)) {
@@ -2366,6 +2455,28 @@ async function closeTask(store, ref) {
2366
2455
  const closed = await closeTaskById(store, task.id);
2367
2456
  return { task: store.db.getTask(task.id) ?? closed, closed: true };
2368
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
+ }
2369
2480
  function requireParent(store, parentId, childId) {
2370
2481
  const parent = store.db.getTask(parentId);
2371
2482
  if (!parent) {
@@ -2388,6 +2499,7 @@ async function addTask(store, input) {
2388
2499
  if (input.parentId) {
2389
2500
  requireParent(store, input.parentId);
2390
2501
  }
2502
+ const repeat = input.repeat ? readRecurrence(input.repeat) : void 0;
2391
2503
  const task = await createTask(store, {
2392
2504
  clientId: input.clientId,
2393
2505
  title: input.title,
@@ -2396,15 +2508,16 @@ async function addTask(store, input) {
2396
2508
  priority: input.priority,
2397
2509
  description: input.description,
2398
2510
  tags: input.tags,
2399
- parentId: input.parentId
2511
+ parentId: input.parentId,
2512
+ repeat
2400
2513
  });
2401
2514
  return { task: store.db.getTask(task.id) ?? task, created: true };
2402
2515
  }
2403
2516
 
2404
2517
  // src/services/listTasks.ts
2405
2518
  function listTasks(store, filter = {}) {
2406
- const tag = filter.tag?.trim().toLowerCase();
2407
- 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) => !tag || (t.tags ?? []).some((x) => x.toLowerCase() === tag)).sort(
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(
2408
2521
  (a, b) => (a.due ?? "\uFFFF").localeCompare(b.due ?? "\uFFFF") || priorityRank(a.priority) - priorityRank(b.priority) || (a.created ?? "").localeCompare(b.created ?? "")
2409
2522
  );
2410
2523
  }
@@ -2512,7 +2625,8 @@ var NONE = "none";
2512
2625
  var USAGE = `Usage: worklog <command> [options]
2513
2626
 
2514
2627
  log Work that was done: marks the day worked on a task (creating the task
2515
- when it's new) and adds the note.
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.
2516
2630
  add Something still to do: creates an open task. No day is marked worked.
2517
2631
  Without --client it goes on the general to-do list.
2518
2632
  update Changes an existing task: due date, priority, description, ...
@@ -2528,7 +2642,7 @@ Every command:
2528
2642
  --client <id> client id from .worklog/config.json; "todos" is the
2529
2643
  general to-do list
2530
2644
 
2531
- Naming a task (log, update, done):
2645
+ Naming a task (log, unlog, update, done):
2532
2646
  --id <t_xxxxxx> this exact task
2533
2647
  --link <url> the task linked to this issue/ticket url
2534
2648
  --title <title> the open task with this title
@@ -2536,6 +2650,13 @@ Naming a task (log, update, done):
2536
2650
  log --client <id> --title <title>:
2537
2651
  --note <text> progress note added to the task
2538
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
2539
2660
 
2540
2661
  add --title <title>:
2541
2662
  --link <url> issue/ticket url
@@ -2544,6 +2665,8 @@ add --title <title>:
2544
2665
  --description <text> what the task is about
2545
2666
  --tag <tag> repeat for several tags
2546
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
2547
2670
 
2548
2671
  update:
2549
2672
  --rename <title> new title
@@ -2554,6 +2677,7 @@ update:
2554
2677
  --tag <tag> add a tag (repeatable)
2555
2678
  --untag <tag> remove a tag (repeatable)
2556
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
2557
2681
 
2558
2682
  hours --client <id> | --event <type>:
2559
2683
  --hours <amount> full, half, or a number of hours; 0 removes the entry.
@@ -2564,14 +2688,16 @@ hours --client <id> | --event <type>:
2564
2688
 
2565
2689
  list:
2566
2690
  --due-by <date> only tasks due on or before this day
2567
- --tag <tag> only tasks with this tag
2568
- --json print JSON instead of one line per task
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
2569
2694
 
2570
- log, add, update, done, hours:
2695
+ log, unlog, add, update, done, hours:
2571
2696
  --dry-run show the change, commit nothing`;
2572
2697
  var OPTIONS = {
2573
- log: ["client", "id", "link", "title", "note", "date", "dry-run"],
2574
- add: ["client", "link", "title", "due", "priority", "description", "tag", "parent", "dry-run"],
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"],
2575
2701
  update: [
2576
2702
  "client",
2577
2703
  "id",
@@ -2585,6 +2711,7 @@ var OPTIONS = {
2585
2711
  "tag",
2586
2712
  "untag",
2587
2713
  "parent",
2714
+ "repeat",
2588
2715
  "dry-run"
2589
2716
  ],
2590
2717
  done: ["client", "id", "link", "title", "dry-run"],
@@ -2593,6 +2720,7 @@ var OPTIONS = {
2593
2720
  };
2594
2721
  var HINTS = {
2595
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".`,
2596
2724
  due: "The day worked is --date; --due is when a to-do is due."
2597
2725
  };
2598
2726
  async function main() {
@@ -2605,6 +2733,7 @@ async function main() {
2605
2733
  id: { type: "string" },
2606
2734
  note: { type: "string" },
2607
2735
  date: { type: "string" },
2736
+ time: { type: "string" },
2608
2737
  due: { type: "string" },
2609
2738
  "due-by": { type: "string" },
2610
2739
  priority: { type: "string" },
@@ -2614,6 +2743,7 @@ async function main() {
2614
2743
  tag: { type: "string", multiple: true },
2615
2744
  untag: { type: "string", multiple: true },
2616
2745
  parent: { type: "string" },
2746
+ repeat: { type: "string" },
2617
2747
  event: { type: "string" },
2618
2748
  hours: { type: "string" },
2619
2749
  json: { type: "boolean" },
@@ -2660,6 +2790,10 @@ function validate(values) {
2660
2790
  throw new Error(`--${option} must be YYYY-MM-DD, got "${value}".`);
2661
2791
  }
2662
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
+ }
2663
2797
  const priority = values.priority;
2664
2798
  if (typeof priority === "string" && !PRIORITIES.some((p) => p.id === priority.trim().toLowerCase())) {
2665
2799
  throw new Error(`--priority must be one of ${PRIORITIES.map((p) => p.id).join(", ")}, got "${priority}".`);
@@ -2679,9 +2813,26 @@ function toEdit(command, values) {
2679
2813
 
2680
2814
  ${USAGE}`);
2681
2815
  }
2682
- const input = { clientId, title, taskId: ref.taskId, link: ref.link, note: str("note"), date: str("date") };
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
+ };
2683
2826
  return async (store) => describeLog(await logWork(store, input), input);
2684
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
+ }
2685
2836
  case "add": {
2686
2837
  const title = str("title");
2687
2838
  if (!title) {
@@ -2697,7 +2848,8 @@ ${USAGE}`);
2697
2848
  priority: str("priority"),
2698
2849
  description: str("description"),
2699
2850
  tags: list2("tag"),
2700
- parentId: str("parent")
2851
+ parentId: str("parent"),
2852
+ repeat: str("repeat")
2701
2853
  };
2702
2854
  return async (store) => describeAdd(await addTask(store, input));
2703
2855
  }
@@ -2711,10 +2863,11 @@ ${USAGE}`);
2711
2863
  status: str("status"),
2712
2864
  addTags: list2("tag"),
2713
2865
  removeTags: list2("untag"),
2714
- parentId: orNull(str("parent"))
2866
+ parentId: orNull(str("parent")),
2867
+ repeat: orNull(str("repeat"))
2715
2868
  };
2716
2869
  if (Object.values(edit).every((v) => v === void 0)) {
2717
- throw new Error(`Nothing to change: pass at least one of --rename, --due, --priority, --description, --status, --tag, --untag, --parent.`);
2870
+ throw new Error(`Nothing to change: pass at least one of --rename, --due, --priority, --description, --status, --tag, --untag, --parent, --repeat.`);
2718
2871
  }
2719
2872
  return async (store) => describeUpdate(await editTask(store, ref, edit));
2720
2873
  }
@@ -2797,7 +2950,7 @@ async function list(repo, values) {
2797
2950
  const tasks = listTasks(store, {
2798
2951
  clientId: values.client,
2799
2952
  dueBy: values["due-by"],
2800
- tag: values.tag?.[0]
2953
+ tags: values.tag
2801
2954
  });
2802
2955
  if (values.json) {
2803
2956
  console.log(JSON.stringify(tasks.map(toJson), null, 2));
@@ -2835,6 +2988,8 @@ function toJson(t) {
2835
2988
  links: t.links.map((l) => l.url),
2836
2989
  parent: t.parentId ?? null,
2837
2990
  repeats: !!t.repeat,
2991
+ worked: t.workedOn ?? [],
2992
+ minutes: t.workedTime ?? {},
2838
2993
  created: t.created ?? null,
2839
2994
  description: t.description ?? null,
2840
2995
  file: t.sourceFile
@@ -2873,19 +3028,28 @@ function listFiles(repo) {
2873
3028
  function label(task) {
2874
3029
  return `${task.id} "${task.title}" (${task.sourceFile})`;
2875
3030
  }
2876
- function describeLog({ task, created, markedWorked, noteAdded }, input) {
3031
+ function describeLog({ task, created, markedWorked, timeSet, noteAdded }, input) {
2877
3032
  const what = [
2878
3033
  created ? "created" : "found",
2879
3034
  markedWorked ? `marked worked ${input.date ?? today()}` : void 0,
3035
+ timeSet ? input.minutes ? `time ${formatDuration(input.minutes)}` : "time cleared" : void 0,
2880
3036
  noteAdded ? "note added" : void 0
2881
3037
  ].filter(Boolean);
2882
3038
  return `${label(task)}: ${what.join(", ")}`;
2883
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
+ }
2884
3047
  function describeAdd({ task, created }) {
2885
3048
  const what = [
2886
3049
  created ? "created" : "already exists, left as is",
2887
3050
  task.due ? `due ${task.due}` : void 0,
2888
- task.priority ? `priority ${task.priority}` : void 0
3051
+ task.priority ? `priority ${task.priority}` : void 0,
3052
+ task.repeat ? `repeats ${formatRecurrence(task.repeat)}` : void 0
2889
3053
  ].filter(Boolean);
2890
3054
  return `${label(task)}: ${what.join(", ")}`;
2891
3055
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estruyf/worklog-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Log work on a task, or add a to-do, in your Worklog timesheet repo from the command line or an AI agent.",
5
5
  "license": "MIT",
6
6
  "author": "Elio Struyf",