@harborppm/cli 0.75.0 → 0.77.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 +3 -1
- package/dist/app.mjs +527 -93
- package/dist/words.mjs +301 -0
- package/lib/cli.mjs +116 -26
- package/lib/words.mjs +20 -0
- package/package.json +1 -1
package/dist/words.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Built from src/lib/plain-words.ts by cli/build.mjs. Edit the source, not this file.
|
|
2
|
+
// src/lib/plain-words.ts
|
|
3
|
+
const DAYS = [
|
|
4
|
+
"sunday",
|
|
5
|
+
"monday",
|
|
6
|
+
"tuesday",
|
|
7
|
+
"wednesday",
|
|
8
|
+
"thursday",
|
|
9
|
+
"friday",
|
|
10
|
+
"saturday"
|
|
11
|
+
];
|
|
12
|
+
const MONTHS = [
|
|
13
|
+
"january",
|
|
14
|
+
"february",
|
|
15
|
+
"march",
|
|
16
|
+
"april",
|
|
17
|
+
"may",
|
|
18
|
+
"june",
|
|
19
|
+
"july",
|
|
20
|
+
"august",
|
|
21
|
+
"september",
|
|
22
|
+
"october",
|
|
23
|
+
"november",
|
|
24
|
+
"december"
|
|
25
|
+
];
|
|
26
|
+
const NUMBER_WORDS = {
|
|
27
|
+
a: 1,
|
|
28
|
+
an: 1,
|
|
29
|
+
one: 1,
|
|
30
|
+
two: 2,
|
|
31
|
+
three: 3,
|
|
32
|
+
four: 4,
|
|
33
|
+
five: 5,
|
|
34
|
+
six: 6,
|
|
35
|
+
seven: 7,
|
|
36
|
+
eight: 8,
|
|
37
|
+
nine: 9,
|
|
38
|
+
ten: 10
|
|
39
|
+
};
|
|
40
|
+
const loose = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
41
|
+
function shift(iso, days) {
|
|
42
|
+
const d = /* @__PURE__ */ new Date(`${iso}T12:00:00Z`);
|
|
43
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
44
|
+
return d.toISOString().slice(0, 10);
|
|
45
|
+
}
|
|
46
|
+
function weekday(iso) {
|
|
47
|
+
return (/* @__PURE__ */ new Date(`${iso}T12:00:00Z`)).getUTCDay();
|
|
48
|
+
}
|
|
49
|
+
function dayOf(y, m, d) {
|
|
50
|
+
const date = new Date(Date.UTC(y, m - 1, d, 12));
|
|
51
|
+
if (date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
|
|
52
|
+
return date.toISOString().slice(0, 10);
|
|
53
|
+
}
|
|
54
|
+
/** A month and day with no year: this year's, or next year's once it has passed. */
|
|
55
|
+
function nextMonthDay(today, m, d) {
|
|
56
|
+
const year = Number(today.slice(0, 4));
|
|
57
|
+
const thisYear = dayOf(year, m, d);
|
|
58
|
+
if (!thisYear) return null;
|
|
59
|
+
return thisYear >= today ? thisYear : dayOf(year + 1, m, d);
|
|
60
|
+
}
|
|
61
|
+
const monthIndex = (word) => {
|
|
62
|
+
const w = word.toLowerCase().replace(/\.$/, "");
|
|
63
|
+
if (w.length < 3) return -1;
|
|
64
|
+
return MONTHS.findIndex((m) => m.startsWith(w) && (w.length >= 3 || m === w));
|
|
65
|
+
};
|
|
66
|
+
const DAY_WORD = "(?:sun|mon|tues?|wed(?:nes)?|thu(?:rs?)?|fri|sat(?:ur)?)(?:day)?";
|
|
67
|
+
const MONTH_WORD = "(?:jan|feb|mar|apr|may|jun|jul|aug|sept?|oct|nov|dec)[a-z]*\\.?";
|
|
68
|
+
/** Every way of saying a day, longest first so "next friday" wins over "friday". */
|
|
69
|
+
const WHEN = [
|
|
70
|
+
"today",
|
|
71
|
+
"tonight",
|
|
72
|
+
"tomorrow",
|
|
73
|
+
"tmrw",
|
|
74
|
+
"the day after tomorrow",
|
|
75
|
+
"end of (?:the )?week",
|
|
76
|
+
"this week",
|
|
77
|
+
"next week",
|
|
78
|
+
"end of (?:the )?month",
|
|
79
|
+
"next month",
|
|
80
|
+
`in (?:\\d{1,2}|a|an|one|two|three|four|five|six|seven|eight|nine|ten) (?:days?|weeks?)`,
|
|
81
|
+
`(?:this |next |on )?${DAY_WORD}`,
|
|
82
|
+
`${MONTH_WORD} \\d{1,2}(?:st|nd|rd|th)?(?:,? \\d{4})?`,
|
|
83
|
+
`\\d{1,2}(?:st|nd|rd|th)? (?:of )?${MONTH_WORD}(?:,? \\d{4})?`,
|
|
84
|
+
"\\d{4}-\\d{1,2}-\\d{1,2}",
|
|
85
|
+
"\\d{1,2}/\\d{1,2}(?:/\\d{2,4})?"
|
|
86
|
+
].join("|");
|
|
87
|
+
const LEAD = "(?:by|due|due on|due by|on|before|until|till)";
|
|
88
|
+
/** The day a phrase names, or null when it names none. */
|
|
89
|
+
function dayFromWords(phrase, today) {
|
|
90
|
+
const p = phrase.toLowerCase().replace(/\s+/g, " ").replace(/^on /, "").trim();
|
|
91
|
+
if (p === "today" || p === "tonight") return today;
|
|
92
|
+
if (p === "tomorrow" || p === "tmrw") return shift(today, 1);
|
|
93
|
+
if (p === "the day after tomorrow") return shift(today, 2);
|
|
94
|
+
const dow = weekday(today);
|
|
95
|
+
if (/^end of (the )?week$/.test(p) || p === "this week") return shift(today, (5 - dow + 7) % 7);
|
|
96
|
+
if (p === "next week") return shift(today, (1 - dow + 7) % 7 || 7);
|
|
97
|
+
if (p === "next month") {
|
|
98
|
+
const [y, m] = [Number(today.slice(0, 4)), Number(today.slice(5, 7))];
|
|
99
|
+
return m === 12 ? dayOf(y + 1, 1, 1) : dayOf(y, m + 1, 1);
|
|
100
|
+
}
|
|
101
|
+
if (/^end of (the )?month$/.test(p)) {
|
|
102
|
+
const [y, m] = [Number(today.slice(0, 4)), Number(today.slice(5, 7))];
|
|
103
|
+
return shift(m === 12 ? dayOf(y + 1, 1, 1) : dayOf(y, m + 1, 1), -1);
|
|
104
|
+
}
|
|
105
|
+
let m = p.match(/^in (\w+) (day|week)s?$/);
|
|
106
|
+
if (m) {
|
|
107
|
+
const n = /^\d+$/.test(m[1]) ? Number(m[1]) : NUMBER_WORDS[m[1]];
|
|
108
|
+
if (n == null || n > 366) return null;
|
|
109
|
+
return shift(today, m[2] === "week" ? n * 7 : n);
|
|
110
|
+
}
|
|
111
|
+
m = p.match(/^(this |next )?([a-z]+)$/);
|
|
112
|
+
if (m) {
|
|
113
|
+
const target = DAYS.findIndex((d) => d.startsWith(m[2].slice(0, 3)) && m[2].length >= 3);
|
|
114
|
+
if (target >= 0 && new RegExp(`^${DAY_WORD}$`).test(m[2])) {
|
|
115
|
+
const ahead = (target - dow + 7) % 7 || 7;
|
|
116
|
+
return shift(today, m[1] === "next " ? ahead + 7 : ahead);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
m = p.match(/^([a-z]+\.?) (\d{1,2})(?:st|nd|rd|th)?(?:,? (\d{4}))?$/);
|
|
120
|
+
if (m && monthIndex(m[1]) >= 0) {
|
|
121
|
+
const month = monthIndex(m[1]) + 1;
|
|
122
|
+
return m[3] ? dayOf(Number(m[3]), month, Number(m[2])) : nextMonthDay(today, month, Number(m[2]));
|
|
123
|
+
}
|
|
124
|
+
m = p.match(/^(\d{1,2})(?:st|nd|rd|th)? (?:of )?([a-z]+\.?)(?:,? (\d{4}))?$/);
|
|
125
|
+
if (m && monthIndex(m[2]) >= 0) {
|
|
126
|
+
const month = monthIndex(m[2]) + 1;
|
|
127
|
+
return m[3] ? dayOf(Number(m[3]), month, Number(m[1])) : nextMonthDay(today, month, Number(m[1]));
|
|
128
|
+
}
|
|
129
|
+
m = p.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
130
|
+
if (m) return dayOf(Number(m[1]), Number(m[2]), Number(m[3]));
|
|
131
|
+
m = p.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
|
|
132
|
+
if (m) {
|
|
133
|
+
if (!m[3]) return nextMonthDay(today, Number(m[1]), Number(m[2]));
|
|
134
|
+
return dayOf(Number(m[3]) < 100 ? 2e3 + Number(m[3]) : Number(m[3]), Number(m[1]), Number(m[2]));
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const PRIORITY_WORDS = {
|
|
139
|
+
urgent: "urgent",
|
|
140
|
+
asap: "urgent",
|
|
141
|
+
top: "urgent",
|
|
142
|
+
p0: "urgent",
|
|
143
|
+
high: "high",
|
|
144
|
+
p1: "high",
|
|
145
|
+
medium: "medium",
|
|
146
|
+
normal: "medium",
|
|
147
|
+
p2: "medium",
|
|
148
|
+
low: "low",
|
|
149
|
+
p3: "low"
|
|
150
|
+
};
|
|
151
|
+
/** Removes found spans from the text, tidying the spaces and commas they leave. */
|
|
152
|
+
function without(text, spans) {
|
|
153
|
+
let out = text;
|
|
154
|
+
for (const s of [...spans].sort((a, b) => b.start - a.start)) out = `${out.slice(0, s.start)} ${out.slice(s.end)}`;
|
|
155
|
+
return out.replace(/\s+([,.;:!?])/g, "$1").replace(/[,;:]+([,.;:!?])/g, "$1").replace(/\s{2,}/g, " ").replace(/(?:[\s,;:–—-]+)$/u, "").replace(/^[\s,;:]+/, "").trim();
|
|
156
|
+
}
|
|
157
|
+
/** What a new task's words say, and the title they leave. */
|
|
158
|
+
function readTaskWords(input, ctx) {
|
|
159
|
+
const skip = ctx.skip ?? /* @__PURE__ */ new Set();
|
|
160
|
+
const text = input;
|
|
161
|
+
const taken = [];
|
|
162
|
+
const free = (start, end) => !taken.some((t) => start < t.end && end > t.start);
|
|
163
|
+
let priority = null;
|
|
164
|
+
if (!skip.has("priority")) for (const re of [
|
|
165
|
+
/(?:^|\s)!(urgent|asap|high|medium|normal|low|p[0-3])\b/gi,
|
|
166
|
+
/(?:^|[\s,])(urgent|top|high|medium|normal|low) priority\b/gi,
|
|
167
|
+
/(?:^|[\s,])priority (urgent|high|medium|normal|low|p[0-3])\b/gi,
|
|
168
|
+
/(?:^|[\s,])(urgent|asap)$/gi
|
|
169
|
+
]) {
|
|
170
|
+
const m = re.exec(text);
|
|
171
|
+
if (m) {
|
|
172
|
+
const lead = m[0].length - m[0].trimStart().length;
|
|
173
|
+
const start = m.index + lead + (m[0].trimStart().startsWith(",") ? 1 : 0);
|
|
174
|
+
priority = {
|
|
175
|
+
value: PRIORITY_WORDS[m[1].toLowerCase()],
|
|
176
|
+
start,
|
|
177
|
+
end: m.index + m[0].length
|
|
178
|
+
};
|
|
179
|
+
taken.push(priority);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let project = null;
|
|
184
|
+
if (!skip.has("project")) for (const m of text.matchAll(/(?:^|\s)#([\p{L}\p{N}_-]+)/gu)) {
|
|
185
|
+
const key = loose(m[1]);
|
|
186
|
+
if (!key) continue;
|
|
187
|
+
const exact = ctx.projects.filter((p) => loose(p.name) === key);
|
|
188
|
+
const begins = ctx.projects.filter((p) => loose(p.name).startsWith(key));
|
|
189
|
+
const hit = exact.length === 1 ? exact[0] : begins.length === 1 ? begins[0] : null;
|
|
190
|
+
if (!hit) continue;
|
|
191
|
+
const start = m.index + (m[0].length - m[0].trimStart().length);
|
|
192
|
+
project = {
|
|
193
|
+
value: hit.id,
|
|
194
|
+
start,
|
|
195
|
+
end: m.index + m[0].length
|
|
196
|
+
};
|
|
197
|
+
taken.push(project);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
let owner = null;
|
|
201
|
+
if (!skip.has("owner")) {
|
|
202
|
+
const byKey = (key) => {
|
|
203
|
+
const full = ctx.people.filter((p) => loose(p.name) === key);
|
|
204
|
+
if (full.length === 1) return full[0];
|
|
205
|
+
const first = ctx.people.filter((p) => loose(p.name.split(/\s+/)[0] ?? "") === key);
|
|
206
|
+
if (first.length === 1) return first[0];
|
|
207
|
+
const begins = ctx.people.filter((p) => loose(p.name).startsWith(key));
|
|
208
|
+
return key.length >= 3 && begins.length === 1 ? begins[0] : null;
|
|
209
|
+
};
|
|
210
|
+
for (const m of text.matchAll(/(?:^|\s)@([\p{L}\p{N}._-]+)/gu)) {
|
|
211
|
+
const hit = byKey(loose(m[1]));
|
|
212
|
+
if (!hit) continue;
|
|
213
|
+
const start = m.index + (m[0].length - m[0].trimStart().length);
|
|
214
|
+
if (!free(start, m.index + m[0].length)) continue;
|
|
215
|
+
owner = {
|
|
216
|
+
value: hit.id,
|
|
217
|
+
start,
|
|
218
|
+
end: m.index + m[0].length
|
|
219
|
+
};
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
if (!owner) for (const m of text.matchAll(/(?:^|[\s,])(?:assign(?:ed)? (?:it )?to|owner(?: is)?:?) (\p{L}+(?: \p{L}+)?)/giu)) {
|
|
223
|
+
const words = m[1].split(" ");
|
|
224
|
+
const two = words.length === 2 ? byKey(loose(m[1])) : null;
|
|
225
|
+
const one = byKey(loose(words[0]));
|
|
226
|
+
const hit = two ?? one;
|
|
227
|
+
if (!hit) continue;
|
|
228
|
+
const lead = m[0].length - m[0].replace(/^[\s,]+/, "").length;
|
|
229
|
+
const start = m.index + lead;
|
|
230
|
+
const end = two ? m.index + m[0].length : m.index + m[0].length - (words.length === 2 ? words[1].length + 1 : 0);
|
|
231
|
+
if (!free(start, end)) continue;
|
|
232
|
+
owner = {
|
|
233
|
+
value: hit.id,
|
|
234
|
+
start,
|
|
235
|
+
end
|
|
236
|
+
};
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
if (owner) taken.push(owner);
|
|
240
|
+
}
|
|
241
|
+
let due = null;
|
|
242
|
+
if (!skip.has("due")) {
|
|
243
|
+
const led = new RegExp(`(?:^|[\\s,])(${LEAD} (${WHEN}))(?=$|[\\s,.;:!?])`, "gi");
|
|
244
|
+
for (const m of text.matchAll(led)) {
|
|
245
|
+
const day = dayFromWords(m[2], ctx.today);
|
|
246
|
+
if (!day) continue;
|
|
247
|
+
const start = m.index + m[0].indexOf(m[1]);
|
|
248
|
+
const end = start + m[1].length;
|
|
249
|
+
if (!free(start, end)) continue;
|
|
250
|
+
due = {
|
|
251
|
+
value: day,
|
|
252
|
+
start,
|
|
253
|
+
end
|
|
254
|
+
};
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
if (!due) {
|
|
258
|
+
const rest = without(text, taken);
|
|
259
|
+
const tail = new RegExp(`(?:^|[\\s,])(${WHEN})[.!]?$`, "i").exec(rest);
|
|
260
|
+
const short = tail && /^(?:sun|mon|tue|wed|thu|fri|sat)$/i.test(tail[1]);
|
|
261
|
+
const said = tail && /\b(?:to|for|from|after|with|since)$/i.test(rest.slice(0, tail.index).trimEnd());
|
|
262
|
+
if (tail && !short && !said && rest.trim().length > tail[1].length) {
|
|
263
|
+
const day = dayFromWords(tail[1], ctx.today);
|
|
264
|
+
if (day) {
|
|
265
|
+
const at = text.toLowerCase().lastIndexOf(tail[1].toLowerCase());
|
|
266
|
+
if (at >= 0 && free(at, at + tail[1].length)) due = {
|
|
267
|
+
value: day,
|
|
268
|
+
start: at,
|
|
269
|
+
end: at + tail[1].length
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (due) taken.push(due);
|
|
275
|
+
}
|
|
276
|
+
const span = (f) => f ? text.slice(f.start, f.end).trim() : "";
|
|
277
|
+
let title = without(text, taken);
|
|
278
|
+
const first = text.search(/\S/);
|
|
279
|
+
if (taken.some((t) => t.start === first)) title = title.charAt(0).toUpperCase() + title.slice(1);
|
|
280
|
+
return {
|
|
281
|
+
title,
|
|
282
|
+
due: due ? {
|
|
283
|
+
value: due.value,
|
|
284
|
+
text: span(due)
|
|
285
|
+
} : null,
|
|
286
|
+
owner: owner ? {
|
|
287
|
+
value: owner.value,
|
|
288
|
+
text: span(owner)
|
|
289
|
+
} : null,
|
|
290
|
+
priority: priority ? {
|
|
291
|
+
value: priority.value,
|
|
292
|
+
text: span(priority)
|
|
293
|
+
} : null,
|
|
294
|
+
project: project ? {
|
|
295
|
+
value: project.value,
|
|
296
|
+
text: span(project)
|
|
297
|
+
} : null
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export { dayFromWords, readTaskWords };
|
package/lib/cli.mjs
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
import { colourDepth, logoLines } from "./logo.mjs";
|
|
65
65
|
import { HarborError, createClient } from "./mcp.mjs";
|
|
66
66
|
import { jsonText, plain } from "./plain.mjs";
|
|
67
|
+
import { dayFromWords, readTaskWords } from "./words.mjs";
|
|
67
68
|
import {
|
|
68
69
|
FETCH_LIMIT,
|
|
69
70
|
SHELLS,
|
|
@@ -164,10 +165,14 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
164
165
|
[--also "NAME, NAME"] [--due DATE|pilot]
|
|
165
166
|
[--priority low|medium|high|urgent]
|
|
166
167
|
[--status S] [--comment TEXT] [--over-limit] [--dry-run]
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
(
|
|
168
|
+
[--literal]
|
|
169
|
+
reads plain words in TITLE as the
|
|
170
|
+
website does: @name, by friday,
|
|
171
|
+
!high, #project (--literal: keep
|
|
172
|
+
them); with no TITLE: one title a
|
|
173
|
+
line from stdin; --due pilot takes
|
|
174
|
+
the date the owner's queue
|
|
175
|
+
suggests (harbor help add)
|
|
171
176
|
harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
|
|
172
177
|
move a task to In play, In review
|
|
173
178
|
or Done
|
|
@@ -486,7 +491,11 @@ function daysToWeekday(v, today) {
|
|
|
486
491
|
return i < 0 ? null : (i - today.getDay() + 7) % 7;
|
|
487
492
|
}
|
|
488
493
|
|
|
489
|
-
/**
|
|
494
|
+
/**
|
|
495
|
+
* --due as people say it: today, tomorrow, friday, +3, or a date, and the
|
|
496
|
+
* rest of what the website's new task box reads ("next friday", "in two
|
|
497
|
+
* weeks", "Nov 15"). Anything else goes to Harbor to judge.
|
|
498
|
+
*/
|
|
490
499
|
export function dueDate(v, today = new Date()) {
|
|
491
500
|
if (v === undefined || v === null) return v;
|
|
492
501
|
const words = { today: 0, tomorrow: 1 };
|
|
@@ -498,11 +507,64 @@ export function dueDate(v, today = new Date()) {
|
|
|
498
507
|
: /^[a-z]+$/i.test(v)
|
|
499
508
|
? daysToWeekday(v, today)
|
|
500
509
|
: null;
|
|
501
|
-
if (days === null) return v;
|
|
510
|
+
if (days === null) return (typeof v === "string" && dayFromWords(v, todayLocal(today))) || v;
|
|
502
511
|
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days);
|
|
503
512
|
return todayLocal(d);
|
|
504
513
|
}
|
|
505
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Each title read in plain words, as the website's new task box reads it:
|
|
517
|
+
* "Book the venue @Quinn by Friday !high #Website". A flag given says that
|
|
518
|
+
* thing itself, so its words stay in the title. The crew and the projects
|
|
519
|
+
* are asked for only when a title could name one.
|
|
520
|
+
*/
|
|
521
|
+
async function readTitles(client, harbor, titles, values, today = todayLocal()) {
|
|
522
|
+
const could = (re) => titles.some((t) => re.test(t));
|
|
523
|
+
const people =
|
|
524
|
+
values.assignee === undefined && could(/(?:^|\s)@|\bassign|\bowner\b/i)
|
|
525
|
+
? await client
|
|
526
|
+
.call("crew_clocks", { harbor })
|
|
527
|
+
.then((r) => [...(r.clocks ?? []).map((c) => c.who), ...(r.noTimeZone ?? [])])
|
|
528
|
+
.catch(() => [])
|
|
529
|
+
: [];
|
|
530
|
+
const projects =
|
|
531
|
+
values.project === undefined && could(/(?:^|\s)#/)
|
|
532
|
+
? await client
|
|
533
|
+
.call("list_projects", { harbor, limit: 100 })
|
|
534
|
+
.then((r) => (r.projects ?? []).map((p) => ({ id: p.name, name: p.name })))
|
|
535
|
+
.catch(() => [])
|
|
536
|
+
: [];
|
|
537
|
+
const skip = new Set(
|
|
538
|
+
[
|
|
539
|
+
values.assignee !== undefined && "owner",
|
|
540
|
+
values.due !== undefined && "due",
|
|
541
|
+
values.priority !== undefined && "priority",
|
|
542
|
+
values.project !== undefined && "project",
|
|
543
|
+
].filter(Boolean),
|
|
544
|
+
);
|
|
545
|
+
return titles.map((title) => {
|
|
546
|
+
const r = readTaskWords(title, {
|
|
547
|
+
today,
|
|
548
|
+
people: people.map((name) => ({ id: name, name })),
|
|
549
|
+
projects,
|
|
550
|
+
skip,
|
|
551
|
+
});
|
|
552
|
+
const read = r.owner || r.due || r.priority || r.project;
|
|
553
|
+
return read && r.title ? r : { title, owner: null, due: null, priority: null, project: null };
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** What a title's words said, in a line: "Quinn (@Quinn), due 2026-11-06 (by Friday)". */
|
|
558
|
+
function wordsLine(r) {
|
|
559
|
+
const bits = [
|
|
560
|
+
r.owner && `${r.owner.value} (${r.owner.text})`,
|
|
561
|
+
r.due && `due ${r.due.value} (${r.due.text})`,
|
|
562
|
+
r.priority && `${r.priority.value} priority (${r.priority.text})`,
|
|
563
|
+
r.project && `in ${r.project.value} (${r.project.text})`,
|
|
564
|
+
].filter(Boolean);
|
|
565
|
+
return bits.length ? `Read from your words: ${bits.join(", ")}.` : null;
|
|
566
|
+
}
|
|
567
|
+
|
|
506
568
|
/** --due pilot: the date the owner's queue suggests, set by Harbor. */
|
|
507
569
|
const PILOT = new Set(["pilot", "suggested"]);
|
|
508
570
|
const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
|
|
@@ -591,7 +653,7 @@ export function hint(message, project) {
|
|
|
591
653
|
(_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
|
|
592
654
|
)
|
|
593
655
|
// The CLI reads today, tomorrow and +N before Harbor sees the date.
|
|
594
|
-
.replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow, friday or +N days");
|
|
656
|
+
.replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow, friday, next friday or +N days");
|
|
595
657
|
if (!seen.has(flag)) seen.set(flag, said);
|
|
596
658
|
}
|
|
597
659
|
return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
|
|
@@ -678,6 +740,12 @@ const DETAIL = {
|
|
|
678
740
|
" --project can be left out when the harbor has one open project (First",
|
|
679
741
|
" voyage aside); the task goes there.",
|
|
680
742
|
"",
|
|
743
|
+
" Plain words in a title are read as the website reads them: \"Book the",
|
|
744
|
+
" venue @Quinn by friday !high #Website\" adds \"Book the venue\" for",
|
|
745
|
+
" Quinn, due Friday, high priority, in Website. Harbor says what it read.",
|
|
746
|
+
" A flag wins over the words (they stay in the title), and --literal keeps",
|
|
747
|
+
" every title exactly as typed.",
|
|
748
|
+
"",
|
|
681
749
|
" --assignee me|none|NAME, --also \"NAME, NAME\" (others on it with the",
|
|
682
750
|
" owner), --due DATE|pilot, --priority low|medium|high|urgent,",
|
|
683
751
|
" --status S, --comment TEXT and --over-limit apply to every task added, so",
|
|
@@ -769,8 +837,8 @@ const DETAIL = {
|
|
|
769
837
|
shore: [
|
|
770
838
|
" harbor shore PROGRAM reads the program's chat channel and",
|
|
771
839
|
" which posts go there",
|
|
772
|
-
" harbor shore PROGRAM --url URL writes: posts go to this Slack, Discord",
|
|
773
|
-
" or
|
|
840
|
+
" harbor shore PROGRAM --url URL writes: posts go to this Slack, Discord,",
|
|
841
|
+
" Teams or Google Chat webhook address",
|
|
774
842
|
" harbor shore PROGRAM --on KINDS writes: turns posts on; KINDS are",
|
|
775
843
|
" forecast, health and notes, with commas",
|
|
776
844
|
" harbor shore PROGRAM --off KINDS writes: turns them off",
|
|
@@ -2388,12 +2456,10 @@ export async function run(argv, io = {}) {
|
|
|
2388
2456
|
case "add": {
|
|
2389
2457
|
const { positionals, values, harbor } = parse(
|
|
2390
2458
|
spec(
|
|
2391
|
-
["over-limit", "dry-run"],
|
|
2459
|
+
["over-limit", "dry-run", "literal"],
|
|
2392
2460
|
["project", "assignee", "also", "due", "priority", "status", "comment"],
|
|
2393
2461
|
),
|
|
2394
2462
|
);
|
|
2395
|
-
// No --project: the harbor's one open project, if that's all there is.
|
|
2396
|
-
if (!values.project) values.project = await onlyProject(client, harbor);
|
|
2397
2463
|
// Several at once: one title a line on stdin, with no title (or -) after add.
|
|
2398
2464
|
const piped =
|
|
2399
2465
|
positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
|
|
@@ -2411,6 +2477,15 @@ export async function run(argv, io = {}) {
|
|
|
2411
2477
|
throw new HarborError(
|
|
2412
2478
|
"That's more than 100 tasks; harbor import FILE takes a spreadsheet.",
|
|
2413
2479
|
);
|
|
2480
|
+
// Plain words, as on the website: @person, by Friday, !high, #Project.
|
|
2481
|
+
// --literal keeps every title exactly as typed.
|
|
2482
|
+
const reads = values.literal
|
|
2483
|
+
? titles.map((title) => ({ title, owner: null, due: null, priority: null, project: null }))
|
|
2484
|
+
: await readTitles(client, harbor, titles, values);
|
|
2485
|
+
// No --project and no #Project: the harbor's one open project, if that's all there is.
|
|
2486
|
+
if (!values.project && !reads.every((r) => r.project))
|
|
2487
|
+
values.project = await onlyProject(client, harbor, "--project NAME or #NAME in the title");
|
|
2488
|
+
const projectOf = (r) => values.project ?? r.project?.value;
|
|
2414
2489
|
if (values["dry-run"]) {
|
|
2415
2490
|
const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
|
|
2416
2491
|
// --due pilot: the date each line would take, each counting the ones before it.
|
|
@@ -2424,12 +2499,23 @@ export async function run(argv, io = {}) {
|
|
|
2424
2499
|
})
|
|
2425
2500
|
).dates
|
|
2426
2501
|
: null;
|
|
2502
|
+
const where = [...new Set(reads.map(projectOf))];
|
|
2427
2503
|
if (asJson)
|
|
2428
2504
|
out(
|
|
2429
2505
|
jsonText({
|
|
2430
2506
|
dryRun: true,
|
|
2431
|
-
project: values.project,
|
|
2432
|
-
titles,
|
|
2507
|
+
project: where.length === 1 ? where[0] : values.project,
|
|
2508
|
+
titles: reads.map((r) => r.title),
|
|
2509
|
+
...(reads.some((r) => r.owner || r.due || r.priority || r.project)
|
|
2510
|
+
? {
|
|
2511
|
+
read: reads.map((r) => ({
|
|
2512
|
+
project: projectOf(r),
|
|
2513
|
+
...(r.owner ? { assignee: r.owner.value } : {}),
|
|
2514
|
+
...(r.due ? { due: r.due.value } : {}),
|
|
2515
|
+
...(r.priority ? { priority: r.priority.value } : {}),
|
|
2516
|
+
})),
|
|
2517
|
+
}
|
|
2518
|
+
: {}),
|
|
2433
2519
|
...(pilot
|
|
2434
2520
|
? { due: pilot.map((d) => d.earliest) }
|
|
2435
2521
|
: values.due
|
|
@@ -2441,11 +2527,11 @@ export async function run(argv, io = {}) {
|
|
|
2441
2527
|
out(
|
|
2442
2528
|
plain(
|
|
2443
2529
|
[
|
|
2444
|
-
`Would add ${n} to ${
|
|
2445
|
-
...
|
|
2446
|
-
(
|
|
2447
|
-
|
|
2448
|
-
),
|
|
2530
|
+
`Would add ${n} to ${where.join(" and ")}:`,
|
|
2531
|
+
...reads.flatMap((r, i) => [
|
|
2532
|
+
` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
|
|
2533
|
+
...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
|
|
2534
|
+
]),
|
|
2449
2535
|
...(pilot
|
|
2450
2536
|
? [
|
|
2451
2537
|
`--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
|
|
@@ -2457,25 +2543,25 @@ export async function run(argv, io = {}) {
|
|
|
2457
2543
|
return 0;
|
|
2458
2544
|
}
|
|
2459
2545
|
const added = [];
|
|
2460
|
-
for (const
|
|
2546
|
+
for (const words of reads) {
|
|
2461
2547
|
const r = await client
|
|
2462
2548
|
.call("create_task", {
|
|
2463
2549
|
harbor,
|
|
2464
|
-
project:
|
|
2465
|
-
title,
|
|
2550
|
+
project: projectOf(words),
|
|
2551
|
+
title: words.title,
|
|
2466
2552
|
// Yours unless you say otherwise, as with n in the app.
|
|
2467
|
-
assignee: values.assignee ?? "me",
|
|
2553
|
+
assignee: values.assignee ?? words.owner?.value ?? "me",
|
|
2468
2554
|
also: values.also === undefined ? undefined : alsoList(values.also),
|
|
2469
|
-
due: duePilot(values.due) ? undefined : dueDate(values.due),
|
|
2555
|
+
due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
|
|
2470
2556
|
suggestedDue: duePilot(values.due),
|
|
2471
|
-
priority: values.priority,
|
|
2557
|
+
priority: values.priority ?? words.priority?.value,
|
|
2472
2558
|
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
2473
2559
|
overLimit: values["over-limit"] || undefined,
|
|
2474
2560
|
})
|
|
2475
2561
|
.catch((err) => {
|
|
2476
2562
|
if (err instanceof HarborError) {
|
|
2477
2563
|
const done = added.length ? ` (added ${added.length} before it)` : "";
|
|
2478
|
-
throw new HarborError(`${hint(err.message,
|
|
2564
|
+
throw new HarborError(`${hint(err.message, projectOf(words))}${done}`);
|
|
2479
2565
|
}
|
|
2480
2566
|
throw err;
|
|
2481
2567
|
});
|
|
@@ -2487,6 +2573,8 @@ export async function run(argv, io = {}) {
|
|
|
2487
2573
|
body: values.comment,
|
|
2488
2574
|
})
|
|
2489
2575
|
).commented;
|
|
2576
|
+
const line = wordsLine(words);
|
|
2577
|
+
if (line) r.read = line;
|
|
2490
2578
|
added.push(r);
|
|
2491
2579
|
}
|
|
2492
2580
|
await stalePromptCache(env);
|
|
@@ -2500,6 +2588,8 @@ export async function run(argv, io = {}) {
|
|
|
2500
2588
|
.map((r) =>
|
|
2501
2589
|
[
|
|
2502
2590
|
`Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
|
|
2591
|
+
// What the title's words said, so nothing is read without being seen.
|
|
2592
|
+
...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
|
|
2503
2593
|
// Pilot speaks up only when a date was given that the queue says won't hold.
|
|
2504
2594
|
...(values.due ? pilotLines(r, r.created) : []),
|
|
2505
2595
|
].join("\n"),
|
package/lib/words.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task in plain words, read the way the website's new task box reads it
|
|
3
|
+
* ("Book the venue @Quinn by Friday !high #Website"): src/lib/plain-words.ts
|
|
4
|
+
* itself. A checkout runs that file as it is (Node strips its types); the
|
|
5
|
+
* published package carries it bundled as dist/words.mjs by build.mjs.
|
|
6
|
+
*/
|
|
7
|
+
async function load() {
|
|
8
|
+
try {
|
|
9
|
+
return await import("../../src/lib/plain-words.ts");
|
|
10
|
+
} catch {
|
|
11
|
+
return await import("../dist/words.mjs");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const words = await load();
|
|
16
|
+
|
|
17
|
+
/** @type {typeof import("../../src/lib/plain-words.ts").readTaskWords} */
|
|
18
|
+
export const readTaskWords = words.readTaskWords;
|
|
19
|
+
/** @type {typeof import("../../src/lib/plain-words.ts").dayFromWords} */
|
|
20
|
+
export const dayFromWords = words.dayFromWords;
|