@wirecat/cli-core 0.454.0 → 0.456.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 (46) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/calendar/cli/index.d.ts +24 -0
  3. package/dist/calendar/cli/index.js +173 -0
  4. package/dist/calendar/index.d.ts +3 -0
  5. package/dist/calendar/index.js +1 -0
  6. package/dist/calendar/mcp/index.d.ts +307 -0
  7. package/dist/calendar/mcp/index.js +132 -0
  8. package/dist/calendar/reads.d.ts +149 -0
  9. package/dist/calendar/reads.js +214 -0
  10. package/dist/calendar/source.d.ts +54 -0
  11. package/dist/calendar/source.js +1 -0
  12. package/dist/calendar/sync.d.ts +65 -0
  13. package/dist/calendar/sync.js +167 -0
  14. package/dist/calendar/testing/index.d.ts +5 -0
  15. package/dist/calendar/testing/index.js +5 -0
  16. package/dist/calendar/testing/memory.d.ts +6 -0
  17. package/dist/calendar/testing/memory.js +478 -0
  18. package/dist/calendar/testing/source-contract.d.ts +31 -0
  19. package/dist/calendar/testing/source-contract.js +171 -0
  20. package/dist/calendar/testing/source.d.ts +38 -0
  21. package/dist/calendar/testing/source.js +110 -0
  22. package/dist/calendar/testing/sync-contract.d.ts +15 -0
  23. package/dist/calendar/testing/sync-contract.js +211 -0
  24. package/dist/cli/index.d.ts +2 -1
  25. package/dist/cli/index.js +2 -1
  26. package/dist/cli/messenger/ask.js +1 -1
  27. package/dist/errors/reasons.js +1 -0
  28. package/dist/meetings/calendar-text.d.ts +1 -1
  29. package/dist/meetings/calendar-text.js +0 -1
  30. package/dist/meetings/event-store.d.ts +42 -15
  31. package/dist/meetings/index.d.ts +1 -1
  32. package/dist/meetings/testing/event-store-contract.js +83 -4
  33. package/dist/sends/guard.d.ts +2 -0
  34. package/dist/sends/guard.js +1 -0
  35. package/dist/services/index.d.ts +1 -1
  36. package/dist/services/index.js +1 -1
  37. package/dist/services/people.d.ts +6 -1
  38. package/dist/services/people.js +14 -11
  39. package/dist/store/sqlite/calendar.js +68 -31
  40. package/dist/store/sqlite/manifest.js +2 -0
  41. package/dist/store/sqlite/migrations.generated.js +17 -0
  42. package/dist/store/sqlite/note-index.d.ts +1 -1
  43. package/dist/store/sqlite/note-index.js +6 -1
  44. package/dist/store/sqlite/schema.d.ts +16 -0
  45. package/dist/store/sqlite/schema.js +4 -0
  46. package/package.json +17 -1
@@ -1,12 +1,23 @@
1
1
  import type { Event } from "./model.js";
2
+ /** RFC 5545 PARTSTAT: a participant's answer, and the owner's own. */
3
+ export type ParticipantResponse = "needs-action" | "accepted" | "tentative" | "declined" | "delegated";
4
+ /** What kind of entry it is. Only `default` and `from-mail` are searched (requirements 3.4). */
5
+ export type EntryEventType = "default" | "out-of-office" | "focus" | "working-location" | "birthday" | "from-mail";
6
+ /** RFC 5545 TRANSP. */
7
+ export type EntryTransparency = "opaque" | "transparent";
8
+ /** RFC 5545 CLASS, plus `default`: the calendar's own setting. */
9
+ export type EntryVisibility = "default" | "public" | "private" | "confidential";
10
+ /** What the owner may do with a calendar: `free-busy` sees only busy slots. */
11
+ export type CalendarAccessRole = "owner" | "writer" | "reader" | "free-busy";
2
12
  /** One calendar as a provider lists it. */
3
13
  export interface CalendarInput {
4
14
  externalId: string;
5
15
  title: string | null;
6
16
  timezone: string | null;
7
- /** owner, writer, reader, freeBusyReader. A calendar the owner owns syncs by default. */
8
- accessRole: string | null;
17
+ accessRole: CalendarAccessRole | null;
9
18
  primary: boolean;
19
+ /** Whether a calendar seen for the first time syncs; the adapter decides. Later lists leave `synced` alone. */
20
+ syncByDefault: boolean;
10
21
  }
11
22
  export interface StoredCalendar {
12
23
  id: number;
@@ -14,7 +25,7 @@ export interface StoredCalendar {
14
25
  externalId: string;
15
26
  title: string | null;
16
27
  timezone: string | null;
17
- accessRole: string | null;
28
+ accessRole: CalendarAccessRole | null;
18
29
  primary: boolean;
19
30
  synced: boolean;
20
31
  seenAt: number | null;
@@ -23,7 +34,7 @@ export interface StoredCalendar {
23
34
  export interface EntryParticipantInput {
24
35
  email: string;
25
36
  name?: string | null;
26
- response?: string | null;
37
+ response?: ParticipantResponse | null;
27
38
  optional?: boolean;
28
39
  organizer?: boolean;
29
40
  self?: boolean;
@@ -53,10 +64,11 @@ export type EntryStatus = "confirmed" | "tentative" | "cancelled";
53
64
  export interface EntryInput {
54
65
  externalId: string;
55
66
  kind: EntryKind;
56
- /** An occurrence's series, by the series entry's `externalId` (Google's `recurringEventId`). */
67
+ /** An occurrence's series, by the series entry's `externalId`. */
57
68
  seriesExternalId?: string | null;
69
+ /** The RFC 5545 UID of the series: the same on the series and every occurrence, and across calendars. */
58
70
  icalUid?: string | null;
59
- /** An occurrence's slot in its series (Google's `originalStartTime`). */
71
+ /** An occurrence's slot in its series, as first scheduled; an all-day slot is midnight UTC of its date. */
60
72
  originalStartsAt?: number | null;
61
73
  exception?: boolean;
62
74
  status: EntryStatus;
@@ -72,10 +84,10 @@ export interface EntryInput {
72
84
  recurrence?: string[] | null;
73
85
  /** A series' last slot; `null` repeats indefinitely. */
74
86
  recurrenceEndsAt?: number | null;
75
- eventType?: string | null;
76
- transparency?: string | null;
77
- visibility?: string | null;
78
- ownResponse?: string | null;
87
+ eventType?: EntryEventType | null;
88
+ transparency?: EntryTransparency | null;
89
+ visibility?: EntryVisibility | null;
90
+ ownResponse?: ParticipantResponse | null;
79
91
  organizer?: {
80
92
  email: string;
81
93
  name?: string | null;
@@ -101,6 +113,8 @@ export interface StoredEntry {
101
113
  calendarId: number;
102
114
  kind: EntryKind;
103
115
  seriesEntryId: number | null;
116
+ /** On an occurrence: its series' `externalId`, also before that series is stored. */
117
+ seriesExternalId: string | null;
104
118
  eventId: number | null;
105
119
  eventSeriesId: number | null;
106
120
  externalId: string;
@@ -119,8 +133,8 @@ export interface StoredEntry {
119
133
  timezone: string | null;
120
134
  recurrence: string[] | null;
121
135
  recurrenceEndsAt: number | null;
122
- eventType: string | null;
123
- ownResponse: string | null;
136
+ eventType: EntryEventType | null;
137
+ ownResponse: ParticipantResponse | null;
124
138
  organizerIdentityId: number | null;
125
139
  joinUrl: string | null;
126
140
  joinKey: string | null;
@@ -134,7 +148,7 @@ export interface StoredParticipant {
134
148
  identityId: number | null;
135
149
  email: string;
136
150
  name: string | null;
137
- response: string | null;
151
+ response: ParticipantResponse | null;
138
152
  optional: boolean;
139
153
  organizer: boolean;
140
154
  self: boolean;
@@ -205,13 +219,26 @@ export interface EventStore {
205
219
  /** One sync page, matched by rules 1–5, in one transaction. */
206
220
  saveEntries(calendarId: number, entries: EntryInput[], now: number): Promise<StoredEntry[]>;
207
221
  /**
208
- * After a full list (`410 Gone`): entries not seen since `seenBefore` get `deleted_at`. Plain occurrences come only
209
- * from the window fetched, so only those inside it count. Events, series and links stay.
222
+ * Entries the provider confirmed deleted and names only by id, cancelled as a cancelled entry would be: past events
223
+ * stay as history, future ones cancel, a series cancels its occurrences. Unknown ids are skipped; the count is of
224
+ * entries cancelled now.
225
+ */
226
+ removeEntries(calendarId: number, externalIds: string[], now: number): Promise<number>;
227
+ /**
228
+ * After a full list: entries not seen since `seenBefore` get `deleted_at`. Plain occurrences come only from the
229
+ * window fetched, so only those inside it count. Events, series and links stay.
210
230
  */
211
231
  markUnseen(calendarId: number, seenBefore: number, window: {
212
232
  from: number;
213
233
  to: number;
214
234
  }, now: number): Promise<number>;
235
+ /**
236
+ * Where the calendar's last sync stopped, as the sync engine wrote it (`{"v":1,"token":…,"window":…,"fullAt":…}`),
237
+ * or `null` before the first one.
238
+ */
239
+ cursor(calendarId: number): Promise<string | null>;
240
+ /** `null` forgets the cursor, so the next sync is a full list. */
241
+ setCursor(calendarId: number, value: string | null, now: number): Promise<void>;
215
242
  events(filter?: EventFilter): Promise<Event[]>;
216
243
  event(id: number): Promise<EventDetails | null>;
217
244
  /** The owner's link of an entry to an event; no rule replaces it. */
@@ -4,7 +4,7 @@ export { calendarText, ENTRY_METADATA_KEYS, entryMetadata, withoutLinkQueries }
4
4
  export type { MeetingContextStore, MeetingEventLinkInput, MeetingEventLinkReceipt, ParticipantPerson, ParticipantPersonInput, ParticipantPersonLinkInput, ParticipantPersonLinkReceipt, } from "./context-store.js";
5
5
  export { type MeetingCueIdentity, meetingCueFingerprint } from "./cue-fingerprint.js";
6
6
  export { EVENT_QUERY } from "./event-query.js";
7
- export type { CalendarInput, EntryAttachment, EntryAttachmentInput, EntryInput, EntryKind, EntryParticipantInput, EntryRevision, EntryStatus, EventDetails, EventFilter, EventStore, LinkReview, ReviewDecision, StoredCalendar, StoredEntry, StoredParticipant, } from "./event-store.js";
7
+ export type { CalendarAccessRole, CalendarInput, EntryAttachment, EntryAttachmentInput, EntryEventType, EntryInput, EntryKind, EntryParticipantInput, EntryRevision, EntryStatus, EntryTransparency, EntryVisibility, EventDetails, EventFilter, EventStore, LinkReview, ParticipantResponse, ReviewDecision, StoredCalendar, StoredEntry, StoredParticipant, } from "./event-store.js";
8
8
  export { linkEvent } from "./events.js";
9
9
  export { exportMeeting, type MeetingExport, type MeetingExportFormat, type MeetingExportOptions, serializeVtt, } from "./export.js";
10
10
  export { type FindingEvidence, type FindingOutcome, findingHash, MEETING_FINDINGS_KIND, type MeetingFactFinding, type MeetingFinding, type MeetingFindingsInput, type MeetingFindingsReceipt, type MeetingTaskFinding, meetingFindingFingerprint, validateMeetingFindings, } from "./findings.js";
@@ -16,20 +16,73 @@ export const eventStoreContract = (make) => {
16
16
  name,
17
17
  async run() {
18
18
  const fixture = await make();
19
- const [calendar] = await fixture.store.saveCalendars(fixture.accountId, [{ externalId: "primary", title: "Work", timezone: "UTC", accessRole: "owner", primary: true }], 1);
19
+ const [calendar] = await fixture.store.saveCalendars(fixture.accountId, [
20
+ {
21
+ externalId: "primary",
22
+ title: "Work",
23
+ timezone: "UTC",
24
+ accessRole: "owner",
25
+ primary: true,
26
+ syncByDefault: true,
27
+ },
28
+ ], 1);
20
29
  await check({ ...fixture, calendar: calendar.id });
21
30
  },
22
31
  });
23
- const second = async ({ store, accountId }, externalId = "personal") => (await store.saveCalendars(accountId, [{ externalId, title: externalId, timezone: "UTC", accessRole: "owner", primary: false }], 1))[0].id;
32
+ const second = async ({ store, accountId }, externalId = "personal") => (await store.saveCalendars(accountId, [
33
+ {
34
+ externalId,
35
+ title: externalId,
36
+ timezone: "UTC",
37
+ accessRole: "owner",
38
+ primary: false,
39
+ syncByDefault: true,
40
+ },
41
+ ], 1))[0].id;
24
42
  const later = CONTRACT_NOW + 24 * HOUR;
25
43
  const earlier = CONTRACT_NOW - 24 * HOUR;
26
44
  return [
27
- test("a calendar the owner owns syncs; one shared with them is listed and does not", async ({ store, accountId, }) => {
28
- await store.saveCalendars(accountId, [{ externalId: "team", title: "Team", timezone: null, accessRole: "reader", primary: false }], 2);
45
+ test("a new calendar syncs as its adapter decides, and a later list keeps the owner's choice", async ({ store, accountId, }) => {
46
+ await store.saveCalendars(accountId, [
47
+ {
48
+ externalId: "team",
49
+ title: "Team",
50
+ timezone: null,
51
+ accessRole: "reader",
52
+ primary: false,
53
+ syncByDefault: false,
54
+ },
55
+ ], 2);
29
56
  assert.deepEqual((await store.calendars(accountId)).map(({ externalId, synced }) => [externalId, synced]), [
30
57
  ["primary", true],
31
58
  ["team", false],
32
59
  ]);
60
+ const team = (await store.calendars(accountId)).find(({ externalId }) => externalId === "team");
61
+ await store.setCalendarSynced(team?.id, true, 3);
62
+ await store.saveCalendars(accountId, [
63
+ {
64
+ externalId: "team",
65
+ title: "Team",
66
+ timezone: null,
67
+ accessRole: "free-busy",
68
+ primary: false,
69
+ syncByDefault: false,
70
+ },
71
+ ], 4);
72
+ const again = (await store.calendars(accountId)).find(({ externalId }) => externalId === "team");
73
+ assert.deepEqual([again?.synced, again?.accessRole], [true, "free-busy"]);
74
+ }),
75
+ test("calendar words are kept in their neutral spelling", async ({ store, calendar }) => {
76
+ const [entry] = await store.saveEntries(calendar, [
77
+ single("ooo", later, {
78
+ eventType: "out-of-office",
79
+ ownResponse: "needs-action",
80
+ participants: [{ email: "alice@example.com", response: "delegated" }],
81
+ }),
82
+ ], 2);
83
+ assert.deepEqual([entry?.eventType, entry?.ownResponse], ["out-of-office", "needs-action"]);
84
+ const details = await store.event(entry?.eventId);
85
+ assert.equal(details?.participants[0]?.response, "delegated");
33
86
  }),
34
87
  test("rule 1: the same calendar and id is the same entry; a change keeps the old values", async ({ store, calendar, }) => {
35
88
  const [first] = await store.saveEntries(calendar, [single("e1", later)], 2);
@@ -124,6 +177,32 @@ export const eventStoreContract = (make) => {
124
177
  const after = await store.event(future?.eventId);
125
178
  assert.equal(after?.event.status, "cancelled");
126
179
  }),
180
+ test("entries removed by id: past stays as history, future cancels, a series cancels its slots", async ({ store, calendar, }) => {
181
+ const series = { ...single("s", earlier - HOUR), kind: "series", recurrence: ["RRULE:FREQ=DAILY"] };
182
+ const slot = (at) => single(`s_${at}`, at, { kind: "occurrence", seriesExternalId: "s", originalStartsAt: at });
183
+ const [past, future, , , slotLater] = await store.saveEntries(calendar, [single("p", earlier), single("f", later), series, slot(earlier), slot(later)], 2);
184
+ assert.equal(await store.removeEntries(calendar, ["p", "f", "s", "unknown"], CONTRACT_NOW), 3);
185
+ const happened = (await store.event(past?.eventId))?.event;
186
+ assert.equal(happened?.sourceDeletedAt, CONTRACT_NOW);
187
+ assert.equal(happened?.title, "Example review");
188
+ assert.equal((await store.event(future?.eventId))?.event.status, "cancelled");
189
+ const occurrence = await store.event(slotLater?.eventId);
190
+ assert.deepEqual([occurrence?.event.status, occurrence?.entries[0]?.status], ["cancelled", "cancelled"]);
191
+ assert.equal(occurrence?.entries[0]?.deletedAt, null);
192
+ assert.equal(await store.removeEntries(calendar, ["p"], CONTRACT_NOW + 1), 0);
193
+ }),
194
+ test("a calendar's cursor is its own, and null forgets it", async (fixture) => {
195
+ const { store, calendar } = fixture;
196
+ const other = await second(fixture);
197
+ assert.equal(await store.cursor(calendar), null);
198
+ const value = JSON.stringify({ v: 1, token: "token-1", window: { from: earlier, to: later }, fullAt: 2 });
199
+ await store.setCursor(calendar, value, 2);
200
+ await store.setCursor(other, "other", 2);
201
+ assert.equal(await store.cursor(calendar), value);
202
+ await store.setCursor(calendar, null, 3);
203
+ assert.equal(await store.cursor(calendar), null);
204
+ assert.equal(await store.cursor(other), "other");
205
+ }),
127
206
  test("a full list marks the entries it did not return, plain occurrences only inside the window", async ({ store, calendar, }) => {
128
207
  const outside = later + 30 * 24 * HOUR;
129
208
  const [kept, gone, far] = await store.saveEntries(calendar, [
@@ -93,6 +93,8 @@ export interface GuardSettings {
93
93
  readOnlyLock?: string;
94
94
  permissions: Readonly<Record<PermissionKey, Level>>;
95
95
  permissionSources: Readonly<Record<PermissionKey, string>>;
96
+ /** A CLI's own defaults (`commandPermissions`); without them a key falls back to the messenger's. */
97
+ permissionDefaults?: Readonly<Record<PermissionKey, Level>>;
96
98
  sendsPerHour: number;
97
99
  }
98
100
  /**
@@ -259,6 +259,7 @@ export const guardFor = (app, settings, warn, env = process.env, ask) => sendGua
259
259
  ...(settings.readOnlyLock ? { readOnlyLock: settings.readOnlyLock } : {}),
260
260
  permissions: () => settings.permissions,
261
261
  permissionSources: settings.permissionSources,
262
+ ...(settings.permissionDefaults ? { permissionDefaults: settings.permissionDefaults } : {}),
262
263
  ...(ask ? { ask } : {}),
263
264
  sendsPerHour: settings.sendsPerHour,
264
265
  journal: new SendJournal(sendsPathFor(app, settings.profile, env), { service: app.command, version: app.version }),
@@ -83,7 +83,7 @@ export { searchStore } from "./messages-archive-search.js";
83
83
  export type { ModerateOptions, ModerationService, ShownRules } from "./moderation.js";
84
84
  export { moderationService } from "./moderation.js";
85
85
  export type { ContactSync, PeopleService } from "./people.js";
86
- export { peopleService, phoneOf } from "./people.js";
86
+ export { linkPeople, peopleService, phoneOf } from "./people.js";
87
87
  export type { ContextEvent, ContextMessage, ContextOptions, NotReadReason, PersonContext, SharedChat, } from "./person-context.js";
88
88
  export { CONTEXT_BYTES, CONTEXT_MESSAGES, identityIn, personContext } from "./person-context.js";
89
89
  export type { PersonPreparation, WaitingDialog } from "./person-preparation.js";
@@ -58,7 +58,7 @@ export { chatIdIn, storedChatId } from "./message-resolution.js";
58
58
  export { DELETE_AT_ONCE, messagesService } from "./messages.js";
59
59
  export { searchStore } from "./messages-archive-search.js";
60
60
  export { moderationService } from "./moderation.js";
61
- export { peopleService, phoneOf } from "./people.js";
61
+ export { linkPeople, peopleService, phoneOf } from "./people.js";
62
62
  export { CONTEXT_BYTES, CONTEXT_MESSAGES, identityIn, personContext } from "./person-context.js";
63
63
  export { PREPARE_BYTES, personPreparation } from "./person-preparation.js";
64
64
  export { personTimeline, SCOPES, TIMELINE_ITEMS, TIMELINE_MAX } from "./person-timeline.js";
@@ -1,7 +1,7 @@
1
1
  import { type Predicate } from "@wirecat/cli-base/query";
2
2
  import type { Contact, Id, Member, Page, PersonCard, PersonProfile, PhoneBookEntry } from "../domain/models.js";
3
3
  import { type Operated } from "../sends/guarded.js";
4
- import type { PersonRecord } from "../store/store.js";
4
+ import type { AccountKey, MessageStore, PersonRecord } from "../store/store.js";
5
5
  import type { PageWindow } from "./chats.js";
6
6
  import { type ServiceDeps } from "./deps.js";
7
7
  import { type ContextOptions, type Detail, type PersonContext, type PersonMessages } from "./person-context.js";
@@ -83,6 +83,11 @@ export interface PeopleService {
83
83
  }
84
84
  export declare const peopleService: (deps: ServiceDeps) => PeopleService;
85
85
  /** Digits, with the `+` and the spaces, dashes and brackets people type dropped. */
86
+ /**
87
+ * The owner's link of two people, each read in the account's messenger unless prefixed (`max:Ana`, `email:ana@x.com`,
88
+ * `person:12`); `me` links the first to the owner. A tool without a messenger (a calendar CLI) calls it directly.
89
+ */
90
+ export declare const linkPeople: (store: MessageStore, account: AccountKey, person: string, other: string, method?: string) => Promise<PersonRecord>;
86
91
  export declare const phoneOf: (typed: string) => string;
87
92
  /** The phone cut to its last four digits: enough to tell two accounts apart. */
88
93
  export declare const maskedAccount: <T extends {
@@ -109,17 +109,7 @@ export const peopleService = (deps) => {
109
109
  ...(detail ? { detail } : {}),
110
110
  });
111
111
  },
112
- link: async (person, other, method = "manual") => {
113
- const store = await deps.store();
114
- const account = await deps.account();
115
- const one = await identityIn(store, account, person);
116
- if (other === "me")
117
- return store.people.linkToOwner(one);
118
- const two = await identityIn(store, account, other);
119
- if (one.provider === two.provider && one.id === two.id)
120
- throw new CliError("validation_error", "that is the same person twice");
121
- return store.people.linkIdentities(one, two, { method, by: "owner" });
122
- },
112
+ link: async (person, other, method = "manual") => linkPeople(await deps.store(), await deps.account(), person, other, method),
123
113
  unlink: async (person) => {
124
114
  const store = await deps.store();
125
115
  return store.people.unlinkIdentity(await identityIn(store, await deps.account(), person), {
@@ -199,6 +189,19 @@ export const peopleService = (deps) => {
199
189
  /** Numbers per request: one big list in one request is what a contact-scraping account sends. */
200
190
  const IMPORT_CHUNK = 10;
201
191
  /** Digits, with the `+` and the spaces, dashes and brackets people type dropped. */
192
+ /**
193
+ * The owner's link of two people, each read in the account's messenger unless prefixed (`max:Ana`, `email:ana@x.com`,
194
+ * `person:12`); `me` links the first to the owner. A tool without a messenger (a calendar CLI) calls it directly.
195
+ */
196
+ export const linkPeople = async (store, account, person, other, method = "manual") => {
197
+ const one = await identityIn(store, account, person);
198
+ if (other === "me")
199
+ return store.people.linkToOwner(one);
200
+ const two = await identityIn(store, account, other);
201
+ if (one.provider === two.provider && one.id === two.id)
202
+ throw new CliError("validation_error", "that is the same person twice");
203
+ return store.people.linkIdentities(one, two, { method, by: "owner" });
204
+ };
202
205
  export const phoneOf = (typed) => {
203
206
  const digits = typed.replace(/[\s()+-]/g, "");
204
207
  if (!/^\d{6,15}$/.test(digits)) {
@@ -22,6 +22,7 @@ const entryOf = (row) => ({
22
22
  calendarId: Number(row.calendar_id),
23
23
  kind: row.kind,
24
24
  seriesEntryId: int(row.series_entry_id),
25
+ seriesExternalId: str(row.series_external_id),
25
26
  eventId: int(row.event_id),
26
27
  eventSeriesId: int(row.event_series_id),
27
28
  externalId: String(row.external_id),
@@ -94,6 +95,7 @@ const attachmentOf = (row) => ({
94
95
  });
95
96
  const TRACKED = ["title", "starts_at", "ends_at", "location", "status", "recurrence", "recurrence_ends_at"];
96
97
  const flag = (value) => (value ? 1 : 0);
98
+ const cursorKey = (calendarId) => `calendar:${calendarId}`;
97
99
  const addressOf = (email) => email.trim().toLowerCase();
98
100
  /** The `email` contact of an address: the same one mail uses, made when missing; it gets a name only when new. */
99
101
  const contactOf = (context, email, name) => {
@@ -331,7 +333,7 @@ export const eventStoreOver = (context) => {
331
333
  .get(seriesEntryId, input.originalStartsAt);
332
334
  return database
333
335
  .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND series_entry_id IS NULL AND original_starts_at = ? " +
334
- "AND json_extract(metadata, '$.recurring_event_id') = ?")
336
+ "AND series_external_id = ?")
335
337
  .get(calendarId, input.originalStartsAt, input.seriesExternalId ?? null);
336
338
  };
337
339
  const saveEntry = (calendarId, input, now) => {
@@ -347,6 +349,7 @@ export const eventStoreOver = (context) => {
347
349
  const values = {
348
350
  kind: input.kind,
349
351
  series_entry_id: seriesEntryId,
352
+ series_external_id: input.kind === "occurrence" ? (input.seriesExternalId ?? null) : null,
350
353
  external_id: input.externalId,
351
354
  ical_uid: input.icalUid ?? null,
352
355
  original_starts_at: input.originalStartsAt ?? null,
@@ -371,17 +374,11 @@ export const eventStoreOver = (context) => {
371
374
  join_url: joinUrl,
372
375
  join_key: joinUrl ? (joinKeyOf(joinUrl) ?? "") : null,
373
376
  conference: input.conference ?? null,
374
- // The calendar's own page of the event names it in the query (`eid`), which opens nothing on its own.
375
377
  url: input.url ?? null,
376
378
  sequence: input.sequence ?? null,
377
379
  etag: input.etag ?? null,
378
380
  external_updated_at: input.externalUpdatedAt ?? null,
379
- metadata: toJson({
380
- ...entryMetadata(input.metadata),
381
- ...(input.kind === "occurrence" && input.seriesExternalId
382
- ? { recurring_event_id: input.seriesExternalId }
383
- : {}),
384
- }),
381
+ metadata: toJson(entryMetadata(input.metadata)),
385
382
  seen_at: now,
386
383
  deleted_at: null,
387
384
  };
@@ -415,23 +412,39 @@ export const eventStoreOver = (context) => {
415
412
  if (input.kind === "series")
416
413
  database
417
414
  .prepare("UPDATE calendar_entries SET series_entry_id = ?, updated_at = ? WHERE calendar_id = ? AND kind = 'occurrence' " +
418
- "AND series_entry_id IS NULL AND json_extract(metadata, '$.recurring_event_id') = ?")
415
+ "AND series_entry_id IS NULL AND series_external_id = ?")
419
416
  .run(id, now, calendarId, input.externalId);
420
417
  return entryOf(entryRow(id));
421
418
  };
419
+ const cancel = (row, now) => {
420
+ database
421
+ .prepare(`INSERT INTO calendar_entry_revisions (entry_id, title, starts_at, ends_at, location, status, recurrence,
422
+ recurrence_ends_at, external_updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
423
+ .run(Number(row.id), str(row.title), int(row.starts_at), int(row.ends_at), str(row.location), str(row.status), str(row.recurrence), int(row.recurrence_ends_at), int(row.external_updated_at), now);
424
+ database
425
+ .prepare("UPDATE calendar_entries SET status = 'cancelled', updated_at = ? WHERE id = ?")
426
+ .run(now, Number(row.id));
427
+ };
422
428
  /** A series cancelled at the provider cancels its occurrences: past ones stay as history, future ones cancel. */
423
- const cancelOccurrences = (series, now) => {
429
+ const cancelOccurrences = (seriesEntryId, now) => {
424
430
  const occurrences = database
425
431
  .prepare("SELECT * FROM calendar_entries WHERE series_entry_id = ? AND status <> 'cancelled' AND deleted_at IS NULL")
426
- .all(series.id);
427
- for (const row of occurrences) {
428
- database
429
- .prepare(`INSERT INTO calendar_entry_revisions (entry_id, title, starts_at, ends_at, location, status, recurrence,
430
- recurrence_ends_at, external_updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`)
431
- .run(Number(row.id), str(row.title), int(row.starts_at), int(row.ends_at), str(row.location), str(row.status), int(row.external_updated_at), now);
432
- database
433
- .prepare("UPDATE calendar_entries SET status = 'cancelled', updated_at = ? WHERE id = ?")
434
- .run(now, Number(row.id));
432
+ .all(seriesEntryId);
433
+ for (const row of occurrences)
434
+ cancel(row, now);
435
+ };
436
+ /** The events and series of these entries and of their occurrences take their fields again. */
437
+ const refreshAround = (entryIds, now) => {
438
+ const ids = JSON.stringify(entryIds);
439
+ const touched = database
440
+ .prepare(`SELECT DISTINCT ce.event_id, ce.event_series_id FROM calendar_entries ce
441
+ WHERE ce.id IN (SELECT value FROM json_each(?)) OR ce.series_entry_id IN (SELECT value FROM json_each(?))`)
442
+ .all(ids, ids);
443
+ for (const row of touched) {
444
+ if (row.event_series_id != null)
445
+ refreshSeries(Number(row.event_series_id), now);
446
+ if (row.event_id != null)
447
+ refreshEvent(Number(row.event_id), now);
435
448
  }
436
449
  };
437
450
  return {
@@ -442,7 +455,7 @@ export const eventStoreOver = (context) => {
442
455
  access_role = excluded.access_role, primary_calendar = excluded.primary_calendar, seen_at = excluded.seen_at,
443
456
  deleted_at = NULL, updated_at = excluded.updated_at
444
457
  RETURNING *`);
445
- return calendars.map((one) => calendarOf(upsert.get(accountId, one.externalId, one.title, one.timezone, one.accessRole, flag(one.primary), flag(one.accessRole === "owner"), now, now, now)));
458
+ return calendars.map((one) => calendarOf(upsert.get(accountId, one.externalId, one.title, one.timezone, one.accessRole, flag(one.primary), flag(one.syncByDefault), now, now, now)));
446
459
  }),
447
460
  calendars: async (accountId) => database
448
461
  .prepare("SELECT * FROM calendars WHERE account_id = ? ORDER BY primary_calendar DESC, title, id")
@@ -466,22 +479,28 @@ export const eventStoreOver = (context) => {
466
479
  });
467
480
  for (const entry of saved)
468
481
  if (entry.kind === "series" && entry.status === "cancelled")
469
- cancelOccurrences(entry, now);
482
+ cancelOccurrences(entry.id, now);
470
483
  const current = saved.map(({ id }) => entryOf(entryRow(id)));
471
484
  for (const entry of current)
472
485
  link(entry, now);
473
- const touched = database
474
- .prepare(`SELECT DISTINCT ce.event_id, ce.event_series_id FROM calendar_entries ce
475
- WHERE ce.id IN (SELECT value FROM json_each(?)) OR ce.series_entry_id IN (SELECT value FROM json_each(?))`)
476
- .all(JSON.stringify(current.map(({ id }) => id)), JSON.stringify(current.map(({ id }) => id)));
477
- for (const row of touched) {
478
- if (row.event_series_id != null)
479
- refreshSeries(Number(row.event_series_id), now);
480
- if (row.event_id != null)
481
- refreshEvent(Number(row.event_id), now);
482
- }
486
+ refreshAround(current.map(({ id }) => id), now);
483
487
  return entries.map((input) => entryOf(entryRow(ids.get(input))));
484
488
  }),
489
+ removeEntries: async (calendarId, externalIds, now) => write(database, () => {
490
+ calendarRow(calendarId);
491
+ const removed = database
492
+ .prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id IN (SELECT value FROM json_each(?))
493
+ AND status <> 'cancelled' AND deleted_at IS NULL`)
494
+ .all(calendarId, JSON.stringify(externalIds));
495
+ for (const row of removed) {
496
+ cancel(row, now);
497
+ database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?").run(now, Number(row.id));
498
+ if (row.kind === "series")
499
+ cancelOccurrences(Number(row.id), now);
500
+ }
501
+ refreshAround(removed.map((row) => Number(row.id)), now);
502
+ return removed.length;
503
+ }),
485
504
  markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => {
486
505
  const marked = database
487
506
  .prepare(`UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE calendar_id = ? AND deleted_at IS NULL
@@ -494,6 +513,24 @@ export const eventStoreOver = (context) => {
494
513
  refreshEvent(eventId, now);
495
514
  return marked.length;
496
515
  }),
516
+ cursor: async (calendarId) => read(database, () => {
517
+ const { account_id } = calendarRow(calendarId);
518
+ return str(database
519
+ .prepare("SELECT value FROM sync_cursors WHERE account_id = ? AND key = ?")
520
+ .get(Number(account_id), cursorKey(calendarId))?.value);
521
+ }),
522
+ setCursor: async (calendarId, value, now) => write(database, () => {
523
+ const accountId = Number(calendarRow(calendarId).account_id);
524
+ if (value === null)
525
+ database
526
+ .prepare("DELETE FROM sync_cursors WHERE account_id = ? AND key = ?")
527
+ .run(accountId, cursorKey(calendarId));
528
+ else
529
+ database
530
+ .prepare(`INSERT INTO sync_cursors (account_id, key, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
531
+ ON CONFLICT (account_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
532
+ .run(accountId, cursorKey(calendarId), value, now, now);
533
+ }),
497
534
  events: async (filter = {}) => {
498
535
  const clauses = ["e.deleted_at IS NULL"];
499
536
  const params = [];
@@ -45,6 +45,8 @@ export const MANIFEST = [
45
45
  { name: "20261025072644_version-33-calendars-triggers", version: 33, minCompatible: 32 },
46
46
  { name: "20261025182500_version-34-vector-index", version: 34, minCompatible: 34 },
47
47
  { name: "20261025182501_version-34-vector-index-triggers", version: 34, minCompatible: 34 },
48
+ { name: "20261027102259_version-35-calendar-neutral", version: 35, minCompatible: 34 },
49
+ { name: "20261027102302_version-35-calendar-neutral-data", version: 35, minCompatible: 34 },
48
50
  ];
49
51
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
50
52
  const migrations = [];
@@ -761,5 +761,22 @@ export const GENERATED = [
761
761
  "CREATE TRIGGER vector_index_conversation_au AFTER UPDATE OF chat_id ON conversations\n WHEN old.chat_id IS NOT new.chat_id BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT id FROM chunks WHERE chunkable_type = 'conversation' AND chunkable_id = new.id;\n DELETE FROM vector_index_redo;\nEND;",
762
762
  "-- Images and conversations are indexed under their chat's account; messages carry their own.\nCREATE TRIGGER vector_index_chat_au AFTER UPDATE OF account_id ON chats WHEN old.account_id IS NOT new.account_id BEGIN\n INSERT INTO vector_index_redo (chunk_id)\n SELECT k.id FROM messages m JOIN attachments a ON a.attachable_type = 'message' AND a.attachable_id = m.id\n JOIN chunks k ON k.chunkable_type = 'image' AND k.chunkable_id = a.id WHERE m.chat_id = new.id\n UNION ALL SELECT k.id FROM conversations c JOIN chunks k ON k.chunkable_type = 'conversation' AND k.chunkable_id = c.id\n WHERE c.chat_id = new.id;\n DELETE FROM vector_index_redo;\nEND;"
763
763
  ]
764
+ },
765
+ {
766
+ "name": "20261027102259_version-35-calendar-neutral",
767
+ "statements": [
768
+ "ALTER TABLE `calendar_entries` ADD `series_external_id` text;",
769
+ "CREATE INDEX `calendar_entries_by_calendar_id_series_external_id` ON `calendar_entries` (`calendar_id`,`series_external_id`) WHERE series_external_id IS NOT NULL;"
770
+ ]
771
+ },
772
+ {
773
+ "name": "20261027102302_version-35-calendar-neutral-data",
774
+ "statements": [
775
+ "-- `metadata.recurring_event_id` stays on the rows it is on: a version-34 build may still open this file and reads it.\nUPDATE calendar_entries SET series_external_id = json_extract(metadata, '$.recurring_event_id')\n WHERE kind = 'occurrence' AND json_valid(metadata) AND json_extract(metadata, '$.recurring_event_id') IS NOT NULL;",
776
+ "UPDATE calendar_entries SET event_type = CASE event_type\n WHEN 'outOfOffice' THEN 'out-of-office'\n WHEN 'focusTime' THEN 'focus'\n WHEN 'workingLocation' THEN 'working-location'\n WHEN 'fromGmail' THEN 'from-mail'\n END\n WHERE event_type IN ('outOfOffice', 'focusTime', 'workingLocation', 'fromGmail');",
777
+ "UPDATE calendar_entries SET own_response = 'needs-action' WHERE own_response = 'needsAction';",
778
+ "UPDATE calendar_entry_participants SET response = 'needs-action' WHERE response = 'needsAction';",
779
+ "UPDATE calendars SET access_role = 'free-busy' WHERE access_role = 'freeBusyReader';"
780
+ ]
764
781
  }
765
782
  ];
@@ -34,7 +34,7 @@ export interface Corpus {
34
34
  /** What an event `e` is searched by besides its title: the cleaned description, the place and the attendees' names. */
35
35
  export declare const EVENT_TEXT: string;
36
36
  /** Calendar entry types that are listed, never searched (requirements 3.4). */
37
- export declare const UNSEARCHED_EVENT_TYPES: readonly ["workingLocation", "focusTime", "outOfOffice", "birthday"];
37
+ export declare const UNSEARCHED_EVENT_TYPES: readonly ["working-location", "focus", "out-of-office", "birthday"];
38
38
  /** Visibilities whose entry, when it carries no text, is a busy-only slot: stored, never searched (requirements 3.4). */
39
39
  export declare const BUSY_ONLY_VISIBILITIES: readonly ["private", "confidential"];
40
40
  /**
@@ -14,7 +14,12 @@ export const EVENT_TEXT = "trim(coalesce(e.description, '') || ' ' || coalesce(e
14
14
  "SELECT group_concat(p.name, ' ') FROM calendar_entries ce JOIN calendar_entry_participants p ON p.entry_id = ce.id " +
15
15
  "WHERE ce.event_id = e.id AND ce.deleted_at IS NULL AND p.resource = 0 AND p.name IS NOT NULL), ''))";
16
16
  /** Calendar entry types that are listed, never searched (requirements 3.4). */
17
- export const UNSEARCHED_EVENT_TYPES = ["workingLocation", "focusTime", "outOfOffice", "birthday"];
17
+ export const UNSEARCHED_EVENT_TYPES = [
18
+ "working-location",
19
+ "focus",
20
+ "out-of-office",
21
+ "birthday",
22
+ ];
18
23
  /** Visibilities whose entry, when it carries no text, is a busy-only slot: stored, never searched (requirements 3.4). */
19
24
  export const BUSY_ONLY_VISIBILITIES = ["private", "confidential"];
20
25
  const sqlList = (values) => values.map((value) => `'${value}'`).join(", ");
@@ -8930,6 +8930,22 @@ export declare const calendarEntries: import("drizzle-orm/sqlite-core").SQLiteTa
8930
8930
  identity: undefined;
8931
8931
  generated: undefined;
8932
8932
  }, {}>;
8933
+ seriesExternalId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
8934
+ name: string;
8935
+ tableName: "calendar_entries";
8936
+ dataType: "string";
8937
+ data: string;
8938
+ driverParam: string;
8939
+ notNull: false;
8940
+ hasDefault: false;
8941
+ isPrimaryKey: false;
8942
+ isAutoincrement: false;
8943
+ hasRuntimeDefault: false;
8944
+ enumValues: [string, ...string[]];
8945
+ baseColumn: never;
8946
+ identity: undefined;
8947
+ generated: undefined;
8948
+ }, {}>;
8933
8949
  };
8934
8950
  dialect: 'sqlite';
8935
8951
  isAlias: false;
@@ -911,12 +911,16 @@ export const calendarEntries = sqliteTable("calendar_entries", {
911
911
  createdAt: integer("created_at").notNull(),
912
912
  updatedAt: integer("updated_at").notNull(),
913
913
  deletedAt: integer("deleted_at"),
914
+ seriesExternalId: text("series_external_id"),
914
915
  }, (table) => [
915
916
  unique().on(table.calendarId, table.externalId),
916
917
  unique().on(table.seriesEntryId, table.originalStartsAt),
917
918
  index("calendar_entries_by_calendar_id_starts_at").on(table.calendarId, table.startsAt),
918
919
  index("calendar_entries_by_ical_uid").on(table.icalUid).where(sql `ical_uid IS NOT NULL`),
919
920
  index("calendar_entries_by_join_key").on(table.joinKey).where(sql `join_key IS NOT NULL`),
921
+ index("calendar_entries_by_calendar_id_series_external_id")
922
+ .on(table.calendarId, table.seriesExternalId)
923
+ .where(sql `series_external_id IS NOT NULL`),
920
924
  index("calendar_entries_by_event_id").on(table.eventId),
921
925
  index("calendar_entries_by_event_series_id").on(table.eventSeriesId),
922
926
  index("calendar_entries_by_event_type_event_id").on(table.eventType, table.eventId),