@estruyf/worklog-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +86 -8
  2. package/dist/worklog.js +907 -56
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,18 +1,30 @@
1
1
  # @estruyf/worklog-cli
2
2
 
3
- Log work on a task in your [Worklog](https://github.com/estruyf/worklog-web) timesheet repo with one
4
- command — made for AI agents that finish a piece of work and should leave a trace of it on your
5
- timesheet. The next time you open Worklog, the task is there, marked as worked.
3
+ Work with your [Worklog](https://github.com/estruyf/worklog-web) timesheet repo from the command line —
4
+ made for AI agents. Log the work they finished, add to-dos with a due date and priority, change or
5
+ close tasks, list what's open, and set the hours worked on a day. The next time you open Worklog, it's all there.
6
6
 
7
7
  ```bash
8
8
  npx @estruyf/worklog-cli log --client acme --title "Fix the mobile picker" \
9
9
  --link https://github.com/acme/app/issues/12 --note "Widened the tap targets"
10
+
11
+ npx @estruyf/worklog-cli add --client acme --title "Email Sam about the workshop" \
12
+ --due 2026-11-13 --priority high --description "Ask whether it becomes a roundtable or a session"
13
+
14
+ npx @estruyf/worklog-cli update --id t_469p6i --due 2026-11-20
15
+ npx @estruyf/worklog-cli done --id t_469p6i
16
+ npx @estruyf/worklog-cli list --due-by 2026-11-30
17
+ npx @estruyf/worklog-cli hours --client acme --hours 4 --note "Picker fixes"
10
18
  ```
11
19
 
12
20
  It edits a **local clone of your timesheet repo** and pushes with your own git credentials. It never
13
21
  talks to the Worklog site, so there is no token to set up.
14
22
 
15
- ## Options
23
+ Every command takes `--repo` (the clone; defaults to `$WORKLOG_REPO`), and every command that writes
24
+ takes `--dry-run` (print the diff, commit nothing). Running the same command twice changes nothing.
25
+ Each command refuses options it doesn't have, so a to-do's due date can never end up as a day worked.
26
+
27
+ ## `log` — work done
16
28
 
17
29
  | Option | Meaning |
18
30
  | --- | --- |
@@ -22,16 +34,82 @@ talks to the Worklog site, so there is no token to set up.
22
34
  | `--id` | Log on this exact task id instead. |
23
35
  | `--note` | Progress note added under the task's `### Notes`. |
24
36
  | `--date` | The day worked, `YYYY-MM-DD`. Defaults to today. |
25
- | `--repo` | Path to the clone. Defaults to `$WORKLOG_REPO`. |
26
- | `--dry-run` | Print the diff, commit nothing. |
27
37
 
28
38
  The task is matched by `--id`, then `--link`, then an **open task of the same title** for that
29
39
  client — so a task you typed into the app yourself is picked up rather than duplicated. With no match
30
- it is created. Running the same command twice changes nothing.
40
+ it is created.
41
+
42
+ ## `add` — a to-do
43
+
44
+ Creates an **open task**: nothing is marked worked and no note is written.
45
+
46
+ | Option | Meaning |
47
+ | --- | --- |
48
+ | `--title` | The task's title. Required. |
49
+ | `--client` | Client id. Leave it out for the general to-do list, the one that belongs to no client. |
50
+ | `--due` | The day the task is due, `YYYY-MM-DD`. |
51
+ | `--priority` | `urgent`, `high`, `normal` or `low`. Defaults to normal. |
52
+ | `--description` | What the task is about, written under its fields. |
53
+ | `--tag` | A tag. Repeat it for several. |
54
+ | `--parent` | A task id, making the new task its subtask. |
55
+ | `--link` | Issue or ticket url. |
56
+
57
+ An open task with the same link or title is left as it is rather than duplicated.
58
+
59
+ ## `update` — change a task
60
+
61
+ Name the task with `--id`, `--link` or `--title` (an open task's exact title; add `--client` when
62
+ several clients have one), then pass only what changes:
63
+
64
+ | Option | Meaning |
65
+ | --- | --- |
66
+ | `--rename` | A new title. |
67
+ | `--due` | A new due date, or `none` to clear it. |
68
+ | `--priority` | A new priority; `normal` clears it. |
69
+ | `--description` | A new description; `""` clears it. |
70
+ | `--status` | A status id from `.worklog/config.json`. A closing one closes the task. |
71
+ | `--tag` / `--untag` | Add or remove a tag. Repeatable. |
72
+ | `--parent` | Move it under this task id, or `none` to make it top-level. |
73
+
74
+ A title that matches open tasks of several clients is refused, with their ids, rather than guessed.
75
+
76
+ ## `done` — close a task
77
+
78
+ Named like `update`. The task moves to the archive with its subtasks, as when you close it in the app.
79
+ A recurring task rolls on to its next occurrence instead.
80
+
81
+ ## `list` — what's open
82
+
83
+ Read-only: it catches up with the remote first, and prints one tab-separated line per open task (id,
84
+ due, priority, client, status, title), soonest due first.
85
+
86
+ | Option | Meaning |
87
+ | --- | --- |
88
+ | `--client` | Only this client's tasks; `todos` for the general to-do list. |
89
+ | `--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. |
92
+
93
+ ## `hours` — time worked
94
+
95
+ Time is tracked per client per day, as in the app's day editor, with the same rules: one line per
96
+ client per day, `full` and `half` follow your working day (`hoursPerDay`), the general to-do list has
97
+ no hours and an archived client is refused.
98
+
99
+ | Option | Meaning |
100
+ | --- | --- |
101
+ | `--client` | The client the time is for. Or… |
102
+ | `--event` | …a day off: `vacation`, `public-holiday`, `out-of-office`, `conference`, `sick` or `other`. |
103
+ | `--hours` | `full`, `half` or a number of hours. **Sets** the day's total for that client; `0` removes it. |
104
+ | `--date` | The day, `YYYY-MM-DD`. Defaults to today. |
105
+ | `--note` | What the time went to. Left out, the note already there is kept. |
106
+
107
+ Without `--hours` it is read-only and prints what's logged that day, and the total against your
108
+ working day.
31
109
 
32
110
  ## Requirements
33
111
 
34
112
  - Node 20 or newer, and git.
35
113
  - A clone of your timesheet repo with no uncommitted changes and no unpushed commits — when its push
36
114
  loses a race with the app, the CLI resets the clone to the remote and replays its edit on top.
37
- - Hours are not logged: it records *that* you worked on a task, not for how long.
115
+ - `log` records *that* you worked on a task, not for how long; hours are `hours`, per client per day.
package/dist/worklog.js CHANGED
@@ -111,6 +111,9 @@ function toISODate(d) {
111
111
  const day = String(d.getDate()).padStart(2, "0");
112
112
  return `${y}-${m}-${day}`;
113
113
  }
114
+ function monthOf(isoDate) {
115
+ return isoDate.slice(0, 7);
116
+ }
114
117
  function nowStamp() {
115
118
  const d = /* @__PURE__ */ new Date();
116
119
  const hh = String(d.getHours()).padStart(2, "0");
@@ -379,6 +382,10 @@ function firstOccurrence(rec, from) {
379
382
  }
380
383
  return start;
381
384
  }
385
+ function nextDueAfterCompletion(rec, currentDue, completedDate) {
386
+ const base = rec.anchor === "completion" ? completedDate : currentDue ?? completedDate;
387
+ return nextOccurrence(rec, base);
388
+ }
382
389
  function stepFromCompletion(rec, base) {
383
390
  let next;
384
391
  switch (rec.unit) {
@@ -896,6 +903,13 @@ function parseWorklogFile(content, sourceFile) {
896
903
  }
897
904
  return entries;
898
905
  }
906
+ function serializeWorklogEntry(entry) {
907
+ const base = `- ${entry.date} ${entry.clientId} ${formatHours(entry.hours)}`;
908
+ return entry.note ? `${base} \u2014 ${entry.note}` : base;
909
+ }
910
+ function formatHours(hours) {
911
+ return Number.isInteger(hours) ? String(hours) : String(hours);
912
+ }
899
913
 
900
914
  // src/parser/dayNotes.ts
901
915
  var DAY_HEADING = /^##[ \t]+(\d{4}-\d{2}-\d{2})[ \t]*$/;
@@ -1202,14 +1216,14 @@ function normalizeStatuses(input) {
1202
1216
  }
1203
1217
  const candidate = raw;
1204
1218
  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)) {
1219
+ const label2 = typeof candidate.label === "string" ? candidate.label.trim() : "";
1220
+ if (!id || !label2 || seen.has(id)) {
1207
1221
  continue;
1208
1222
  }
1209
1223
  seen.add(id);
1210
1224
  defs.push({
1211
1225
  id,
1212
- label: label.slice(0, MAX_STATUS_LABEL),
1226
+ label: label2.slice(0, MAX_STATUS_LABEL),
1213
1227
  terminal: candidate.terminal === true || void 0,
1214
1228
  color: parseStatusColor(candidate.color)
1215
1229
  });
@@ -1230,6 +1244,12 @@ function normalizeStatuses(input) {
1230
1244
  { ...terminal, terminal: true }
1231
1245
  ];
1232
1246
  }
1247
+ function terminalStatusId(statuses) {
1248
+ return (statuses.find((s) => s.terminal) ?? statuses[statuses.length - 1])?.id ?? "done";
1249
+ }
1250
+ function isTerminalStatus(statuses, id) {
1251
+ return statuses.find((s) => s.id === id)?.terminal === true;
1252
+ }
1233
1253
  function openStatusId(statuses) {
1234
1254
  return (statuses.find((s) => !s.terminal) ?? statuses[0])?.id ?? "open";
1235
1255
  }
@@ -1400,8 +1420,8 @@ function parseLinks(value) {
1400
1420
  return [];
1401
1421
  }
1402
1422
  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 } : {} };
1423
+ const label2 = typeof l.label === "string" ? l.label.trim() : "";
1424
+ return { url: l.url.trim(), ...label2 ? { label: label2 } : {} };
1405
1425
  });
1406
1426
  }
1407
1427
  function parseClientLinks(value) {
@@ -1590,11 +1610,72 @@ function dirName(path) {
1590
1610
  return parts.pop() ?? "";
1591
1611
  }
1592
1612
 
1613
+ // src/model/todos.ts
1614
+ var GENERAL_TODO_CLIENT_ID = "todos";
1615
+ var GENERAL_TODO_LABEL = "To-dos";
1616
+ var GENERAL_TODO_COLOR = "#64748b";
1617
+ function isGeneralTodoClientId(id) {
1618
+ return id === GENERAL_TODO_CLIENT_ID;
1619
+ }
1620
+ function generalTodoClient() {
1621
+ return { id: GENERAL_TODO_CLIENT_ID, name: GENERAL_TODO_LABEL, color: GENERAL_TODO_COLOR };
1622
+ }
1623
+
1593
1624
  // src/model/worklog.ts
1594
1625
  var EVENT_WORKLOG_PREFIX = "event:";
1626
+ function eventWorklogClientId(eventType) {
1627
+ const normalized = eventType.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1628
+ if (!normalized) {
1629
+ throw new Error("Event type is required.");
1630
+ }
1631
+ return `${EVENT_WORKLOG_PREFIX}${normalized}`;
1632
+ }
1595
1633
  function isEventWorklogClientId(clientId) {
1596
1634
  return clientId.startsWith(EVENT_WORKLOG_PREFIX);
1597
1635
  }
1636
+ function eventTypeFromClientId(clientId) {
1637
+ return isEventWorklogClientId(clientId) ? clientId.slice(EVENT_WORKLOG_PREFIX.length) : "";
1638
+ }
1639
+ function formatEventTypeLabel(eventType) {
1640
+ return eventType.split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1641
+ }
1642
+ var EVENT_TYPES = [
1643
+ { id: "vacation", label: "Vacation" },
1644
+ { id: "public-holiday", label: "Public holiday" },
1645
+ { id: "out-of-office", label: "Out of office" },
1646
+ { id: "conference", label: "Conference" },
1647
+ { id: "sick", label: "Sick day" },
1648
+ { id: "other", label: "Other" }
1649
+ ];
1650
+ function eventLabel(eventType) {
1651
+ return EVENT_TYPES.find((e) => e.id === eventType)?.label ?? formatEventTypeLabel(eventType);
1652
+ }
1653
+ function roundHours(n) {
1654
+ return Math.round(n * 100) / 100;
1655
+ }
1656
+ function resolveHours(amount, hoursPerDay) {
1657
+ if (amount === "full") {
1658
+ return hoursPerDay;
1659
+ }
1660
+ if (amount === "half") {
1661
+ return hoursPerDay / 2;
1662
+ }
1663
+ const hours = typeof amount === "number" ? amount : Number(amount.trim().replace(/h$/i, ""));
1664
+ return isFinite(hours) && hours >= 0 && String(amount).trim() !== "" ? roundHours(hours) : void 0;
1665
+ }
1666
+ function unloggableReason(clients, clientId) {
1667
+ if (isGeneralTodoClientId(clientId)) {
1668
+ return "The general to-do list belongs to no client, so it has no hours.";
1669
+ }
1670
+ const client = clients.find((c) => c.id === clientId);
1671
+ if (!client) {
1672
+ return `Unknown client "${clientId}".`;
1673
+ }
1674
+ if (client.archived) {
1675
+ return `"${client.name}" is archived; restore it in Settings to log time on it.`;
1676
+ }
1677
+ return void 0;
1678
+ }
1598
1679
 
1599
1680
  // src/model/recurringTask.ts
1600
1681
  function withSeededDue(task, from = today()) {
@@ -1718,22 +1799,14 @@ var BY_ID = new Map(PRIORITIES.map((p) => [p.id, p]));
1718
1799
  function priorityDef(raw) {
1719
1800
  return raw ? BY_ID.get(raw.trim().toLowerCase()) : void 0;
1720
1801
  }
1802
+ function priorityRank(raw) {
1803
+ return (priorityDef(raw) ?? NORMAL).rank;
1804
+ }
1721
1805
  function writablePriority(raw) {
1722
1806
  const def = priorityDef(raw);
1723
1807
  return def && def.id !== NORMAL_PRIORITY_ID ? def.id : void 0;
1724
1808
  }
1725
1809
 
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
1810
  // src/parser/ids.ts
1738
1811
  var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
1739
1812
  function newTaskId(size = 6) {
@@ -1761,6 +1834,21 @@ async function appendTaskBlock(ws, client, block) {
1761
1834
  if (existing === void 0 || existing.trim() === "") {
1762
1835
  content = `# ${client.name}
1763
1836
 
1837
+ ${block}
1838
+ `;
1839
+ } else {
1840
+ content = existing.replace(/\s+$/, "") + "\n\n" + block + "\n";
1841
+ }
1842
+ await writeText(path, content);
1843
+ }
1844
+ async function appendArchiveBlock(ws, clientId, clientName, month, block) {
1845
+ await ensureDir(`${ws.archiveDir}/${clientId}`);
1846
+ const path = ws.archiveFile(clientId, month);
1847
+ const existing = await readText(path);
1848
+ let content;
1849
+ if (existing === void 0 || existing.trim() === "") {
1850
+ content = `# ${clientName} \u2014 ${month}
1851
+
1764
1852
  ${block}
1765
1853
  `;
1766
1854
  } else {
@@ -1770,9 +1858,37 @@ ${block}
1770
1858
  }
1771
1859
 
1772
1860
  // src/services/taskOps.ts
1861
+ function collectDescendants(store, taskId) {
1862
+ const out = [];
1863
+ const visit = (id) => {
1864
+ for (const child of store.db.getChildren(id)) {
1865
+ out.push(child);
1866
+ visit(child.id);
1867
+ }
1868
+ };
1869
+ visit(taskId);
1870
+ return out;
1871
+ }
1773
1872
  function isClosed(task) {
1774
1873
  return !!task.completed;
1775
1874
  }
1875
+ async function setTaskStatus(store, taskId, statusId) {
1876
+ const task = store.db.getTask(taskId);
1877
+ if (!task) {
1878
+ throw new Error(`Task ${taskId} not found.`);
1879
+ }
1880
+ const statuses = store.getConfig().statuses;
1881
+ const goingTerminal = isTerminalStatus(statuses, statusId);
1882
+ if (goingTerminal && !isClosed(task)) {
1883
+ return closeCascade(store, taskId, statusId, today());
1884
+ }
1885
+ if (!goingTerminal && isClosed(task)) {
1886
+ return reopenTask(store, taskId, statusId);
1887
+ }
1888
+ await updateInPlace(store, task, (t) => ({ ...t, status: statusId }));
1889
+ await store.rebuild("setStatus");
1890
+ return { ...task, status: statusId };
1891
+ }
1776
1892
  async function toggleTaskWorkedOn(store, taskId, date) {
1777
1893
  const task = store.db.getTask(taskId);
1778
1894
  if (!task) {
@@ -1815,6 +1931,246 @@ async function addTaskNote(store, taskId, text) {
1815
1931
  await store.rebuild("addNote");
1816
1932
  return store.db.getTask(taskId) ?? task;
1817
1933
  }
1934
+ async function closeTaskById(store, taskId, completedDate = today()) {
1935
+ const terminal = terminalStatusId(store.getConfig().statuses);
1936
+ return closeCascade(store, taskId, terminal, completedDate);
1937
+ }
1938
+ async function closeCascade(store, taskId, statusId, completedDate, endSeries = false) {
1939
+ const root = store.db.getTask(taskId);
1940
+ if (!root) {
1941
+ throw new Error(`Task ${taskId} not found.`);
1942
+ }
1943
+ const openDescendants = collectDescendants(store, taskId).filter(
1944
+ (t) => !isClosed(t)
1945
+ );
1946
+ for (const child of openDescendants) {
1947
+ await closeOne(store, child, statusId, completedDate, endSeries);
1948
+ }
1949
+ const closed = await closeOne(store, root, statusId, completedDate, endSeries);
1950
+ await store.rebuild("closeTask");
1951
+ return closed;
1952
+ }
1953
+ async function closeOne(store, task, statusId, completedDate, endSeries = false) {
1954
+ const clientId = task.clientIds[0];
1955
+ const clientName = store.db.getClients().find((c) => c.id === clientId)?.name ?? clientId;
1956
+ const clientUri = store.ws.clientFile(clientId);
1957
+ const content = await readText(clientUri);
1958
+ if (content === void 0) {
1959
+ throw new Error(`Could not read ${clientId}.md to close "${task.title}".`);
1960
+ }
1961
+ const extracted = extractBlock(content, task.id);
1962
+ if (!extracted) {
1963
+ throw new Error(`Could not locate task ${task.id} in ${clientId}.md.`);
1964
+ }
1965
+ const source = withSeededDue(
1966
+ parseTaskFile(extracted.block, clientUri, clientId).tasks[0] ?? task
1967
+ );
1968
+ if (source.repeat && !endSeries) {
1969
+ const nextDue = nextDueAfterCompletion(
1970
+ source.repeat,
1971
+ source.due,
1972
+ completedDate
1973
+ );
1974
+ if (nextDue) {
1975
+ return rollOccurrence(store, {
1976
+ source,
1977
+ clientId,
1978
+ clientName,
1979
+ clientUri,
1980
+ content,
1981
+ statusId,
1982
+ completedDate,
1983
+ nextDue
1984
+ });
1985
+ }
1986
+ }
1987
+ const closed = {
1988
+ ...source,
1989
+ status: statusId,
1990
+ completed: completedDate
1991
+ };
1992
+ await writeText(clientUri, extracted.remainder);
1993
+ await appendArchiveBlock(
1994
+ store.ws,
1995
+ clientId,
1996
+ clientName,
1997
+ monthOf(closed.completed),
1998
+ serializeTask(closed, clientId)
1999
+ );
2000
+ return closed;
2001
+ }
2002
+ async function rollOccurrence(store, args) {
2003
+ const { source, clientId, clientName, clientUri, content } = args;
2004
+ const snapshot = {
2005
+ ...source,
2006
+ id: newTaskId(),
2007
+ status: args.statusId,
2008
+ completed: args.completedDate,
2009
+ repeat: void 0,
2010
+ lastDone: void 0,
2011
+ repeatOf: source.id
2012
+ };
2013
+ const live = {
2014
+ ...source,
2015
+ status: openStatusId(store.getConfig().statuses),
2016
+ due: args.nextDue,
2017
+ lastDone: args.completedDate,
2018
+ completed: void 0,
2019
+ notes: void 0,
2020
+ // The queue is the plan and comes along; the prompts that ran belong to the
2021
+ // occurrence that ran them and stay on its snapshot, or a weekly task would
2022
+ // carry every prompt it ever ran into every future week.
2023
+ prompts: queuedPrompts(source.prompts),
2024
+ // Same reasoning one step further: the steps are the plan for the series and
2025
+ // come along, but unticked — the ticks belong to the occurrence that earned
2026
+ // them and stay on its snapshot.
2027
+ checklist: untickedChecklist(source.checklist),
2028
+ workedOn: void 0
2029
+ };
2030
+ const replaced = replaceBlock(
2031
+ content,
2032
+ source.id,
2033
+ serializeTask(live, clientId)
2034
+ );
2035
+ if (replaced === void 0) {
2036
+ throw new Error(`Could not locate task ${source.id} to roll forward.`);
2037
+ }
2038
+ await writeText(clientUri, replaced.replace(/\s*$/, "") + "\n");
2039
+ await appendArchiveBlock(
2040
+ store.ws,
2041
+ clientId,
2042
+ clientName,
2043
+ monthOf(args.completedDate),
2044
+ serializeTask(snapshot, clientId)
2045
+ );
2046
+ return live;
2047
+ }
2048
+ function queuedPrompts(prompts) {
2049
+ const queued = (prompts ?? []).filter((p) => !p.ran);
2050
+ return queued.length ? queued : void 0;
2051
+ }
2052
+ function untickedChecklist(checklist) {
2053
+ return checklist?.length ? checklist.map((i) => ({ ...i, done: false })) : void 0;
2054
+ }
2055
+ async function reopenTask(store, taskId, statusId) {
2056
+ const task = store.db.getTask(taskId);
2057
+ if (!task) {
2058
+ throw new Error(`Task ${taskId} not found.`);
2059
+ }
2060
+ if (task.repeatOf) {
2061
+ const template = store.db.getTask(task.repeatOf);
2062
+ if (template?.repeat) {
2063
+ return undoOccurrence(store, task, template, statusId);
2064
+ }
2065
+ }
2066
+ const clientId = task.clientIds[0];
2067
+ const client = store.db.getClients().find((c) => c.id === clientId);
2068
+ const archiveUri = task.sourceFile;
2069
+ const content = await readText(archiveUri);
2070
+ if (content === void 0) {
2071
+ throw new Error(`Could not read the archive file for "${task.title}".`);
2072
+ }
2073
+ const extracted = extractBlock(content, task.id);
2074
+ if (!extracted) {
2075
+ throw new Error(`Could not locate task ${task.id} in the archive.`);
2076
+ }
2077
+ const parsed = parseTaskFile(extracted.block, archiveUri, clientId).tasks[0];
2078
+ const reopened = {
2079
+ ...parsed ?? task,
2080
+ status: statusId,
2081
+ completed: void 0
2082
+ };
2083
+ await writeText(archiveUri, extracted.remainder);
2084
+ await appendTaskBlock(
2085
+ store.ws,
2086
+ client ?? { id: clientId, name: clientId },
2087
+ serializeTask(reopened, clientId)
2088
+ );
2089
+ await store.rebuild("reopenTask");
2090
+ return reopened;
2091
+ }
2092
+ async function undoOccurrence(store, snapshot, template, statusId) {
2093
+ const archiveUri = snapshot.sourceFile;
2094
+ const archiveContent = await readText(archiveUri);
2095
+ if (archiveContent === void 0) {
2096
+ throw new Error(`Could not read the archive file for "${snapshot.title}".`);
2097
+ }
2098
+ const extracted = extractBlock(archiveContent, snapshot.id);
2099
+ if (!extracted) {
2100
+ throw new Error(`Could not locate occurrence ${snapshot.id} in the archive.`);
2101
+ }
2102
+ const previous = store.db.occurrencesOf(template.id).find((o) => o.id !== snapshot.id && o.completed);
2103
+ await writeText(archiveUri, extracted.remainder);
2104
+ await updateInPlace(store, template, (t) => ({
2105
+ ...t,
2106
+ status: statusId,
2107
+ due: snapshot.due,
2108
+ lastDone: previous?.completed,
2109
+ notes: snapshot.notes,
2110
+ prompts: snapshot.prompts,
2111
+ checklist: snapshot.checklist,
2112
+ workedOn: snapshot.workedOn
2113
+ }));
2114
+ await store.rebuild("undoOccurrence");
2115
+ return store.db.getTask(template.id) ?? template;
2116
+ }
2117
+ async function updateTask(store, taskId, fields) {
2118
+ const task = store.db.getTask(taskId);
2119
+ if (!task) {
2120
+ throw new Error(`Task ${taskId} not found.`);
2121
+ }
2122
+ const current = task.clientIds[0];
2123
+ const target = fields.clientId && fields.clientId.trim() ? fields.clientId : current;
2124
+ const apply = (t) => withSeededDue({
2125
+ ...t,
2126
+ title: fields.title !== void 0 ? fields.title.trim() || t.title : t.title,
2127
+ // Passing the field at all is a decision about priority, so an unrecognised
2128
+ // value clears it rather than leaving the old one standing.
2129
+ priority: fields.priority !== void 0 ? writablePriority(fields.priority) : t.priority,
2130
+ parentId: fields.parentId !== void 0 ? fields.parentId || void 0 : t.parentId,
2131
+ links: fields.links !== void 0 ? parseLinks(fields.links) : t.links,
2132
+ description: fields.description !== void 0 ? fields.description.trim() || void 0 : t.description,
2133
+ due: fields.due !== void 0 ? fields.due.trim() || void 0 : t.due,
2134
+ tags: fields.tags !== void 0 ? fields.tags.map((tag) => tag.trim()).filter(Boolean) : t.tags,
2135
+ repeat: fields.repeat !== void 0 ? fields.repeat ?? void 0 : t.repeat,
2136
+ // Dropping the rule drops the series' bookkeeping with it.
2137
+ lastDone: fields.repeat === null ? void 0 : t.lastDone
2138
+ });
2139
+ if (target === current) {
2140
+ await updateInPlace(store, task, apply);
2141
+ } else {
2142
+ const uri = task.sourceFile;
2143
+ const content = await readText(uri);
2144
+ if (content === void 0) {
2145
+ throw new Error(`Could not read ${task.sourceFile}.`);
2146
+ }
2147
+ const extracted = extractBlock(content, task.id);
2148
+ if (!extracted) {
2149
+ throw new Error(`Could not locate task ${task.id}.`);
2150
+ }
2151
+ const parsed = parseTaskFile(extracted.block, uri, current).tasks[0] ?? task;
2152
+ const moved = apply({ ...parsed, clientIds: [target] });
2153
+ await writeText(uri, extracted.remainder);
2154
+ if (moved.completed) {
2155
+ const name = store.db.getClients().find((c) => c.id === target)?.name ?? target;
2156
+ await appendArchiveBlock(
2157
+ store.ws,
2158
+ target,
2159
+ name,
2160
+ monthOf(moved.completed),
2161
+ serializeTask(moved, target)
2162
+ );
2163
+ } else {
2164
+ const client = store.db.getClients().find((c) => c.id === target) ?? {
2165
+ id: target,
2166
+ name: target
2167
+ };
2168
+ await appendTaskBlock(store.ws, client, serializeTask(moved, target));
2169
+ }
2170
+ }
2171
+ await store.rebuild("updateTask");
2172
+ return store.db.getTask(taskId) ?? task;
2173
+ }
1818
2174
  async function updateInPlace(store, task, mutate) {
1819
2175
  const uri = task.sourceFile;
1820
2176
  const content = await readText(uri);
@@ -1917,22 +2273,328 @@ function normalizeUrl(url) {
1917
2273
  return url.trim().replace(/\/+$/, "");
1918
2274
  }
1919
2275
 
2276
+ // src/services/editTask.ts
2277
+ function requireTask(store, ref, { includeClosed = false } = {}) {
2278
+ if (ref.taskId) {
2279
+ const byId = store.db.getTask(ref.taskId);
2280
+ if (!byId) {
2281
+ throw new Error(`Task ${ref.taskId} not found.`);
2282
+ }
2283
+ return byId;
2284
+ }
2285
+ if (!ref.link && !ref.title) {
2286
+ throw new Error("Name the task with an id, a link or a title.");
2287
+ }
2288
+ const scoped = store.db.getAllTasks().filter((t) => !ref.clientId || t.clientIds.includes(ref.clientId));
2289
+ const link = ref.link ? normalizeUrl(ref.link) : void 0;
2290
+ const title = ref.title?.trim().toLowerCase();
2291
+ const matches = scoped.filter(
2292
+ (t) => link ? t.links.some((l) => normalizeUrl(l.url) === link) : t.title.trim().toLowerCase() === title
2293
+ );
2294
+ const named = link ? `linked to ${ref.link}` : `called "${ref.title}"`;
2295
+ const open = matches.filter((t) => !t.completed);
2296
+ if (open.length > 1) {
2297
+ const list2 = open.map((t) => `${t.id} (${t.sourceFile})`).join(", ");
2298
+ throw new Error(`Several open tasks are ${named}: ${list2}. Pass --id, or --client to narrow it down.`);
2299
+ }
2300
+ if (open.length === 1) {
2301
+ return open[0];
2302
+ }
2303
+ const closed = matches.filter((t) => t.completed).sort((a, b) => (b.completed ?? "").localeCompare(a.completed ?? ""))[0];
2304
+ if (includeClosed && closed) {
2305
+ return closed;
2306
+ }
2307
+ throw new Error(`No open task is ${named}${ref.clientId ? ` for client "${ref.clientId}"` : ""}.`);
2308
+ }
2309
+ async function editTask(store, ref, edit) {
2310
+ let task = requireTask(store, ref);
2311
+ const fields = {};
2312
+ const changed = [];
2313
+ const title = edit.title?.trim();
2314
+ if (title && title !== task.title) {
2315
+ fields.title = title;
2316
+ changed.push("title");
2317
+ }
2318
+ if (edit.due !== void 0 && (edit.due ?? void 0) !== task.due) {
2319
+ fields.due = edit.due ?? "";
2320
+ changed.push("due");
2321
+ }
2322
+ if (edit.priority !== void 0 && writablePriority(edit.priority) !== writablePriority(task.priority)) {
2323
+ fields.priority = edit.priority;
2324
+ changed.push("priority");
2325
+ }
2326
+ if (edit.description !== void 0 && (edit.description.trim() || void 0) !== task.description) {
2327
+ fields.description = edit.description;
2328
+ changed.push("description");
2329
+ }
2330
+ if (edit.addTags?.length || edit.removeTags?.length) {
2331
+ const current = task.tags ?? [];
2332
+ const remove = new Set((edit.removeTags ?? []).map((t) => t.trim().toLowerCase()));
2333
+ const kept = current.filter((t) => !remove.has(t.toLowerCase()));
2334
+ const added = (edit.addTags ?? []).map((t) => t.trim()).filter((t) => t && !kept.some((k) => k.toLowerCase() === t.toLowerCase()));
2335
+ const tags = [...kept, ...added];
2336
+ if (tags.join("\n") !== current.join("\n")) {
2337
+ fields.tags = tags;
2338
+ changed.push("tags");
2339
+ }
2340
+ }
2341
+ if (edit.parentId !== void 0 && (edit.parentId ?? void 0) !== task.parentId) {
2342
+ if (edit.parentId) {
2343
+ requireParent(store, edit.parentId, task.id);
2344
+ }
2345
+ fields.parentId = edit.parentId ?? "";
2346
+ changed.push("parent");
2347
+ }
2348
+ if (Object.keys(fields).length > 0) {
2349
+ task = await updateTask(store, task.id, fields);
2350
+ }
2351
+ if (edit.status !== void 0 && edit.status !== task.status) {
2352
+ const statuses = store.getConfig().statuses;
2353
+ if (!statuses.some((s) => s.id === edit.status)) {
2354
+ throw new Error(`Unknown status "${edit.status}". Known: ${statuses.map((s) => s.id).join(", ")}.`);
2355
+ }
2356
+ task = await setTaskStatus(store, task.id, edit.status);
2357
+ changed.push("status");
2358
+ }
2359
+ return { task: store.db.getTask(task.id) ?? task, changed };
2360
+ }
2361
+ async function closeTask(store, ref) {
2362
+ const task = requireTask(store, ref, { includeClosed: true });
2363
+ if (task.completed || task.repeat && task.lastDone === today()) {
2364
+ return { task, closed: false };
2365
+ }
2366
+ const closed = await closeTaskById(store, task.id);
2367
+ return { task: store.db.getTask(task.id) ?? closed, closed: true };
2368
+ }
2369
+ function requireParent(store, parentId, childId) {
2370
+ const parent = store.db.getTask(parentId);
2371
+ if (!parent) {
2372
+ throw new Error(`Parent task ${parentId} not found.`);
2373
+ }
2374
+ for (let p = parent; p; p = p.parentId ? store.db.getTask(p.parentId) : void 0) {
2375
+ if (p.id === childId) {
2376
+ throw new Error(`Task ${childId} can't go under ${parentId}: that is the task itself or one of its subtasks.`);
2377
+ }
2378
+ }
2379
+ return parent;
2380
+ }
2381
+
2382
+ // src/services/addTask.ts
2383
+ async function addTask(store, input) {
2384
+ const existing = findTask(store, input);
2385
+ if (existing) {
2386
+ return { task: existing, created: false };
2387
+ }
2388
+ if (input.parentId) {
2389
+ requireParent(store, input.parentId);
2390
+ }
2391
+ const task = await createTask(store, {
2392
+ clientId: input.clientId,
2393
+ title: input.title,
2394
+ links: input.link ? [input.link] : void 0,
2395
+ due: input.due,
2396
+ priority: input.priority,
2397
+ description: input.description,
2398
+ tags: input.tags,
2399
+ parentId: input.parentId
2400
+ });
2401
+ return { task: store.db.getTask(task.id) ?? task, created: true };
2402
+ }
2403
+
2404
+ // src/services/listTasks.ts
2405
+ 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(
2408
+ (a, b) => (a.due ?? "\uFFFF").localeCompare(b.due ?? "\uFFFF") || priorityRank(a.priority) - priorityRank(b.priority) || (a.created ?? "").localeCompare(b.created ?? "")
2409
+ );
2410
+ }
2411
+
2412
+ // src/services/worklog.ts
2413
+ function withEntry(existing, month, date, clientId, hours, note) {
2414
+ const line = serializeWorklogEntry({ date, clientId, hours, note });
2415
+ if (existing === void 0 || existing.trim() === "") {
2416
+ return `# Worklog ${month}
2417
+
2418
+ ${line}
2419
+ `;
2420
+ }
2421
+ const lines = existing.split(/\r?\n/);
2422
+ const prefix = `- ${date} ${clientId} `;
2423
+ const idx = lines.findIndex((l) => l.startsWith(prefix));
2424
+ if (idx >= 0) {
2425
+ lines[idx] = line;
2426
+ } else {
2427
+ let insertAt = lines.length;
2428
+ while (insertAt > 0 && lines[insertAt - 1].trim() === "") {
2429
+ insertAt--;
2430
+ }
2431
+ lines.splice(insertAt, 0, line);
2432
+ }
2433
+ return lines.join("\n").replace(/\s+$/, "") + "\n";
2434
+ }
2435
+ async function setWorklog(store, date, clientId, hours, note) {
2436
+ if (!(hours > 0)) {
2437
+ return removeWorklog(store, date, clientId);
2438
+ }
2439
+ const month = monthOf(date);
2440
+ const uri = store.ws.worklogFile(month);
2441
+ await ensureDir(store.ws.worklogDir);
2442
+ const existing = await readText(uri);
2443
+ await writeText(uri, withEntry(existing, month, date, clientId, hours, note));
2444
+ await store.rebuild("setWorklog");
2445
+ }
2446
+ async function removeWorklog(store, date, clientId) {
2447
+ const uri = store.ws.worklogFile(monthOf(date));
2448
+ const existing = await readText(uri);
2449
+ if (existing === void 0) {
2450
+ return;
2451
+ }
2452
+ const prefix = `- ${date} ${clientId} `;
2453
+ const lines = existing.split(/\r?\n/).filter((l) => !l.startsWith(prefix));
2454
+ await writeText(uri, lines.join("\n").replace(/\s+$/, "") + "\n");
2455
+ await store.rebuild("removeWorklog");
2456
+ }
2457
+
2458
+ // src/services/logHours.ts
2459
+ async function logHours(store, input) {
2460
+ const config = store.getConfig();
2461
+ const date = input.date ?? today();
2462
+ if (!!input.clientId === !!input.event) {
2463
+ throw new Error("Name either a client or a day event.");
2464
+ }
2465
+ let target;
2466
+ if (input.event) {
2467
+ target = eventWorklogClientId(input.event);
2468
+ if (!EVENT_TYPES.some((e) => e.id === eventTypeFromClientId(target))) {
2469
+ throw new Error(`Unknown day event "${input.event}". Known: ${EVENT_TYPES.map((e) => e.id).join(", ")}.`);
2470
+ }
2471
+ } else {
2472
+ target = input.clientId;
2473
+ const reason = unloggableReason(store.db.getClients(), target);
2474
+ if (reason) {
2475
+ throw new Error(reason);
2476
+ }
2477
+ }
2478
+ const hours = resolveHours(input.hours, config.hoursPerDay);
2479
+ if (hours === void 0) {
2480
+ throw new Error(`Hours must be full, half or a number, got "${input.hours}".`);
2481
+ }
2482
+ const previous = dayEntries(store, date).find((e) => e.clientId === target);
2483
+ const note = input.note !== void 0 ? input.note.trim() || void 0 : previous?.note;
2484
+ const changed = hours > 0 ? previous?.hours !== hours || previous?.note !== note : !!previous;
2485
+ if (changed) {
2486
+ await setWorklog(store, date, target, hours, note);
2487
+ }
2488
+ return {
2489
+ target,
2490
+ date,
2491
+ hours,
2492
+ previous,
2493
+ changed,
2494
+ dayTotal: roundHours(dayEntries(store, date).reduce((sum, e) => sum + e.hours, 0)),
2495
+ hoursPerDay: config.hoursPerDay
2496
+ };
2497
+ }
2498
+ function dayEntries(store, date) {
2499
+ return store.db.getAllWorklog().filter((e) => e.date === date);
2500
+ }
2501
+ function targetLabel(store, target) {
2502
+ if (isEventWorklogClientId(target)) {
2503
+ return eventLabel(eventTypeFromClientId(target));
2504
+ }
2505
+ return store.db.getClients().find((c) => c.id === target)?.name ?? target;
2506
+ }
2507
+
1920
2508
  // cli/worklog.ts
1921
2509
  var TEXT_PATH = /\.(md|json)$|\.worklog$/;
1922
2510
  var PUSH_ATTEMPTS = 3;
1923
- var USAGE = `Usage: worklog log --client <id> --title <title> [options]
2511
+ var NONE = "none";
2512
+ var USAGE = `Usage: worklog <command> [options]
2513
+
2514
+ log Work that was done: marks the day worked on a task (creating the task
2515
+ when it's new) and adds the note.
2516
+ add Something still to do: creates an open task. No day is marked worked.
2517
+ Without --client it goes on the general to-do list.
2518
+ update Changes an existing task: due date, priority, description, ...
2519
+ done Closes a task. A recurring one rolls on to its next occurrence.
2520
+ list Prints the open tasks. Read-only.
2521
+ hours Sets the hours worked for a client (or a day event) on a day. Without
2522
+ --hours it prints what's logged that day.
2523
+
2524
+ All but list, and hours without --hours, commit and push to your timesheet repo.
2525
+
2526
+ Every command:
2527
+ --repo <path> timesheet clone (default: $WORKLOG_REPO)
2528
+ --client <id> client id from .worklog/config.json; "todos" is the
2529
+ general to-do list
2530
+
2531
+ Naming a task (log, update, done):
2532
+ --id <t_xxxxxx> this exact task
2533
+ --link <url> the task linked to this issue/ticket url
2534
+ --title <title> the open task with this title
2535
+
2536
+ log --client <id> --title <title>:
2537
+ --note <text> progress note added to the task
2538
+ --date <date> day worked, YYYY-MM-DD (default: today)
1924
2539
 
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.
2540
+ add --title <title>:
2541
+ --link <url> issue/ticket url
2542
+ --due <date> day the task is due, YYYY-MM-DD
2543
+ --priority <level> ${PRIORITIES.map((p) => p.id).join(", ")}
2544
+ --description <text> what the task is about
2545
+ --tag <tag> repeat for several tags
2546
+ --parent <t_xxxxxx> make it a subtask of this task
1927
2547
 
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`;
2548
+ update:
2549
+ --rename <title> new title
2550
+ --due <date|none> new due date, or none to clear it
2551
+ --priority <level> new priority; normal clears it
2552
+ --description <text> new description; "" clears it
2553
+ --status <id> a status from .worklog/config.json
2554
+ --tag <tag> add a tag (repeatable)
2555
+ --untag <tag> remove a tag (repeatable)
2556
+ --parent <id|none> move it under this task, or none to make it top-level
2557
+
2558
+ hours --client <id> | --event <type>:
2559
+ --hours <amount> full, half, or a number of hours; 0 removes the entry.
2560
+ Sets the day's total for that client, it doesn't add
2561
+ --date <date> the day, YYYY-MM-DD (default: today)
2562
+ --note <text> what the time went to (default: keep the note there)
2563
+ --event <type> ${EVENT_TYPES.map((e) => e.id).join(", ")}
2564
+
2565
+ list:
2566
+ --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
2569
+
2570
+ log, add, update, done, hours:
2571
+ --dry-run show the change, commit nothing`;
2572
+ var OPTIONS = {
2573
+ log: ["client", "id", "link", "title", "note", "date", "dry-run"],
2574
+ add: ["client", "link", "title", "due", "priority", "description", "tag", "parent", "dry-run"],
2575
+ update: [
2576
+ "client",
2577
+ "id",
2578
+ "link",
2579
+ "title",
2580
+ "rename",
2581
+ "due",
2582
+ "priority",
2583
+ "description",
2584
+ "status",
2585
+ "tag",
2586
+ "untag",
2587
+ "parent",
2588
+ "dry-run"
2589
+ ],
2590
+ done: ["client", "id", "link", "title", "dry-run"],
2591
+ list: ["client", "due-by", "tag", "json"],
2592
+ hours: ["client", "event", "hours", "date", "note", "dry-run"]
2593
+ };
2594
+ var HINTS = {
2595
+ date: `A to-do's date is --due; --date is the day worked, for "worklog log" and "worklog hours".`,
2596
+ due: "The day worked is --date; --due is when a to-do is due."
2597
+ };
1936
2598
  async function main() {
1937
2599
  const { values, positionals } = parseArgs({
1938
2600
  allowPositionals: true,
@@ -1943,6 +2605,18 @@ async function main() {
1943
2605
  id: { type: "string" },
1944
2606
  note: { type: "string" },
1945
2607
  date: { type: "string" },
2608
+ due: { type: "string" },
2609
+ "due-by": { type: "string" },
2610
+ priority: { type: "string" },
2611
+ description: { type: "string" },
2612
+ rename: { type: "string" },
2613
+ status: { type: "string" },
2614
+ tag: { type: "string", multiple: true },
2615
+ untag: { type: "string", multiple: true },
2616
+ parent: { type: "string" },
2617
+ event: { type: "string" },
2618
+ hours: { type: "string" },
2619
+ json: { type: "boolean" },
1946
2620
  repo: { type: "string" },
1947
2621
  "dry-run": { type: "boolean" },
1948
2622
  help: { type: "boolean", short: "h" }
@@ -1952,35 +2626,117 @@ async function main() {
1952
2626
  console.log(USAGE);
1953
2627
  return;
1954
2628
  }
1955
- if (positionals[0] !== "log" || positionals.length > 1) {
2629
+ const command = positionals[0];
2630
+ if (!(command in OPTIONS) || positionals.length > 1) {
1956
2631
  throw new Error(`Unknown command "${positionals.join(" ")}".
1957
2632
 
1958
2633
  ${USAGE}`);
1959
2634
  }
1960
- if (!values.client || !values.title) {
1961
- throw new Error(`--client and --title are required.
2635
+ const allowed = /* @__PURE__ */ new Set([...OPTIONS[command], "repo", "help"]);
2636
+ const misplaced = Object.keys(values).find((o) => !allowed.has(o));
2637
+ if (misplaced) {
2638
+ const hint = HINTS[misplaced] ? ` ${HINTS[misplaced]}` : "";
2639
+ throw new Error(`"worklog ${command}" has no --${misplaced} option.${hint}
1962
2640
 
1963
2641
  ${USAGE}`);
1964
2642
  }
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
- };
2643
+ validate(values);
1976
2644
  const repo = values.repo ?? process.env.WORKLOG_REPO;
1977
2645
  if (!repo) {
1978
2646
  throw new Error("Point --repo or $WORKLOG_REPO at a clone of your timesheet repo.");
1979
2647
  }
1980
- return run(repo, input, !!values["dry-run"]);
2648
+ if (command === "list") {
2649
+ return list(repo, values);
2650
+ }
2651
+ if (command === "hours" && values.hours === void 0) {
2652
+ return showDay(repo, values.date ?? today());
2653
+ }
2654
+ return run(repo, toEdit(command, values), !!values["dry-run"]);
1981
2655
  }
1982
- async function run(repo, input, dryRun) {
1983
- const git = (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
2656
+ function validate(values) {
2657
+ for (const option of ["date", "due", "due-by"]) {
2658
+ const value = values[option];
2659
+ if (typeof value === "string" && !(option === "due" && value === NONE) && !isValidISODate(value)) {
2660
+ throw new Error(`--${option} must be YYYY-MM-DD, got "${value}".`);
2661
+ }
2662
+ }
2663
+ const priority = values.priority;
2664
+ if (typeof priority === "string" && !PRIORITIES.some((p) => p.id === priority.trim().toLowerCase())) {
2665
+ throw new Error(`--priority must be one of ${PRIORITIES.map((p) => p.id).join(", ")}, got "${priority}".`);
2666
+ }
2667
+ }
2668
+ function toEdit(command, values) {
2669
+ const str = (key) => values[key];
2670
+ const list2 = (key) => values[key];
2671
+ const ref = { taskId: str("id"), link: str("link"), title: str("title"), clientId: str("client") };
2672
+ const orNull = (value) => value === NONE ? null : value;
2673
+ switch (command) {
2674
+ case "log": {
2675
+ const clientId = str("client");
2676
+ const title = str("title");
2677
+ if (!clientId || !title) {
2678
+ throw new Error(`--client and --title are required.
2679
+
2680
+ ${USAGE}`);
2681
+ }
2682
+ const input = { clientId, title, taskId: ref.taskId, link: ref.link, note: str("note"), date: str("date") };
2683
+ return async (store) => describeLog(await logWork(store, input), input);
2684
+ }
2685
+ case "add": {
2686
+ const title = str("title");
2687
+ if (!title) {
2688
+ throw new Error(`--title is required.
2689
+
2690
+ ${USAGE}`);
2691
+ }
2692
+ const input = {
2693
+ clientId: str("client") ?? GENERAL_TODO_CLIENT_ID,
2694
+ title,
2695
+ link: ref.link,
2696
+ due: str("due"),
2697
+ priority: str("priority"),
2698
+ description: str("description"),
2699
+ tags: list2("tag"),
2700
+ parentId: str("parent")
2701
+ };
2702
+ return async (store) => describeAdd(await addTask(store, input));
2703
+ }
2704
+ case "update": {
2705
+ requireRef(ref);
2706
+ const edit = {
2707
+ title: str("rename"),
2708
+ due: orNull(str("due")),
2709
+ priority: str("priority"),
2710
+ description: str("description"),
2711
+ status: str("status"),
2712
+ addTags: list2("tag"),
2713
+ removeTags: list2("untag"),
2714
+ parentId: orNull(str("parent"))
2715
+ };
2716
+ 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.`);
2718
+ }
2719
+ return async (store) => describeUpdate(await editTask(store, ref, edit));
2720
+ }
2721
+ case "hours": {
2722
+ const input = { clientId: str("client"), event: str("event"), hours: str("hours") ?? "", note: str("note"), date: str("date") };
2723
+ return async (store) => describeHours(store, await logHours(store, input));
2724
+ }
2725
+ default: {
2726
+ requireRef(ref);
2727
+ return async (store) => describeDone(await closeTask(store, ref));
2728
+ }
2729
+ }
2730
+ }
2731
+ function requireRef(ref) {
2732
+ if (!ref.taskId && !ref.link && !ref.title) {
2733
+ throw new Error(`Name the task with --id, --link or --title.
2734
+
2735
+ ${USAGE}`);
2736
+ }
2737
+ }
2738
+ async function run(repo, edit, dryRun) {
2739
+ const git = gitIn(repo);
1984
2740
  if (git("status", "--porcelain", "--untracked-files=no")) {
1985
2741
  throw new Error(`${repo} has uncommitted changes; commit or discard them first.`);
1986
2742
  }
@@ -1990,39 +2746,104 @@ async function run(repo, input, dryRun) {
1990
2746
  }
1991
2747
  git("merge", "--ff-only", "--quiet", "@{u}");
1992
2748
  for (let attempt = 1; ; attempt++) {
1993
- const { result, changed } = await applyToClone(repo, input);
2749
+ const { line, changed } = await applyToClone(repo, edit);
1994
2750
  if (changed.length === 0) {
1995
- console.log(describe(result, input, "nothing new to log"));
2751
+ console.log(`${line} \u2014 nothing new to write`);
1996
2752
  return;
1997
2753
  }
1998
2754
  if (dryRun) {
1999
2755
  git("add", "--intent-to-add", "--", ...changed);
2000
2756
  console.log(git("--no-pager", "diff", "--", ...changed));
2001
2757
  git("reset", "--quiet", "--hard", "HEAD");
2002
- console.log(describe(result, input, "dry run, nothing committed"));
2758
+ console.log(`${line} \u2014 dry run, nothing committed`);
2003
2759
  return;
2004
2760
  }
2005
2761
  git("add", "--", ...changed);
2006
2762
  git("commit", "--quiet", "-m", `chore: worklog sync ${today()}`);
2007
2763
  try {
2008
2764
  git("push", "--quiet");
2009
- console.log(describe(result, input, `pushed ${git("rev-parse", "--short", "HEAD")}`));
2765
+ console.log(`${line} \u2014 pushed ${git("rev-parse", "--short", "HEAD")}`);
2010
2766
  return;
2011
2767
  } catch (err) {
2012
2768
  git("reset", "--quiet", "--hard", "@{u}");
2013
2769
  const raced = /rejected|fetch first|non-fast-forward/.test(String(err.stderr));
2014
2770
  if (!raced) {
2015
- throw new Error("Push failed; nothing was logged.", { cause: err });
2771
+ throw new Error("Push failed; nothing was written.", { cause: err });
2016
2772
  }
2017
2773
  if (attempt >= PUSH_ATTEMPTS) {
2018
- throw new Error(`Push rejected ${PUSH_ATTEMPTS} times; nothing was logged.`, { cause: err });
2774
+ throw new Error(`Push rejected ${PUSH_ATTEMPTS} times; nothing was written.`, { cause: err });
2019
2775
  }
2020
2776
  git("fetch", "--quiet");
2021
2777
  git("reset", "--quiet", "--hard", "@{u}");
2022
2778
  }
2023
2779
  }
2024
2780
  }
2025
- async function applyToClone(repo, input) {
2781
+ function catchUp(repo) {
2782
+ const git = gitIn(repo);
2783
+ try {
2784
+ git("fetch", "--quiet");
2785
+ if (git("status", "--porcelain", "--untracked-files=no") || git("rev-list", "--count", "@{u}..HEAD") !== "0") {
2786
+ console.error(`${repo} has local changes; reading it as it is, without the latest from the remote.`);
2787
+ } else {
2788
+ git("merge", "--ff-only", "--quiet", "@{u}");
2789
+ }
2790
+ } catch {
2791
+ console.error("Could not fetch the remote; reading the clone as it is.");
2792
+ }
2793
+ }
2794
+ async function list(repo, values) {
2795
+ catchUp(repo);
2796
+ const { store } = await loadClone(repo);
2797
+ const tasks = listTasks(store, {
2798
+ clientId: values.client,
2799
+ dueBy: values["due-by"],
2800
+ tag: values.tag?.[0]
2801
+ });
2802
+ if (values.json) {
2803
+ console.log(JSON.stringify(tasks.map(toJson), null, 2));
2804
+ return;
2805
+ }
2806
+ if (tasks.length === 0) {
2807
+ console.log("No open tasks match.");
2808
+ return;
2809
+ }
2810
+ for (const t of tasks) {
2811
+ console.log(
2812
+ [t.id, t.due ?? "-", t.priority ?? "-", t.clientIds[0], t.status, t.title].join(" ")
2813
+ );
2814
+ }
2815
+ }
2816
+ async function showDay(repo, date) {
2817
+ catchUp(repo);
2818
+ const { store } = await loadClone(repo);
2819
+ const entries = dayEntries(store, date);
2820
+ const total = entries.reduce((sum, e) => sum + e.hours, 0);
2821
+ console.log(`${date}: ${total}h logged of ${store.getConfig().hoursPerDay}h`);
2822
+ for (const e of entries) {
2823
+ console.log(["", e.clientId, `${e.hours}h`, targetLabel(store, e.clientId), e.note ?? ""].join(" ").trimEnd());
2824
+ }
2825
+ }
2826
+ function toJson(t) {
2827
+ return {
2828
+ id: t.id,
2829
+ title: t.title,
2830
+ client: t.clientIds[0],
2831
+ status: t.status,
2832
+ priority: t.priority ?? "normal",
2833
+ due: t.due ?? null,
2834
+ tags: t.tags ?? [],
2835
+ links: t.links.map((l) => l.url),
2836
+ parent: t.parentId ?? null,
2837
+ repeats: !!t.repeat,
2838
+ created: t.created ?? null,
2839
+ description: t.description ?? null,
2840
+ file: t.sourceFile
2841
+ };
2842
+ }
2843
+ function gitIn(repo) {
2844
+ return (...args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
2845
+ }
2846
+ async function loadClone(repo) {
2026
2847
  const fm = new FileMap();
2027
2848
  for (const path of listFiles(repo)) {
2028
2849
  if (isWorklogPath(path) && TEXT_PATH.test(path)) {
@@ -2033,24 +2854,54 @@ async function applyToClone(repo, input) {
2033
2854
  mountFileMap(fm);
2034
2855
  const store = new Store();
2035
2856
  await store.rebuild("load");
2036
- const result = await logWork(store, input);
2037
- const changed = [...fm.dirty].filter((path) => fm.text.has(path));
2857
+ return { store, fm };
2858
+ }
2859
+ async function applyToClone(repo, edit) {
2860
+ const { store, fm } = await loadClone(repo);
2861
+ const before = new Map(fm.text);
2862
+ const line = await edit(store);
2863
+ const changed = [...fm.dirty].filter((path) => fm.text.has(path) && fm.text.get(path) !== before.get(path));
2038
2864
  for (const path of changed) {
2039
2865
  mkdirSync(dirname(join(repo, path)), { recursive: true });
2040
2866
  writeFileSync(join(repo, path), fm.text.get(path) ?? "");
2041
2867
  }
2042
- return { result, changed };
2868
+ return { line, changed };
2043
2869
  }
2044
2870
  function listFiles(repo) {
2045
2871
  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
2872
  }
2047
- function describe({ task, created, markedWorked, noteAdded }, input, outcome) {
2873
+ function label(task) {
2874
+ return `${task.id} "${task.title}" (${task.sourceFile})`;
2875
+ }
2876
+ function describeLog({ task, created, markedWorked, noteAdded }, input) {
2048
2877
  const what = [
2049
2878
  created ? "created" : "found",
2050
2879
  markedWorked ? `marked worked ${input.date ?? today()}` : void 0,
2051
2880
  noteAdded ? "note added" : void 0
2052
2881
  ].filter(Boolean);
2053
- return `${task.id} "${task.title}" (${task.sourceFile}): ${what.join(", ")} \u2014 ${outcome}`;
2882
+ return `${label(task)}: ${what.join(", ")}`;
2883
+ }
2884
+ function describeAdd({ task, created }) {
2885
+ const what = [
2886
+ created ? "created" : "already exists, left as is",
2887
+ task.due ? `due ${task.due}` : void 0,
2888
+ task.priority ? `priority ${task.priority}` : void 0
2889
+ ].filter(Boolean);
2890
+ return `${label(task)}: ${what.join(", ")}`;
2891
+ }
2892
+ function describeUpdate({ task, changed }) {
2893
+ return `${label(task)}: ${changed.length ? `changed ${changed.join(", ")}` : "already up to date"}`;
2894
+ }
2895
+ function describeHours(store, r) {
2896
+ const was = !r.previous ? "new" : r.previous.hours === r.hours && r.changed ? "note updated" : `was ${r.previous.hours}h`;
2897
+ const what = r.hours > 0 ? `${r.hours}h (${was})` : r.previous ? `removed (was ${r.previous.hours}h)` : "nothing logged";
2898
+ return `${targetLabel(store, r.target)} on ${r.date}: ${what}; day total ${r.dayTotal}h of ${r.hoursPerDay}h`;
2899
+ }
2900
+ function describeDone({ task, closed }) {
2901
+ if (!closed) {
2902
+ return `${label(task)}: ${task.completed ? `already closed on ${task.completed}` : `already done today, next due ${task.due}`}`;
2903
+ }
2904
+ return `${label(task)}: ${task.completed ? "closed" : `done, next due ${task.due}`}`;
2054
2905
  }
2055
2906
  main().catch((err) => {
2056
2907
  console.error(err instanceof Error ? err.message : String(err));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@estruyf/worklog-cli",
3
- "version": "0.1.0",
4
- "description": "Log work on a task in your Worklog timesheet repo from the command line or an AI agent.",
3
+ "version": "0.2.0",
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",
7
7
  "type": "module",