@wirecat/cli-core 0.529.0 → 0.531.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/CHANGELOG.md +45 -0
- package/dist/cli/config-command.d.ts +11 -1
- package/dist/cli/config-command.js +12 -10
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +1 -1
- package/dist/cli/list-query.d.ts +0 -4
- package/dist/cli/list-query.js +19 -1
- package/dist/cli/messenger/download-command.d.ts +2 -1
- package/dist/cli/messenger/download-command.js +2 -1
- package/dist/cli/messenger/schedule-command.d.ts +2 -19
- package/dist/cli/messenger/schedule-command.js +2 -231
- package/dist/cli/messenger/topics-command.js +22 -5
- package/dist/cli/schedule/schedule-command.d.ts +19 -0
- package/dist/cli/schedule/schedule-command.js +261 -0
- package/dist/cli/shared-command-exports.d.ts +2 -1
- package/dist/cli/shared-command-exports.js +2 -1
- package/dist/domain/models.d.ts +2 -0
- package/dist/entities/index.d.ts +41 -2
- package/dist/entities/index.js +3 -2
- package/dist/entities/list-results.d.ts +3 -0
- package/dist/entities/list-results.js +1 -0
- package/dist/entities/task-results.d.ts +30 -0
- package/dist/entities/task-results.js +1 -0
- package/dist/entities/task.d.ts +7 -0
- package/dist/entities/task.js +2 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +1 -0
- package/dist/files/safe-name.d.ts +5 -0
- package/dist/files/safe-name.js +18 -0
- package/dist/mail/account-key.d.ts +2 -0
- package/dist/mail/account-key.js +1 -0
- package/dist/mail/cli/index.js +10 -10
- package/dist/mail/index.d.ts +1 -0
- package/dist/mail/index.js +1 -0
- package/dist/mail/retry.js +2 -1
- package/dist/mail/rule-stored.js +2 -1
- package/dist/mail/sender-scores.js +2 -1
- package/dist/mail/sync.js +3 -2
- package/dist/ports/messenger.d.ts +3 -0
- package/dist/services/file-download.d.ts +2 -5
- package/dist/services/file-download.js +3 -18
- package/dist/services/tasks.d.ts +4 -2
- package/dist/services/tasks.js +2 -2
- package/dist/services/topics.d.ts +3 -0
- package/dist/services/topics.js +20 -6
- package/dist/store/index.d.ts +1 -0
- package/dist/store/sqlite/task-board.js +3 -1
- package/dist/store/sqlite/task-comments.d.ts +23 -0
- package/dist/store/sqlite/task-comments.js +41 -0
- package/dist/store/sqlite/things.js +8 -1
- package/dist/store/sqlite/work-tasks.d.ts +13 -2
- package/dist/store/sqlite/work-tasks.js +52 -1
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* A name another person chose cannot climb out of the folder, cannot be a dot file we would then
|
|
4
|
+
* hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
|
|
5
|
+
*/
|
|
6
|
+
export const safeName = (name, platform = process.platform) => {
|
|
7
|
+
let plain = name === undefined
|
|
8
|
+
? ""
|
|
9
|
+
: basename(name.replaceAll("\\", "/"))
|
|
10
|
+
.replace(/[\p{Cc}---]/gu, "")
|
|
11
|
+
.replace(/^\.+/, "");
|
|
12
|
+
if (platform === "win32") {
|
|
13
|
+
plain = plain.replace(/[<>:"|?*]/g, "_").replace(/[. ]+$/, "");
|
|
14
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(plain))
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return plain === "" ? undefined : plain;
|
|
18
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const mailAccountKey = (address) => ({ provider: "email", account: address.toLowerCase() });
|
package/dist/mail/cli/index.js
CHANGED
|
@@ -84,9 +84,9 @@ const searchCommand = (mail) => {
|
|
|
84
84
|
partial: true,
|
|
85
85
|
output: { entity: "message", view: "hit" },
|
|
86
86
|
examples: [
|
|
87
|
-
`${mail.app.command}
|
|
88
|
-
`${mail.app.command}
|
|
89
|
-
`${mail.app.command}
|
|
87
|
+
`${mail.app.command} personal search messages invoice`,
|
|
88
|
+
`${mail.app.command} personal search messages 'from:ann@example.com has:attachment date>30d'`,
|
|
89
|
+
`${mail.app.command} personal search messages 'list:github.com is:unread' --newest`,
|
|
90
90
|
],
|
|
91
91
|
})
|
|
92
92
|
.summary(mail.server ? "find emails by words and fields" : "find stored emails by words and fields")
|
|
@@ -103,7 +103,7 @@ const searchCommand = (mail) => {
|
|
|
103
103
|
.option("--timezone <zone>", "the IANA timezone for calendar date boundaries")
|
|
104
104
|
.addHelpText("after", `
|
|
105
105
|
Fields: from: to: cc: bcc: subject: body: mailbox: (or label:) list: (a List-Id or its domain) is:unread|read|starred
|
|
106
|
-
has:attachment, filename:, content: (the text of attachments), size>, date: — \`${mail.app.command}
|
|
106
|
+
has:attachment, filename:, content: (the text of attachments), size>, date: — \`${mail.app.command} personal search messages 'content:invoice'\`.
|
|
107
107
|
chat: names a thread by its subject. Mail sent to many is in the mailbox bulk; NOT mailbox:bulk leaves it out.
|
|
108
108
|
Mail from senders with signs of fraud comes after the rest of the page; --plain-order keeps it in place.
|
|
109
109
|
--json: the list is items; each hit's reference is ref (msg:email/…), which messages show reads.${mail.server
|
|
@@ -194,8 +194,8 @@ const messagesCommand = (mail) => {
|
|
|
194
194
|
annotate(messages.command("show"), {
|
|
195
195
|
effects: LOCAL_READ,
|
|
196
196
|
examples: [
|
|
197
|
-
`${mail.app.command}
|
|
198
|
-
`${mail.app.command}
|
|
197
|
+
`${mail.app.command} personal messages show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E'`,
|
|
198
|
+
`${mail.app.command} personal messages show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E' --before-n 2`,
|
|
199
199
|
],
|
|
200
200
|
})
|
|
201
201
|
.summary("one stored email whole, with the text of its attachments")
|
|
@@ -243,8 +243,8 @@ const conversationsCommand = (mail) => {
|
|
|
243
243
|
const list = withQuery(annotate(conversations.command("list"), {
|
|
244
244
|
effects: LOCAL_READ,
|
|
245
245
|
examples: [
|
|
246
|
-
`${mail.app.command}
|
|
247
|
-
`${mail.app.command}
|
|
246
|
+
`${mail.app.command} personal conversations list`,
|
|
247
|
+
`${mail.app.command} personal conversations list subject:invoice emails_count>1`,
|
|
248
248
|
],
|
|
249
249
|
})
|
|
250
250
|
.summary("stored email threads, newest first")
|
|
@@ -291,8 +291,8 @@ const conversationsCommand = (mail) => {
|
|
|
291
291
|
annotate(conversations.command("show"), {
|
|
292
292
|
effects: LOCAL_READ,
|
|
293
293
|
examples: [
|
|
294
|
-
`${mail.app.command}
|
|
295
|
-
`${mail.app.command}
|
|
294
|
+
`${mail.app.command} personal conversations show 'chat:email/me%40example.com/thread-1'`,
|
|
295
|
+
`${mail.app.command} personal conversations show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E'`,
|
|
296
296
|
],
|
|
297
297
|
})
|
|
298
298
|
.summary("one email thread, every email oldest first")
|
package/dist/mail/index.d.ts
CHANGED
package/dist/mail/index.js
CHANGED
package/dist/mail/retry.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { mailAccountKey } from "./account-key.js";
|
|
2
3
|
import { addressOf, FETCH_CHUNK, readAttachments } from "./attachments.js";
|
|
3
4
|
import { mailFolders } from "./folders.js";
|
|
4
5
|
import { mailHeadersOf } from "./headers.js";
|
|
@@ -12,7 +13,7 @@ export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
|
|
|
12
13
|
*/
|
|
13
14
|
export const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
|
|
14
15
|
const startedAt = now();
|
|
15
|
-
const key =
|
|
16
|
+
const key = mailAccountKey(account.address);
|
|
16
17
|
const find = source.find;
|
|
17
18
|
if (find === undefined)
|
|
18
19
|
throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
|
package/dist/mail/rule-stored.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { mailAccountKey } from "./account-key.js";
|
|
2
3
|
import { PRESET_NAMES } from "./presets.js";
|
|
3
4
|
const ACTION_OF = { full: "keep", envelope: "envelope", skip: "skip" };
|
|
4
5
|
const KINDS = {
|
|
@@ -56,7 +57,7 @@ export const storedMailRule = (stored) => {
|
|
|
56
57
|
export const storedMailRuleSet = async (store, account, query) => {
|
|
57
58
|
let stored;
|
|
58
59
|
try {
|
|
59
|
-
const { id } = await store.accounts.storedAccount(
|
|
60
|
+
const { id } = await store.accounts.storedAccount(mailAccountKey(account.address));
|
|
60
61
|
stored = await store.mail.rules.list(id, query);
|
|
61
62
|
}
|
|
62
63
|
catch (error) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { mailAccountKey } from "./account-key.js";
|
|
2
3
|
import { domainsToAge, groupKey, probeMail } from "./probe.js";
|
|
3
4
|
import { decide } from "./rules.js";
|
|
4
5
|
import { FRAUD_SIGNALS, FREE_MAIL, registrableOf, WEIGHTS } from "./score.js";
|
|
@@ -18,7 +19,7 @@ const RECENT = 2 * 365 * DAY;
|
|
|
18
19
|
* looked up first, for senders heard from in the last two years.
|
|
19
20
|
*/
|
|
20
21
|
export const scoreSenders = async (store, address, ages, now = Date.now(), rules = []) => {
|
|
21
|
-
const key =
|
|
22
|
+
const key = mailAccountKey(address);
|
|
22
23
|
const last = (await store.sync.syncState(key, SCORED))?.value;
|
|
23
24
|
const done = last === undefined ? undefined : JSON.parse(last);
|
|
24
25
|
if (done !== undefined && done.version === SCORE_VERSION && now - done.at < DAY)
|
package/dist/mail/sync.js
CHANGED
|
@@ -2,6 +2,7 @@ import { setTimeout } from "node:timers/promises";
|
|
|
2
2
|
import { CliError } from "@wirecat/cli-base";
|
|
3
3
|
import { importEngine } from "../attachments/extract.js";
|
|
4
4
|
import { isBusy } from "../store/sqlite/transaction.js";
|
|
5
|
+
import { mailAccountKey } from "./account-key.js";
|
|
5
6
|
import { FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
|
|
6
7
|
import { BULK, bulkBox } from "./bulk.js";
|
|
7
8
|
import { parsedEmail } from "./email-input.js";
|
|
@@ -108,7 +109,7 @@ export const syncMail = async (source, store, options) => {
|
|
|
108
109
|
})),
|
|
109
110
|
];
|
|
110
111
|
const complete = last.complete && plan.stopped === null && unreadable.length === 0 && remaining === 0;
|
|
111
|
-
const account =
|
|
112
|
+
const account = mailAccountKey(options.account.address);
|
|
112
113
|
const indexed = plan.stopped === null ? await index?.(store, account) : undefined;
|
|
113
114
|
const deletionsSkipped = rounds.map((round) => round.deletionsSkipped).findLast((text) => text !== undefined);
|
|
114
115
|
const { unreadable: _unreadable, ...rest } = last;
|
|
@@ -155,7 +156,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
155
156
|
index,
|
|
156
157
|
});
|
|
157
158
|
const startedAt = now();
|
|
158
|
-
const key =
|
|
159
|
+
const key = mailAccountKey(account.address);
|
|
159
160
|
const accountId = await store.accounts.saveAccount(key, { name: account.address });
|
|
160
161
|
const folders = await mailFolders(account, source);
|
|
161
162
|
if (folders.length < 1 || folders.length > 50)
|
|
@@ -272,6 +272,7 @@ export interface ForumControl {
|
|
|
272
272
|
enableForum(chatId: Id): Promise<ForumState>;
|
|
273
273
|
createTopic(chatId: Id, title: string, options: {
|
|
274
274
|
sendId: string;
|
|
275
|
+
icon?: string;
|
|
275
276
|
}): Promise<Topic>;
|
|
276
277
|
}
|
|
277
278
|
export interface TopicEditing {
|
|
@@ -281,6 +282,8 @@ export interface TopicEditing {
|
|
|
281
282
|
orderPinnedTopics(chatId: Id, topicIds: Id[]): Promise<void>;
|
|
282
283
|
/** Deletes the topic and every message in it, for everyone; it cannot be undone. A topic that is gone is `not_found`. */
|
|
283
284
|
deleteTopic?(chatId: Id, topicId: Id): Promise<void>;
|
|
285
|
+
/** The emojis any account may give a topic as its icon. */
|
|
286
|
+
topicIcons?(): Promise<string[]>;
|
|
284
287
|
}
|
|
285
288
|
export interface ThreadAddressing {
|
|
286
289
|
validateThread(chatId: Id, threadId: Id, options: {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RefreshedFile } from "../domain/attachments.js";
|
|
2
2
|
import type { Id } from "../domain/models.js";
|
|
3
|
+
import { safeName } from "../files/safe-name.js";
|
|
3
4
|
import type { RemoteFile } from "../ports/messenger.js";
|
|
4
5
|
import { batchProgress } from "./batch.js";
|
|
5
6
|
import type { MessagesService } from "./messages.js";
|
|
@@ -39,11 +40,7 @@ export declare const saveFiles: (messages: Pick<MessagesService, "keepDownloaded
|
|
|
39
40
|
maxErrorPercent: number;
|
|
40
41
|
};
|
|
41
42
|
}>;
|
|
42
|
-
|
|
43
|
-
* A name another person chose cannot climb out of the folder, cannot be a dot file we would then
|
|
44
|
-
* hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
|
|
45
|
-
*/
|
|
46
|
-
export declare const safeName: (name: string | undefined, platform?: NodeJS.Platform) => string | undefined;
|
|
43
|
+
export { safeName };
|
|
47
44
|
/**
|
|
48
45
|
* The bytes go to a temporary name first and are then hard-linked to the real one, which fails if the
|
|
49
46
|
* name is taken — so an existing file survives, and an interrupted download leaves no half file under
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createWriteStream, mkdirSync } from "node:fs";
|
|
2
2
|
import { link, rm } from "node:fs/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { Readable, Transform } from "node:stream";
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { CliError } from "@wirecat/cli-base";
|
|
7
|
+
import { safeName } from "../files/safe-name.js";
|
|
7
8
|
import { batchProgress } from "./batch.js";
|
|
8
9
|
/** The files the messenger gave a new reference for while their bytes were read, to save without a path. */
|
|
9
10
|
export const refreshedFiles = (files) => files.flatMap(({ kind, name, position, providerRef }) => providerRef === undefined || Object.keys(providerRef).length === 0
|
|
@@ -90,23 +91,7 @@ const EXTENSIONS = {
|
|
|
90
91
|
};
|
|
91
92
|
/** Telegram sends a photo with no name and no type; it is always a JPEG. */
|
|
92
93
|
const BY_KIND = { photo: "jpg" };
|
|
93
|
-
|
|
94
|
-
* A name another person chose cannot climb out of the folder, cannot be a dot file we would then
|
|
95
|
-
* hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
|
|
96
|
-
*/
|
|
97
|
-
export const safeName = (name, platform = process.platform) => {
|
|
98
|
-
let plain = name === undefined
|
|
99
|
-
? ""
|
|
100
|
-
: basename(name.replaceAll("\\", "/"))
|
|
101
|
-
.replace(/[\p{Cc}---]/gu, "")
|
|
102
|
-
.replace(/^\.+/, "");
|
|
103
|
-
if (platform === "win32") {
|
|
104
|
-
plain = plain.replace(/[<>:"|?*]/g, "_").replace(/[. ]+$/, "");
|
|
105
|
-
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(plain))
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
return plain === "" ? undefined : plain;
|
|
109
|
-
};
|
|
94
|
+
export { safeName };
|
|
110
95
|
/**
|
|
111
96
|
* The bytes go to a temporary name first and are then hard-linked to the real one, which fails if the
|
|
112
97
|
* name is taken — so an existing file survives, and an interrupted download leaves no half file under
|
package/dist/services/tasks.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type QueryAst } from "@wirecat/cli-base/query";
|
|
|
2
2
|
import type * as v from "valibot";
|
|
3
3
|
import { task as taskEntity } from "../entities/task.js";
|
|
4
4
|
import { type CloseSuggestion, type SimilarTask, type TaskDraft, type TaskPair, type TaskPairSide, type TaskRelation } from "../store/index.js";
|
|
5
|
-
import type { AccountKey, MessageStore, TaskDetails, ThingLink } from "../store/store.js";
|
|
5
|
+
import type { AccountKey, Author, MessageStore, TaskDetails, ThingLink } from "../store/store.js";
|
|
6
6
|
import { type ClosedState, type GroupStats, TASK_KINDS, TASK_STATES, type Task, type TaskKind, type TaskOrigin } from "../tasks/index.js";
|
|
7
7
|
import type { ServiceDeps } from "./deps.js";
|
|
8
8
|
import { type TaskCategory, type UnlistedCategory } from "./task-categories.js";
|
|
@@ -126,7 +126,9 @@ export interface TasksService {
|
|
|
126
126
|
* unless `choice` says to attach the message to one of them or to open the task anyway. A task opened while one
|
|
127
127
|
* closed in the last 14 days is likely or maybe the same job follows it up (`followUpOf`); that one stays closed.
|
|
128
128
|
*/
|
|
129
|
-
add(message: string, type: TaskKind, origin: TaskOrigin, plan?: TaskPlan, choice?: DuplicateChoice
|
|
129
|
+
add(message: string, type: TaskKind, origin: TaskOrigin, plan?: TaskPlan, choice?: DuplicateChoice,
|
|
130
|
+
/** Who adds it, when an agent is known by name; otherwise the origin says. */
|
|
131
|
+
author?: Author): Promise<{
|
|
130
132
|
task: TaskView;
|
|
131
133
|
created: boolean;
|
|
132
134
|
attached?: boolean;
|
package/dist/services/tasks.js
CHANGED
|
@@ -218,7 +218,7 @@ export const tasksService = (deps, categories) => {
|
|
|
218
218
|
...(previews ? { previews: await Promise.all(shown.map(({ task }) => taskView(store, account, task))) } : {}),
|
|
219
219
|
};
|
|
220
220
|
}),
|
|
221
|
-
add: (message, type, origin, { category: named, ...plan } = {}, choice = {}) => inStore(async (store, account) => {
|
|
221
|
+
add: (message, type, origin, { category: named, ...plan } = {}, choice = {}, author = undefined) => inStore(async (store, account) => {
|
|
222
222
|
const chosen = named === undefined ? undefined : await categoryOf(store, named);
|
|
223
223
|
const source = message.trim();
|
|
224
224
|
const native = isNoteReference(source);
|
|
@@ -253,7 +253,7 @@ export const tasksService = (deps, categories) => {
|
|
|
253
253
|
...(plan.dueAt ? { due: plan.dueAt } : {}),
|
|
254
254
|
};
|
|
255
255
|
const subject = { account: taskAccount(account), draft };
|
|
256
|
-
const by = origin === "owner" ? "owner" : { bot: origin };
|
|
256
|
+
const by = author ?? (origin === "owner" ? "owner" : { bot: origin });
|
|
257
257
|
if (choice.attach !== undefined) {
|
|
258
258
|
const target = await owned(store, account, choice.attach);
|
|
259
259
|
const similarity = await store.tasks.compare(subject, target.id);
|
|
@@ -3,6 +3,8 @@ import { type Operated } from "../sends/guarded.js";
|
|
|
3
3
|
import type { ServiceDeps } from "./deps.js";
|
|
4
4
|
export interface TopicsService {
|
|
5
5
|
show(chat: string, topic: string): Promise<Topic>;
|
|
6
|
+
/** The emojis `create` and `edit` take as an icon. */
|
|
7
|
+
icons(): Promise<string[]>;
|
|
6
8
|
enable(chat: string, options: {
|
|
7
9
|
upgrade: boolean;
|
|
8
10
|
}): Promise<Operated<{
|
|
@@ -13,6 +15,7 @@ export interface TopicsService {
|
|
|
13
15
|
}>>;
|
|
14
16
|
create(chat: string, title: string, options: {
|
|
15
17
|
sendId?: string;
|
|
18
|
+
icon?: string;
|
|
16
19
|
}): Promise<Operated<{
|
|
17
20
|
chatId: string;
|
|
18
21
|
topic: Topic;
|
package/dist/services/topics.js
CHANGED
|
@@ -2,9 +2,9 @@ import { CliError, isCliError } from "@wirecat/cli-base";
|
|
|
2
2
|
import { capability } from "../ports/messenger.js";
|
|
3
3
|
import { guardedWrite } from "../sends/guarded.js";
|
|
4
4
|
import { newOperationId, newSendId } from "../sends/send-id.js";
|
|
5
|
-
const actionOf = ({ title, closed, pinned, hidden }) => {
|
|
5
|
+
const actionOf = ({ title, closed, pinned, hidden, icon }) => {
|
|
6
6
|
const set = [closed, pinned, hidden].filter((one) => one !== undefined).length;
|
|
7
|
-
if (title !== undefined || set !== 1)
|
|
7
|
+
if (title !== undefined || icon !== undefined || set !== 1)
|
|
8
8
|
return "topic-edit";
|
|
9
9
|
if (closed !== undefined)
|
|
10
10
|
return closed ? "topic-close" : "topic-reopen";
|
|
@@ -16,6 +16,12 @@ const validTitle = (title) => {
|
|
|
16
16
|
if (title.trim() === "" || new TextEncoder().encode(title).byteLength > 128)
|
|
17
17
|
throw new CliError("validation_error", "a topic title needs 1–128 UTF-8 bytes");
|
|
18
18
|
};
|
|
19
|
+
const validIcon = (icon) => {
|
|
20
|
+
const emoji = icon.trim();
|
|
21
|
+
if (emoji === "")
|
|
22
|
+
throw new CliError("validation_error", "which icon? give an emoji from `topics icons list`");
|
|
23
|
+
return emoji;
|
|
24
|
+
};
|
|
19
25
|
export const topicsService = (deps) => {
|
|
20
26
|
const online = async () => {
|
|
21
27
|
if (deps.offline)
|
|
@@ -31,6 +37,11 @@ export const topicsService = (deps) => {
|
|
|
31
37
|
throw new CliError("validation_error", "which topic? give its id, from `topics list`");
|
|
32
38
|
return capability(await deps.connection(), "topic", "show a forum topic")(chat, id);
|
|
33
39
|
},
|
|
40
|
+
icons: async () => {
|
|
41
|
+
if (deps.offline)
|
|
42
|
+
throw new CliError("validation_error", "`topics icons list` asks the messenger; not with --offline");
|
|
43
|
+
return capability(await deps.connection(), "topicIcons", "list topic icons")();
|
|
44
|
+
},
|
|
34
45
|
enable: async (chat, { upgrade }) => {
|
|
35
46
|
const connection = await online();
|
|
36
47
|
const probe = capability(connection, "forumState", "configure forum topics");
|
|
@@ -108,8 +119,9 @@ export const topicsService = (deps) => {
|
|
|
108
119
|
forum: true,
|
|
109
120
|
};
|
|
110
121
|
},
|
|
111
|
-
create: async (chat, title, { sendId }) => {
|
|
122
|
+
create: async (chat, title, { sendId, icon: typedIcon }) => {
|
|
112
123
|
validTitle(title);
|
|
124
|
+
const icon = typedIcon === undefined ? undefined : validIcon(typedIcon);
|
|
113
125
|
const connection = await online();
|
|
114
126
|
const create = capability(connection, "createTopic", "create forum topics");
|
|
115
127
|
const probe = capability(connection, "forumState", "read forum settings");
|
|
@@ -124,7 +136,7 @@ export const topicsService = (deps) => {
|
|
|
124
136
|
action: "topic-create",
|
|
125
137
|
key: "topics.create",
|
|
126
138
|
length: title.length,
|
|
127
|
-
}, () => create(target, title, { sendId: id }), (done) => ({ threadId: done.id, resultChatId: target }), async () => {
|
|
139
|
+
}, () => create(target, title, { sendId: id, ...(icon === undefined ? {} : { icon }) }), (done) => ({ threadId: done.id, resultChatId: target }), async () => {
|
|
128
140
|
const state = await probe(chatId);
|
|
129
141
|
if (!state.forum)
|
|
130
142
|
throw new CliError("validation_error", "enable topics first with `topics enable`; creating a topic never upgrades a group");
|
|
@@ -138,8 +150,9 @@ export const topicsService = (deps) => {
|
|
|
138
150
|
},
|
|
139
151
|
edit: async (chat, typedTopic, change) => {
|
|
140
152
|
const { title, closed, pinned, hidden } = change;
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
const icon = change.icon == null ? change.icon : validIcon(change.icon);
|
|
154
|
+
if ([title, closed, pinned, hidden, icon].every((one) => one === undefined))
|
|
155
|
+
throw new CliError("validation_error", "nothing to change — rename, close, reopen, pin, unpin, hide or give an icon");
|
|
143
156
|
if (title !== undefined)
|
|
144
157
|
validTitle(title);
|
|
145
158
|
const topicId = typedTopic.trim();
|
|
@@ -155,6 +168,7 @@ export const topicsService = (deps) => {
|
|
|
155
168
|
...(closed === undefined ? {} : { closed }),
|
|
156
169
|
...(pinned === undefined ? {} : { pinned }),
|
|
157
170
|
...(hidden === undefined ? {} : { hidden }),
|
|
171
|
+
...(icon === undefined ? {} : { icon }),
|
|
158
172
|
}));
|
|
159
173
|
return { operationId, chatId, topic };
|
|
160
174
|
},
|
package/dist/store/index.d.ts
CHANGED
|
@@ -59,6 +59,7 @@ export type { TaskSort } from "./sqlite/task-board.js";
|
|
|
59
59
|
export { PROJECT_TASK_FIELDS, TASK_SORTS } from "./sqlite/task-board.js";
|
|
60
60
|
export type { SimilarSubject, SimilarTask, TaskDraft } from "./sqlite/task-candidates.js";
|
|
61
61
|
export { type AgentCloseSettings, type CloseSuggestion, certaintyOf } from "./sqlite/task-close.js";
|
|
62
|
+
export type { NamedAuthor, TaskComment } from "./sqlite/task-comments.js";
|
|
62
63
|
export type { TaskPair, TaskPairDecision, TaskPairSide } from "./sqlite/task-pairs.js";
|
|
63
64
|
export { TASK_PAIR_DECISIONS } from "./sqlite/task-pairs.js";
|
|
64
65
|
export type { TaskRelation, TaskRelationKind, TaskRelationsStore } from "./sqlite/task-relations.js";
|
|
@@ -27,7 +27,9 @@ export const PROJECT_TASK_FIELDS = [
|
|
|
27
27
|
relation: "low, medium, high, critical or none; a range of labels or of the 0–1 number: priority:[high TO critical]",
|
|
28
28
|
}),
|
|
29
29
|
field("agent", "kw", "agent:hermes", { relation: "the agent working on it, by name; none or any" }),
|
|
30
|
-
field("due", "time", "due:[today TO 2026-12-31]", {
|
|
30
|
+
field("due", "time", "due:[today TO 2026-12-31]", {
|
|
31
|
+
relation: "the due date; today, tomorrow and yesterday are your calendar days; NOT due:* has none",
|
|
32
|
+
}),
|
|
31
33
|
field("overdue", "bool", "overdue:true", { relation: "open work due before today" }),
|
|
32
34
|
field("when", "enum", "when:today", {
|
|
33
35
|
values: ["overdue", "today", "upcoming", "later", "none"],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Actor } from "./actors.js";
|
|
2
|
+
import { type Author } from "./memories.js";
|
|
3
|
+
import type { StoreContext } from "./open.js";
|
|
4
|
+
type Row = Record<string, unknown>;
|
|
5
|
+
/** Who wrote a row with `author_type` and `author_id`, by the name the store holds now. */
|
|
6
|
+
export type NamedAuthor = {
|
|
7
|
+
type: Actor["type"];
|
|
8
|
+
id: string;
|
|
9
|
+
name: string | null;
|
|
10
|
+
owner: boolean;
|
|
11
|
+
};
|
|
12
|
+
export interface TaskComment {
|
|
13
|
+
ref: string;
|
|
14
|
+
text: string;
|
|
15
|
+
author: NamedAuthor | null;
|
|
16
|
+
at: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const authorNamed: (database: StoreContext["database"], row: Row) => NamedAuthor | null;
|
|
19
|
+
/** A task's comments are notes about it, oldest first. */
|
|
20
|
+
export declare const commentsOf: (database: StoreContext["database"], task: number) => TaskComment[];
|
|
21
|
+
/** In the author's name, unlike `notes.addNote`, which is always the owner's. */
|
|
22
|
+
export declare const addComment: (context: StoreContext, task: number, body: string, by: Author) => TaskComment;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { authorActor } from "./memories.js";
|
|
2
|
+
import { write } from "./transaction.js";
|
|
3
|
+
export const authorNamed = (database, row) => {
|
|
4
|
+
if (row.author_type == null)
|
|
5
|
+
return null;
|
|
6
|
+
const named = database
|
|
7
|
+
.prepare(row.author_type === "bot"
|
|
8
|
+
? "SELECT name, 0 AS owner FROM bots WHERE id = ?"
|
|
9
|
+
: "SELECT name, owner FROM persons WHERE id = ?")
|
|
10
|
+
.get(Number(row.author_id));
|
|
11
|
+
return {
|
|
12
|
+
type: row.author_type,
|
|
13
|
+
id: String(row.author_id),
|
|
14
|
+
name: named?.name == null ? null : String(named.name),
|
|
15
|
+
owner: Number(named?.owner) === 1,
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/** A task's comments are notes about it, oldest first. */
|
|
19
|
+
export const commentsOf = (database, task) => database
|
|
20
|
+
.prepare("SELECT n.id, n.body, n.created_at, n.author_type, n.author_id FROM notes n " +
|
|
21
|
+
"WHERE n.notable_type = 'task' AND n.notable_id = ? AND n.deleted_at IS NULL ORDER BY n.created_at, n.id")
|
|
22
|
+
.all(task)
|
|
23
|
+
.map((row) => ({
|
|
24
|
+
ref: `note:${row.id}`,
|
|
25
|
+
text: String(row.body),
|
|
26
|
+
author: authorNamed(database, row),
|
|
27
|
+
at: new Date(Number(row.created_at)).toISOString(),
|
|
28
|
+
}));
|
|
29
|
+
/** In the author's name, unlike `notes.addNote`, which is always the owner's. */
|
|
30
|
+
export const addComment = (context, task, body, by) => {
|
|
31
|
+
const { database, now } = context;
|
|
32
|
+
const made = write(database, () => {
|
|
33
|
+
const { actor } = authorActor(context, by);
|
|
34
|
+
const at = now();
|
|
35
|
+
return Number(database
|
|
36
|
+
.prepare("INSERT INTO notes (notable_type, notable_id, body, author_type, author_id, revision, created_at, updated_at) " +
|
|
37
|
+
"VALUES ('task', ?, ?, ?, ?, 1, ?, ?) RETURNING id")
|
|
38
|
+
.get(task, body, actor.type, actor.id, at, at)?.id);
|
|
39
|
+
});
|
|
40
|
+
return commentsOf(database, task).find((one) => one.ref === `note:${made}`);
|
|
41
|
+
};
|
|
@@ -223,7 +223,14 @@ export const stateOfThing = (database, thing) => {
|
|
|
223
223
|
/** The `tasks.id` behind what callers name a task by: the package's id, or the task's key. */
|
|
224
224
|
export const taskRowOf = (database, id) => {
|
|
225
225
|
const row = database.prepare("SELECT id FROM tasks WHERE package_id = ? OR key = ? ORDER BY id LIMIT 1").get(id, id);
|
|
226
|
-
|
|
226
|
+
if (row)
|
|
227
|
+
return Number(row.id);
|
|
228
|
+
// A task moved to another project took a new key there; its old key still names it.
|
|
229
|
+
const moved = database
|
|
230
|
+
.prepare("SELECT task_id FROM task_events WHERE kind = 'project' AND json_extract(changes, '$.key[0]') = ? " +
|
|
231
|
+
"ORDER BY id DESC LIMIT 1")
|
|
232
|
+
.get(id);
|
|
233
|
+
return moved ? Number(moved.task_id) : undefined;
|
|
227
234
|
};
|
|
228
235
|
/** The package's id for a task row: the reverse of `taskRowOf`. */
|
|
229
236
|
export const taskIdOf = (database, rowId) => {
|
|
@@ -8,9 +8,10 @@ import type { StoreContext } from "./open.js";
|
|
|
8
8
|
import { type Where } from "./query-filter.js";
|
|
9
9
|
import { type DueGroup, type TaskSort } from "./task-board.js";
|
|
10
10
|
import { type CloseSuggestion } from "./task-close.js";
|
|
11
|
+
import { type NamedAuthor, type TaskComment } from "./task-comments.js";
|
|
11
12
|
import { type TaskRelationsStore } from "./task-relations.js";
|
|
12
13
|
export declare const TASK_ROLES: readonly ["assignee", "reviewer", "watcher"];
|
|
13
|
-
export declare const TASK_EVENT_KINDS: readonly ["created", "title", "description", "status", "assigned", "unassigned", "due", "priority", "moved", "linked", "unlinked", "category", "close-suggestion", "agent", "claimed", "released", "next-check", "resolution", "verdict", "close-certainty", "tags"];
|
|
14
|
+
export declare const TASK_EVENT_KINDS: readonly ["created", "title", "description", "status", "assigned", "unassigned", "due", "priority", "moved", "linked", "unlinked", "category", "close-suggestion", "agent", "claimed", "released", "next-check", "resolution", "verdict", "close-certainty", "tags", "project", "type"];
|
|
14
15
|
export type WorkTaskType = (typeof WORK_TASK_TYPES)[number];
|
|
15
16
|
export type TaskType = (typeof TASK_TYPES)[number];
|
|
16
17
|
export type TaskRole = (typeof TASK_ROLES)[number];
|
|
@@ -30,8 +31,12 @@ export interface TaskFields {
|
|
|
30
31
|
reason?: string | null;
|
|
31
32
|
/** A name the caller checked against its category list; every change is a `category` event. */
|
|
32
33
|
category?: string;
|
|
34
|
+
/** Another owner project, by key or `project:<id>`: the task takes the next key there; its old key still finds it. */
|
|
35
|
+
project?: string;
|
|
36
|
+
/** A project task's kind, bug, feature or chore. */
|
|
37
|
+
type?: WorkTaskType;
|
|
33
38
|
}
|
|
34
|
-
export interface NewWorkTask extends Omit<TaskFields, "reason"> {
|
|
39
|
+
export interface NewWorkTask extends Omit<TaskFields, "reason" | "project" | "type"> {
|
|
35
40
|
/** `project:<id>` or the project's key. */
|
|
36
41
|
project: string;
|
|
37
42
|
title: string;
|
|
@@ -51,6 +56,8 @@ export interface TaskDetails {
|
|
|
51
56
|
/** The project's key and name, as `boardCounts` names it. */
|
|
52
57
|
projectKey: string;
|
|
53
58
|
projectName: string;
|
|
59
|
+
/** Who added it: the owner, or an agent by its bot name; `null` for a row with no author. */
|
|
60
|
+
createdBy: NamedAuthor | null;
|
|
54
61
|
title: string;
|
|
55
62
|
description: string | null;
|
|
56
63
|
type: TaskType;
|
|
@@ -185,6 +192,10 @@ export interface WorkTasks extends TaskRelationsStore {
|
|
|
185
192
|
limit?: number;
|
|
186
193
|
}): Promise<TaskDetails[]>;
|
|
187
194
|
events(id: string): Promise<TaskEvent[]>;
|
|
195
|
+
/** A note about the task in the author's name, as an agent's comment is the agent's; the revision stays. */
|
|
196
|
+
comment(id: string, text: string, by: Author): Promise<TaskComment>;
|
|
197
|
+
/** Its comments, oldest first. */
|
|
198
|
+
comments(id: string): Promise<TaskComment[]>;
|
|
188
199
|
/** The owner's project tasks, every project at once, narrowed by a query over `PROJECT_TASK_FIELDS`. */
|
|
189
200
|
board(query: QueryAst | undefined, options: BoardOptions): Promise<{
|
|
190
201
|
items: TaskDetails[];
|
|
@@ -10,6 +10,7 @@ import { queryFilter } from "./query-filter.js";
|
|
|
10
10
|
import { stemmerCache } from "./stems.js";
|
|
11
11
|
import { BOARD_SCOPE, boardOrder, dueGroups, projectTaskColumns, withoutField, } from "./task-board.js";
|
|
12
12
|
import { closeSuggestionOf, SUGGESTION_COLUMNS } from "./task-close.js";
|
|
13
|
+
import { addComment, authorNamed, commentsOf } from "./task-comments.js";
|
|
13
14
|
import { INBOX_KEY_LENGTH, inboxKey } from "./task-keys.js";
|
|
14
15
|
import { taskRelationsStoreOver } from "./task-relations.js";
|
|
15
16
|
import { taskIdOf, taskRowOf, thingOf } from "./things.js";
|
|
@@ -37,6 +38,8 @@ export const TASK_EVENT_KINDS = [
|
|
|
37
38
|
"verdict",
|
|
38
39
|
"close-certainty",
|
|
39
40
|
"tags",
|
|
41
|
+
"project",
|
|
42
|
+
"type",
|
|
40
43
|
];
|
|
41
44
|
const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
|
|
42
45
|
export const recordTaskEvent = (database, task, actor, kind, changes, at) => {
|
|
@@ -122,6 +125,7 @@ export const workTaskOperationsOver = (context) => {
|
|
|
122
125
|
key: String(row.key),
|
|
123
126
|
project: `project:${row.project_id}`,
|
|
124
127
|
...projectNamed(Number(row.project_id)),
|
|
128
|
+
createdBy: authorNamed(database, row),
|
|
125
129
|
title: String(row.title),
|
|
126
130
|
description: row.description == null ? null : String(row.description),
|
|
127
131
|
type: row.type,
|
|
@@ -164,11 +168,43 @@ export const workTaskOperationsOver = (context) => {
|
|
|
164
168
|
const countOf = (where) => Number(database.prepare(`SELECT count(*) AS n FROM tasks t WHERE ${where.sql}`).get(...where.params)?.n ?? 0);
|
|
165
169
|
const rowById = (id) => database.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
166
170
|
const details = (id) => detailsOf(rowById(id));
|
|
171
|
+
const onBoard = (task) => Boolean(database.prepare(`SELECT 1 FROM tasks t WHERE t.id = ? AND ${BOARD_SCOPE}`).get(task));
|
|
172
|
+
/** The task takes the target's next number and key; the source's counter never goes back, so its old key stays unique. */
|
|
173
|
+
const moveToProject = (task, before, target, actor, at) => {
|
|
174
|
+
if (target === Number(before.project_id))
|
|
175
|
+
return;
|
|
176
|
+
if (!onBoard(task))
|
|
177
|
+
throw new CliError("validation_error", "only a project task moves to another project");
|
|
178
|
+
const targetRow = database.prepare("SELECT key FROM projects WHERE id = ?").get(target);
|
|
179
|
+
if (String(targetRow.key).length === INBOX_KEY_LENGTH)
|
|
180
|
+
throw new CliError("validation_error", "a task moves only into one of your projects");
|
|
181
|
+
const children = database
|
|
182
|
+
.prepare("SELECT key FROM tasks WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id")
|
|
183
|
+
.all(task)
|
|
184
|
+
.map((row) => String(row.key));
|
|
185
|
+
if (children.length)
|
|
186
|
+
throw new CliError("validation_error", `${String(before.key)} has subtasks (${children.join(", ")}); move or detach them first`, { reason: "task.has-subtasks", subtasks: children });
|
|
187
|
+
const counted = database
|
|
188
|
+
.prepare("UPDATE projects SET tasks_count = tasks_count + 1, updated_at = ? WHERE id = ? RETURNING key, tasks_count")
|
|
189
|
+
.get(at, target);
|
|
190
|
+
const number = Number(counted.tasks_count);
|
|
191
|
+
const key = `${String(counted.key)}-${number}`;
|
|
192
|
+
database
|
|
193
|
+
.prepare("UPDATE tasks SET project_id = ?, number = ?, key = ?, parent_id = NULL, updated_at = ? WHERE id = ?")
|
|
194
|
+
.run(target, number, key, at, task);
|
|
195
|
+
recordTaskEvent(database, task, actor, "project", {
|
|
196
|
+
project: [`project:${before.project_id}`, `project:${target}`],
|
|
197
|
+
key: [String(before.key), key],
|
|
198
|
+
...(before.parent_id == null ? {} : { parent: [`task:${taskIdOf(database, Number(before.parent_id))}`, null] }),
|
|
199
|
+
}, at);
|
|
200
|
+
};
|
|
167
201
|
const edit = (task, fields, actor, created = false) => {
|
|
168
202
|
const before = rowById(task);
|
|
169
203
|
const at = now();
|
|
170
204
|
const set = [];
|
|
171
205
|
const values = [];
|
|
206
|
+
// The revision triggers do not watch `type`, so a kind change bumps it here.
|
|
207
|
+
let bump = false;
|
|
172
208
|
const change = (column, value, event, key = column) => {
|
|
173
209
|
if ((before[column] ?? null) === value)
|
|
174
210
|
return;
|
|
@@ -237,8 +273,21 @@ export const workTaskOperationsOver = (context) => {
|
|
|
237
273
|
}
|
|
238
274
|
if (fields.category !== undefined)
|
|
239
275
|
change("category", fields.category, "category");
|
|
240
|
-
if (
|
|
276
|
+
if (fields.type !== undefined && fields.type !== before.type) {
|
|
277
|
+
if (!WORK_TASK_TYPES.includes(fields.type) || !onBoard(task))
|
|
278
|
+
throw new CliError("validation_error", `a project task's kind is one of ${WORK_TASK_TYPES.join(", ")}`);
|
|
279
|
+
change("type", fields.type, "type");
|
|
280
|
+
bump = true;
|
|
281
|
+
}
|
|
282
|
+
if (fields.project !== undefined)
|
|
283
|
+
moveToProject(task, before, projectOf(fields.project), actor, at);
|
|
284
|
+
if (set.length === 0) {
|
|
285
|
+
if (bump)
|
|
286
|
+
database.prepare("UPDATE tasks SET revision = revision + 1 WHERE id = ?").run(task);
|
|
241
287
|
return;
|
|
288
|
+
}
|
|
289
|
+
if (bump)
|
|
290
|
+
set.push("revision = revision + 1");
|
|
242
291
|
database.prepare(`UPDATE tasks SET ${set.join(", ")}, updated_at = ? WHERE id = ?`).run(...values, at, task);
|
|
243
292
|
};
|
|
244
293
|
/** The column's tasks in board order, the moving one left out; no priority reads as 0, the bottom of low. */
|
|
@@ -555,6 +604,8 @@ export const workTaskOperationsOver = (context) => {
|
|
|
555
604
|
owner: Number(row.owner) === 1,
|
|
556
605
|
tasks: Number(row.n),
|
|
557
606
|
})),
|
|
607
|
+
comment: async (id, text, by) => addComment(context, required(id), textOf(text, "a comment", 20000), by),
|
|
608
|
+
comments: async (id) => commentsOf(database, required(id)),
|
|
558
609
|
events: async (id) => database
|
|
559
610
|
.prepare("SELECT e.actor_type, e.actor_id, e.kind, e.changes, e.created_at, " +
|
|
560
611
|
"CASE e.actor_type WHEN 'person' THEN p.name WHEN 'bot' THEN b.name END AS actor_name FROM task_events e " +
|