@wirecat/cli-core 0.452.0 → 0.453.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 (48) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli/messenger/search-answers.d.ts +1 -1
  3. package/dist/cli/messenger/search-answers.js +1 -1
  4. package/dist/cli/messenger/search-command.js +6 -3
  5. package/dist/entities/contact-context.d.ts +42 -0
  6. package/dist/entities/contact-context.js +11 -0
  7. package/dist/entities/hit.d.ts +3 -3
  8. package/dist/entities/hit.js +3 -3
  9. package/dist/entities/index.d.ts +61 -21
  10. package/dist/entities/task-results.d.ts +20 -20
  11. package/dist/entities/task-results.js +3 -15
  12. package/dist/meetings/event-store.d.ts +23 -0
  13. package/dist/meetings/index.d.ts +1 -1
  14. package/dist/search/query.js +1 -3
  15. package/dist/services/events-search.d.ts +67 -0
  16. package/dist/services/events-search.js +131 -0
  17. package/dist/services/index.d.ts +3 -2
  18. package/dist/services/index.js +2 -1
  19. package/dist/services/notes-search.d.ts +25 -1
  20. package/dist/services/notes-search.js +24 -11
  21. package/dist/services/person-context.d.ts +20 -1
  22. package/dist/services/person-context.js +46 -1
  23. package/dist/services/search-all-meetings.d.ts +1 -1
  24. package/dist/services/search-all-meetings.js +7 -2
  25. package/dist/services/search-all.d.ts +12 -7
  26. package/dist/services/search-all.js +39 -4
  27. package/dist/store/index.d.ts +1 -0
  28. package/dist/store/index.js +1 -0
  29. package/dist/store/project-transfer/request.d.ts +1 -1
  30. package/dist/store/project-transfer/schema.d.ts +2 -2
  31. package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
  32. package/dist/store/sqlite/calendar.js +85 -11
  33. package/dist/store/sqlite/event-search.d.ts +66 -0
  34. package/dist/store/sqlite/event-search.js +163 -0
  35. package/dist/store/sqlite/files.d.ts +4 -0
  36. package/dist/store/sqlite/files.js +28 -0
  37. package/dist/store/sqlite/link-kinds.d.ts +5 -1
  38. package/dist/store/sqlite/link-kinds.js +5 -0
  39. package/dist/store/sqlite/note-index.d.ts +17 -1
  40. package/dist/store/sqlite/note-index.js +32 -8
  41. package/dist/store/sqlite/note-search.d.ts +43 -0
  42. package/dist/store/sqlite/note-search.js +24 -24
  43. package/dist/store/sqlite/notes.d.ts +4 -5
  44. package/dist/store/sqlite/notes.js +2 -2
  45. package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
  46. package/dist/store/store.d.ts +2 -1
  47. package/dist/store/store.js +2 -1
  48. package/package.json +1 -1
@@ -5,16 +5,16 @@ import type { AccountKey, MessageStore } from "../store/store.js";
5
5
  import type { SearchFound, SearchQuery } from "./messages.js";
6
6
  import { type MeaningLane, type MeaningSkipped } from "./messages-discovery.js";
7
7
  import type { Backend, ServerOptions, ServerSearched } from "./server-search.js";
8
- export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes"];
8
+ export declare const RESOURCES_SEARCHED: readonly ["messages", "mail", "notes", "events"];
9
9
  export type SearchedResource = (typeof RESOURCES_SEARCHED)[number];
10
10
  export interface SearchAllItem {
11
- kind: "message" | "mail" | "note" | "document";
12
- /** `msg:…` for a message or a mail, `note:…` for a note. */
11
+ kind: "message" | "mail" | "note" | "document" | "event";
12
+ /** `msg:…` for a message or a mail, `note:…` for a note, `event:…` for a calendar event. */
13
13
  ref: string;
14
14
  provider: string;
15
15
  /** The account it was found in; a note's folder id, or `internal` for a note written in memo. */
16
16
  account: string;
17
- /** The chat, the mail thread's subject or the note's title. */
17
+ /** The chat, the mail thread's subject, the note's or the event's title. */
18
18
  url?: string;
19
19
  title: string | null;
20
20
  timestamp: string;
@@ -63,6 +63,11 @@ export interface SearchAllFound {
63
63
  by: "words" | "words and meaning";
64
64
  meaningSkipped?: string;
65
65
  };
66
+ /** How calendar events were searched, when they were. */
67
+ events?: {
68
+ by: "words" | "words and meaning";
69
+ meaningSkipped?: string;
70
+ };
66
71
  /** How the messages were searched, when they were; never by meaning with `exact` or the server alone. */
67
72
  messages?: {
68
73
  by: "words" | "words and meaning";
@@ -79,7 +84,7 @@ export interface SearchAllRequest {
79
84
  only?: readonly SearchedResource[];
80
85
  exact?: boolean;
81
86
  timezone?: string;
82
- /** For messages only; mail and notes are always the local store's. */
87
+ /** For messages only; mail, notes and events are always the local store's. */
83
88
  backend?: Backend;
84
89
  server?: ServerOptions;
85
90
  env?: NodeJS.ProcessEnv;
@@ -95,8 +100,8 @@ export type SearchAllMessenger = Partial<Pick<MessengerTraits, "savedChatId" | "
95
100
  hintCommand?: string | null;
96
101
  };
97
102
  /**
98
- * `search all`: messages, mail and notes from the local store — messages also from the messenger's
103
+ * `search all`: messages, mail, notes and calendar events from the local store — messages also from the messenger's
99
104
  * server, as `search messages` asks it — each list best first, merged by reciprocal rank so no
100
- * resource's own scores have to be compared with another's.
105
+ * resource's own scores have to be compared with another's. Events are read only when the store holds a calendar.
101
106
  */
102
107
  export declare const searchAll: (store: MessageStore, account: AccountKey, request: SearchAllRequest, messenger?: SearchAllMessenger, searchMessages?: (query: SearchQuery) => Promise<SearchFound>, meaning?: MeaningLane) => Promise<SearchAllFound>;
@@ -6,11 +6,12 @@ import { runEmbedders } from "../embeddings/embed.js";
6
6
  import { DEFAULT_TEXT_MODEL } from "../embeddings/models.js";
7
7
  import { MESSAGE_PARSING } from "../search/lucene/types.js";
8
8
  import { planQuestion } from "../search/question-plan.js";
9
+ import { calendarAccounts, searchEvents } from "./events-search.js";
9
10
  import { excerpt } from "./excerpt.js";
10
11
  import { MAX_SEARCH_OFFSET, searchStore } from "./messages-archive-search.js";
11
12
  import { nearEligible } from "./messages-discovery.js";
12
13
  import { notesScopeOf, RRF_K, searchNotes } from "./notes-search.js";
13
- export const RESOURCES_SEARCHED = ["messages", "mail", "notes"];
14
+ export const RESOURCES_SEARCHED = ["messages", "mail", "notes", "events"];
14
15
  /** A query a resource cannot answer is skipped with its reason, never a failure of the whole search. */
15
16
  const skippable = (error) => error instanceof CliError && (error.code === "validation_error" || error.code === "not_found");
16
17
  /**
@@ -28,9 +29,9 @@ const filtersBeside = (text) => {
28
29
  : undefined;
29
30
  };
30
31
  /**
31
- * `search all`: messages, mail and notes from the local store — messages also from the messenger's
32
+ * `search all`: messages, mail, notes and calendar events from the local store — messages also from the messenger's
32
33
  * server, as `search messages` asks it — each list best first, merged by reciprocal rank so no
33
- * resource's own scores have to be compared with another's.
34
+ * resource's own scores have to be compared with another's. Events are read only when the store holds a calendar.
34
35
  */
35
36
  export const searchAll = async (store, account, request, messenger = {}, searchMessages = (query) => searchStore(store, account, query, messenger), meaning) => {
36
37
  if (!request.text.trim())
@@ -48,6 +49,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
48
49
  const skipped = [];
49
50
  let hasMore = false;
50
51
  let notes;
52
+ let events;
51
53
  let messagesBy;
52
54
  let server;
53
55
  const messagesOf = async (kind) => {
@@ -133,7 +135,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
133
135
  });
134
136
  return [...fused.values()].sort((a, b) => b.score - a.score).map(({ item }) => item);
135
137
  };
136
- // Messages and notes both look up by meaning with the same model: loading it costs most of a second.
138
+ // Messages, notes and events all look up by meaning with the same model: loading it costs most of a second.
137
139
  const embedders = runEmbedders();
138
140
  try {
139
141
  for (const resource of only) {
@@ -172,6 +174,38 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
172
174
  ];
173
175
  accounts.push(...read.map((account) => ({ resource, provider: "notes", account })));
174
176
  }
177
+ else if (resource === "events") {
178
+ const read = request.scope?.events ?? (await calendarAccounts(store));
179
+ if (!read.length)
180
+ continue;
181
+ const found = await searchEvents(store, request.text, {
182
+ limit: window,
183
+ accounts: read,
184
+ embedders,
185
+ ...(command === undefined ? {} : { command }),
186
+ ...(request.env === undefined ? {} : { env: request.env }),
187
+ ...(request.exact ? { exact: true } : {}),
188
+ ...(request.timezone === undefined ? {} : { timezone: request.timezone }),
189
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
190
+ });
191
+ hasMore ||= found.has_more;
192
+ events = {
193
+ by: found.by,
194
+ ...(found.meaning_skipped === undefined ? {} : { meaningSkipped: found.meaning_skipped }),
195
+ };
196
+ lists.push(found.hits.map((hit) => ({
197
+ kind: "event",
198
+ ref: hit.ref,
199
+ provider: hit.provider ?? "calendar",
200
+ account: hit.account ?? "",
201
+ ...(hit.url ? { url: hit.url } : {}),
202
+ title: hit.title,
203
+ timestamp: hit.starts_at ?? hit.updated_at,
204
+ text: hit.excerpt,
205
+ foundBy: hit.found_by,
206
+ })));
207
+ accounts.push(...read.map(({ provider, account }) => ({ resource, provider, account })));
208
+ }
175
209
  else
176
210
  lists.push(await messagesOf(resource));
177
211
  searched.push(resource);
@@ -204,6 +238,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
204
238
  accounts,
205
239
  skipped,
206
240
  ...(notes === undefined ? {} : { notes }),
241
+ ...(events === undefined ? {} : { events }),
207
242
  ...(messagesBy === undefined ? {} : { messages: messagesBy }),
208
243
  ...(server === undefined ? {} : { server }),
209
244
  };
@@ -30,6 +30,7 @@ export type { CatalogActor, CatalogEntry, CatalogInput, CatalogQuery, CatalogSto
30
30
  export { CATALOG_QUERY } from "./sqlite/catalog.js";
31
31
  export type { ChatMetadata } from "./sqlite/chats.js";
32
32
  export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
33
+ export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
33
34
  export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
34
35
  export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
35
36
  export { IMPORTED_DOCUMENT_QUERY, type ImportedDocuments } from "./sqlite/imported-documents.js";
@@ -16,6 +16,7 @@ export { planProjectAnnotationSnapshot } from "./project-transfer/snapshot-plan.
16
16
  export { resetAttachmentWords } from "./sqlite/attachment-texts.js";
17
17
  export { backfillNormalized, pendingNormalization } from "./sqlite/backfill.js";
18
18
  export { CATALOG_QUERY } from "./sqlite/catalog.js";
19
+ export { EVENT_FIELDS, } from "./sqlite/event-search.js";
19
20
  export { IMPORTED_DOCUMENT_QUERY } from "./sqlite/imported-documents.js";
20
21
  export { ORGANIZATION_KINDS, PROJECT_TYPES, SCOPES } from "./sqlite/knowledge.js";
21
22
  export { NOTE_FIELDS, } from "./sqlite/note-search.js";
@@ -22,7 +22,7 @@ export declare const projectAnnotationRequest: (request: ProjectAnnotationTarget
22
22
  ref: string;
23
23
  from: string;
24
24
  to: string | null;
25
- kind: "about" | "answered-by" | "assigned-to" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
25
+ kind: "about" | "answered-by" | "assigned-to" | "attached" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
26
26
  origin: "file" | "message" | "owner" | "suggested";
27
27
  anchor: string | null;
28
28
  target_text: string | null;
@@ -56,7 +56,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
56
56
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
57
57
  readonly from: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
58
58
  readonly to: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
59
- readonly kind: v.PicklistSchema<readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to"], undefined>;
59
+ readonly kind: v.PicklistSchema<readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to", "attached"], undefined>;
60
60
  readonly origin: v.PicklistSchema<readonly ["file", "owner", "suggested", "message"], undefined>;
61
61
  readonly anchor: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
62
62
  readonly target_text: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
@@ -71,7 +71,7 @@ export declare const projectAnnotationsSchema: v.StrictObjectSchema<{
71
71
  ref: string;
72
72
  from: string;
73
73
  to: string | null;
74
- kind: "about" | "answered-by" | "assigned-to" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
74
+ kind: "about" | "answered-by" | "assigned-to" | "attached" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
75
75
  origin: "file" | "message" | "owner" | "suggested";
76
76
  anchor: string | null;
77
77
  target_text: string | null;
@@ -37,7 +37,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
37
37
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
38
38
  readonly from: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>;
39
39
  readonly to: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
40
- readonly kind: v.PicklistSchema<readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to"], undefined>;
40
+ readonly kind: v.PicklistSchema<readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to", "attached"], undefined>;
41
41
  readonly origin: v.PicklistSchema<readonly ["file", "owner", "suggested", "message"], undefined>;
42
42
  readonly anchor: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
43
43
  readonly target_text: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, number, undefined>]>, undefined>;
@@ -52,7 +52,7 @@ export declare const projectAnnotationSnapshotSchema: v.StrictObjectSchema<{
52
52
  ref: string;
53
53
  from: string;
54
54
  to: string | null;
55
- kind: "about" | "answered-by" | "assigned-to" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
55
+ kind: "about" | "answered-by" | "assigned-to" | "attached" | "blocked-by" | "created-from" | "duplicate-of" | "evidence" | "follow-up-of" | "labelled" | "links-to" | "member-of" | "related-to";
56
56
  origin: "file" | "message" | "owner" | "suggested";
57
57
  anchor: string | null;
58
58
  target_text: string | null;
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { calendarText, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
3
3
  import { bool, eventOf, fromJson, int, str, toJson } from "./events.js";
4
- import { logFileLink } from "./files.js";
4
+ import { driveDocumentOf, driveFileTarget, logFileLink } from "./files.js";
5
5
  import { identityOf } from "./identities.js";
6
6
  import { reviewLink } from "./link-reviews.js";
7
7
  import { read, write } from "./transaction.js";
@@ -86,6 +86,12 @@ const reviewOf = (row) => ({
86
86
  decidedAt: int(row.decided_at),
87
87
  createdAt: Number(row.created_at),
88
88
  });
89
+ const attachmentOf = (row) => ({
90
+ entryId: Number(row.from_id),
91
+ fileId: String(row.target_text).slice(driveFileTarget("").length),
92
+ title: str(fromJson(row.metadata)?.title),
93
+ documentId: int(row.to_id),
94
+ });
89
95
  const TRACKED = ["title", "starts_at", "ends_at", "location", "status", "recurrence", "recurrence_ends_at"];
90
96
  const flag = (value) => (value ? 1 : 0);
91
97
  const addressOf = (email) => email.trim().toLowerCase();
@@ -204,17 +210,34 @@ export const eventStoreOver = (context) => {
204
210
  return;
205
211
  setEntity(entry, newEntity(entry, now), "new", "rules", now);
206
212
  };
207
- /** The event's fields from its preferred entry: the owner's copy when the owner organizes, else the latest. */
213
+ /**
214
+ * The event's fields from its preferred entry: a copy with text before one without (a busy-only slot), then the
215
+ * owner's copy when the owner organizes, then the latest. With no live copy left, a past event stays as history
216
+ * and a future one is cancelled.
217
+ */
208
218
  const refreshEvent = (eventId, now) => {
219
+ // Search reads the event's status and its copies' visibility, which no index trigger watches.
220
+ database.prepare("INSERT OR IGNORE INTO event_index_pending (indexable_type, id) VALUES ('event', ?)").run(eventId);
209
221
  const entries = database
210
222
  .prepare(`SELECT ce.*, EXISTS (SELECT 1 FROM calendar_entry_participants p WHERE p.entry_id = ce.id AND p.self = 1
211
223
  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`)
224
+ ORDER BY trim(coalesce(ce.title, '') || coalesce(ce.description, '') || coalesce(ce.location, '')) <> '' DESC,
225
+ own DESC, coalesce(ce.external_updated_at, ce.updated_at) DESC, ce.id DESC`)
213
226
  .all(eventId);
214
227
  const preferred = entries[0];
215
- if (!preferred)
216
- return;
217
228
  const event = database.prepare("SELECT * FROM events WHERE id = ?").get(eventId);
229
+ if (!preferred) {
230
+ if (!event || !database.prepare("SELECT 1 FROM calendar_entries WHERE event_id = ?").get(eventId))
231
+ return;
232
+ const startsAt = int(event.starts_at);
233
+ if (startsAt !== null && startsAt < now)
234
+ database
235
+ .prepare("UPDATE events SET source_deleted_at = coalesce(source_deleted_at, ?), updated_at = ? WHERE id = ?")
236
+ .run(now, now, eventId);
237
+ else
238
+ database.prepare("UPDATE events SET status = 'cancelled', updated_at = ? WHERE id = ?").run(now, eventId);
239
+ return;
240
+ }
218
241
  const cancelled = entries.every((row) => row.status === "cancelled");
219
242
  const series = preferred.series_entry_id == null ? null : entryRow(Number(preferred.series_entry_id));
220
243
  const startsAt = int(preferred.starts_at) ?? int(event.starts_at);
@@ -259,6 +282,42 @@ export const eventStoreOver = (context) => {
259
282
  for (const email of stale)
260
283
  drop.run(entryId, email);
261
284
  };
285
+ /**
286
+ * The entry's Drive files as `attached` links: the list given replaces the one held. A link still waiting for its
287
+ * document tries again, since the file may have become one account's since.
288
+ */
289
+ const saveAttachments = (entryId, attachments, now) => {
290
+ const held = new Map(database
291
+ .prepare("SELECT id, target_text, to_id FROM links WHERE from_type = 'calendar_entry' AND from_id = ? AND kind = 'attached'")
292
+ .all(entryId)
293
+ .map((row) => [String(row.target_text), { id: Number(row.id), documentId: int(row.to_id) }]));
294
+ const insert = database.prepare(`INSERT INTO links (from_type, from_id, to_type, to_id, kind, source, target_text, metadata, confirmed, author,
295
+ created_at, updated_at) VALUES ('calendar_entry', ?, 'document', ?, 'attached', 'calendar', ?, ?, 1, 'rule', ?, ?)`);
296
+ const retitle = database.prepare("UPDATE links SET metadata = ?, updated_at = ? WHERE id = ? AND metadata IS NOT ?");
297
+ const resolve = database.prepare("UPDATE links SET to_id = ?, updated_at = ? WHERE id = ?");
298
+ const handled = new Set();
299
+ for (const { fileId, title } of attachments) {
300
+ const file = fileId.trim();
301
+ const target = driveFileTarget(file);
302
+ if (!file || handled.has(target))
303
+ continue;
304
+ handled.add(target);
305
+ const metadata = title ? JSON.stringify({ title }) : null;
306
+ const link = held.get(target);
307
+ if (link === undefined)
308
+ insert.run(entryId, driveDocumentOf(database, file), target, metadata, now, now);
309
+ else {
310
+ retitle.run(metadata, now, link.id, metadata);
311
+ const document = link.documentId === null ? driveDocumentOf(database, file) : null;
312
+ if (document !== null)
313
+ resolve.run(document, now, link.id);
314
+ }
315
+ held.delete(target);
316
+ }
317
+ const drop = database.prepare("DELETE FROM links WHERE id = ?");
318
+ for (const { id } of held.values())
319
+ drop.run(id);
320
+ };
262
321
  /** Rules 1 and 2: the row this entry already is, if any. */
263
322
  const existingFor = (calendarId, input, seriesEntryId) => {
264
323
  const same = database
@@ -351,6 +410,8 @@ export const eventStoreOver = (context) => {
351
410
  }
352
411
  if (input.participants)
353
412
  saveParticipants(id, input.participants, now);
413
+ if (input.attachments)
414
+ saveAttachments(id, input.attachments, now);
354
415
  if (input.kind === "series")
355
416
  database
356
417
  .prepare("UPDATE calendar_entries SET series_entry_id = ?, updated_at = ? WHERE calendar_id = ? AND kind = 'occurrence' " +
@@ -421,11 +482,18 @@ export const eventStoreOver = (context) => {
421
482
  }
422
483
  return entries.map((input) => entryOf(entryRow(ids.get(input))));
423
484
  }),
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),
485
+ markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => {
486
+ const marked = database
487
+ .prepare(`UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE calendar_id = ? AND deleted_at IS NULL
488
+ AND (seen_at IS NULL OR seen_at < ?)
489
+ AND (kind <> 'occurrence' OR exception = 1 OR (original_starts_at >= ? AND original_starts_at < ?))
490
+ RETURNING event_id`)
491
+ .all(now, now, calendarId, seenBefore, window.from, window.to);
492
+ for (const eventId of new Set(marked.map((row) => int(row.event_id))))
493
+ if (eventId !== null)
494
+ refreshEvent(eventId, now);
495
+ return marked.length;
496
+ }),
429
497
  events: async (filter = {}) => {
430
498
  const clauses = ["e.deleted_at IS NULL"];
431
499
  const params = [];
@@ -456,7 +524,7 @@ export const eventStoreOver = (context) => {
456
524
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
457
525
  throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
458
526
  return database
459
- .prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY e.starts_at, e.id LIMIT ? OFFSET ?`)
527
+ .prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY ${filter.newest ? "e.starts_at DESC, e.id DESC" : "e.starts_at, e.id"} LIMIT ? OFFSET ?`)
460
528
  .all(...params, limit, offset)
461
529
  .map(eventOf);
462
530
  },
@@ -480,6 +548,12 @@ export const eventStoreOver = (context) => {
480
548
  .prepare("SELECT * FROM calendar_entry_revisions WHERE entry_id IN (SELECT value FROM json_each(?)) ORDER BY created_at, id")
481
549
  .all(ids)
482
550
  .map(revisionOf),
551
+ attachments: database
552
+ .prepare("SELECT l.from_id, l.target_text, CASE WHEN d.deleted_at IS NULL THEN l.to_id END AS to_id, l.metadata " +
553
+ "FROM links l LEFT JOIN documents d ON d.id = l.to_id WHERE l.from_type = 'calendar_entry' " +
554
+ "AND l.kind = 'attached' AND l.from_id IN (SELECT value FROM json_each(?)) ORDER BY l.from_id, l.id")
555
+ .all(ids)
556
+ .map(attachmentOf),
483
557
  };
484
558
  }),
485
559
  linkEntry: async (entryId, eventId, now) => write(database, () => {
@@ -0,0 +1,66 @@
1
+ import type { Event } from "../../meetings/index.js";
2
+ import { type NoteIndexState } from "./note-index.js";
3
+ import { type CorpusQuery } from "./note-search.js";
4
+ import type { StoreContext } from "./open.js";
5
+ /** The fields an event has; a message or note field is refused rather than matching nothing. */
6
+ export declare const EVENT_FIELDS: readonly ["text", "exact", "body", "title", "date", "in"];
7
+ /**
8
+ * `accountIds` are stored account ids; `limit` and `offset` count hits, one per series. `candidates` is how many
9
+ * matching events are read before they are grouped (50,000 by default).
10
+ */
11
+ export type EventQuery = CorpusQuery & {
12
+ candidates?: number;
13
+ };
14
+ export interface EventHit {
15
+ /** `event:<id>`, of the occurrence shown. */
16
+ ref: string;
17
+ /** The occurrence shown: of a series' matches, the one nearest now that is not cancelled. */
18
+ event: Event;
19
+ /** What the words were matched in besides the title: description, place, attendees. */
20
+ text: string;
21
+ /** bm25 of the words or stems; lower is better. `null` when ranked by time. */
22
+ relevance: number | null;
23
+ exact?: boolean;
24
+ /** How many occurrences of its series matched; 1 for an event that does not repeat. */
25
+ occurrences: number;
26
+ /** The calendar account it is in, the first of several; `null` for an event no calendar holds (a Zoom one). */
27
+ account: {
28
+ provider: string;
29
+ account: string;
30
+ } | null;
31
+ /** The event's page at the calendar. */
32
+ url: string | null;
33
+ }
34
+ export interface EventChunkToEmbed {
35
+ hash: string;
36
+ text: string;
37
+ }
38
+ export interface NearestEvent extends Omit<EventHit, "relevance" | "exact"> {
39
+ /** Cosine of the best piece of its series; higher is nearer. */
40
+ score: number;
41
+ }
42
+ export interface EventSearch {
43
+ /** Indexes what was written since, then searches: the occurrences of one series are one hit. */
44
+ search(query: EventQuery): Promise<{
45
+ items: EventHit[];
46
+ hasMore: boolean;
47
+ /** More events matched than `candidates`: the ones past it were not read, so the last page may not be the end. */
48
+ truncated: boolean;
49
+ }>;
50
+ eventIndexState(): Promise<NoteIndexState>;
51
+ /** Live events' pieces with no vector of `model`, by hash from `after`, each with its text cut again. */
52
+ chunksToEmbed(model: string, options: {
53
+ after?: string;
54
+ limit: number;
55
+ }): Promise<EventChunkToEmbed[]>;
56
+ /** Whether any live event with a searchable copy in these accounts has a vector of `model`. */
57
+ embedded(model: string, options?: {
58
+ accountIds?: string[];
59
+ }): Promise<boolean>;
60
+ /** Events nearest in meaning to `query`, one hit per series, best first. Vectors are unit length. */
61
+ nearest(model: string, query: Float32Array, options: {
62
+ limit: number;
63
+ accountIds?: string[];
64
+ }): Promise<NearestEvent[]>;
65
+ }
66
+ export declare const eventSearchOver: (context: StoreContext) => EventSearch;
@@ -0,0 +1,163 @@
1
+ import { QUERY_LIMITS } from "../../search/lucene/types.js";
2
+ import { eventOf } from "./events.js";
3
+ import { CORPORA, corpusIndexState, drainCorpus, EVENT_LIVE, EVENT_TEXT, noteIndexText, searchableCopy, } from "./note-index.js";
4
+ import { searchCorpus } from "./note-search.js";
5
+ import { stemmerCache } from "./stems.js";
6
+ import { read } from "./transaction.js";
7
+ import { dot } from "./vectors.js";
8
+ /** The fields an event has; a message or note field is refused rather than matching nothing. */
9
+ export const EVENT_FIELDS = ["text", "exact", "body", "title", "date", "in"];
10
+ /**
11
+ * Whether the event `event` (an SQL expression of its id) has a searchable copy in the accounts of the one parameter,
12
+ * a JSON list of stored account ids. Words and meaning both filter by it, so an event held in several accounts is
13
+ * found through each of them.
14
+ */
15
+ const inEventAccounts = (event) => searchableCopy(event, "c.account_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))");
16
+ const EVENT_CORPUS = {
17
+ type: "event",
18
+ noun: "event",
19
+ fields: EVENT_FIELDS,
20
+ view: `(SELECT e.id, NULL AS folder_id, NULL AS path, e.title, ${EVENT_TEXT} AS text, NULL AS metadata, ` +
21
+ `e.starts_at AS at, e.deleted_at, ${EVENT_LIVE} AS live FROM events e)`,
22
+ words: "event_words",
23
+ stems: "event_stems",
24
+ live: "n.live = 1",
25
+ inAccounts: (ids) => ({
26
+ sql: inEventAccounts("n.id"),
27
+ params: [ids],
28
+ exact: true,
29
+ }),
30
+ };
31
+ const SCAN_PAGE = 5_000;
32
+ export const eventSearchOver = (context) => {
33
+ const { database } = context;
34
+ const stemmerFor = stemmerCache();
35
+ const drain = () => drainCorpus(database, CORPORA.event, stemmerFor);
36
+ /** The live event pieces with a searchable copy in these accounts, as SQL over chunks `k`. */
37
+ const live = (accountIds) => {
38
+ const sql = ` AND k.chunkable_type = 'event' AND EXISTS (SELECT 1 FROM events e WHERE e.id = k.chunkable_id AND ${EVENT_LIVE})`;
39
+ return accountIds?.length
40
+ ? { sql: `${sql} AND ${inEventAccounts("k.chunkable_id")}`, params: [JSON.stringify(accountIds)] }
41
+ : { sql, params: [] };
42
+ };
43
+ /** Events in rank order, as hits: one per series, at its occurrence nearest now that is not cancelled. */
44
+ const grouped = (rows, accountIds) => {
45
+ const facts = new Map(database
46
+ .prepare("SELECT id, event_series_id AS series, starts_at, status FROM events WHERE id IN (SELECT value FROM json_each(?))")
47
+ .all(JSON.stringify(rows.map(({ id }) => id)))
48
+ .map((row) => [Number(row.id), row]));
49
+ const groups = new Map();
50
+ for (const row of rows) {
51
+ const fact = facts.get(row.id);
52
+ if (!fact)
53
+ continue;
54
+ const key = fact.series == null ? `e${row.id}` : `s${fact.series}`;
55
+ const group = groups.get(key) ?? { first: row, members: [] };
56
+ group.members.push(fact);
57
+ groups.set(key, group);
58
+ }
59
+ const now = context.now();
60
+ const nearest = (members) => members.toSorted((a, b) => Number(a.status === "cancelled") - Number(b.status === "cancelled") ||
61
+ Math.abs(Number(a.starts_at ?? 0) - now) - Math.abs(Number(b.starts_at ?? 0) - now) ||
62
+ Number(a.id) - Number(b.id))[0];
63
+ const event = database.prepare(`SELECT e.*, ${EVENT_TEXT} AS text FROM events e WHERE e.id = ?`);
64
+ const place = database.prepare("SELECT a.provider, a.external_id AS account, ce.url FROM calendar_entries ce " +
65
+ "JOIN calendars c ON c.id = ce.calendar_id JOIN accounts a ON a.id = c.account_id " +
66
+ "WHERE ce.event_id = ? " +
67
+ "ORDER BY c.account_id NOT IN (SELECT CAST(value AS INTEGER) FROM json_each(?)), ce.deleted_at IS NOT NULL, ce.id LIMIT 1");
68
+ const chosen = JSON.stringify(accountIds ?? []);
69
+ return [...groups.values()].map(({ first, members }) => {
70
+ const id = Number(nearest(members).id);
71
+ const row = event.get(id);
72
+ const at = place.get(id, chosen);
73
+ return {
74
+ first,
75
+ hit: {
76
+ ref: `event:${id}`,
77
+ event: eventOf(row),
78
+ text: String(row.text ?? ""),
79
+ occurrences: members.length,
80
+ account: at ? { provider: String(at.provider), account: String(at.account) } : null,
81
+ url: at?.url == null ? null : String(at.url),
82
+ },
83
+ };
84
+ });
85
+ };
86
+ return {
87
+ search: async (query) => {
88
+ drain();
89
+ const offset = query.offset ?? 0;
90
+ const candidates = query.candidates ?? QUERY_LIMITS.candidates;
91
+ const matched = searchCorpus(context, { ...query, limit: candidates, offset: 0 }, EVENT_CORPUS);
92
+ const truncated = matched.length > candidates;
93
+ const rows = truncated ? matched.slice(0, candidates) : matched;
94
+ return read(database, () => {
95
+ const groups = grouped(rows, query.accountIds);
96
+ return {
97
+ items: groups.slice(offset, offset + query.limit).map(({ first, hit }) => ({
98
+ ...hit,
99
+ relevance: first.relevance,
100
+ ...(first.exact === undefined ? {} : { exact: first.exact }),
101
+ })),
102
+ hasMore: groups.length > offset + query.limit,
103
+ truncated,
104
+ };
105
+ });
106
+ },
107
+ eventIndexState: async () => corpusIndexState(database, CORPORA.event),
108
+ chunksToEmbed: async (model, { after, limit }) => {
109
+ drain();
110
+ const scope = live(undefined);
111
+ const text = database.prepare(`SELECT e.title, ${EVENT_TEXT} AS body FROM events e WHERE e.id = ?`);
112
+ return database
113
+ .prepare(`SELECT k.content_hash AS hash, min(k.chunkable_id) AS owner, k.start_offset AS start, k.end_offset AS end
114
+ FROM chunks k
115
+ WHERE k.content_hash > ?${scope.sql}
116
+ AND NOT EXISTS (SELECT 1 FROM embeddings v WHERE v.model = ? AND v.content_hash = k.content_hash)
117
+ GROUP BY k.content_hash ORDER BY k.content_hash LIMIT ?`)
118
+ .all(after ?? "", ...scope.params, model, limit)
119
+ .flatMap((row) => {
120
+ const event = text.get(Number(row.owner));
121
+ if (!event)
122
+ return [];
123
+ const whole = noteIndexText(event.title == null ? null : String(event.title), String(event.body));
124
+ return [{ hash: String(row.hash), text: whole.slice(Number(row.start), Number(row.end)) }];
125
+ });
126
+ },
127
+ embedded: async (model, { accountIds } = {}) => {
128
+ drain();
129
+ const scope = live(accountIds);
130
+ return (database
131
+ .prepare(`SELECT 1 FROM chunks k JOIN embeddings v ON v.model = ? AND v.content_hash = k.content_hash
132
+ WHERE 1 = 1${scope.sql} LIMIT 1`)
133
+ .get(model, ...scope.params) !== undefined);
134
+ },
135
+ nearest: async (model, query, { limit, accountIds }) => {
136
+ drain();
137
+ const scope = live(accountIds);
138
+ const page = database.prepare(`SELECT k.id, k.chunkable_id AS owner, v.vector
139
+ FROM chunks k JOIN embeddings v ON v.model = ? AND v.content_hash = k.content_hash
140
+ WHERE k.id > ?${scope.sql}
141
+ ORDER BY k.id LIMIT ?`);
142
+ const best = new Map();
143
+ let after = 0;
144
+ for (;;) {
145
+ const rows = page.all(model, after, ...scope.params, SCAN_PAGE);
146
+ for (const row of rows) {
147
+ const score = dot(query, row.vector);
148
+ const owner = Number(row.owner);
149
+ if (score > (best.get(owner) ?? Number.NEGATIVE_INFINITY))
150
+ best.set(owner, score);
151
+ }
152
+ const last = rows.at(-1);
153
+ if (!last || rows.length < SCAN_PAGE)
154
+ break;
155
+ after = Number(last.id);
156
+ }
157
+ const ranked = [...best.entries()].map(([id, score]) => ({ id, score })).sort((a, b) => b.score - a.score);
158
+ return read(database, () => grouped(ranked, accountIds)
159
+ .slice(0, limit)
160
+ .map(({ first, hit }) => ({ ...hit, score: first.score })));
161
+ },
162
+ };
163
+ };
@@ -15,6 +15,10 @@ interface Placed {
15
15
  export declare const logFileLink: (database: CacheDatabase, now: number, fileId: number, from: unknown, to: number | null, method?: string, author?: string) => {
16
16
  changes: number;
17
17
  };
18
+ /** How an `attached` link names a Drive file it waits for: by its Drive id, since no row may hold it yet. */
19
+ export declare const driveFileTarget: (fileId: string) => string;
20
+ /** The document of the Drive file with this id; `null` when no Drive account holds it, or two do. */
21
+ export declare const driveDocumentOf: (database: CacheDatabase, fileId: string) => number | null;
18
22
  /**
19
23
  * Writes the `files` row of one document from the document as it is now: the document's source record. A Drive
20
24
  * document (catalog metadata or Drive front matter) is keyed by its Drive file id and placed under its first parent;