@harborppm/cli 0.76.0 → 0.78.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 +5 -2
- package/dist/app.mjs +637 -84
- package/dist/words.mjs +301 -0
- package/lib/cli.mjs +129 -24
- package/lib/format.mjs +26 -0
- 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
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
lifeboatsText,
|
|
40
40
|
followedText,
|
|
41
41
|
soundingsText,
|
|
42
|
+
goalsText,
|
|
42
43
|
thanksText,
|
|
43
44
|
ashoreText,
|
|
44
45
|
voyageText,
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
import { colourDepth, logoLines } from "./logo.mjs";
|
|
65
66
|
import { HarborError, createClient } from "./mcp.mjs";
|
|
66
67
|
import { jsonText, plain } from "./plain.mjs";
|
|
68
|
+
import { dayFromWords, readTaskWords } from "./words.mjs";
|
|
67
69
|
import {
|
|
68
70
|
FETCH_LIMIT,
|
|
69
71
|
SHELLS,
|
|
@@ -164,10 +166,14 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
164
166
|
[--also "NAME, NAME"] [--due DATE|pilot]
|
|
165
167
|
[--priority low|medium|high|urgent]
|
|
166
168
|
[--status S] [--comment TEXT] [--over-limit] [--dry-run]
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
(
|
|
169
|
+
[--literal]
|
|
170
|
+
reads plain words in TITLE as the
|
|
171
|
+
website does: @name, by friday,
|
|
172
|
+
!high, #project (--literal: keep
|
|
173
|
+
them); with no TITLE: one title a
|
|
174
|
+
line from stdin; --due pilot takes
|
|
175
|
+
the date the owner's queue
|
|
176
|
+
suggests (harbor help add)
|
|
171
177
|
harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
|
|
172
178
|
move a task to In play, In review
|
|
173
179
|
or Done
|
|
@@ -255,6 +261,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
255
261
|
your answer, or read the spread
|
|
256
262
|
harbor soundings [PROJECT] open tasks with a sounding, and
|
|
257
263
|
the ones waiting on your answer
|
|
264
|
+
harbor goals [PORTFOLIO] each portfolio's goals, how far
|
|
265
|
+
along and whether on track
|
|
258
266
|
harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
|
|
259
267
|
public holidays and time away
|
|
260
268
|
coming up, and whose holidays
|
|
@@ -486,7 +494,11 @@ function daysToWeekday(v, today) {
|
|
|
486
494
|
return i < 0 ? null : (i - today.getDay() + 7) % 7;
|
|
487
495
|
}
|
|
488
496
|
|
|
489
|
-
/**
|
|
497
|
+
/**
|
|
498
|
+
* --due as people say it: today, tomorrow, friday, +3, or a date, and the
|
|
499
|
+
* rest of what the website's new task box reads ("next friday", "in two
|
|
500
|
+
* weeks", "Nov 15"). Anything else goes to Harbor to judge.
|
|
501
|
+
*/
|
|
490
502
|
export function dueDate(v, today = new Date()) {
|
|
491
503
|
if (v === undefined || v === null) return v;
|
|
492
504
|
const words = { today: 0, tomorrow: 1 };
|
|
@@ -498,11 +510,64 @@ export function dueDate(v, today = new Date()) {
|
|
|
498
510
|
: /^[a-z]+$/i.test(v)
|
|
499
511
|
? daysToWeekday(v, today)
|
|
500
512
|
: null;
|
|
501
|
-
if (days === null) return v;
|
|
513
|
+
if (days === null) return (typeof v === "string" && dayFromWords(v, todayLocal(today))) || v;
|
|
502
514
|
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days);
|
|
503
515
|
return todayLocal(d);
|
|
504
516
|
}
|
|
505
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Each title read in plain words, as the website's new task box reads it:
|
|
520
|
+
* "Book the venue @Quinn by Friday !high #Website". A flag given says that
|
|
521
|
+
* thing itself, so its words stay in the title. The crew and the projects
|
|
522
|
+
* are asked for only when a title could name one.
|
|
523
|
+
*/
|
|
524
|
+
async function readTitles(client, harbor, titles, values, today = todayLocal()) {
|
|
525
|
+
const could = (re) => titles.some((t) => re.test(t));
|
|
526
|
+
const people =
|
|
527
|
+
values.assignee === undefined && could(/(?:^|\s)@|\bassign|\bowner\b/i)
|
|
528
|
+
? await client
|
|
529
|
+
.call("crew_clocks", { harbor })
|
|
530
|
+
.then((r) => [...(r.clocks ?? []).map((c) => c.who), ...(r.noTimeZone ?? [])])
|
|
531
|
+
.catch(() => [])
|
|
532
|
+
: [];
|
|
533
|
+
const projects =
|
|
534
|
+
values.project === undefined && could(/(?:^|\s)#/)
|
|
535
|
+
? await client
|
|
536
|
+
.call("list_projects", { harbor, limit: 100 })
|
|
537
|
+
.then((r) => (r.projects ?? []).map((p) => ({ id: p.name, name: p.name })))
|
|
538
|
+
.catch(() => [])
|
|
539
|
+
: [];
|
|
540
|
+
const skip = new Set(
|
|
541
|
+
[
|
|
542
|
+
values.assignee !== undefined && "owner",
|
|
543
|
+
values.due !== undefined && "due",
|
|
544
|
+
values.priority !== undefined && "priority",
|
|
545
|
+
values.project !== undefined && "project",
|
|
546
|
+
].filter(Boolean),
|
|
547
|
+
);
|
|
548
|
+
return titles.map((title) => {
|
|
549
|
+
const r = readTaskWords(title, {
|
|
550
|
+
today,
|
|
551
|
+
people: people.map((name) => ({ id: name, name })),
|
|
552
|
+
projects,
|
|
553
|
+
skip,
|
|
554
|
+
});
|
|
555
|
+
const read = r.owner || r.due || r.priority || r.project;
|
|
556
|
+
return read && r.title ? r : { title, owner: null, due: null, priority: null, project: null };
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** What a title's words said, in a line: "Quinn (@Quinn), due 2026-11-06 (by Friday)". */
|
|
561
|
+
function wordsLine(r) {
|
|
562
|
+
const bits = [
|
|
563
|
+
r.owner && `${r.owner.value} (${r.owner.text})`,
|
|
564
|
+
r.due && `due ${r.due.value} (${r.due.text})`,
|
|
565
|
+
r.priority && `${r.priority.value} priority (${r.priority.text})`,
|
|
566
|
+
r.project && `in ${r.project.value} (${r.project.text})`,
|
|
567
|
+
].filter(Boolean);
|
|
568
|
+
return bits.length ? `Read from your words: ${bits.join(", ")}.` : null;
|
|
569
|
+
}
|
|
570
|
+
|
|
506
571
|
/** --due pilot: the date the owner's queue suggests, set by Harbor. */
|
|
507
572
|
const PILOT = new Set(["pilot", "suggested"]);
|
|
508
573
|
const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
|
|
@@ -591,7 +656,7 @@ export function hint(message, project) {
|
|
|
591
656
|
(_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
|
|
592
657
|
)
|
|
593
658
|
// 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");
|
|
659
|
+
.replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow, friday, next friday or +N days");
|
|
595
660
|
if (!seen.has(flag)) seen.set(flag, said);
|
|
596
661
|
}
|
|
597
662
|
return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
|
|
@@ -678,6 +743,12 @@ const DETAIL = {
|
|
|
678
743
|
" --project can be left out when the harbor has one open project (First",
|
|
679
744
|
" voyage aside); the task goes there.",
|
|
680
745
|
"",
|
|
746
|
+
" Plain words in a title are read as the website reads them: \"Book the",
|
|
747
|
+
" venue @Quinn by friday !high #Website\" adds \"Book the venue\" for",
|
|
748
|
+
" Quinn, due Friday, high priority, in Website. Harbor says what it read.",
|
|
749
|
+
" A flag wins over the words (they stay in the title), and --literal keeps",
|
|
750
|
+
" every title exactly as typed.",
|
|
751
|
+
"",
|
|
681
752
|
" --assignee me|none|NAME, --also \"NAME, NAME\" (others on it with the",
|
|
682
753
|
" owner), --due DATE|pilot, --priority low|medium|high|urgent,",
|
|
683
754
|
" --status S, --comment TEXT and --over-limit apply to every task added, so",
|
|
@@ -942,6 +1013,11 @@ const DETAIL = {
|
|
|
942
1013
|
" these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
|
|
943
1014
|
" NZ US (national holidays only).",
|
|
944
1015
|
],
|
|
1016
|
+
goals: [
|
|
1017
|
+
" harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
|
|
1018
|
+
" left out), off track first, with how far",
|
|
1019
|
+
" along each key result is",
|
|
1020
|
+
],
|
|
945
1021
|
soundings: [
|
|
946
1022
|
" harbor soundings [PROJECT] reads: open tasks with a sounding",
|
|
947
1023
|
" (all projects when left out), and the",
|
|
@@ -2388,12 +2464,10 @@ export async function run(argv, io = {}) {
|
|
|
2388
2464
|
case "add": {
|
|
2389
2465
|
const { positionals, values, harbor } = parse(
|
|
2390
2466
|
spec(
|
|
2391
|
-
["over-limit", "dry-run"],
|
|
2467
|
+
["over-limit", "dry-run", "literal"],
|
|
2392
2468
|
["project", "assignee", "also", "due", "priority", "status", "comment"],
|
|
2393
2469
|
),
|
|
2394
2470
|
);
|
|
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
2471
|
// Several at once: one title a line on stdin, with no title (or -) after add.
|
|
2398
2472
|
const piped =
|
|
2399
2473
|
positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
|
|
@@ -2411,6 +2485,15 @@ export async function run(argv, io = {}) {
|
|
|
2411
2485
|
throw new HarborError(
|
|
2412
2486
|
"That's more than 100 tasks; harbor import FILE takes a spreadsheet.",
|
|
2413
2487
|
);
|
|
2488
|
+
// Plain words, as on the website: @person, by Friday, !high, #Project.
|
|
2489
|
+
// --literal keeps every title exactly as typed.
|
|
2490
|
+
const reads = values.literal
|
|
2491
|
+
? titles.map((title) => ({ title, owner: null, due: null, priority: null, project: null }))
|
|
2492
|
+
: await readTitles(client, harbor, titles, values);
|
|
2493
|
+
// No --project and no #Project: the harbor's one open project, if that's all there is.
|
|
2494
|
+
if (!values.project && !reads.every((r) => r.project))
|
|
2495
|
+
values.project = await onlyProject(client, harbor, "--project NAME or #NAME in the title");
|
|
2496
|
+
const projectOf = (r) => values.project ?? r.project?.value;
|
|
2414
2497
|
if (values["dry-run"]) {
|
|
2415
2498
|
const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
|
|
2416
2499
|
// --due pilot: the date each line would take, each counting the ones before it.
|
|
@@ -2424,12 +2507,23 @@ export async function run(argv, io = {}) {
|
|
|
2424
2507
|
})
|
|
2425
2508
|
).dates
|
|
2426
2509
|
: null;
|
|
2510
|
+
const where = [...new Set(reads.map(projectOf))];
|
|
2427
2511
|
if (asJson)
|
|
2428
2512
|
out(
|
|
2429
2513
|
jsonText({
|
|
2430
2514
|
dryRun: true,
|
|
2431
|
-
project: values.project,
|
|
2432
|
-
titles,
|
|
2515
|
+
project: where.length === 1 ? where[0] : values.project,
|
|
2516
|
+
titles: reads.map((r) => r.title),
|
|
2517
|
+
...(reads.some((r) => r.owner || r.due || r.priority || r.project)
|
|
2518
|
+
? {
|
|
2519
|
+
read: reads.map((r) => ({
|
|
2520
|
+
project: projectOf(r),
|
|
2521
|
+
...(r.owner ? { assignee: r.owner.value } : {}),
|
|
2522
|
+
...(r.due ? { due: r.due.value } : {}),
|
|
2523
|
+
...(r.priority ? { priority: r.priority.value } : {}),
|
|
2524
|
+
})),
|
|
2525
|
+
}
|
|
2526
|
+
: {}),
|
|
2433
2527
|
...(pilot
|
|
2434
2528
|
? { due: pilot.map((d) => d.earliest) }
|
|
2435
2529
|
: values.due
|
|
@@ -2441,11 +2535,11 @@ export async function run(argv, io = {}) {
|
|
|
2441
2535
|
out(
|
|
2442
2536
|
plain(
|
|
2443
2537
|
[
|
|
2444
|
-
`Would add ${n} to ${
|
|
2445
|
-
...
|
|
2446
|
-
(
|
|
2447
|
-
|
|
2448
|
-
),
|
|
2538
|
+
`Would add ${n} to ${where.join(" and ")}:`,
|
|
2539
|
+
...reads.flatMap((r, i) => [
|
|
2540
|
+
` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
|
|
2541
|
+
...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
|
|
2542
|
+
]),
|
|
2449
2543
|
...(pilot
|
|
2450
2544
|
? [
|
|
2451
2545
|
`--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
|
|
@@ -2457,25 +2551,25 @@ export async function run(argv, io = {}) {
|
|
|
2457
2551
|
return 0;
|
|
2458
2552
|
}
|
|
2459
2553
|
const added = [];
|
|
2460
|
-
for (const
|
|
2554
|
+
for (const words of reads) {
|
|
2461
2555
|
const r = await client
|
|
2462
2556
|
.call("create_task", {
|
|
2463
2557
|
harbor,
|
|
2464
|
-
project:
|
|
2465
|
-
title,
|
|
2558
|
+
project: projectOf(words),
|
|
2559
|
+
title: words.title,
|
|
2466
2560
|
// Yours unless you say otherwise, as with n in the app.
|
|
2467
|
-
assignee: values.assignee ?? "me",
|
|
2561
|
+
assignee: values.assignee ?? words.owner?.value ?? "me",
|
|
2468
2562
|
also: values.also === undefined ? undefined : alsoList(values.also),
|
|
2469
|
-
due: duePilot(values.due) ? undefined : dueDate(values.due),
|
|
2563
|
+
due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
|
|
2470
2564
|
suggestedDue: duePilot(values.due),
|
|
2471
|
-
priority: values.priority,
|
|
2565
|
+
priority: values.priority ?? words.priority?.value,
|
|
2472
2566
|
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
2473
2567
|
overLimit: values["over-limit"] || undefined,
|
|
2474
2568
|
})
|
|
2475
2569
|
.catch((err) => {
|
|
2476
2570
|
if (err instanceof HarborError) {
|
|
2477
2571
|
const done = added.length ? ` (added ${added.length} before it)` : "";
|
|
2478
|
-
throw new HarborError(`${hint(err.message,
|
|
2572
|
+
throw new HarborError(`${hint(err.message, projectOf(words))}${done}`);
|
|
2479
2573
|
}
|
|
2480
2574
|
throw err;
|
|
2481
2575
|
});
|
|
@@ -2487,6 +2581,8 @@ export async function run(argv, io = {}) {
|
|
|
2487
2581
|
body: values.comment,
|
|
2488
2582
|
})
|
|
2489
2583
|
).commented;
|
|
2584
|
+
const line = wordsLine(words);
|
|
2585
|
+
if (line) r.read = line;
|
|
2490
2586
|
added.push(r);
|
|
2491
2587
|
}
|
|
2492
2588
|
await stalePromptCache(env);
|
|
@@ -2500,6 +2596,8 @@ export async function run(argv, io = {}) {
|
|
|
2500
2596
|
.map((r) =>
|
|
2501
2597
|
[
|
|
2502
2598
|
`Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
|
|
2599
|
+
// What the title's words said, so nothing is read without being seen.
|
|
2600
|
+
...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
|
|
2503
2601
|
// Pilot speaks up only when a date was given that the queue says won't hold.
|
|
2504
2602
|
...(values.due ? pilotLines(r, r.created) : []),
|
|
2505
2603
|
].join("\n"),
|
|
@@ -3077,6 +3175,13 @@ export async function run(argv, io = {}) {
|
|
|
3077
3175
|
soundingsText(r),
|
|
3078
3176
|
);
|
|
3079
3177
|
}
|
|
3178
|
+
case "goals": {
|
|
3179
|
+
const { positionals, harbor } = parse(spec());
|
|
3180
|
+
const portfolio = positionals.join(" ").trim();
|
|
3181
|
+
return call("list_goals", { harbor, ...(portfolio ? { portfolio } : {}) }, (r) =>
|
|
3182
|
+
goalsText(r),
|
|
3183
|
+
);
|
|
3184
|
+
}
|
|
3080
3185
|
case "ashore": {
|
|
3081
3186
|
const { positionals, values, harbor } = parse(spec([], ["weeks", "country", "person"]));
|
|
3082
3187
|
none(positionals);
|
package/lib/format.mjs
CHANGED
|
@@ -1229,3 +1229,29 @@ export function catchupText(r) {
|
|
|
1229
1229
|
if (more) out.push("", `${r.handed.length} handed to you in all; harbor catchup --json lists them.`);
|
|
1230
1230
|
return out.join("\n");
|
|
1231
1231
|
}
|
|
1232
|
+
|
|
1233
|
+
/** list_goals as text: each portfolio's goals, what needs attention first, with their key results. */
|
|
1234
|
+
export function goalsText(r) {
|
|
1235
|
+
const order = ["off-track", "at-risk", "not-started", "on-track", "achieved"];
|
|
1236
|
+
const withGoals = (r.portfolios ?? []).filter((p) => p.goals?.length);
|
|
1237
|
+
if (!withGoals.length)
|
|
1238
|
+
return "No goals yet. Set one at the top of a portfolio's page on the website.";
|
|
1239
|
+
const lines = [];
|
|
1240
|
+
for (const p of withGoals) {
|
|
1241
|
+
if (lines.length) lines.push("");
|
|
1242
|
+
lines.push(p.name);
|
|
1243
|
+
const goals = [...p.goals].sort(
|
|
1244
|
+
(a, b) =>
|
|
1245
|
+
order.indexOf(a.status) - order.indexOf(b.status) ||
|
|
1246
|
+
(a.due ?? "9999").localeCompare(b.due ?? "9999"),
|
|
1247
|
+
);
|
|
1248
|
+
for (const g of goals) {
|
|
1249
|
+
const bits = [`${g.progress}%`, g.statusText.toLowerCase()];
|
|
1250
|
+
if (g.owner) bits.push(g.owner);
|
|
1251
|
+
if (g.due && g.status !== "achieved") bits.push(`by ${monthDay(g.due)}`);
|
|
1252
|
+
lines.push(` ${g.title} · ${bits.join(" · ")}`);
|
|
1253
|
+
for (const k of g.keyResults) lines.push(` ${k.title} · ${k.text}`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return lines.join("\n");
|
|
1257
|
+
}
|
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;
|