@wirecat/cli-core 0.418.0 → 0.420.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 (101) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/domain/providers.d.ts +20 -2
  3. package/dist/domain/providers.js +23 -3
  4. package/dist/domain/tag-consolidation.d.ts +30 -1
  5. package/dist/entities/index.d.ts +4 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/meetings/calendar-text.d.ts +13 -0
  9. package/dist/meetings/calendar-text.js +57 -0
  10. package/dist/meetings/event-store.d.ts +202 -0
  11. package/dist/meetings/event-store.js +1 -0
  12. package/dist/meetings/events.js +5 -1
  13. package/dist/meetings/index.d.ts +3 -0
  14. package/dist/meetings/index.js +2 -0
  15. package/dist/meetings/join-key.d.ts +7 -0
  16. package/dist/meetings/join-key.js +38 -0
  17. package/dist/meetings/model.d.ts +8 -2
  18. package/dist/meetings/read-answers.d.ts +6 -2
  19. package/dist/meetings/read-answers.js +9 -2
  20. package/dist/meetings/store.d.ts +2 -0
  21. package/dist/meetings/testing/event-store-contract.d.ts +10 -0
  22. package/dist/meetings/testing/event-store-contract.js +161 -0
  23. package/dist/meetings/testing/index.d.ts +1 -0
  24. package/dist/meetings/testing/index.js +1 -0
  25. package/dist/parity/bin.js +3 -1
  26. package/dist/search/account-list.js +39 -7
  27. package/dist/services/agenda.js +5 -5
  28. package/dist/services/document-source.d.ts +7 -3
  29. package/dist/services/document-source.js +24 -12
  30. package/dist/services/documents/ingestion.d.ts +6 -1
  31. package/dist/services/documents/ingestion.js +4 -0
  32. package/dist/services/notes-search.js +3 -2
  33. package/dist/services/search-all.js +1 -0
  34. package/dist/services/task-tag-filters.d.ts +3 -0
  35. package/dist/services/task-tag-filters.js +8 -0
  36. package/dist/services/tasks.js +5 -9
  37. package/dist/store/document-transfer/index.js +12 -2
  38. package/dist/store/index.d.ts +2 -1
  39. package/dist/store/sqlite/accounts.js +1 -0
  40. package/dist/store/sqlite/calendar.d.ts +3 -0
  41. package/dist/store/sqlite/calendar.js +593 -0
  42. package/dist/store/sqlite/catalog.js +16 -5
  43. package/dist/store/sqlite/chat-rekey.js +1 -1
  44. package/dist/store/sqlite/chats.js +2 -2
  45. package/dist/store/sqlite/context-document-source.js +8 -4
  46. package/dist/store/sqlite/document-transfer/apply.js +24 -2
  47. package/dist/store/sqlite/document-transfer/collection.d.ts +19 -0
  48. package/dist/store/sqlite/document-transfer/collection.js +66 -0
  49. package/dist/store/sqlite/document-transfer/plan.js +15 -8
  50. package/dist/store/sqlite/document-transfer/snapshot.js +12 -7
  51. package/dist/store/sqlite/entity-types.d.ts +5 -1
  52. package/dist/store/sqlite/entity-types.js +5 -1
  53. package/dist/store/sqlite/events.d.ts +8 -2
  54. package/dist/store/sqlite/events.js +36 -12
  55. package/dist/store/sqlite/files.d.ts +93 -0
  56. package/dist/store/sqlite/files.js +293 -0
  57. package/dist/store/sqlite/folded.d.ts +3 -0
  58. package/dist/store/sqlite/folded.js +1 -0
  59. package/dist/store/sqlite/imported-documents.js +1 -1
  60. package/dist/store/sqlite/join-keys.d.ts +8 -0
  61. package/dist/store/sqlite/join-keys.js +27 -0
  62. package/dist/store/sqlite/knowledge.js +6 -6
  63. package/dist/store/sqlite/link-reviews.d.ts +9 -0
  64. package/dist/store/sqlite/link-reviews.js +12 -0
  65. package/dist/store/sqlite/lucene-fields.js +8 -6
  66. package/dist/store/sqlite/manifest.js +11 -0
  67. package/dist/store/sqlite/meeting-selection.js +1 -1
  68. package/dist/store/sqlite/meetings.js +6 -1
  69. package/dist/store/sqlite/migrations.generated.js +143 -0
  70. package/dist/store/sqlite/note-index.d.ts +8 -1
  71. package/dist/store/sqlite/note-index.js +14 -0
  72. package/dist/store/sqlite/note-search.js +6 -5
  73. package/dist/store/sqlite/notes.d.ts +18 -1
  74. package/dist/store/sqlite/notes.js +237 -40
  75. package/dist/store/sqlite/open-store.js +4 -0
  76. package/dist/store/sqlite/person-links.js +7 -2
  77. package/dist/store/sqlite/schema/canonical-tags.d.ts +87 -0
  78. package/dist/store/sqlite/schema/canonical-tags.js +8 -0
  79. package/dist/store/sqlite/schema/tag-consolidations.d.ts +270 -0
  80. package/dist/store/sqlite/schema/tag-consolidations.js +34 -0
  81. package/dist/store/sqlite/schema.d.ts +3589 -1634
  82. package/dist/store/sqlite/schema.js +191 -13
  83. package/dist/store/sqlite/tag-consolidation-apply.d.ts +3 -0
  84. package/dist/store/sqlite/tag-consolidation-apply.js +88 -0
  85. package/dist/store/sqlite/tag-consolidation-input.d.ts +16 -0
  86. package/dist/store/sqlite/tag-consolidation-input.js +52 -0
  87. package/dist/store/sqlite/tag-consolidation.js +51 -66
  88. package/dist/store/sqlite/tag-creation.js +17 -8
  89. package/dist/store/sqlite/tag-resolution.d.ts +5 -0
  90. package/dist/store/sqlite/tag-resolution.js +33 -0
  91. package/dist/store/sqlite/tags.d.ts +3 -1
  92. package/dist/store/sqlite/tags.js +70 -50
  93. package/dist/store/sqlite/tasks.d.ts +2 -0
  94. package/dist/store/sqlite/tasks.js +6 -1
  95. package/dist/store/sqlite/things.js +2 -1
  96. package/dist/store/sqlite/vocabulary.d.ts +1 -0
  97. package/dist/store/sqlite/vocabulary.js +30 -8
  98. package/dist/store/store.d.ts +6 -0
  99. package/dist/store/store.js +10 -0
  100. package/dist/store/tags.js +2 -2
  101. package/package.json +2 -1
@@ -0,0 +1,593 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { calendarText, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
3
+ import { bool, eventOf, fromJson, int, str, toJson } from "./events.js";
4
+ import { logFileLink } from "./files.js";
5
+ import { identityOf } from "./identities.js";
6
+ import { reviewLink } from "./link-reviews.js";
7
+ import { read, write } from "./transaction.js";
8
+ const calendarOf = (row) => ({
9
+ id: Number(row.id),
10
+ accountId: Number(row.account_id),
11
+ externalId: String(row.external_id),
12
+ title: str(row.title),
13
+ timezone: str(row.timezone),
14
+ accessRole: str(row.access_role),
15
+ primary: Number(row.primary_calendar) === 1,
16
+ synced: Number(row.synced) === 1,
17
+ seenAt: int(row.seen_at),
18
+ deletedAt: int(row.deleted_at),
19
+ });
20
+ const entryOf = (row) => ({
21
+ id: Number(row.id),
22
+ calendarId: Number(row.calendar_id),
23
+ kind: row.kind,
24
+ seriesEntryId: int(row.series_entry_id),
25
+ eventId: int(row.event_id),
26
+ eventSeriesId: int(row.event_series_id),
27
+ externalId: String(row.external_id),
28
+ icalUid: str(row.ical_uid),
29
+ originalStartsAt: int(row.original_starts_at),
30
+ exception: Number(row.exception) === 1,
31
+ status: row.status,
32
+ title: str(row.title),
33
+ description: str(row.description),
34
+ location: str(row.location),
35
+ startsAt: int(row.starts_at),
36
+ endsAt: int(row.ends_at),
37
+ allDay: Number(row.all_day) === 1,
38
+ startDate: str(row.start_date),
39
+ endDate: str(row.end_date),
40
+ timezone: str(row.timezone),
41
+ recurrence: fromJson(row.recurrence),
42
+ recurrenceEndsAt: int(row.recurrence_ends_at),
43
+ eventType: str(row.event_type),
44
+ ownResponse: str(row.own_response),
45
+ organizerIdentityId: int(row.organizer_identity_id),
46
+ joinUrl: str(row.join_url),
47
+ joinKey: str(row.join_key),
48
+ url: str(row.url),
49
+ externalUpdatedAt: int(row.external_updated_at),
50
+ seenAt: int(row.seen_at),
51
+ deletedAt: int(row.deleted_at),
52
+ });
53
+ const participantOf = (row) => ({
54
+ entryId: Number(row.entry_id),
55
+ identityId: int(row.identity_id),
56
+ email: String(row.email),
57
+ name: str(row.name),
58
+ response: str(row.response),
59
+ optional: bool(row.optional) === true,
60
+ organizer: bool(row.organizer) === true,
61
+ self: bool(row.self) === true,
62
+ resource: bool(row.resource) === true,
63
+ });
64
+ const revisionOf = (row) => ({
65
+ entryId: Number(row.entry_id),
66
+ title: str(row.title),
67
+ startsAt: int(row.starts_at),
68
+ endsAt: int(row.ends_at),
69
+ location: str(row.location),
70
+ status: str(row.status),
71
+ recurrence: fromJson(row.recurrence),
72
+ recurrenceEndsAt: int(row.recurrence_ends_at),
73
+ externalUpdatedAt: int(row.external_updated_at),
74
+ createdAt: Number(row.created_at),
75
+ });
76
+ const reviewOf = (row) => ({
77
+ id: Number(row.id),
78
+ linkableType: String(row.linkable_type),
79
+ linkableId: Number(row.linkable_id),
80
+ entityType: String(row.entity_type),
81
+ candidates: fromJson(row.candidates),
82
+ reason: String(row.reason),
83
+ status: row.status,
84
+ chosenId: int(row.chosen_id),
85
+ decidedBy: str(row.decided_by),
86
+ decidedAt: int(row.decided_at),
87
+ createdAt: Number(row.created_at),
88
+ });
89
+ const TRACKED = ["title", "starts_at", "ends_at", "location", "status", "recurrence", "recurrence_ends_at"];
90
+ const flag = (value) => (value ? 1 : 0);
91
+ const addressOf = (email) => email.trim().toLowerCase();
92
+ /** The `email` contact of an address: the same one mail uses, made when missing; it gets a name only when new. */
93
+ const contactOf = (context, email, name) => {
94
+ const address = addressOf(email);
95
+ if (!/^[^\s@]+@[^\s@]+$/u.test(address))
96
+ return null;
97
+ const found = context.database
98
+ .prepare("SELECT id FROM identities WHERE provider = 'email' AND external_id = ?")
99
+ .get(address);
100
+ return found ? Number(found.id) : identityOf(context, "email", address, name ?? null);
101
+ };
102
+ const logLink = (database, { type, id, entity, from, to, method, author, now }) => {
103
+ database
104
+ .prepare("INSERT INTO entity_link_events (linkable_type, linkable_id, entity_type, from_entity_id, to_entity_id, method, " +
105
+ "confidence, author, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
106
+ .run(type, id, entity, from, to, method, author === "owner" ? 1 : null, author, now);
107
+ };
108
+ /** The owner's last word on a source record: once they unlinked it, no rule links it again. */
109
+ const ownerUnlinked = (database, type, id) => {
110
+ const last = database
111
+ .prepare("SELECT to_entity_id FROM entity_link_events WHERE linkable_type = ? AND linkable_id = ? AND author = 'owner' " +
112
+ "ORDER BY id DESC LIMIT 1")
113
+ .get(type, id);
114
+ return last !== undefined && last.to_entity_id == null;
115
+ };
116
+ const distinctIds = (rows, column = "id") => [...new Set(rows.map((row) => Number(row[column])))];
117
+ const overlap = "starts_at < ? AND coalesce(ends_at, starts_at + 1) > ?";
118
+ /** The entry's time as an interval that is never empty, so a point in time still overlaps what it falls in. */
119
+ const spanOf = (entry) => {
120
+ const start = entry.startsAt ?? 0;
121
+ return { start, end: Math.max(entry.endsAt ?? start, start + 1) };
122
+ };
123
+ export const eventStoreOver = (context) => {
124
+ const { database } = context;
125
+ const entryRow = (id) => database.prepare("SELECT * FROM calendar_entries WHERE id = ?").get(id);
126
+ const calendarRow = (id) => {
127
+ const row = database.prepare("SELECT * FROM calendars WHERE id = ?").get(id);
128
+ if (!row)
129
+ throw new CliError("not_found", `no calendar ${id} in the local store`);
130
+ return row;
131
+ };
132
+ /** Rule 3, then rule 4: the entities this entry may be a copy of. */
133
+ const candidatesFor = (entry) => {
134
+ const entity = entry.kind === "series" ? "event_series_id" : "event_id";
135
+ if (entry.icalUid) {
136
+ const rows = database
137
+ .prepare(`SELECT DISTINCT ${entity} AS id FROM calendar_entries WHERE ical_uid = ? AND calendar_id <> ? AND kind = ?
138
+ AND deleted_at IS NULL AND ${entity} IS NOT NULL AND original_starts_at IS ?`)
139
+ .all(entry.icalUid, entry.calendarId, entry.kind, entry.originalStartsAt);
140
+ if (rows.length)
141
+ return { ids: distinctIds(rows), method: "ical-uid" };
142
+ }
143
+ if (entry.joinKey) {
144
+ if (entry.kind === "series") {
145
+ const rows = database
146
+ .prepare("SELECT DISTINCT s.id, ms.join_url FROM event_series s JOIN meeting_series ms ON ms.event_series_id = s.id " +
147
+ "WHERE s.origin = 'auto' AND ms.deleted_at IS NULL AND ms.join_url IS NOT NULL")
148
+ .all()
149
+ .filter((row) => joinKeyOf(str(row.join_url)) === entry.joinKey);
150
+ return { ids: distinctIds(rows), method: "join-key" };
151
+ }
152
+ const { start, end } = spanOf(entry);
153
+ const rows = database
154
+ .prepare(`SELECT DISTINCT e.id FROM events e JOIN meetings m ON m.event_id = e.id
155
+ WHERE e.origin = 'auto' AND e.deleted_at IS NULL AND m.deleted_at IS NULL AND m.join_key = ?
156
+ AND e.${overlap}`)
157
+ .all(entry.joinKey, end, start);
158
+ return { ids: distinctIds(rows), method: "join-key" };
159
+ }
160
+ return { ids: [], method: "new" };
161
+ };
162
+ const setEntity = (entry, to, method, author, now) => {
163
+ const column = entry.kind === "series" ? "event_series_id" : "event_id";
164
+ const from = entry.kind === "series" ? entry.eventSeriesId : entry.eventId;
165
+ database.prepare(`UPDATE calendar_entries SET ${column} = ?, updated_at = ? WHERE id = ?`).run(to, now, entry.id);
166
+ logLink(database, {
167
+ type: "calendar_entry",
168
+ id: entry.id,
169
+ entity: entry.kind === "series" ? "event_series" : "event",
170
+ from,
171
+ to,
172
+ method,
173
+ author,
174
+ now,
175
+ });
176
+ };
177
+ const newEntity = (entry, now) => entry.kind === "series"
178
+ ? Number(database
179
+ .prepare("INSERT INTO event_series (title, recurrence, origin, created_at, updated_at) VALUES (?, ?, 'calendar', ?, ?) RETURNING id")
180
+ .get(entry.title, toJson(entry.recurrence), now, now)?.id)
181
+ : Number(database
182
+ .prepare("INSERT INTO events (title, starts_at, ends_at, timezone, origin, created_at, updated_at) VALUES (?, ?, ?, ?, 'calendar', ?, ?) RETURNING id")
183
+ .get(entry.title, entry.startsAt, entry.endsAt, entry.timezone, now, now)?.id);
184
+ /** Rules 3–5 for an entry with no entity yet. */
185
+ const link = (entry, now) => {
186
+ const linked = entry.kind === "series" ? entry.eventSeriesId : entry.eventId;
187
+ if (linked !== null || ownerUnlinked(database, "calendar_entry", entry.id))
188
+ return;
189
+ const entity = entry.kind === "series" ? "event_series" : "event";
190
+ const { ids, method } = candidatesFor(entry);
191
+ if (ids.length > 1)
192
+ return reviewLink(database, { type: "calendar_entry", id: entry.id }, entity, ids, method, now);
193
+ if (ids.length === 1) {
194
+ const [id] = ids;
195
+ setEntity(entry, id, method, "rules", now);
196
+ if (method === "join-key")
197
+ database
198
+ .prepare(`UPDATE ${entity === "event" ? "events" : "event_series"} SET origin = 'calendar', updated_at = ? WHERE id = ?`)
199
+ .run(now, id);
200
+ return;
201
+ }
202
+ // A slot cancelled before it was ever stored is no event.
203
+ if (entry.status === "cancelled")
204
+ return;
205
+ setEntity(entry, newEntity(entry, now), "new", "rules", now);
206
+ };
207
+ /** The event's fields from its preferred entry: the owner's copy when the owner organizes, else the latest. */
208
+ const refreshEvent = (eventId, now) => {
209
+ const entries = database
210
+ .prepare(`SELECT ce.*, EXISTS (SELECT 1 FROM calendar_entry_participants p WHERE p.entry_id = ce.id AND p.self = 1
211
+ AND p.organizer = 1) AS own FROM calendar_entries ce WHERE ce.event_id = ? AND ce.deleted_at IS NULL
212
+ ORDER BY own DESC, coalesce(ce.external_updated_at, ce.updated_at) DESC, ce.id DESC`)
213
+ .all(eventId);
214
+ const preferred = entries[0];
215
+ if (!preferred)
216
+ return;
217
+ const event = database.prepare("SELECT * FROM events WHERE id = ?").get(eventId);
218
+ const cancelled = entries.every((row) => row.status === "cancelled");
219
+ const series = preferred.series_entry_id == null ? null : entryRow(Number(preferred.series_entry_id));
220
+ const startsAt = int(preferred.starts_at) ?? int(event.starts_at);
221
+ // History: what was on the calendar and has passed stays as a meeting that happened (NEED-1764 A).
222
+ const past = startsAt !== null && startsAt < now;
223
+ database
224
+ .prepare(`UPDATE events SET title = ?, description = ?, location = ?, starts_at = ?, ends_at = ?, timezone = ?,
225
+ all_day = ?, status = ?, source_deleted_at = ?, event_series_id = coalesce(?, event_series_id), updated_at = ?
226
+ WHERE id = ?`)
227
+ .run(cancelled && past ? str(event.title) : str(preferred.title), cancelled && past ? str(event.description) : str(preferred.description), cancelled && past ? str(event.location) : str(preferred.location), cancelled && past ? int(event.starts_at) : int(preferred.starts_at), cancelled && past ? int(event.ends_at) : int(preferred.ends_at), str(preferred.timezone), Number(preferred.all_day), cancelled ? (past ? str(event.status) : "cancelled") : String(preferred.status), cancelled && past ? (int(event.source_deleted_at) ?? now) : null, series ? int(series.event_series_id) : null, now, eventId);
228
+ };
229
+ const refreshSeries = (seriesId, now) => {
230
+ const preferred = database
231
+ .prepare("SELECT * FROM calendar_entries WHERE event_series_id = ? AND deleted_at IS NULL " +
232
+ "ORDER BY coalesce(external_updated_at, updated_at) DESC, id DESC LIMIT 1")
233
+ .get(seriesId);
234
+ if (preferred)
235
+ database
236
+ .prepare("UPDATE event_series SET title = ?, recurrence = ?, updated_at = ? WHERE id = ?")
237
+ .run(str(preferred.title), str(preferred.recurrence), now, seriesId);
238
+ };
239
+ const saveParticipants = (entryId, participants, now) => {
240
+ const kept = new Set();
241
+ const upsert = database.prepare(`INSERT INTO calendar_entry_participants (entry_id, identity_id, email, name, response, optional, organizer, self,
242
+ resource, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
243
+ ON CONFLICT (entry_id, email) DO UPDATE SET identity_id = excluded.identity_id, name = excluded.name,
244
+ response = excluded.response, optional = excluded.optional, organizer = excluded.organizer,
245
+ self = excluded.self, resource = excluded.resource, updated_at = excluded.updated_at`);
246
+ for (const one of participants) {
247
+ const email = addressOf(one.email);
248
+ if (!email || kept.has(email))
249
+ continue;
250
+ kept.add(email);
251
+ upsert.run(entryId, one.resource ? null : contactOf(context, email, one.name), email, one.name ?? null, one.response ?? null, flag(one.optional), flag(one.organizer), flag(one.self), flag(one.resource), now, now);
252
+ }
253
+ const stale = database
254
+ .prepare("SELECT email FROM calendar_entry_participants WHERE entry_id = ?")
255
+ .all(entryId)
256
+ .map((row) => String(row.email))
257
+ .filter((email) => !kept.has(email));
258
+ const drop = database.prepare("DELETE FROM calendar_entry_participants WHERE entry_id = ? AND email = ?");
259
+ for (const email of stale)
260
+ drop.run(entryId, email);
261
+ };
262
+ /** Rules 1 and 2: the row this entry already is, if any. */
263
+ const existingFor = (calendarId, input, seriesEntryId) => {
264
+ const same = database
265
+ .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id = ?")
266
+ .get(calendarId, input.externalId);
267
+ if (same || input.kind !== "occurrence" || input.originalStartsAt == null)
268
+ return same;
269
+ if (seriesEntryId !== null)
270
+ return database
271
+ .prepare("SELECT * FROM calendar_entries WHERE series_entry_id = ? AND original_starts_at = ?")
272
+ .get(seriesEntryId, input.originalStartsAt);
273
+ return database
274
+ .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND series_entry_id IS NULL AND original_starts_at = ? " +
275
+ "AND json_extract(metadata, '$.recurring_event_id') = ?")
276
+ .get(calendarId, input.originalStartsAt, input.seriesExternalId ?? null);
277
+ };
278
+ const saveEntry = (calendarId, input, now) => {
279
+ if (input.startsAt != null && input.endsAt != null && input.endsAt < input.startsAt)
280
+ throw new CliError("validation_error", `calendar entry ${input.externalId} ends before it starts`);
281
+ const series = input.kind === "occurrence" && input.seriesExternalId
282
+ ? database
283
+ .prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id = ? AND kind = 'series'")
284
+ .get(calendarId, input.seriesExternalId)
285
+ : undefined;
286
+ const seriesEntryId = series ? Number(series.id) : null;
287
+ const joinUrl = joinUrlWithoutQuery(input.joinUrl);
288
+ const values = {
289
+ kind: input.kind,
290
+ series_entry_id: seriesEntryId,
291
+ external_id: input.externalId,
292
+ ical_uid: input.icalUid ?? null,
293
+ original_starts_at: input.originalStartsAt ?? null,
294
+ exception: flag(input.exception),
295
+ status: input.status,
296
+ title: calendarText(input.title, { dropSecretLines: false }),
297
+ description: calendarText(input.description),
298
+ location: calendarText(input.location),
299
+ starts_at: input.startsAt ?? null,
300
+ ends_at: input.endsAt ?? null,
301
+ all_day: flag(input.allDay),
302
+ start_date: input.startDate ?? null,
303
+ end_date: input.endDate ?? null,
304
+ timezone: input.timezone ?? null,
305
+ recurrence: toJson(input.recurrence ?? null),
306
+ recurrence_ends_at: input.recurrenceEndsAt ?? null,
307
+ event_type: input.eventType ?? null,
308
+ transparency: input.transparency ?? null,
309
+ visibility: input.visibility ?? null,
310
+ own_response: input.ownResponse ?? null,
311
+ organizer_identity_id: input.organizer ? contactOf(context, input.organizer.email, input.organizer.name) : null,
312
+ join_url: joinUrl,
313
+ join_key: joinUrl ? (joinKeyOf(joinUrl) ?? "") : null,
314
+ conference: input.conference ?? null,
315
+ // The calendar's own page of the event names it in the query (`eid`), which opens nothing on its own.
316
+ url: input.url ?? null,
317
+ sequence: input.sequence ?? null,
318
+ etag: input.etag ?? null,
319
+ external_updated_at: input.externalUpdatedAt ?? null,
320
+ metadata: toJson({
321
+ ...entryMetadata(input.metadata),
322
+ ...(input.kind === "occurrence" && input.seriesExternalId
323
+ ? { recurring_event_id: input.seriesExternalId }
324
+ : {}),
325
+ }),
326
+ seen_at: now,
327
+ deleted_at: null,
328
+ };
329
+ const found = existingFor(calendarId, input, seriesEntryId);
330
+ let id;
331
+ if (found) {
332
+ id = Number(found.id);
333
+ if (TRACKED.some((key) => found[key] !== values[key]))
334
+ database
335
+ .prepare(`INSERT INTO calendar_entry_revisions (entry_id, title, starts_at, ends_at, location, status, recurrence,
336
+ recurrence_ends_at, external_updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
337
+ .run(id, str(found.title), int(found.starts_at), int(found.ends_at), str(found.location), str(found.status), str(found.recurrence), int(found.recurrence_ends_at), int(found.external_updated_at), now);
338
+ // Rule 2 found the slot's row under another id; rule 1 found none under this one, so it is free to take.
339
+ database
340
+ .prepare(`UPDATE calendar_entries SET ${Object.keys(values)
341
+ .map((key) => `${key} = ?`)
342
+ .join(", ")}, updated_at = ? WHERE id = ?`)
343
+ .run(...Object.values(values), now, id);
344
+ }
345
+ else {
346
+ const columns = Object.keys(values);
347
+ id = Number(database
348
+ .prepare(`INSERT INTO calendar_entries (calendar_id, ${columns.join(", ")}, created_at, updated_at)
349
+ VALUES (?, ${columns.map(() => "?").join(", ")}, ?, ?) RETURNING id`)
350
+ .get(calendarId, ...Object.values(values), now, now)?.id);
351
+ }
352
+ if (input.participants)
353
+ saveParticipants(id, input.participants, now);
354
+ if (input.kind === "series")
355
+ database
356
+ .prepare("UPDATE calendar_entries SET series_entry_id = ?, updated_at = ? WHERE calendar_id = ? AND kind = 'occurrence' " +
357
+ "AND series_entry_id IS NULL AND json_extract(metadata, '$.recurring_event_id') = ?")
358
+ .run(id, now, calendarId, input.externalId);
359
+ return entryOf(entryRow(id));
360
+ };
361
+ /** A series cancelled at the provider cancels its occurrences: past ones stay as history, future ones cancel. */
362
+ const cancelOccurrences = (series, now) => {
363
+ const occurrences = database
364
+ .prepare("SELECT * FROM calendar_entries WHERE series_entry_id = ? AND status <> 'cancelled' AND deleted_at IS NULL")
365
+ .all(series.id);
366
+ for (const row of occurrences) {
367
+ database
368
+ .prepare(`INSERT INTO calendar_entry_revisions (entry_id, title, starts_at, ends_at, location, status, recurrence,
369
+ recurrence_ends_at, external_updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`)
370
+ .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);
371
+ database
372
+ .prepare("UPDATE calendar_entries SET status = 'cancelled', updated_at = ? WHERE id = ?")
373
+ .run(now, Number(row.id));
374
+ }
375
+ };
376
+ return {
377
+ saveCalendars: async (accountId, calendars, now) => write(database, () => {
378
+ const upsert = database.prepare(`INSERT INTO calendars (account_id, external_id, title, timezone, access_role, primary_calendar, synced, seen_at,
379
+ created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
380
+ ON CONFLICT (account_id, external_id) DO UPDATE SET title = excluded.title, timezone = excluded.timezone,
381
+ access_role = excluded.access_role, primary_calendar = excluded.primary_calendar, seen_at = excluded.seen_at,
382
+ deleted_at = NULL, updated_at = excluded.updated_at
383
+ RETURNING *`);
384
+ 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)));
385
+ }),
386
+ calendars: async (accountId) => database
387
+ .prepare("SELECT * FROM calendars WHERE account_id = ? ORDER BY primary_calendar DESC, title, id")
388
+ .all(accountId)
389
+ .map(calendarOf),
390
+ setCalendarSynced: async (calendarId, synced, now) => write(database, () => {
391
+ calendarRow(calendarId);
392
+ database
393
+ .prepare("UPDATE calendars SET synced = ?, updated_at = ? WHERE id = ?")
394
+ .run(flag(synced), now, calendarId);
395
+ }),
396
+ saveEntries: async (calendarId, entries, now) => write(database, () => {
397
+ calendarRow(calendarId);
398
+ // A series first, so an occurrence in the same page finds it.
399
+ const ordered = [...entries].sort((a, b) => Number(b.kind === "series") - Number(a.kind === "series"));
400
+ const ids = new Map();
401
+ const saved = ordered.map((input) => {
402
+ const entry = saveEntry(calendarId, input, now);
403
+ ids.set(input, entry.id);
404
+ return entry;
405
+ });
406
+ for (const entry of saved)
407
+ if (entry.kind === "series" && entry.status === "cancelled")
408
+ cancelOccurrences(entry, now);
409
+ const current = saved.map(({ id }) => entryOf(entryRow(id)));
410
+ for (const entry of current)
411
+ link(entry, now);
412
+ const touched = database
413
+ .prepare(`SELECT DISTINCT ce.event_id, ce.event_series_id FROM calendar_entries ce
414
+ WHERE ce.id IN (SELECT value FROM json_each(?)) OR ce.series_entry_id IN (SELECT value FROM json_each(?))`)
415
+ .all(JSON.stringify(current.map(({ id }) => id)), JSON.stringify(current.map(({ id }) => id)));
416
+ for (const row of touched) {
417
+ if (row.event_series_id != null)
418
+ refreshSeries(Number(row.event_series_id), now);
419
+ if (row.event_id != null)
420
+ refreshEvent(Number(row.event_id), now);
421
+ }
422
+ return entries.map((input) => entryOf(entryRow(ids.get(input))));
423
+ }),
424
+ markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => database
425
+ .prepare(`UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE calendar_id = ? AND deleted_at IS NULL
426
+ AND (seen_at IS NULL OR seen_at < ?)
427
+ AND (kind <> 'occurrence' OR exception = 1 OR (original_starts_at >= ? AND original_starts_at < ?))`)
428
+ .run(now, now, calendarId, seenBefore, window.from, window.to).changes),
429
+ events: async (filter = {}) => {
430
+ const clauses = ["e.deleted_at IS NULL"];
431
+ const params = [];
432
+ const entries = (extra) => `EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id AND ${extra})`;
433
+ if (filter.from !== undefined) {
434
+ clauses.push("coalesce(e.ends_at, e.starts_at + 1) > ?");
435
+ params.push(filter.from);
436
+ }
437
+ if (filter.to !== undefined) {
438
+ clauses.push("e.starts_at < ?");
439
+ params.push(filter.to);
440
+ }
441
+ if (filter.calendarId !== undefined) {
442
+ clauses.push(entries("ce.calendar_id = ?"));
443
+ params.push(filter.calendarId);
444
+ }
445
+ if (filter.accountId !== undefined) {
446
+ clauses.push(entries("c.account_id = ?"));
447
+ params.push(filter.accountId);
448
+ }
449
+ if (filter.personId !== undefined) {
450
+ clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
451
+ "WHERE p.entry_id = ce.id AND il.person_id = ?)"));
452
+ params.push(filter.personId);
453
+ }
454
+ const limit = filter.limit ?? 100;
455
+ const offset = filter.offset ?? 0;
456
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
457
+ throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
458
+ return database
459
+ .prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY e.starts_at, e.id LIMIT ? OFFSET ?`)
460
+ .all(...params, limit, offset)
461
+ .map(eventOf);
462
+ },
463
+ event: async (id) => read(database, () => {
464
+ const row = database.prepare("SELECT * FROM events WHERE id = ?").get(id);
465
+ if (!row)
466
+ return null;
467
+ const entries = database
468
+ .prepare("SELECT * FROM calendar_entries WHERE event_id = ? ORDER BY id")
469
+ .all(id)
470
+ .map(entryOf);
471
+ const ids = JSON.stringify(entries.map((entry) => entry.id));
472
+ return {
473
+ event: eventOf(row),
474
+ entries,
475
+ participants: database
476
+ .prepare("SELECT * FROM calendar_entry_participants WHERE entry_id IN (SELECT value FROM json_each(?)) ORDER BY entry_id, email")
477
+ .all(ids)
478
+ .map(participantOf),
479
+ revisions: database
480
+ .prepare("SELECT * FROM calendar_entry_revisions WHERE entry_id IN (SELECT value FROM json_each(?)) ORDER BY created_at, id")
481
+ .all(ids)
482
+ .map(revisionOf),
483
+ };
484
+ }),
485
+ linkEntry: async (entryId, eventId, now) => write(database, () => {
486
+ const row = database.prepare("SELECT * FROM calendar_entries WHERE id = ?").get(entryId);
487
+ if (!row)
488
+ throw new CliError("not_found", `no calendar entry ${entryId} in the local store`);
489
+ const entry = entryOf(row);
490
+ const table = entry.kind === "series" ? "event_series" : "events";
491
+ if (!database.prepare(`SELECT 1 FROM ${table} WHERE id = ?`).get(eventId))
492
+ throw new CliError("not_found", `no ${entry.kind === "series" ? "event series" : "event"} ${eventId}`);
493
+ setEntity(entry, eventId, "owner", "owner", now);
494
+ if (entry.kind !== "series")
495
+ refreshEvent(eventId, now);
496
+ }),
497
+ unlinkEntry: async (entryId, now) => write(database, () => {
498
+ const row = database.prepare("SELECT * FROM calendar_entries WHERE id = ?").get(entryId);
499
+ if (!row)
500
+ throw new CliError("not_found", `no calendar entry ${entryId} in the local store`);
501
+ const entry = entryOf(row);
502
+ const column = entry.kind === "series" ? "event_series_id" : "event_id";
503
+ database.prepare(`UPDATE calendar_entries SET ${column} = NULL, updated_at = ? WHERE id = ?`).run(now, entryId);
504
+ logLink(database, {
505
+ type: "calendar_entry",
506
+ id: entryId,
507
+ entity: entry.kind === "series" ? "event_series" : "event",
508
+ from: entry.kind === "series" ? entry.eventSeriesId : entry.eventId,
509
+ to: null,
510
+ method: "owner",
511
+ author: "owner",
512
+ now,
513
+ });
514
+ }),
515
+ reviews: async (filter = {}) => database
516
+ .prepare(`SELECT * FROM link_reviews${filter.status ? " WHERE status = ?" : ""} ORDER BY created_at, id`)
517
+ .all(...(filter.status ? [filter.status] : []))
518
+ .map(reviewOf),
519
+ resolveReview: async (id, decision, now) => write(database, () => {
520
+ const row = database.prepare("SELECT * FROM link_reviews WHERE id = ?").get(id);
521
+ if (!row)
522
+ throw new CliError("not_found", `no review ${id} in the local store`);
523
+ const review = reviewOf(row);
524
+ if (review.status !== "open")
525
+ throw new CliError("validation_error", `review ${id} is already ${review.status}`);
526
+ if ("choose" in decision && !review.candidates.includes(decision.choose))
527
+ throw new CliError("validation_error", `${decision.choose} is not one of review ${id}'s candidates`);
528
+ const chosen = "choose" in decision ? decision.choose : null;
529
+ if (chosen !== null && review.linkableType === "calendar_entry") {
530
+ setEntity(entryOf(entryRow(review.linkableId)), chosen, "owner", "owner", now);
531
+ if (review.entityType === "event")
532
+ refreshEvent(chosen, now);
533
+ }
534
+ if (chosen !== null && review.linkableType === "meeting") {
535
+ database
536
+ .prepare("UPDATE meetings SET event_id = ?, updated_at = ? WHERE id = ?")
537
+ .run(chosen, now, review.linkableId);
538
+ logLink(database, {
539
+ type: "meeting",
540
+ id: review.linkableId,
541
+ entity: "event",
542
+ from: null,
543
+ to: chosen,
544
+ method: "owner",
545
+ author: "owner",
546
+ now,
547
+ });
548
+ }
549
+ if (chosen !== null && review.linkableType === "file") {
550
+ // The document made for the file while the choice was open goes when nothing else holds it.
551
+ const file = database.prepare("SELECT document_id FROM files WHERE id = ?").get(review.linkableId);
552
+ const made = file?.document_id == null ? null : Number(file.document_id);
553
+ database
554
+ .prepare("UPDATE files SET document_id = ?, updated_at = ? WHERE id = ?")
555
+ .run(chosen, now, review.linkableId);
556
+ logFileLink(database, now, review.linkableId, made, chosen, "owner", "owner");
557
+ if (made !== null && made !== chosen)
558
+ database
559
+ .prepare("UPDATE documents SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL AND NOT EXISTS " +
560
+ "(SELECT 1 FROM files WHERE document_id = ? AND deleted_at IS NULL)")
561
+ .run(now, now, made, made);
562
+ }
563
+ if (chosen === null && review.linkableType === "calendar_entry") {
564
+ const entry = entryOf(entryRow(review.linkableId));
565
+ setEntity(entry, newEntity(entry, now), "owner", "owner", now);
566
+ }
567
+ if (chosen === null && review.linkableType === "meeting") {
568
+ const event = Number(database
569
+ .prepare(`INSERT INTO events (title, description, location, starts_at, ends_at, timezone, origin, created_at, updated_at)
570
+ SELECT title, description, location, started_at, ended_at, timezone, 'auto', ?, ? FROM meetings WHERE id = ?
571
+ RETURNING id`)
572
+ .get(now, now, review.linkableId)?.id);
573
+ database
574
+ .prepare("UPDATE meetings SET event_id = ?, updated_at = ? WHERE id = ?")
575
+ .run(event, now, review.linkableId);
576
+ logLink(database, {
577
+ type: "meeting",
578
+ id: review.linkableId,
579
+ entity: "event",
580
+ from: null,
581
+ to: event,
582
+ method: "owner",
583
+ author: "owner",
584
+ now,
585
+ });
586
+ }
587
+ database
588
+ .prepare("UPDATE link_reviews SET status = ?, chosen_id = ?, decided_by = 'owner', decided_at = ?, updated_at = ? WHERE id = ?")
589
+ .run(chosen === null ? "apart" : "linked", chosen, now, now, id);
590
+ return reviewOf(database.prepare("SELECT * FROM link_reviews WHERE id = ?").get(id));
591
+ }),
592
+ };
593
+ };
@@ -2,6 +2,7 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { defineQuery, validateQuery, walkQuery } from "@wirecat/cli-base/query";
3
3
  import { normalizeTag } from "../../domain/tags.js";
4
4
  import { fold } from "../normalize.js";
5
+ import { syncFile } from "./files.js";
5
6
  import { queryFilter } from "./query-filter.js";
6
7
  import { canonicalTagNamed } from "./tags.js";
7
8
  import { write } from "./transaction.js";
@@ -169,6 +170,9 @@ export const catalogStoreOver = ({ database, now }) => {
169
170
  .prepare("INSERT INTO documents(account_id,external_id,kind,title,url,mime,size,external_created_at,external_updated_at,metadata,front_matter,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")
170
171
  .run(id, input.externalId, input.kind, input.name, input.url ?? null, input.mime ?? null, input.size ?? null, created, modified, JSON.stringify(metadata), JSON.stringify(frontMatter), at, at);
171
172
  }
173
+ const written = database.prepare("SELECT id FROM documents WHERE account_id = ? AND external_id = ?");
174
+ for (const { input } of checked)
175
+ syncFile(database, at, Number(written.get(id, input.externalId)?.id));
172
176
  });
173
177
  },
174
178
  reconcile: async (accountId, externalIds) => {
@@ -176,10 +180,17 @@ export const catalogStoreOver = ({ database, now }) => {
176
180
  if (externalIds.length > 10000)
177
181
  throw new CliError("validation_error", "catalog scope exceeds 10000 entries");
178
182
  const current = [...new Set(externalIds.map((key) => field(key)))];
179
- return write(database, () => database
180
- .prepare("UPDATE documents SET metadata=json_set(metadata, '$.catalog.active', json('false')) WHERE account_id=? AND json_type(metadata, '$.catalog')='object' AND json_extract(metadata, '$.catalog.active')=1" +
181
- (current.length ? " AND external_id NOT IN (SELECT value FROM json_each(?))" : ""))
182
- .run(...(current.length ? [id, JSON.stringify(current)] : [id])).changes);
183
+ return write(database, () => {
184
+ const retired = database
185
+ .prepare("UPDATE documents SET metadata=json_set(metadata, '$.catalog.active', json('false')) WHERE account_id=? AND json_type(metadata, '$.catalog')='object' AND json_extract(metadata, '$.catalog.active')=1" +
186
+ (current.length ? " AND external_id NOT IN (SELECT value FROM json_each(?))" : "") +
187
+ " RETURNING id")
188
+ .all(...(current.length ? [id, JSON.stringify(current)] : [id]));
189
+ const at = now();
190
+ for (const row of retired)
191
+ syncFile(database, at, Number(row.id), { seen: false });
192
+ return retired.length;
193
+ });
183
194
  },
184
195
  get: async (accountId, externalId, includeInactive = false) => {
185
196
  const id = account(accountId);
@@ -205,7 +216,7 @@ export const catalogStoreOver = ({ database, now }) => {
205
216
  params.push(...values);
206
217
  };
207
218
  const actorSql = "json_each(d.metadata, '$.catalog.folded.people')";
208
- const tagSql = "SELECT 1 FROM taggings g JOIN tags t ON t.id=g.tag_id WHERE g.taggable_type='document' AND g.taggable_id=d.id AND t.name=?";
219
+ const tagSql = "SELECT 1 FROM taggings g JOIN resolved_tags t ON t.id=g.tag_id WHERE g.taggable_type='document' AND g.taggable_id=d.id AND t.name=?";
209
220
  const tag = (name) => canonicalTagNamed(database, name)?.name ?? normalizeTag(name);
210
221
  if (query.parent !== undefined)
211
222
  add("EXISTS(SELECT 1 FROM json_each(d.metadata, '$.catalog.parents') WHERE value=?)", query.parent);
@@ -127,7 +127,7 @@ const absorb = (context, kept, gone) => {
127
127
  "member_stays",
128
128
  "member_counts",
129
129
  "member_observations",
130
- "auto_tag_claims",
130
+ "canonical_auto_tag_claims",
131
131
  ])
132
132
  database.prepare(`UPDATE OR IGNORE ${table} SET chat_id = ? WHERE chat_id = ?`).run(kept, gone);
133
133
  // Moved members fire no insert trigger, so the chat's involvements are queued by hand.