@wirecat/cli-core 0.546.0 → 0.548.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 (112) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/dist/calendar/cli/answers.d.ts +7 -2
  3. package/dist/calendar/cli/answers.js +12 -4
  4. package/dist/calendar/cli/index.js +9 -8
  5. package/dist/calendar/feeds.d.ts +19 -0
  6. package/dist/calendar/feeds.js +52 -9
  7. package/dist/calendar/source.d.ts +10 -2
  8. package/dist/calendar/sync.d.ts +2 -0
  9. package/dist/calendar/sync.js +10 -6
  10. package/dist/calendar/testing/memory.js +39 -0
  11. package/dist/cli/messenger/history-query.d.ts +10 -4
  12. package/dist/cli/messenger/history-query.js +87 -31
  13. package/dist/cli/messenger/messages-command.js +10 -4
  14. package/dist/cli/messenger/messages-comments-command.js +4 -2
  15. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  16. package/dist/cli/option-hints.d.ts +6 -0
  17. package/dist/cli/option-hints.js +15 -0
  18. package/dist/errors/reasons.js +13 -1
  19. package/dist/mail/cli/index.js +4 -4
  20. package/dist/mail/index.d.ts +2 -0
  21. package/dist/mail/index.js +2 -0
  22. package/dist/mail/partial.d.ts +34 -0
  23. package/dist/mail/partial.js +94 -0
  24. package/dist/mail/server-search.js +17 -9
  25. package/dist/mail/source.d.ts +13 -2
  26. package/dist/mail/source.js +7 -1
  27. package/dist/mail/sync-types.d.ts +112 -0
  28. package/dist/mail/sync-types.js +1 -0
  29. package/dist/mail/sync.d.ts +4 -106
  30. package/dist/mail/sync.js +49 -4
  31. package/dist/mail/testing/fake.d.ts +3 -1
  32. package/dist/mail/testing/fake.js +10 -2
  33. package/dist/meetings/event-store.d.ts +10 -0
  34. package/dist/meetings/testing/event-store-contract.js +36 -0
  35. package/dist/services/deps.d.ts +0 -2
  36. package/dist/services/deps.js +0 -2
  37. package/dist/services/documents/ingestion.js +1 -1
  38. package/dist/services/documents/knowledge.d.ts +10 -6
  39. package/dist/services/documents/knowledge.js +25 -11
  40. package/dist/services/index.d.ts +1 -0
  41. package/dist/services/index.js +1 -0
  42. package/dist/services/messages.d.ts +2 -0
  43. package/dist/services/messages.js +25 -23
  44. package/dist/services/proposal-tasks.d.ts +3 -2
  45. package/dist/services/proposal-tasks.js +5 -2
  46. package/dist/services/proposals/ids.d.ts +5 -0
  47. package/dist/services/proposals/ids.js +43 -0
  48. package/dist/services/proposals/index.d.ts +3 -0
  49. package/dist/services/proposals/index.js +2 -0
  50. package/dist/services/proposals/kinds/action.d.ts +7 -0
  51. package/dist/services/proposals/kinds/action.js +45 -0
  52. package/dist/services/proposals/kinds/fact.d.ts +4 -0
  53. package/dist/services/proposals/kinds/fact.js +43 -0
  54. package/dist/services/proposals/kinds/findings.d.ts +7 -0
  55. package/dist/services/proposals/kinds/findings.js +75 -0
  56. package/dist/services/proposals/kinds/key-document.d.ts +9 -0
  57. package/dist/services/proposals/kinds/key-document.js +75 -0
  58. package/dist/services/proposals/kinds/memory.d.ts +4 -0
  59. package/dist/services/proposals/kinds/memory.js +42 -0
  60. package/dist/services/proposals/kinds/relationship.d.ts +10 -0
  61. package/dist/services/proposals/kinds/relationship.js +82 -0
  62. package/dist/services/proposals/kinds/shared.d.ts +25 -0
  63. package/dist/services/proposals/kinds/shared.js +49 -0
  64. package/dist/services/proposals/registry.d.ts +9 -0
  65. package/dist/services/proposals/registry.js +129 -0
  66. package/dist/services/proposals/types.d.ts +85 -0
  67. package/dist/services/proposals/types.js +1 -0
  68. package/dist/services/server-search.d.ts +7 -4
  69. package/dist/services/server-search.js +5 -2
  70. package/dist/services/tasks.js +8 -4
  71. package/dist/store/decisions.d.ts +65 -0
  72. package/dist/store/decisions.js +55 -0
  73. package/dist/store/evidence-findings.d.ts +6 -6
  74. package/dist/store/index.d.ts +4 -3
  75. package/dist/store/index.js +2 -1
  76. package/dist/store/meeting-findings.d.ts +2 -0
  77. package/dist/store/messages.d.ts +4 -9
  78. package/dist/store/sqlite/accounts.d.ts +8 -0
  79. package/dist/store/sqlite/accounts.js +2 -2
  80. package/dist/store/sqlite/actors.d.ts +13 -2
  81. package/dist/store/sqlite/actors.js +24 -1
  82. package/dist/store/sqlite/calendar-forget.d.ts +17 -0
  83. package/dist/store/sqlite/calendar-forget.js +68 -0
  84. package/dist/store/sqlite/calendar.js +8 -0
  85. package/dist/store/sqlite/email-locations.d.ts +16 -0
  86. package/dist/store/sqlite/email-locations.js +26 -0
  87. package/dist/store/sqlite/emails.d.ts +6 -2
  88. package/dist/store/sqlite/emails.js +2 -1
  89. package/dist/store/sqlite/evidence-findings.js +13 -3
  90. package/dist/store/sqlite/facts.d.ts +16 -8
  91. package/dist/store/sqlite/facts.js +48 -15
  92. package/dist/store/sqlite/findings-core.d.ts +2 -1
  93. package/dist/store/sqlite/findings-core.js +4 -4
  94. package/dist/store/sqlite/knowledge.d.ts +43 -7
  95. package/dist/store/sqlite/knowledge.js +55 -15
  96. package/dist/store/sqlite/meeting-findings.js +6 -6
  97. package/dist/store/sqlite/memories.d.ts +12 -8
  98. package/dist/store/sqlite/memories.js +33 -10
  99. package/dist/store/sqlite/notes.d.ts +5 -1
  100. package/dist/store/sqlite/notes.js +18 -2
  101. package/dist/store/sqlite/open-store.d.ts +4 -1
  102. package/dist/store/sqlite/open-store.js +8 -2
  103. package/dist/store/sqlite/open.d.ts +3 -0
  104. package/dist/store/sqlite/proposed-actions.d.ts +21 -8
  105. package/dist/store/sqlite/proposed-actions.js +29 -9
  106. package/dist/store/sqlite/reads.d.ts +13 -2
  107. package/dist/store/sqlite/reads.js +8 -4
  108. package/dist/store/sqlite/relation-proposals.d.ts +34 -0
  109. package/dist/store/sqlite/relation-proposals.js +114 -0
  110. package/dist/store/store.d.ts +3 -0
  111. package/dist/store/store.js +2 -0
  112. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,179 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.548.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **A feed is asked whether it changed.** `sync` sends a feed the last answer's `ETag` / `Last-Modified` back as
15
+ `If-None-Match` / `If-Modified-Since`, and a `304` keeps every stored event without reading the feed again
16
+ (`feeds[].unchanged` in the answer). The validators are the feed calendar's sync token in the store, valid for one
17
+ window: the first sync of a new day, and `sync --rescan`, read the feed in full. `fetchFeedIfChanged` is exported;
18
+ a `fullListOnly` source now gets its last token in `changes()`, and a page may say `unchanged: true`.
19
+ - **`calendars add` warns once on stderr for an `http://` feed**: the feed and any token in its address travel
20
+ unencrypted. It is still allowed; `webcal://` and `https://` do not warn.
21
+
22
+ ### Changed — may break callers
23
+
24
+ - **`calendars remove` deletes a feed's events instead of cancelling them**, with the calendar row, its cursor, the
25
+ events and series only it held, their links, tags, search words and vectors, and people only it had seen. An event
26
+ another calendar also holds stays with that copy. The answer's `events` counts the events deleted. A new
27
+ `EventStore.forgetCalendar(calendarId, now)` does it: an `EventStore` written outside cli-core must add it
28
+ (`memoryEventStore` and the SQLite store have it, and `eventStoreContract` checks it). `calendars unsync` is
29
+ unchanged.
30
+
31
+ ## 0.547.0 — 02.11.2026
32
+
33
+ ### Added
34
+
35
+ - **A server's refused SEARCH fails that folder, not the run, and proves nothing.** A mail source throws
36
+ `mail.search-refused` (`SEARCH_REFUSED`, `searchRefused`) when the server answers SEARCH with NO or BAD. `syncMail`
37
+ reads the other folders, answers `state: "partial"` with `refusedFolders`, and for the refused folder marks no mail
38
+ deleted, changes no membership, moves no resume point and leaves `mail_coverage` as it was. Before, a mail source that
39
+ read the refusal as "nothing found" made the folder look empty.
40
+ - **Server search reads big mail text-first, and the next sync completes it.** A candidate is read up to
41
+ `SERVER_BOUNDS.textBytes` (256 KiB, `fetchRaw(items, { bytes })`); one cut short is stored with its text and no
42
+ attachments, marked `metadata.partial`, and `meta.issues` says `search.server-partial`. `syncMail` then reads up to
43
+ 100 partial emails whole and clears the mark (`partial: { completed, left }`; `completePartial: false` skips it).
44
+ `MailStore.partialEmails` lists them. No store migration: the mark is a JSON key older builds ignore.
45
+ - **`readClientFile` (from `@wirecat/cli-core/oauth`) reads a user's own Google Desktop OAuth client file** (the
46
+ `{ "installed": { client_id, client_secret } }` JSON from Google's console). It refuses a file other users can read
47
+ (`sign-in.client-file-open`) and a file of the wrong shape (`sign-in.client-file-invalid`), and never puts the file's
48
+ contents in an error. gcal, drive and mail each kept a copy of this reader.
49
+ - **`openBrowser(url, browser?)` is exported from `@wirecat/cli-core/cli`**, so a CLI that does not start through
50
+ `startCli` opens the sign-in page the same way: `open`, `rundll32` or `xdg-open`, or the named browser.
51
+ - **iCalendar feeds as read-only calendars.** With `deps.feeds` (`icsFeeds`), `calendars add <url> [--title]` follows a
52
+ public holiday calendar, a sports schedule or a published `.ics` address, and `calendars remove <calendar>` stops.
53
+ `sync` reads each feed after the provider (`syncWithFeeds`), so its events show in `events list`,
54
+ `search events`, `overlapping:` and `events remind`. Repeating events are expanded here (`expandSeries`: RRULE in
55
+ the event's own zone, RDATE, EXDATE and overrides). The address is a secret input, kept by the CLI and never
56
+ printed; a fetch follows redirects only to https and stops at 10 MB and 30 s. `readIcs` takes
57
+ `{ overrides: true }` and returns the calendar's `name`. See `docs/dev/CALENDARS.md`, "Feeds".
58
+ - **History limits for pushed history, per profile.** A messenger whose history is pushed (`history: "store"`,
59
+ WhatsApp) saves one-to-one chats back `history_dialog_days` (1095 by default), groups of more than two people back
60
+ `history_group_days` (365) and nothing of a group above `history_group_members_max` people (50); at most 1095 days.
61
+ The chat row stays, so the chat is still listed. Live messages, `messages sync` and what the store already holds are
62
+ never dropped. `messages sync <chat> --full-history` fetches a chat and keeps its pushed history in full from then on
63
+ (`history_included` in the answer). `ChatsStore` gains `historySkips`, `historyShapes`, `recordHistorySkips` and
64
+ `setHistoryIncluded`; `Saving` takes `historyLimits`.
65
+ - **Helpers for reading money and questions in a chat, for the project status work.** `amountsOf(text)` returns
66
+ every amount in a text, in order (`amountOf` is its first). `MONEY_REQUEST`, `MONEY_DONE` and `PROMISE` word lists
67
+ (ru, en, es) with `moneyClassesOf(text)` say which a text uses. `questions(messages, { asker: "owner" })` lists the
68
+ owner's own questions and whether anyone but the owner answered them, by a reply or by speaking next; the default,
69
+ `"others"`, is what it did before. `linkedChatsOf` is exported, now with each link's `role`, and so is
70
+ `AGENDA_DEFAULTS`, the silence `tasks agenda` waits before it lists a chat.
71
+ - **A command's own history handler learns whether each batch reached the store**: `HistoryBatchHandler` takes
72
+ `saved`, a promise of the same `true` or `false` the messenger gets, so a link can count only what was kept.
73
+ - **Every messenger command that takes a chat or a message also takes the reference other WireCat CLIs print**:
74
+ `chat:<provider>/<account>/<chat>` wherever a chat is named (`messages list chat:max/34871122/63488582`, bot
75
+ commands too), and `msg:<provider>/<account>/<chat>/<message>` wherever a chat or a message is, online and offline. A person lookup takes `contact:<provider>/<id>`. A reference of another
76
+ account is refused, naming the account it belongs to and the profiles of this CLI the store saw on it; it never
77
+ switches account. One of another messenger is refused with the command that opens it (`open it with tg <profile>
78
+ messages list chat:telegram/…`), and another kind (`document:413` given as a chat) with what the argument takes.
79
+ - **`event:<id>` and `meeting:<account>/<meeting>[/<transcript>[/<cue>]]` parse with `parseReference`** and
80
+ `canonicalReference`, as every other kind: `event:N` was printed by the calendar and person answers and could not be
81
+ read back. The kinds are one table, `REF_KINDS`; `looksLikeReference(text)` tells a reference from a title such as
82
+ `Note: plan`. Reference errors carry a reason: `ref.malformed`, `ref.unknown-kind` (with the kind meant:
83
+ `"doc:413" … did you mean document:413?`), `ref.wrong-kind`, `ref.wrong-provider`, `ref.wrong-account`.
84
+ - **`messages list` reads a window bounded on both sides, offline too.** The query takes a start and an end,
85
+ `'date>=2026-10-01 date<2026-10-20'` or `'date:[2026-10-01 TO 2026-10-19]'`, and `id>` with `date<`. A start reads
86
+ forward, oldest first, and stops at the end; an end alone reads back from it, newest page first, as before.
87
+ `--offline` and a messenger whose history is read from the store (WhatsApp) now answer both ways from the store
88
+ instead of refusing. A forward cursor carries the end, `after:<id>~<epoch ms>`, so the next page stops at the same
89
+ place. `--since`, `--since-time`, `--until`, `--until-time`, `--after-time` and `--before-time` on `messages list`
90
+ fail with the query form to type instead. The store's `messages()` window takes `after` (a message or a moment)
91
+ and `until`.
92
+ - **One proposal inbox behind one check: `proposalRegistry(store, policy)`, `ProposalKind`, `parseProposalIds`
93
+ (`@wirecat/cli-core/services`) and `mayDecide` (`@wirecat/cli-core/store`).** The registry lists every pending
94
+ proposal newest first across kinds (`relationship`, `key_document`, `fact`, `memory`, `findings` for evidence
95
+ findings, and `action` for every other stored proposal), and decides many ids at once, each on its own: the answer
96
+ lists `decided` and `refused`, and one refusal never undoes the others. A new kind registers itself with its id
97
+ `prefix` or the `stored` proposal kinds it owns; nothing else changes. `parseProposalIds` reads
98
+ `17070-17079,fact:500-502,17080/f2` and refuses a range across prefixes. Unconfirmed agent and rule links show as
99
+ `link:<id>` (relationships) and `key:<id>` (key documents) until the store moves them.
100
+ - **`knowledge.proposeRelation(scope, input, by)`**: an agent's or a rule's relationship is a stored `relationship`
101
+ proposal for the owner, not a link; the same pending pair returns the proposal already held. Removing a link to a
102
+ project, or rejecting any suggested link, leaves a rejected `relationship` proposal, so a later scan can skip it.
103
+ - **`proposedActions.list({ kinds, except, project, proposedBy })`**, `proposedBy` on `facts.list` and
104
+ `memories.list`, `order: "created"` on `facts.list`, and the bot's `kind` (`agent` or `rule`) on the author of a
105
+ proposed action, a fact, a memory and an evidence findings batch; bots may have the kind `rule`, and an `Author` may
106
+ name it.
107
+ - **`knowledge.decideLinkProposal(id, decision)`** decides a stored `relationship` or `key_document` proposal, and
108
+ **`knowledge.decideSuggestedLink(id, decision)`** an older unconfirmed link, each in one transaction: the link and
109
+ the decision land together or not at all. A decision's `reason` is kept: on a rejected proposal as its result, on a
110
+ fact in `metadata.decision`, on an evidence findings receipt.
111
+
112
+ ### Changed — may break callers
113
+
114
+ - **`SERVER_BOUNDS.maxMessageBytes`, `ServerSearched.tooLarge` and `search.server-too-large` are gone**: a big
115
+ candidate is no longer left on the server; see `textBytes`, `ServerSearched.partial` and `search.server-partial`.
116
+ - **An option after `--` is read as the option.** `messages sync -- -100 --json` now syncs `-100` and answers in JSON;
117
+ it failed with "too many arguments". After `--`, a word that is exactly one of the command's options, its own or a
118
+ global one (`--json`, `--limit 5`, `--json=id`), moves in front of `--`; every other word stays a positional. A
119
+ negative id needs no `--` at all. Commands that take free text keep every word after `--` literal: `messages send` and
120
+ `edit` (also the bot's), `polls create`, `topics create`, `chats create`, `chats folders create`, `contacts rename`,
121
+ `contacts aliases set`, `messages press`, `bot commands set`, `schedule command`, `events remind`, `complete`, and the
122
+ notes templates. Mark such a command with `annotate(command, { literal_after_dashes: true })`; MCP calls are never
123
+ lifted. A script that passed the literal word `--json` as a positional of another command now gets the option instead.
124
+ - **A write to a calendar the account may only read refuses before anything is asked or journaled**
125
+ (`calendar.read-only`): create, quick add, import, update, delete, respond and move, for a feed and for a provider's
126
+ `reader` or `free-busy` calendar alike. Before, only the target of a move was checked; the provider refused the rest.
127
+ `calendars busy` and `search --backend server` leave feed calendars out.
128
+ - **A locator of another account or messenger is refused with a new text and a reason.** `messages show`, `context`,
129
+ `link` and `download` said "that locator belongs to another account; select its profile first" or "…belongs to
130
+ another messenger"; they now say `<ref> belongs to account <id>; profile <name> is account <id>` (`ref.wrong-account`)
131
+ or `<ref> is a <messenger> message; open it with …` (`ref.wrong-provider`). A script matching the old text matches
132
+ the reason instead.
133
+ - **A text of the form `<kind>:<no spaces>` given as a chat is read as a reference**, so a chat titled `note:plan` is
134
+ no longer found by that title; give its id or a part of the title.
135
+ - The provider seeds of `telegram`, `max` and `whatsapp` name their CLI (`command: tg`, `max`, `wa`), which a
136
+ provider clash message now shows (`telegram (messages, from tg)`).
137
+ - **Every decide method takes who decides, a `Decider`** (`"owner"` or `{ bot, model?, reason }`):
138
+ `facts.accept(ref, by)`, `facts.reject(ref, by)`, `memories.confirm(ref, by)`, `knowledge.confirmRelation(scope, id,
139
+ by)`, `knowledge.removeRelation(scope, id, by)`, `proposedActions.approve(id, by, result?)`,
140
+ `proposedActions.reject(id, by)`, `approveAsTask(store, id, by)`, and `by` is now required in
141
+ `evidenceFindings.review` and `meetingFindings.review`. `DocumentKnowledge.fact` and `.memory` take `by` for a
142
+ `confirm`, and `memories.markStale(ref, by)` and `.memory(…, "stale", by)` take it too: marking an agent's memory
143
+ stale is how it is turned down. `knowledge.relate` needs `by`: the owner's relationship is a link, an agent's is a
144
+ `relationship` proposal it returns instead. `notes.addLink` refuses any confirmed link without `by: "owner"`, so an
145
+ agent writes `confirmed: false` (the task service now does). `notes.removeLink(id, by)`, `facts.reverse(ref, by)`,
146
+ `facts.remove(ref, by)` and `memories.remove(ref, by)` take who removes; the document port's `addMemory` needs
147
+ `author`, `addFact` and `addLink` need `by`, with no owner default. `proposalRegistry(store)` takes no policy: the
148
+ host sets it once with `openStore({ decisions })`, and `store.decisionPolicy` is what the registry and every store
149
+ check read. A host passes the owner only when the owner runs the command, and
150
+ the agent's bot and reason otherwise.
151
+ An agent's confirmation of a relationship is recorded in the link's provenance, and of a fact or proposal as its
152
+ decider.
153
+
154
+ ### Fixed
155
+
156
+ - **A sum written in groups of three read as the wrong amount: `50 000 ₽` was `0` and `4 300 ₽` was `300`**, because
157
+ only the last group was read. Groups after a space, a no-break or thin space, a dot or a comma (`50 000`,
158
+ `1.200 €`, `$1,200`) are one number now, a decimal part after them is kept (`5 000.50 ₽`), and amounts keep two
159
+ decimals instead of a whole number (`15.5 eur` was 16). This is the amount the duplicate check compares between
160
+ two tasks, so a reminder worded with a grouped sum is no longer compared on the wrong figure.
161
+ - **`page.next` of `messages list` and `messages comments` runs as written.** It repeated the query's `id`/`date`
162
+ bound beside `--cursor`, which the command refuses as two starting points; it now keeps only `topic:` and
163
+ `starred:`.
164
+ - **An inclusive start keeps a message sent exactly at it.** `messages list 'date>=2026-10-01'` skipped a message
165
+ sent at the first millisecond of the day, and `date<="…"` one sent at that moment.
166
+
167
+ ### Security
168
+
169
+ - **An agent can no longer decide its own proposal.** Before, `facts.accept`/`reject`,
170
+ `memories.confirm`, `knowledge.confirmRelation`, `proposedActions.approve`/`reject`, `approveAsTask` and both
171
+ findings reviews never asked who decided, so over MCP an agent could confirm what it had proposed itself. Every one
172
+ of them, and the registry, now runs `mayDecide`: the owner always may; an agent needs a reason
173
+ (`proposal.reason-required`), is refused its own bot's proposal (`proposal.own`), a rule scorer's proposal and one
174
+ that names no author (`proposal.owner-only`), and every decision when the host turns agent decisions off
175
+ (`proposals.agent-decisions-off`). An agent can no longer add a memory already `confirmed`, mark its own memory
176
+ stale, write a confirmed project link itself, or remove a link. A link records only free-text provenance, which
177
+ anyone can write, so only the owner decides an unconfirmed link or removes a link; the rejected record a removal
178
+ leaves names who removed it. Findings an agent accepted for the owner record that agent, not the owner. Only the
179
+ owner removes or reverses an accepted fact or another bot's memory; an agent withdraws only its own row while it is
180
+ proposed. Turning agent decisions off now holds for every store method, not only the registry, and a bot is the
181
+ same bot whatever its letter case (`Claude-Code` is `claude-code`).
182
+
10
183
  ## 0.546.0 — 02.11.2026
11
184
 
12
185
  ### Added
@@ -24,7 +24,7 @@ export declare const calendarFeed: v.StrictObjectSchema<{
24
24
  readonly events: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
25
25
  readonly hint: v.StringSchema<undefined>;
26
26
  }, undefined>;
27
- /** `calendars remove`: the feed's calendar, no longer synced, and how many stored entries were marked deleted. */
27
+ /** `calendars remove`: the feed's calendar, gone, how many stored entries were deleted, and how many events with them. */
28
28
  export declare const calendarFeedRemoval: v.StrictObjectSchema<{
29
29
  readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
30
30
  readonly external_id: v.StringSchema<undefined>;
@@ -35,6 +35,7 @@ export declare const calendarFeedRemoval: v.StrictObjectSchema<{
35
35
  readonly synced: v.BooleanSchema<undefined>;
36
36
  readonly seen_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
37
37
  readonly removed: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
38
+ readonly events: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
38
39
  }, undefined>;
39
40
  export declare const calendarBusy: v.StrictObjectSchema<{
40
41
  readonly start: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
@@ -100,11 +101,15 @@ export declare const calendarSync: v.StrictObjectSchema<{
100
101
  readonly embedded: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
101
102
  readonly left: v.BooleanSchema<undefined>;
102
103
  }, undefined>, undefined>;
103
- /** Each feed's events as read, and how many it could not take (`syncWithFeeds`). */
104
+ /**
105
+ * Each feed's events as read, and how many it could not take (`syncWithFeeds`); `unchanged` when the feed answered
106
+ * 304 and the counts are those of the read before.
107
+ */
104
108
  readonly feeds: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
105
109
  readonly calendar_id: v.NumberSchema<undefined>;
106
110
  readonly events: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
107
111
  readonly skipped: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
112
+ readonly unchanged: v.OptionalSchema<v.LiteralSchema<true, undefined>, undefined>;
108
113
  }, undefined>, undefined>, undefined>;
109
114
  }, undefined>;
110
115
  export declare const calendarSyncProgress: v.StrictObjectSchema<{
@@ -21,8 +21,8 @@ export const calendar = v.strictObject({
21
21
  });
22
22
  /** `calendars add`: the feed's calendar, how many events the feed holds now, and the sync to run next. */
23
23
  export const calendarFeed = v.strictObject({ ...calendar.entries, events: count, hint: text });
24
- /** `calendars remove`: the feed's calendar, no longer synced, and how many stored entries were marked deleted. */
25
- export const calendarFeedRemoval = v.strictObject({ ...calendar.entries, removed: count });
24
+ /** `calendars remove`: the feed's calendar, gone, how many stored entries were deleted, and how many events with them. */
25
+ export const calendarFeedRemoval = v.strictObject({ ...calendar.entries, removed: count, events: count });
26
26
  const busyRow = v.strictObject({
27
27
  /** One of the account's calendars, by its id; or `person`, by the address asked. */
28
28
  calendar: v.optional(id),
@@ -72,8 +72,16 @@ export const calendarSync = v.strictObject({
72
72
  hint: v.optional(text),
73
73
  /** What `sync --embed` embedded: `embedEvents`' answer. */
74
74
  embeddings: v.optional(v.strictObject({ model: text, embedded: count, left: v.boolean() })),
75
- /** Each feed's events as read, and how many it could not take (`syncWithFeeds`). */
76
- feeds: v.optional(v.array(v.strictObject({ calendar_id: v.number(), events: count, skipped: count }))),
75
+ /**
76
+ * Each feed's events as read, and how many it could not take (`syncWithFeeds`); `unchanged` when the feed answered
77
+ * 304 and the counts are those of the read before.
78
+ */
79
+ feeds: v.optional(v.array(v.strictObject({
80
+ calendar_id: v.number(),
81
+ events: count,
82
+ skipped: count,
83
+ unchanged: v.optional(v.literal(true)),
84
+ }))),
77
85
  });
78
86
  export const calendarSyncProgress = v.strictObject({
79
87
  calendar_id: id,
@@ -3,7 +3,7 @@ import { annotate, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { timezoneOf } from "@wirecat/cli-base/query";
4
4
  import { clockMeta } from "@wirecat/cli-base/standards";
5
5
  import { Argument, InvalidArgumentError, Option } from "commander";
6
- import { environmentOf, runBudgetOf } from "../../cli/context.js";
6
+ import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
7
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
8
  import { changesStore, ownerOnlyOption, writesFileOnlyWith } from "../../cli/effects.js";
9
9
  import { queryTerms, withQuery } from "../../cli/list-query.js";
@@ -14,7 +14,7 @@ import { searchEvents } from "../../services/events-search.js";
14
14
  import { dateWindow } from "../../services/moment.js";
15
15
  import { linkPeople } from "../../services/people.js";
16
16
  import { BUSY_QUERY, BUSY_QUERY_NOTE, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
17
- import { isFeedCalendar, syncWithFeeds } from "../feeds.js";
17
+ import { isFeedCalendar, isPlainHttp, syncWithFeeds } from "../feeds.js";
18
18
  import { eventPreparation, nextMeeting, PREPARE_MESSAGES } from "../prepare.js";
19
19
  import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, EXPORT_MAX, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, eventsIcs, peopleAnswer, resolveAnswer, reviewsAnswer, sourceAccounts, weekFilter, } from "../reads.js";
20
20
  import { REPEAT_HELP } from "../recurrence.js";
@@ -30,8 +30,6 @@ const REMOTE_READ = { remote: "read", local: "none" };
30
30
  const LOCAL_OR_REMOTE_READ = { remote: "read", local: "read" };
31
31
  const REMOTE_WRITE = { remote: "write", local: "write" };
32
32
  const GROUP = { remote: "none", local: "none" };
33
- /** Every time a store can hold, for marking all of a calendar's entries at once. */
34
- const EVERYWHERE = { from: -8.64e15, to: 8.64e15 };
35
33
  /** How many pieces one `sync --embed` embeds, as drive's. */
36
34
  const EMBED_MAX = 600;
37
35
  // A list's marker goes on each item: a host's list envelope is strict and `--jsonl` prints only the items.
@@ -130,6 +128,9 @@ const addCalendarCommandsDefinition = (program, deps) => {
130
128
  if (command.optsWithGlobals().offline)
131
129
  throw new CliError("permission_error", "calendars add reads the feed; leave out --offline");
132
130
  const { calendar, events } = await feeds().add(url, opts.title === undefined ? {} : { title: opts.title });
131
+ if (isPlainHttp(url))
132
+ outputFor(command).renderer.warn("this feed's address starts with http://, so the feed and any token in its address travel unencrypted; " +
133
+ "use https:// if the server offers it");
133
134
  const [saved] = await deps.store.saveCalendars(deps.accountId, [calendar], now());
134
135
  print({
135
136
  ...calendarItem(saved),
@@ -138,7 +139,8 @@ const addCalendarCommandsDefinition = (program, deps) => {
138
139
  }, command);
139
140
  });
140
141
  example(annotate(calendars.command("remove"), { effects: LOCAL_WRITE, mcp: false })
141
- .description("stop following a feed calendars add added: its address is forgotten and its stored events go")
142
+ .description("stop following a feed calendars add added: its address is forgotten and its stored events are deleted, " +
143
+ "not cancelled. Only a feed; calendars unsync stops a provider's calendar")
142
144
  .argument("<calendar>", "the feed calendar's id, its ics: id, or its title"), "calendars remove 7").action(async (reference, _opts, command) => {
143
145
  const calendar = await calendarIn(deps.store, deps.accountId, reference);
144
146
  if (!isFeedCalendar(calendar))
@@ -146,9 +148,8 @@ const addCalendarCommandsDefinition = (program, deps) => {
146
148
  reason: "feed.not-a-feed",
147
149
  });
148
150
  await feeds().remove(calendar.externalId);
149
- await deps.store.setCalendarSynced(calendar.id, false, now());
150
- const removed = await deps.store.markUnseen(calendar.id, now() + 1, EVERYWHERE, now());
151
- print({ ...calendarItem({ ...calendar, synced: false }), removed }, command);
151
+ const { entries, events } = await deps.store.forgetCalendar(calendar.id, now());
152
+ print({ ...calendarItem({ ...calendar, synced: false }), removed: entries, events }, command);
152
153
  });
153
154
  }
154
155
  if (deps.freeBusy)
@@ -28,6 +28,8 @@ export interface FeedSyncStats {
28
28
  calendarId: number;
29
29
  events: number;
30
30
  skipped: number;
31
+ /** The feed answered 304: nothing was read again, and the counts are those of the read before. */
32
+ unchanged?: true;
31
33
  }
32
34
  /** The profile's iCalendar feeds: a source the engine expands locally, and how one is added and forgotten. */
33
35
  export interface CalendarFeeds {
@@ -47,6 +49,8 @@ export interface CalendarFeeds {
47
49
  }
48
50
  /** `webcal://` is `https://`: calendar apps write it so a click opens them, and the feed itself is served over https. */
49
51
  export declare const feedAddress: (text: string) => URL;
52
+ /** An `http://` address: allowed, but the feed and any token in its address travel unencrypted. */
53
+ export declare const isPlainHttp: (text: string) => boolean;
50
54
  export interface FetchFeedOptions {
51
55
  /** Names the feed in errors; the address never appears in one. */
52
56
  name: string;
@@ -54,11 +58,26 @@ export interface FetchFeedOptions {
54
58
  signal?: AbortSignal;
55
59
  limits?: FeedLimits;
56
60
  }
61
+ /** The last answer's `ETag` and `Last-Modified`, sent back so the server can say the feed has not changed. */
62
+ export interface FeedValidators {
63
+ etag: string | null;
64
+ lastModified: string | null;
65
+ }
57
66
  /**
58
67
  * The feed's text: http or https, each redirect followed by hand and only to https, the body cut off at its limit and
59
68
  * the request at its time. Errors name the feed and the status, never the address, which may hold a token.
60
69
  */
61
70
  export declare const fetchFeed: (address: string, options: FetchFeedOptions) => Promise<string>;
71
+ /**
72
+ * `fetchFeed` that sends `since` as `If-None-Match` and `If-Modified-Since`: `"unchanged"` when the server answers 304,
73
+ * otherwise the text and the validators to send next time.
74
+ */
75
+ export declare const fetchFeedIfChanged: (address: string, options: FetchFeedOptions & {
76
+ since?: FeedValidators;
77
+ }) => Promise<{
78
+ text: string;
79
+ validators: FeedValidators;
80
+ } | "unchanged">;
62
81
  /** One feed event in the neutral words, its text cleaned as an invitation's is: feed text is somebody else's. */
63
82
  export declare const feedEntry: (event: IcsEvent, zone: string) => EntryInput;
64
83
  export interface FeedRead {
@@ -26,6 +26,8 @@ export const feedAddress = (text) => {
26
26
  throw invalidAddress("must not hold a user name or a password");
27
27
  return url;
28
28
  };
29
+ /** An `http://` address: allowed, but the feed and any token in its address travel unencrypted. */
30
+ export const isPlainHttp = (text) => feedAddress(text).protocol === "http:";
29
31
  const sameAddress = (a, b) => feedAddress(a).href === feedAddress(b).href;
30
32
  const readBody = async (response, name, maxBytes) => {
31
33
  const reader = response.body?.getReader();
@@ -58,8 +60,17 @@ const STATUS_ERRORS = {
58
60
  * The feed's text: http or https, each redirect followed by hand and only to https, the body cut off at its limit and
59
61
  * the request at its time. Errors name the feed and the status, never the address, which may hold a token.
60
62
  */
61
- export const fetchFeed = async (address, options) => {
62
- const { name, limits = FEED_LIMITS } = options;
63
+ export const fetchFeed = async (address, options) => (await fetchFeedIfChanged(address, options)).text;
64
+ /**
65
+ * `fetchFeed` that sends `since` as `If-None-Match` and `If-Modified-Since`: `"unchanged"` when the server answers 304,
66
+ * otherwise the text and the validators to send next time.
67
+ */
68
+ export const fetchFeedIfChanged = async (address, options) => {
69
+ const { name, limits = FEED_LIMITS, since } = options;
70
+ const conditional = {
71
+ ...(since?.etag ? { "if-none-match": since.etag } : {}),
72
+ ...(since?.lastModified ? { "if-modified-since": since.lastModified } : {}),
73
+ };
63
74
  const run = options.fetch ?? fetch;
64
75
  const timeout = AbortSignal.timeout(limits.timeoutMs);
65
76
  const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
@@ -69,8 +80,12 @@ export const fetchFeed = async (address, options) => {
69
80
  const response = await run(url, {
70
81
  redirect: "manual",
71
82
  signal,
72
- headers: { accept: "text/calendar, text/plain;q=0.5, */*;q=0.1" },
83
+ headers: { accept: "text/calendar, text/plain;q=0.5, */*;q=0.1", ...conditional },
73
84
  });
85
+ if (response.status === 304 && Object.keys(conditional).length) {
86
+ await response.body?.cancel();
87
+ return "unchanged";
88
+ }
74
89
  const location = response.headers.get("location");
75
90
  if (response.status >= 300 && response.status < 400 && location) {
76
91
  await response.body?.cancel();
@@ -95,7 +110,11 @@ export const fetchFeed = async (address, options) => {
95
110
  retryable: response.status >= 500 || response.status === 429,
96
111
  });
97
112
  }
98
- return await readBody(response, name, limits.maxBytes);
113
+ const text = await readBody(response, name, limits.maxBytes);
114
+ return {
115
+ text,
116
+ validators: { etag: response.headers.get("etag"), lastModified: response.headers.get("last-modified") },
117
+ };
99
118
  }
100
119
  }
101
120
  catch (error) {
@@ -213,6 +232,19 @@ export const readFeed = (text, zone, window) => {
213
232
  skipped,
214
233
  };
215
234
  };
235
+ const lastRead = (token, window) => {
236
+ if (token === null)
237
+ return null;
238
+ try {
239
+ const held = JSON.parse(token);
240
+ if (held?.v !== 1 || held.window?.from !== window.from || held.window.to !== window.to || !held.validators)
241
+ return null;
242
+ return held;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ };
216
248
  const calendarOf = (feed, timezone = null) => ({
217
249
  externalId: `${FEED_PREFIX}${feed.id}`,
218
250
  title: feed.title,
@@ -236,8 +268,9 @@ export const icsFeeds = (options) => {
236
268
  });
237
269
  return feed;
238
270
  };
239
- const read = (url, name) => fetchFeed(url, {
271
+ const read = (url, name, since) => fetchFeedIfChanged(url, {
240
272
  name,
273
+ ...(since ? { since } : {}),
241
274
  ...(options.fetch ? { fetch: options.fetch } : {}),
242
275
  ...(options.signal ? { signal: options.signal } : {}),
243
276
  ...(options.limits ? { limits: options.limits } : {}),
@@ -248,9 +281,16 @@ export const icsFeeds = (options) => {
248
281
  async calendars() {
249
282
  return (await options.registry.read()).map((feed) => calendarOf(feed));
250
283
  },
251
- async changes(calendar, { window }) {
284
+ async changes(calendar, { cursor, window }) {
252
285
  const feed = await feedOf(calendar.externalId);
253
- const text = await read(feed.url, `feed ${calendar.id}`);
286
+ const held = lastRead(cursor, window);
287
+ const got = await read(feed.url, `feed ${calendar.id}`, held?.validators);
288
+ if (got === "unchanged") {
289
+ const [events, skipped] = [held?.events ?? 0, held?.skipped ?? 0];
290
+ stats.set(calendar.id, { calendarId: calendar.id, events, skipped, unchanged: true });
291
+ return { entries: [], removed: [], cursor, unchanged: true };
292
+ }
293
+ const { text, validators } = got;
254
294
  const found = readFeed(text, calendar.timezone ?? options.zone, window);
255
295
  for (const entry of found.entries) {
256
296
  const changed = found.overrides.get(entry.externalId);
@@ -258,7 +298,10 @@ export const icsFeeds = (options) => {
258
298
  overridesOf.set(entry, changed);
259
299
  }
260
300
  stats.set(calendar.id, { calendarId: calendar.id, events: found.events, skipped: found.skipped });
261
- return { entries: found.entries, removed: [], cursor: null };
301
+ const next = validators.etag || validators.lastModified
302
+ ? { v: 1, validators, window, events: found.events, skipped: found.skipped }
303
+ : null;
304
+ return { entries: found.entries, removed: [], cursor: next && JSON.stringify(next) };
262
305
  },
263
306
  };
264
307
  return {
@@ -271,7 +314,7 @@ export const icsFeeds = (options) => {
271
314
  throw new CliError("validation_error", "this profile follows a feed at this address already", {
272
315
  reason: "feed.duplicate",
273
316
  });
274
- const found = readFeed(await read(address, "the feed"), options.zone);
317
+ const found = readFeed((await read(address, "the feed")).text, options.zone);
275
318
  const feed = {
276
319
  id: randomBytes(6).toString("hex"),
277
320
  url: address,
@@ -13,7 +13,10 @@ export interface CalendarSourceTraits {
13
13
  expands: "on-request" | "in-changes" | "local";
14
14
  /** The cursor encodes the window (Graph delta): a new window means a new full round. */
15
15
  cursorHoldsWindow: boolean;
16
- /** No incremental cursor: every run is a full list (an ICS feed). */
16
+ /**
17
+ * No incremental cursor: every run is a full list (an ICS feed). The token the last list gave still reaches
18
+ * `changes()`, so the source can ask whether anything changed since.
19
+ */
17
20
  fullListOnly: boolean;
18
21
  }
19
22
  export interface ChangePage {
@@ -27,6 +30,11 @@ export interface ChangePage {
27
30
  nextPage?: string;
28
31
  /** On the last page: the cursor for the next run; `null` when the source has none. */
29
32
  cursor?: string | null;
33
+ /**
34
+ * A full-list source's list is the one its token was given with (an ICS feed's 304): nothing was read, so nothing
35
+ * is saved and nothing stored is marked unseen. Only on a single page with no entries.
36
+ */
37
+ unchanged?: true;
30
38
  }
31
39
  /** `reset`: the cursor is dead (Google 410, Graph `syncStateNotFound`, CalDAV `valid-sync-token`). */
32
40
  export type ChangeResult = ChangePage | {
@@ -61,7 +69,7 @@ export interface CalendarSource {
61
69
  readonly provider: string;
62
70
  readonly traits: CalendarSourceTraits;
63
71
  calendars(): Promise<CalendarInput[]>;
64
- /** `cursor: null` asks for a full list of the window. */
72
+ /** `cursor: null` asks for a full list of the window; a `fullListOnly` source lists in full whatever it is given. */
65
73
  changes(calendar: StoredCalendar, request: {
66
74
  cursor: string | null;
67
75
  page?: string;
@@ -23,6 +23,8 @@ export interface PendingRound {
23
23
  series: string[];
24
24
  /** A full round asks every series it lists; otherwise only those that changed. Absent reads as true. */
25
25
  everySeries?: boolean;
26
+ /** The source said its list is unchanged since `from`: the round marks nothing unseen. */
27
+ unchanged?: true;
26
28
  }
27
29
  /** What the engine keeps in `EventStore.cursor`. Version 1 had no `round`, `updatedAt` or `done`. */
28
30
  export interface CalendarCursor {
@@ -29,7 +29,7 @@ const roundOf = (value) => {
29
29
  round.series.some((id) => typeof id !== "string") ||
30
30
  (round.everySeries !== undefined && typeof round.everySeries !== "boolean"))
31
31
  return undefined;
32
- const { full, from, window, startedAt, reset, page, listed, next, series, everySeries } = round;
32
+ const { full, from, window, startedAt, reset, page, listed, next, series, everySeries, unchanged } = round;
33
33
  return {
34
34
  full,
35
35
  from,
@@ -41,6 +41,7 @@ const roundOf = (value) => {
41
41
  next,
42
42
  series,
43
43
  ...(everySeries === false ? { everySeries } : {}),
44
+ ...(unchanged === true ? { unchanged } : {}),
44
45
  };
45
46
  };
46
47
  export const parseCalendarCursor = (value) => {
@@ -87,9 +88,9 @@ export const calendarSyncWindow = (now) => {
87
88
  return { from: Date.UTC(year - 1, month, date), to: Date.UTC(year, month + 3, date) };
88
89
  };
89
90
  const CONTINUE = "run sync again to continue";
90
- const freshRound = (full, from, window, now, reset = false, everySeries = true) => ({
91
+ const freshRound = (full, from, window, now, reset = false, everySeries = true, keepToken = false) => ({
91
92
  full,
92
- from: full ? null : from,
93
+ from: full && !keepToken ? null : from,
93
94
  window,
94
95
  startedAt: now,
95
96
  reset,
@@ -112,7 +113,7 @@ const roundFor = (source, stored, { window, now }) => {
112
113
  // The daily full round asks only the series that changed, and every series once the window's end moved on far
113
114
  // enough that their new slots matter (foundation §6.2 step 7).
114
115
  const everySeries = !full || stored?.slotsTo === undefined || window.to - stored.slotsTo >= SLOTS_EVERY;
115
- return freshRound(full, stored?.token ?? null, window, now, false, everySeries);
116
+ return freshRound(full, stored?.token ?? null, window, now, false, everySeries, source.traits.fullListOnly);
116
117
  };
117
118
  const deadList = (provider) => new CliError("invalid_response", `${provider} refused a full list as a dead cursor`);
118
119
  /**
@@ -218,7 +219,9 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
218
219
  }
219
220
  if (page.removed.length)
220
221
  result.removed += await store.removeEntries(calendar.id, page.removed, now);
221
- if (page.nextPage === undefined)
222
+ if (page.unchanged && source.traits.fullListOnly && page.entries.length === 0 && page.nextPage === undefined)
223
+ round = { ...round, page: null, listed: true, next: page.cursor ?? null, unchanged: true };
224
+ else if (page.nextPage === undefined)
222
225
  round = { ...round, page: null, listed: true, next: page.cursor ?? null };
223
226
  else {
224
227
  if (seen.has(page.nextPage))
@@ -260,7 +263,8 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
260
263
  }
261
264
  if (signal?.aborted)
262
265
  return "cancelled";
263
- result.unseen = round.full ? await store.markUnseen(calendar.id, round.startedAt, round.window, now) : 0;
266
+ result.unseen =
267
+ round.full && !round.unchanged ? await store.markUnseen(calendar.id, round.startedAt, round.window, now) : 0;
264
268
  await save({
265
269
  token: round.next,
266
270
  window: round.window,
@@ -343,6 +343,45 @@ export const memoryEventStore = () => {
343
343
  async setCalendarSynced(calendarId, synced) {
344
344
  calendarOf(calendarId).synced = synced;
345
345
  },
346
+ async forgetCalendar(calendarId, now) {
347
+ const calendar = calendarOf(calendarId);
348
+ const mine = entries.filter((one) => one.calendarId === calendarId);
349
+ const ids = new Set(mine.map(({ id }) => id));
350
+ const eventIds = new Set(mine.flatMap(({ eventId }) => (eventId === null ? [] : [eventId])));
351
+ const seriesIds = new Set(mine.flatMap(({ eventSeriesId }) => (eventSeriesId === null ? [] : [eventSeriesId])));
352
+ for (let at = entries.length - 1; at >= 0; at--)
353
+ if (ids.has(entries[at].id))
354
+ entries.splice(at, 1);
355
+ for (let at = revisions.length - 1; at >= 0; at--)
356
+ if (ids.has(revisions[at].entryId))
357
+ revisions.splice(at, 1);
358
+ for (let at = reviews.length - 1; at >= 0; at--)
359
+ if (reviews[at].linkableType === "calendar_entry" &&
360
+ ids.has(reviews[at].linkableId))
361
+ reviews.splice(at, 1);
362
+ participants = participants.filter((one) => !ids.has(one.entryId));
363
+ attachments = attachments.filter((one) => !ids.has(one.entryId));
364
+ for (const id of ids)
365
+ unlinked.delete(id);
366
+ let gone = 0;
367
+ for (const id of eventIds)
368
+ if (entries.some((one) => one.eventId === id))
369
+ refreshEvent(id, now);
370
+ else if (events.delete(id))
371
+ gone++;
372
+ for (const id of seriesIds)
373
+ if (entries.some((one) => one.eventSeriesId === id))
374
+ refreshSeries(id);
375
+ else {
376
+ series.delete(id);
377
+ for (const event of events.values())
378
+ if (event.eventSeriesId === id)
379
+ event.eventSeriesId = null;
380
+ }
381
+ cursors.delete(calendarId);
382
+ calendars.splice(calendars.indexOf(calendar), 1);
383
+ return { entries: ids.size, events: gone };
384
+ },
346
385
  async saveEntries(calendarId, list, now) {
347
386
  calendarOf(calendarId);
348
387
  for (const input of list)