@wirecat/cli-core 0.575.0 → 0.577.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 (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/findings-input.js +2 -0
  3. package/dist/cli/messenger/tasks-command.js +2 -0
  4. package/dist/domain/external-links.d.ts +13 -0
  5. package/dist/domain/external-links.js +30 -0
  6. package/dist/entities/index.d.ts +15 -0
  7. package/dist/entities/mail-findings.d.ts +6 -0
  8. package/dist/entities/mail-findings.js +6 -0
  9. package/dist/entities/task-results.d.ts +20 -0
  10. package/dist/entities/task-results.js +4 -0
  11. package/dist/entities/task.d.ts +4 -0
  12. package/dist/entities/task.js +4 -0
  13. package/dist/errors/reasons.js +5 -0
  14. package/dist/mail/cli/bulk-settings.d.ts +6 -0
  15. package/dist/mail/cli/bulk-settings.js +65 -0
  16. package/dist/mail/cli/index.d.ts +4 -0
  17. package/dist/mail/cli/index.js +3 -1
  18. package/dist/mail/cli/marks.d.ts +6 -0
  19. package/dist/mail/cli/marks.js +69 -1
  20. package/dist/mail/cli/review.js +34 -13
  21. package/dist/mail/findings.d.ts +2 -0
  22. package/dist/mail/findings.js +15 -1
  23. package/dist/mail/index.d.ts +2 -1
  24. package/dist/mail/index.js +2 -1
  25. package/dist/mail/marks.d.ts +48 -7
  26. package/dist/mail/marks.js +158 -16
  27. package/dist/mail/newsletter.d.ts +52 -0
  28. package/dist/mail/newsletter.js +189 -0
  29. package/dist/mail/review.d.ts +2 -0
  30. package/dist/meetings/findings-rules.d.ts +4 -0
  31. package/dist/store/sqlite/email-activity.d.ts +2 -0
  32. package/dist/store/sqlite/email-activity.js +4 -0
  33. package/dist/store/sqlite/findings-core.js +2 -0
  34. package/dist/store/sqlite/manifest.js +1 -0
  35. package/dist/store/sqlite/migrations.generated.js +10 -0
  36. package/dist/store/sqlite/orders.d.ts +4 -0
  37. package/dist/store/sqlite/orders.js +6 -3
  38. package/dist/store/sqlite/schema.d.ts +64 -0
  39. package/dist/store/sqlite/schema.js +5 -0
  40. package/dist/store/sqlite/work-tasks.d.ts +7 -0
  41. package/dist/store/sqlite/work-tasks.js +10 -0
  42. package/package.json +1 -1
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Mail sent to many is often an order, an invoice or a site's lead form that carries List-Unsubscribe. This tells a
3
+ * newsletter from it by words in the subject and the first lines, so a holder can leave out only what is surely a
4
+ * newsletter and give the rest to its agent as an excerpt. No model: it runs on every read past a mark.
5
+ */
6
+ /** Words that say "advertising"; each matches at the start of a word, any case. */
7
+ export const NEWSLETTER_WORDS = [
8
+ "скидк",
9
+ "акци",
10
+ "распродаж",
11
+ "вебинар",
12
+ "рассылк",
13
+ "промокод",
14
+ "спецпредложени",
15
+ "дайджест",
16
+ "новинк",
17
+ "подборк",
18
+ "кешбэк",
19
+ "кэшбэк",
20
+ "только сегодня",
21
+ "успейте",
22
+ "чёрная пятница",
23
+ "черная пятница",
24
+ "выгодн",
25
+ "newsletter",
26
+ "unsubscribe",
27
+ "% off",
28
+ "sale",
29
+ "discount",
30
+ "webinar",
31
+ "digest",
32
+ "weekly",
33
+ "promo",
34
+ "deal",
35
+ "limited time",
36
+ "free shipping",
37
+ "new arrivals",
38
+ "black friday",
39
+ "cyber monday",
40
+ "coupon",
41
+ "save up to",
42
+ "last chance",
43
+ "don't miss",
44
+ "descuento",
45
+ "oferta",
46
+ "rebaja",
47
+ "boletín",
48
+ "seminario web",
49
+ "novedades",
50
+ "promoción",
51
+ "no te pierdas",
52
+ "últimos días",
53
+ ];
54
+ /** Parts of a sender's address that say "a newsletter": `news@…`, `newsletter.example.com`, `….substack.com`. */
55
+ export const NEWSLETTER_SENDERS = [
56
+ "news",
57
+ "newsletter",
58
+ "newsletters",
59
+ "digest",
60
+ "weekly",
61
+ "promo",
62
+ "marketing",
63
+ "offers",
64
+ "deals",
65
+ "sale",
66
+ "substack.com",
67
+ ];
68
+ /** Words that say "this is about something of yours"; one of them keeps the email for the agent. */
69
+ export const ORDER_WORDS = [
70
+ "заказ",
71
+ "счёт",
72
+ "счет на",
73
+ "счета",
74
+ "оплат",
75
+ "квитанци",
76
+ "кассовый чек",
77
+ "электронный чек",
78
+ "заявк",
79
+ "форма",
80
+ "доставк",
81
+ "трек",
82
+ "договор",
83
+ "подтвержд",
84
+ "код подтверждения",
85
+ "код для входа",
86
+ "пароль",
87
+ "order",
88
+ "invoice",
89
+ "receipt",
90
+ "payment",
91
+ "shipped",
92
+ "delivery",
93
+ "tracking",
94
+ "booking",
95
+ "reservation",
96
+ "confirm",
97
+ "your account",
98
+ "verification",
99
+ "password",
100
+ "refund",
101
+ "statement",
102
+ "pedido",
103
+ "factura",
104
+ "recibo",
105
+ "pago",
106
+ "envío",
107
+ "reserva",
108
+ "confirmación",
109
+ "contraseña",
110
+ ];
111
+ /** A number an order, an invoice or a request is known by, after the word that names it. */
112
+ const REFERENCE_NUMBER = /(?<![\p{L}])(?:заказ|заявк|сч[её]т|квитанци|order|invoice|ticket|pedido|factura)\p{L}*\s*(?:№|#|n[o°]\.?)?\s?\d{3,}/iu;
113
+ /** The top of a newsletter: a link to read it in a browser, or to leave the list. */
114
+ const NEWSLETTER_TOP = /в браузере|веб-верси|view (?:it |this email )?(?:in|on) (?:your |a )?browser|unsubscribe|отписаться|darse de baja|ver en (?:el )?navegador/iu;
115
+ const PICTOGRAPH = /\p{Extended_Pictographic}/u;
116
+ /** A reply or a forward is a conversation, whatever it says. */
117
+ const REPLY = /^\s*(?:re|fw|fwd|aw|wg|ответ|отв|rv|res)\s*(?:\[\d+\])?\s*:/iu;
118
+ export const NEWSLETTER_DEFAULTS = {
119
+ newsletterWords: NEWSLETTER_WORDS,
120
+ newsletterSenders: NEWSLETTER_SENDERS,
121
+ orderWords: ORDER_WORDS,
122
+ threshold: 2,
123
+ excerptChars: 400,
124
+ };
125
+ const escaped = (word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ const matcherOf = (words) => {
127
+ const list = words.map((word) => word.trim().toLowerCase()).filter(Boolean);
128
+ if (list.length === 0)
129
+ return () => [];
130
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}])(?:${list.map(escaped).join("|")})`, "giu");
131
+ return (text) => [...new Set([...text.toLowerCase().matchAll(pattern)].map(([found]) => found))];
132
+ };
133
+ /** The first lines of an email's own text, at most `chars`, cut at a word. */
134
+ export const excerptOf = (text, chars) => {
135
+ const flat = text
136
+ .split("\n")
137
+ .map((line) => line.trim())
138
+ .filter(Boolean)
139
+ .join("\n");
140
+ if (flat.length <= chars)
141
+ return flat;
142
+ const cut = flat.slice(0, chars);
143
+ const space = cut.search(/\s\S*$/);
144
+ return `${space > chars / 2 ? cut.slice(0, space) : cut}…`;
145
+ };
146
+ /**
147
+ * Built once per read, so the word lists compile once. A reply, an order word in the subject or an order's number
148
+ * keeps the email. Otherwise a newsletter word counts 2 in the subject and 1 in the first lines, a newsletter's
149
+ * address 2, an emoji in the subject and a browser or unsubscribe link at the top 1 each, and each order word in
150
+ * the first lines takes 2 off. Measured 2026-11-05 on 30 days of the owner's bulk mail: 99 of 100 sampled
151
+ * newsletter verdicts were newsletters, and about half of the newsletters were found.
152
+ */
153
+ export const newsletterSorter = (cues = NEWSLETTER_DEFAULTS) => {
154
+ const advertising = matcherOf(cues.newsletterWords);
155
+ const ordered = matcherOf(cues.orderWords);
156
+ const senders = cues.newsletterSenders.map((part) => part.trim().toLowerCase()).filter(Boolean);
157
+ const fromNewsletter = (address) => {
158
+ const domain = address.split("@")[1] ?? "";
159
+ const parts = new Set(address.split(/[.@_+-]/));
160
+ return senders.some((one) => (one.includes(".") ? domain === one || domain.endsWith(`.${one}`) : parts.has(one)));
161
+ };
162
+ return ({ subject, text, from }) => {
163
+ const excerpt = excerptOf(text, cues.excerptChars);
164
+ const title = subject ?? "";
165
+ const number = REFERENCE_NUMBER.exec(`${title}\n${excerpt}`);
166
+ const kept = [...(REPLY.test(title) ? ["a reply"] : []), ...ordered(title), ...(number ? [number[0]] : [])];
167
+ if (kept.length > 0)
168
+ return { verdict: "excerpt", score: 0, cues: kept, excerpt };
169
+ const inTitle = advertising(title);
170
+ const signs = [
171
+ ...inTitle.map((word) => [word, 2]),
172
+ ...advertising(excerpt)
173
+ .filter((word) => !inTitle.includes(word))
174
+ .map((word) => [word, 1]),
175
+ ...(PICTOGRAPH.test(title) ? [["an emoji in the subject", 1]] : []),
176
+ ...(NEWSLETTER_TOP.test(excerpt) ? [["a browser or unsubscribe link", 1]] : []),
177
+ ...(fromNewsletter((from ?? "").toLowerCase()) ? [["a newsletter's address", 2]] : []),
178
+ ...ordered(excerpt).map((word) => [`-${word}`, -2]),
179
+ ];
180
+ const score = signs.reduce((sum, [, weight]) => sum + weight, 0);
181
+ return {
182
+ verdict: score >= cues.threshold ? "newsletter" : "excerpt",
183
+ score,
184
+ cues: signs.map(([cue]) => cue),
185
+ excerpt,
186
+ };
187
+ };
188
+ };
189
+ export const BULK_TRIAGE_DEFAULTS = { excerpts: true, perRead: 30, cues: NEWSLETTER_DEFAULTS };
@@ -18,8 +18,10 @@ export interface MailReviewOptions {
18
18
  now?: number;
19
19
  }
20
20
  /** A review of mail: each thread a chat, each email a stored hit with its locator. */
21
+ /** `excerptOnly`: a bulk email a holder gets as its subject and first lines, its `text` cut to them. */
21
22
  export type MailHit = StoredHit & {
22
23
  marks?: EmailMarks;
24
+ excerptOnly?: true;
23
25
  };
24
26
  export interface MailReview extends Review {
25
27
  chats: (Omit<ReviewChat, "messages"> & {
@@ -39,6 +39,10 @@ export interface TaskFinding extends FindingBase {
39
39
  dueEvidence?: string[];
40
40
  assignee?: string;
41
41
  assigneeEvidence?: string[];
42
+ /** Mail only: `<service>:<id>` in the outside service the mail came from, such as `avito:4512345678`. */
43
+ externalId?: string;
44
+ /** Mail only: the http or https page the task is about, such as the conversation the mail links to. */
45
+ sourceUrl?: string;
42
46
  }
43
47
  export type Finding = FactFinding | TaskFinding | PaymentFinding;
44
48
  export interface FindingsInput {
@@ -67,6 +67,8 @@ export interface MailActivityStore {
67
67
  * processing mark reads next. The id is the store's insert order, so mail stored late is never behind the mark.
68
68
  */
69
69
  emailsAfter(accountId: number, afterId: number, limit: number, ids?: readonly number[]): Promise<MarkedEmail[]>;
70
+ /** The account's live emails stored before `beforeId`, newest first, at most `limit`: what a mark moved back reads. */
71
+ emailsBefore(accountId: number, beforeId: number, limit: number): Promise<MarkedEmail[]>;
70
72
  /** The account's newest store id; 0 with no mail. */
71
73
  newestEmailId(accountId: number): Promise<number>;
72
74
  /** Every `sync_cursors` value of the account whose key starts with `prefix`. */
@@ -125,6 +125,10 @@ export const emailsAfter = (database, accountId, afterId, limit, ids = []) => {
125
125
  };
126
126
  export const mailActivity = (database) => ({
127
127
  emailsAfter: async (accountId, afterId, limit, ids) => emailsAfter(database, accountId, afterId, limit, ids),
128
+ emailsBefore: async (accountId, beforeId, limit) => database
129
+ .prepare(`${MARKED} WHERE e.id < ? AND e.account_id = ? AND e.deleted_at IS NULL ORDER BY e.id DESC LIMIT ?`)
130
+ .all(beforeId, accountId, limit)
131
+ .map(markedOf),
128
132
  newestEmailId: async (accountId) => Number(database.prepare("SELECT max(id) AS id FROM emails WHERE account_id = ?").get(accountId)?.id ?? 0),
129
133
  statesLike: async (accountId, prefix) => database
130
134
  .prepare("SELECT key, value, updated_at FROM sync_cursors WHERE account_id = ? AND substr(key, 1, ?) = ?")
@@ -93,6 +93,8 @@ export const findingsCoreOver = (context, kind) => {
93
93
  type: finding.type,
94
94
  description: `${finding.commitment}: ${finding.reason}`,
95
95
  ...(finding.dueAt ? { dueAt: new Date(finding.dueAt) } : {}),
96
+ ...(finding.externalId ? { externalId: finding.externalId } : {}),
97
+ ...(finding.sourceUrl ? { sourceUrl: finding.sourceUrl } : {}),
96
98
  }, agent);
97
99
  const id = taskRowOf(database, task.id);
98
100
  if (id === undefined)
@@ -67,6 +67,7 @@ export const MANIFEST = [
67
67
  { name: "20261104232153_version-49-relation-amounts", version: 49, minCompatible: 34 },
68
68
  { name: "20261105125314_version-50-orders", version: 50, minCompatible: 34 },
69
69
  { name: "20261105125315_version-50-orders-rules", version: 50, minCompatible: 34 },
70
+ { name: "20261105144549_version-51-task-links", version: 51, minCompatible: 34 },
70
71
  ];
71
72
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
72
73
  const migrations = [];
@@ -974,5 +974,15 @@ export const GENERATED = [
974
974
  "-- The store's code checks these first and says why; the triggers keep a stray write from another build or a hand\n-- edit from storing an order with an unknown status or purpose, or an amount without its currency.\nCREATE TRIGGER orders_valid_bi BEFORE INSERT ON orders\n WHEN new.customer_type NOT IN ('person', 'organization')\n OR new.purpose NOT IN ('personal', 'business', 'client', 'other')\n OR new.status NOT IN ('placed', 'shipped', 'delivered', 'cancelled', 'returned')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.total IS NULL) <> (new.currency IS NULL) OR new.total < 0 BEGIN\n SELECT RAISE(ABORT, 'orders: a person or organization customer, a known purpose, status and review, an amount with its currency');\nEND;",
975
975
  "CREATE TRIGGER orders_valid_bu BEFORE UPDATE ON orders\n WHEN new.customer_type NOT IN ('person', 'organization')\n OR new.purpose NOT IN ('personal', 'business', 'client', 'other')\n OR new.status NOT IN ('placed', 'shipped', 'delivered', 'cancelled', 'returned')\n OR new.review NOT IN ('proposed', 'confirmed', 'rejected')\n OR (new.total IS NULL) <> (new.currency IS NULL) OR new.total < 0 BEGIN\n SELECT RAISE(ABORT, 'orders: a person or organization customer, a known purpose, status and review, an amount with its currency');\nEND;"
976
976
  ]
977
+ },
978
+ {
979
+ "name": "20261105144549_version-51-task-links",
980
+ "statements": [
981
+ "ALTER TABLE `orders` ADD `source_url` text;",
982
+ "ALTER TABLE `tasks` ADD `provider` text;",
983
+ "ALTER TABLE `tasks` ADD `external_id` text;",
984
+ "ALTER TABLE `tasks` ADD `source_url` text;",
985
+ "CREATE INDEX `tasks_by_provider_external_id` ON `tasks` (`provider`,`external_id`) WHERE external_id IS NOT NULL;"
986
+ ]
977
987
  }
978
988
  ];
@@ -26,6 +26,8 @@ export interface Order extends Reviewed {
26
26
  fulfilledDate: string | null;
27
27
  invoice: string | null;
28
28
  payment: string | null;
29
+ /** The order's page at the shop, http or https. */
30
+ sourceUrl: string | null;
29
31
  }
30
32
  export interface OrderInput extends Written {
31
33
  /** `person:` or `organization:`; the owner's own person when left out. */
@@ -43,6 +45,8 @@ export interface OrderInput extends Written {
43
45
  fulfilledDate?: string;
44
46
  invoice?: string;
45
47
  payment?: string;
48
+ /** The order's page at the shop: an http or https address. */
49
+ sourceUrl?: string;
46
50
  }
47
51
  export interface OrderFilter {
48
52
  status?: OrderStatus;
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { sourceUrlOf } from "../../domain/external-links.js";
2
3
  import { FINANCE_REVIEWS } from "../../domain/finance.js";
3
4
  import { currencyOf, formatDecimal, minorUnits, scaleOf } from "../../domain/money.js";
4
5
  import { ORDER_PURPOSES, ORDER_STATUSES, orderTitle } from "../../domain/orders.js";
@@ -42,6 +43,7 @@ export const orderOperationsOver = (context) => {
42
43
  fulfilledDate: row.fulfilled_date == null ? null : String(row.fulfilled_date),
43
44
  invoice: row.invoice_id == null ? null : `invoice:${row.invoice_id}`,
44
45
  payment: row.payment_id == null ? null : `payment:${row.payment_id}`,
46
+ sourceUrl: row.source_url == null ? null : String(row.source_url),
45
47
  ...reviewedOf(row, "order"),
46
48
  };
47
49
  };
@@ -96,6 +98,7 @@ export const orderOperationsOver = (context) => {
96
98
  refuse("an order's amount is 0 or more", "money.amount");
97
99
  const orderDate = isoDate(input.orderDate, "an order's date");
98
100
  const fulfilledDate = isoDate(input.fulfilledDate, "an order's delivery date");
101
+ const sourceUrl = input.sourceUrl === undefined ? null : sourceUrlOf(input.sourceUrl, "order.source-url");
99
102
  const id = write(database, () => {
100
103
  if (number !== null && ordersOfShop(database, supplierName, number).length)
101
104
  refuse(`order ${number} from ${supplierName} is already stored; change its status instead`, "order.duplicate");
@@ -108,9 +111,9 @@ export const orderOperationsOver = (context) => {
108
111
  const made = Number(database
109
112
  .prepare("INSERT INTO orders (customer_type, customer_id, purpose, supplier_type, supplier_id, supplier_name, number, " +
110
113
  "currency, total, status, order_date, fulfilled_date, invoice_id, payment_id, project_id, review, " +
111
- "author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, metadata, created_at, updated_at) " +
112
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
113
- .get(customer.type, customer.id, purpose, supplier?.type ?? null, supplier?.id ?? null, supplierName, number, currency, total, status, orderDate, fulfilledDate, invoice, payment, by.project, by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at)?.id);
114
+ "author_type, author_id, model, confirmed_by_type, confirmed_by_id, source, metadata, created_at, updated_at, " +
115
+ "source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
116
+ .get(customer.type, customer.id, purpose, supplier?.type ?? null, supplier?.id ?? null, supplierName, number, currency, total, status, orderDate, fulfilledDate, invoice, payment, by.project, by.review, by.actor.type, by.actor.id, by.model, by.confirmedType, by.confirmedId, by.source, by.metadata, at, at, sourceUrl)?.id);
114
117
  keepEvidence("order", made, by);
115
118
  return made;
116
119
  });
@@ -12747,6 +12747,54 @@ export declare const tasks: import("drizzle-orm/sqlite-core").SQLiteTableWithCol
12747
12747
  identity: undefined;
12748
12748
  generated: undefined;
12749
12749
  }, {}>;
12750
+ provider: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12751
+ name: string;
12752
+ tableName: "tasks";
12753
+ dataType: "string";
12754
+ data: string;
12755
+ driverParam: string;
12756
+ notNull: false;
12757
+ hasDefault: false;
12758
+ isPrimaryKey: false;
12759
+ isAutoincrement: false;
12760
+ hasRuntimeDefault: false;
12761
+ enumValues: [string, ...string[]];
12762
+ baseColumn: never;
12763
+ identity: undefined;
12764
+ generated: undefined;
12765
+ }, {}>;
12766
+ externalId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12767
+ name: string;
12768
+ tableName: "tasks";
12769
+ dataType: "string";
12770
+ data: string;
12771
+ driverParam: string;
12772
+ notNull: false;
12773
+ hasDefault: false;
12774
+ isPrimaryKey: false;
12775
+ isAutoincrement: false;
12776
+ hasRuntimeDefault: false;
12777
+ enumValues: [string, ...string[]];
12778
+ baseColumn: never;
12779
+ identity: undefined;
12780
+ generated: undefined;
12781
+ }, {}>;
12782
+ sourceUrl: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12783
+ name: string;
12784
+ tableName: "tasks";
12785
+ dataType: "string";
12786
+ data: string;
12787
+ driverParam: string;
12788
+ notNull: false;
12789
+ hasDefault: false;
12790
+ isPrimaryKey: false;
12791
+ isAutoincrement: false;
12792
+ hasRuntimeDefault: false;
12793
+ enumValues: [string, ...string[]];
12794
+ baseColumn: never;
12795
+ identity: undefined;
12796
+ generated: undefined;
12797
+ }, {}>;
12750
12798
  };
12751
12799
  dialect: 'sqlite';
12752
12800
  isAlias: false;
@@ -15326,6 +15374,22 @@ export declare const orders: import("drizzle-orm/sqlite-core").SQLiteTableWithCo
15326
15374
  identity: undefined;
15327
15375
  generated: undefined;
15328
15376
  }, {}>;
15377
+ sourceUrl: import("drizzle-orm/sqlite-core").SQLiteColumn<{
15378
+ name: string;
15379
+ tableName: "orders";
15380
+ dataType: "string";
15381
+ data: string;
15382
+ driverParam: string;
15383
+ notNull: false;
15384
+ hasDefault: false;
15385
+ isPrimaryKey: false;
15386
+ isAutoincrement: false;
15387
+ hasRuntimeDefault: false;
15388
+ enumValues: [string, ...string[]];
15389
+ baseColumn: never;
15390
+ identity: undefined;
15391
+ generated: undefined;
15392
+ }, {}>;
15329
15393
  };
15330
15394
  dialect: 'sqlite';
15331
15395
  isAlias: false;
@@ -1271,6 +1271,9 @@ export const tasks = sqliteTable("tasks", {
1271
1271
  priorityScore: real("priority_score"),
1272
1272
  agent: text("agent"),
1273
1273
  revision: integer("revision").notNull().default(1),
1274
+ provider: text("provider"),
1275
+ externalId: text("external_id"),
1276
+ sourceUrl: text("source_url"),
1274
1277
  }, (table) => [
1275
1278
  unique().on(table.projectId, table.number),
1276
1279
  index("tasks_by_title_folded").on(table.titleFolded),
@@ -1283,6 +1286,7 @@ export const tasks = sqliteTable("tasks", {
1283
1286
  index("tasks_by_project_id_status_next_check_at").on(table.projectId, table.status, table.nextCheckAt),
1284
1287
  index("tasks_by_project_id_source_locator").on(table.projectId, table.sourceLocator),
1285
1288
  index("tasks_by_source_group").on(table.sourceGroup),
1289
+ index("tasks_by_provider_external_id").on(table.provider, table.externalId).where(sql `external_id IS NOT NULL`),
1286
1290
  index("tasks_by_parent_id").on(table.parentId),
1287
1291
  index("tasks_by_closed_by_type_closed_by_id").on(table.closedByType, table.closedById),
1288
1292
  index("tasks_by_author_type_author_id").on(table.authorType, table.authorId),
@@ -1521,6 +1525,7 @@ export const orders = sqliteTable("orders", {
1521
1525
  createdAt: integer("created_at").notNull(),
1522
1526
  updatedAt: integer("updated_at").notNull(),
1523
1527
  deletedAt: integer("deleted_at"),
1528
+ sourceUrl: text("source_url"),
1524
1529
  }, (table) => [
1525
1530
  index("orders_by_customer_type_customer_id").on(table.customerType, table.customerId),
1526
1531
  index("orders_by_supplier_type_supplier_id").on(table.supplierType, table.supplierId),
@@ -39,6 +39,10 @@ export interface TaskFields {
39
39
  project?: string;
40
40
  /** A project task's kind, bug, feature or chore. */
41
41
  type?: WorkTaskType;
42
+ /** `<service>:<id>`, the task's id in an outside service, such as `avito:4512345678`; `null` clears it. */
43
+ externalId?: string | null;
44
+ /** The http or https page the task is about, such as an Avito ad; `null` clears it. */
45
+ sourceUrl?: string | null;
42
46
  }
43
47
  export interface NewWorkTask extends Omit<TaskFields, "reason" | "project" | "type"> {
44
48
  /** `project:<id>` or the project's key. */
@@ -75,6 +79,9 @@ export interface TaskDetails {
75
79
  parent: string | null;
76
80
  /** What a task found in a message points at; null for a task made here. */
77
81
  source: string | null;
82
+ /** `<service>:<id>` in an outside service, such as `avito:4512345678`. */
83
+ externalId: string | null;
84
+ sourceUrl: string | null;
78
85
  dueAt: string | null;
79
86
  nextCheckAt: string | null;
80
87
  closedAt: string | null;
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { timezoneOf } from "@wirecat/cli-base/query";
3
+ import { externalIdOf, externalIdText, sourceUrlOf } from "../../domain/external-links.js";
3
4
  import { TASK_TYPES, WORK_TASK_TYPES } from "../../domain/knowledge.js";
4
5
  import { checkedScore, isClosedState, legacyPriority, PRIORITY_LABELS, priorityBand, priorityLabel, TASK_STATES, } from "../../tasks/index.js";
5
6
  import { folded } from "./folded.js";
@@ -136,6 +137,8 @@ export const workTaskOperationsOver = (context) => {
136
137
  revision: Number(row.revision),
137
138
  parent: row.parent_id == null ? null : reference(Number(row.parent_id)),
138
139
  source: row.source_locator == null ? null : String(row.source_locator),
140
+ externalId: externalIdText(row.provider, row.external_id),
141
+ sourceUrl: row.source_url == null ? null : String(row.source_url),
139
142
  dueAt: iso(row.due_at),
140
143
  nextCheckAt: iso(row.next_check_at),
141
144
  closedAt: iso(row.closed_at),
@@ -282,6 +285,13 @@ export const workTaskOperationsOver = (context) => {
282
285
  change("close_reason", reason);
283
286
  change("close_reason_folded", folded(reason));
284
287
  }
288
+ if (fields.externalId !== undefined) {
289
+ const external = fields.externalId === null ? null : externalIdOf(fields.externalId);
290
+ change("provider", external?.provider ?? null);
291
+ change("external_id", external?.id ?? null);
292
+ }
293
+ if (fields.sourceUrl !== undefined)
294
+ change("source_url", fields.sourceUrl === null ? null : sourceUrlOf(fields.sourceUrl));
285
295
  if (fields.category !== undefined)
286
296
  change("category", fields.category, "category");
287
297
  if (fields.type !== undefined && fields.type !== before.type) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.575.0",
3
+ "version": "0.577.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",