@wirecat/cli-core 0.530.0 → 0.532.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli/index.d.ts +2 -2
  3. package/dist/cli/index.js +1 -1
  4. package/dist/cli/list-query.d.ts +0 -4
  5. package/dist/cli/list-query.js +19 -1
  6. package/dist/cli/messenger/attachments-command.d.ts +1 -0
  7. package/dist/cli/messenger/attachments-command.js +1 -1
  8. package/dist/cli/messenger/download-command.js +37 -10
  9. package/dist/cli/messenger/schedule-command.d.ts +2 -19
  10. package/dist/cli/messenger/schedule-command.js +2 -231
  11. package/dist/cli/messenger/search-answers.js +7 -2
  12. package/dist/cli/messenger/topics-command.js +22 -5
  13. package/dist/cli/schedule/schedule-command.d.ts +19 -0
  14. package/dist/cli/schedule/schedule-command.js +261 -0
  15. package/dist/cli/shared-command-exports.d.ts +1 -1
  16. package/dist/cli/shared-command-exports.js +2 -1
  17. package/dist/domain/attachments.d.ts +9 -1
  18. package/dist/domain/attachments.js +12 -0
  19. package/dist/domain/message-link.d.ts +14 -0
  20. package/dist/domain/message-link.js +8 -0
  21. package/dist/domain/models.d.ts +2 -0
  22. package/dist/entities/attachment.d.ts +7 -0
  23. package/dist/entities/attachment.js +2 -0
  24. package/dist/entities/hit.d.ts +7 -0
  25. package/dist/entities/hit.js +3 -0
  26. package/dist/entities/index.d.ts +61 -2
  27. package/dist/entities/index.js +6 -3
  28. package/dist/entities/list-results.d.ts +3 -0
  29. package/dist/entities/list-results.js +1 -0
  30. package/dist/entities/message-link.d.ts +8 -0
  31. package/dist/entities/message-link.js +9 -1
  32. package/dist/entities/task-results.d.ts +30 -0
  33. package/dist/entities/task-results.js +1 -0
  34. package/dist/entities/task.d.ts +7 -0
  35. package/dist/entities/task.js +2 -0
  36. package/dist/errors/reasons.js +1 -0
  37. package/dist/mail/account-key.d.ts +2 -0
  38. package/dist/mail/account-key.js +1 -0
  39. package/dist/mail/cli/index.js +10 -10
  40. package/dist/mail/index.d.ts +1 -0
  41. package/dist/mail/index.js +1 -0
  42. package/dist/mail/retry.js +2 -1
  43. package/dist/mail/rule-stored.js +2 -1
  44. package/dist/mail/sender-scores.js +2 -1
  45. package/dist/mail/sync.js +3 -2
  46. package/dist/ports/messenger.d.ts +3 -0
  47. package/dist/services/attachments.d.ts +11 -0
  48. package/dist/services/attachments.js +45 -2
  49. package/dist/services/deps.d.ts +1 -1
  50. package/dist/services/deps.js +1 -1
  51. package/dist/services/messages.d.ts +2 -3
  52. package/dist/services/messages.js +27 -8
  53. package/dist/services/search-all.d.ts +3 -0
  54. package/dist/services/search-all.js +2 -0
  55. package/dist/services/tasks.d.ts +4 -2
  56. package/dist/services/tasks.js +2 -2
  57. package/dist/services/topics.d.ts +3 -0
  58. package/dist/services/topics.js +20 -6
  59. package/dist/store/index.d.ts +1 -0
  60. package/dist/store/sqlite/task-board.js +3 -1
  61. package/dist/store/sqlite/task-comments.d.ts +23 -0
  62. package/dist/store/sqlite/task-comments.js +41 -0
  63. package/dist/store/sqlite/things.js +8 -1
  64. package/dist/store/sqlite/work-tasks.d.ts +13 -2
  65. package/dist/store/sqlite/work-tasks.js +52 -1
  66. package/package.json +1 -1
  67. package/parity.json +3 -0
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { parseLucene } from "@wirecat/cli-base/query";
3
+ import { hitFiles } from "../domain/attachments.js";
3
4
  import { parseLocator } from "../domain/locator.js";
4
5
  import { senderIsMe } from "../domain/models.js";
5
6
  import { INTERNAL_NOTES, kindIn } from "../domain/providers.js";
@@ -102,6 +103,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
102
103
  : { name: hit.senderName, ref: null },
103
104
  senderIsMe: senderIsMe(hit, at.account),
104
105
  ...(hit.email ? { email: hit.email } : {}),
106
+ attachments: hitFiles(hit.attachments),
105
107
  foundBy,
106
108
  };
107
109
  };
@@ -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): Promise<{
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;
@@ -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;
@@ -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
- if (title === undefined && closed === undefined && pinned === undefined && hidden === undefined)
142
- throw new CliError("validation_error", "nothing to change — rename, close, reopen, pin, unpin or hide it");
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
  },
@@ -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]", { relation: "the due date; NOT due:* has none" }),
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
- return row ? Number(row.id) : undefined;
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 (set.length === 0)
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 " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.530.0",
3
+ "version": "0.532.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
package/parity.json CHANGED
@@ -3329,6 +3329,9 @@
3329
3329
  "--all": {
3330
3330
  "in": "all"
3331
3331
  },
3332
+ "--attachment": {
3333
+ "in": "all"
3334
+ },
3332
3335
  "--extract": "all",
3333
3336
  "--output-dir": {
3334
3337
  "in": "all"