@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.
- package/README.md +86 -8
- package/dist/worklog.js +907 -56
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
# @estruyf/worklog-cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
1206
|
-
if (!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:
|
|
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
|
|
1404
|
-
return { url: l.url.trim(), ...
|
|
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
|
|
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
|
-
|
|
1926
|
-
|
|
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
|
-
|
|
1929
|
-
--
|
|
1930
|
-
--
|
|
1931
|
-
--
|
|
1932
|
-
--
|
|
1933
|
-
--
|
|
1934
|
-
--
|
|
1935
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
1961
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1983
|
-
|
|
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 {
|
|
2749
|
+
const { line, changed } = await applyToClone(repo, edit);
|
|
1994
2750
|
if (changed.length === 0) {
|
|
1995
|
-
console.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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2037
|
-
|
|
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 {
|
|
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
|
|
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 `${
|
|
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.
|
|
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",
|