@wirecat/cli-core 0.452.0 → 0.454.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.
- package/CHANGELOG.md +71 -0
- package/dist/cli/mcp-server-command.js +5 -1
- package/dist/cli/messenger/search-answers.d.ts +1 -1
- package/dist/cli/messenger/search-answers.js +1 -1
- package/dist/cli/messenger/search-command.js +6 -3
- package/dist/entities/contact-context.d.ts +42 -0
- package/dist/entities/contact-context.js +11 -0
- package/dist/entities/hit.d.ts +3 -3
- package/dist/entities/hit.js +3 -3
- package/dist/entities/index.d.ts +61 -21
- package/dist/entities/task-results.d.ts +20 -20
- package/dist/entities/task-results.js +3 -15
- package/dist/mcp/http/activity.d.ts +24 -0
- package/dist/mcp/http/activity.js +86 -0
- package/dist/mcp/http/serve.js +14 -0
- package/dist/mcp/http/state.d.ts +5 -0
- package/dist/mcp/http/state.js +14 -4
- package/dist/meetings/event-store.d.ts +23 -0
- package/dist/meetings/index.d.ts +1 -1
- package/dist/search/query.js +1 -3
- package/dist/services/events-search.d.ts +67 -0
- package/dist/services/events-search.js +131 -0
- package/dist/services/index.d.ts +3 -2
- package/dist/services/index.js +2 -1
- package/dist/services/notes-search.d.ts +25 -1
- package/dist/services/notes-search.js +24 -11
- package/dist/services/person-context.d.ts +20 -1
- package/dist/services/person-context.js +46 -1
- package/dist/services/search-all-meetings.d.ts +1 -1
- package/dist/services/search-all-meetings.js +7 -2
- package/dist/services/search-all.d.ts +12 -7
- package/dist/services/search-all.js +39 -4
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.js +1 -0
- package/dist/store/project-transfer/request.d.ts +1 -1
- package/dist/store/project-transfer/schema.d.ts +2 -2
- package/dist/store/project-transfer/snapshot-model.d.ts +2 -2
- package/dist/store/sqlite/calendar.js +85 -11
- package/dist/store/sqlite/event-search.d.ts +66 -0
- package/dist/store/sqlite/event-search.js +163 -0
- package/dist/store/sqlite/files.d.ts +4 -0
- package/dist/store/sqlite/files.js +28 -0
- package/dist/store/sqlite/link-kinds.d.ts +5 -1
- package/dist/store/sqlite/link-kinds.js +5 -0
- package/dist/store/sqlite/note-index.d.ts +17 -1
- package/dist/store/sqlite/note-index.js +32 -8
- package/dist/store/sqlite/note-search.d.ts +43 -0
- package/dist/store/sqlite/note-search.js +24 -24
- package/dist/store/sqlite/notes.d.ts +4 -5
- package/dist/store/sqlite/notes.js +2 -2
- package/dist/store/sqlite/project-transfer/snapshot.d.ts +1 -1
- package/dist/store/store.d.ts +2 -1
- package/dist/store/store.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
|
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, () =>
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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;
|
|
@@ -48,6 +48,30 @@ export const logFileLink = (database, now, fileId, from, to, method = "import",
|
|
|
48
48
|
.prepare("INSERT INTO entity_link_events (linkable_type, linkable_id, entity_type, from_entity_id, to_entity_id, method, " +
|
|
49
49
|
"confidence, author, created_at) VALUES ('file', ?, 'document', ?, ?, ?, 1, ?, ?)")
|
|
50
50
|
.run(fileId, from == null ? null : Number(from), to, method, author, now);
|
|
51
|
+
/** How an `attached` link names a Drive file it waits for: by its Drive id, since no row may hold it yet. */
|
|
52
|
+
export const driveFileTarget = (fileId) => `google-drive:${fileId}`;
|
|
53
|
+
/** The document of the Drive file with this id; `null` when no Drive account holds it, or two do. */
|
|
54
|
+
export const driveDocumentOf = (database, fileId) => {
|
|
55
|
+
const rows = database
|
|
56
|
+
.prepare("SELECT DISTINCT f.document_id AS id FROM files f JOIN accounts a ON a.id = f.account_id WHERE a.provider = 'google-drive' " +
|
|
57
|
+
"AND f.external_id = ? AND f.deleted_at IS NULL AND f.document_id IS NOT NULL LIMIT 2")
|
|
58
|
+
.all(fileId);
|
|
59
|
+
return rows.length === 1 ? Number(rows[0]?.id) : null;
|
|
60
|
+
};
|
|
61
|
+
/** Calendar entries that named this Drive file before Drive stored it now link its document. */
|
|
62
|
+
const linkAttached = (database, now, fileId) => {
|
|
63
|
+
const target = driveFileTarget(fileId);
|
|
64
|
+
if (!database
|
|
65
|
+
.prepare("SELECT 1 FROM links WHERE to_type = 'document' AND to_id IS NULL AND target_text = ? AND kind = 'attached'")
|
|
66
|
+
.get(target))
|
|
67
|
+
return;
|
|
68
|
+
const document = driveDocumentOf(database, fileId);
|
|
69
|
+
if (document !== null)
|
|
70
|
+
database
|
|
71
|
+
.prepare("UPDATE links SET to_id = ?, updated_at = ? WHERE to_type = 'document' AND to_id IS NULL AND target_text = ? " +
|
|
72
|
+
"AND kind = 'attached'")
|
|
73
|
+
.run(document, now, target);
|
|
74
|
+
};
|
|
51
75
|
/** Moves the rows under a folder whose path changed, in one statement over the old prefix. */
|
|
52
76
|
const movePaths = (database, accountId, from, to) => {
|
|
53
77
|
database
|
|
@@ -136,6 +160,8 @@ export const syncFile = (database, now, documentId, { seen = true, depth = 0, pl
|
|
|
136
160
|
.get(...Object.values(values), Number(doc.created_at), now);
|
|
137
161
|
const placed = { id: Number(row?.id), path };
|
|
138
162
|
logFileLink(database, now, placed.id, null, documentId, method);
|
|
163
|
+
if (remote)
|
|
164
|
+
linkAttached(database, now, externalId);
|
|
139
165
|
if (remote && kind === "folder")
|
|
140
166
|
placeChildren(database, now, accountId, String(doc.external_id), depth);
|
|
141
167
|
return placed;
|
|
@@ -152,6 +178,8 @@ export const syncFile = (database, now, documentId, { seen = true, depth = 0, pl
|
|
|
152
178
|
.run(...Object.values(values), changed ? now : Number(existing.updated_at), id);
|
|
153
179
|
if (kind === "folder" && existing.path !== path)
|
|
154
180
|
movePaths(database, accountId, String(existing.path), path);
|
|
181
|
+
if (remote && changed)
|
|
182
|
+
linkAttached(database, now, externalId);
|
|
155
183
|
}
|
|
156
184
|
return { id, path };
|
|
157
185
|
};
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* Every kind a `links` row may have. The schema doc's description of `links` names exactly these
|
|
3
3
|
* (`schema.test.ts` checks it); a writer that needs a new kind adds it here first.
|
|
4
4
|
*/
|
|
5
|
-
export declare const LINK_KINDS: readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to"];
|
|
5
|
+
export declare const LINK_KINDS: readonly ["links-to", "blocked-by", "follow-up-of", "about", "member-of", "labelled", "answered-by", "evidence", "created-from", "duplicate-of", "related-to", "assigned-to", "attached"];
|
|
6
6
|
export type LinkKind = (typeof LINK_KINDS)[number];
|
|
7
|
+
/** Where a caller may say a link came from. */
|
|
8
|
+
export declare const LINK_ORIGINS: readonly ["file", "owner", "suggested", "message"];
|
|
9
|
+
/** Where a stored link came from: an origin a caller gives, or `calendar`, which only the store writes. */
|
|
10
|
+
export declare const LINK_SOURCES: readonly ["file", "owner", "suggested", "message", "calendar"];
|
|
7
11
|
/** The kinds `knowledge.relate` makes between people, organizations and projects. */
|
|
8
12
|
export declare const RELATION_KINDS: readonly ["member-of", "related-to"];
|
|
9
13
|
/** A kind as an SQL literal, so a kind written into a query is checked against the list. */
|
|
@@ -15,7 +15,12 @@ export const LINK_KINDS = [
|
|
|
15
15
|
"duplicate-of",
|
|
16
16
|
"related-to",
|
|
17
17
|
"assigned-to",
|
|
18
|
+
"attached",
|
|
18
19
|
];
|
|
20
|
+
/** Where a caller may say a link came from. */
|
|
21
|
+
export const LINK_ORIGINS = ["file", "owner", "suggested", "message"];
|
|
22
|
+
/** Where a stored link came from: an origin a caller gives, or `calendar`, which only the store writes. */
|
|
23
|
+
export const LINK_SOURCES = [...LINK_ORIGINS, "calendar"];
|
|
19
24
|
/** The kinds `knowledge.relate` makes between people, organizations and projects. */
|
|
20
25
|
export const RELATION_KINDS = ["member-of", "related-to"];
|
|
21
26
|
/** A kind as an SQL literal, so a kind written into a query is checked against the list. */
|
|
@@ -31,6 +31,23 @@ export interface Corpus {
|
|
|
31
31
|
stems: string;
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
/** What an event `e` is searched by besides its title: the cleaned description, the place and the attendees' names. */
|
|
35
|
+
export declare const EVENT_TEXT: string;
|
|
36
|
+
/** Calendar entry types that are listed, never searched (requirements 3.4). */
|
|
37
|
+
export declare const UNSEARCHED_EVENT_TYPES: readonly ["workingLocation", "focusTime", "outOfOffice", "birthday"];
|
|
38
|
+
/** Visibilities whose entry, when it carries no text, is a busy-only slot: stored, never searched (requirements 3.4). */
|
|
39
|
+
export declare const BUSY_ONLY_VISIBILITIES: readonly ["private", "confidential"];
|
|
40
|
+
/**
|
|
41
|
+
* Whether the event `event` (an SQL expression of its id) has a searchable calendar copy, and with `account` one in
|
|
42
|
+
* those accounts (an SQL condition over `c.account_id`). Only live copies count, unless none is: an event its
|
|
43
|
+
* calendars deleted keeps its past copies as history.
|
|
44
|
+
*/
|
|
45
|
+
export declare const searchableCopy: (event: string, account?: string) => string;
|
|
46
|
+
/**
|
|
47
|
+
* Whether an event `e` is searched at all: by words, by meaning and in `search all`. The one place that names what
|
|
48
|
+
* is left out. An event no calendar holds (a Zoom meeting's own) is searched; a cancelled one is not.
|
|
49
|
+
*/
|
|
50
|
+
export declare const EVENT_LIVE: string;
|
|
34
51
|
export declare const CORPORA: {
|
|
35
52
|
readonly document: {
|
|
36
53
|
readonly type: "document";
|
|
@@ -81,7 +98,6 @@ export declare const CORPORA: {
|
|
|
81
98
|
readonly index: "event_index";
|
|
82
99
|
readonly table: "events";
|
|
83
100
|
readonly read: string;
|
|
84
|
-
readonly pieces: false;
|
|
85
101
|
};
|
|
86
102
|
};
|
|
87
103
|
/** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
|
|
@@ -9,6 +9,34 @@ import { speech } from "./lucene-fields.js";
|
|
|
9
9
|
import { savedStemmers } from "./stems.js";
|
|
10
10
|
import { write } from "./transaction.js";
|
|
11
11
|
import { dropUnusedVectors } from "./vector-index.js";
|
|
12
|
+
/** What an event `e` is searched by besides its title: the cleaned description, the place and the attendees' names. */
|
|
13
|
+
export const EVENT_TEXT = "trim(coalesce(e.description, '') || ' ' || coalesce(e.location, '') || ' ' || coalesce((" +
|
|
14
|
+
"SELECT group_concat(p.name, ' ') FROM calendar_entries ce JOIN calendar_entry_participants p ON p.entry_id = ce.id " +
|
|
15
|
+
"WHERE ce.event_id = e.id AND ce.deleted_at IS NULL AND p.resource = 0 AND p.name IS NOT NULL), ''))";
|
|
16
|
+
/** Calendar entry types that are listed, never searched (requirements 3.4). */
|
|
17
|
+
export const UNSEARCHED_EVENT_TYPES = ["workingLocation", "focusTime", "outOfOffice", "birthday"];
|
|
18
|
+
/** Visibilities whose entry, when it carries no text, is a busy-only slot: stored, never searched (requirements 3.4). */
|
|
19
|
+
export const BUSY_ONLY_VISIBILITIES = ["private", "confidential"];
|
|
20
|
+
const sqlList = (values) => values.map((value) => `'${value}'`).join(", ");
|
|
21
|
+
/** A calendar copy `ce` that may be searched: of a searchable type, and not a busy-only slot. */
|
|
22
|
+
const SEARCHABLE_COPY = `(ce.event_type IS NULL OR ce.event_type NOT IN (${sqlList(UNSEARCHED_EVENT_TYPES)})) ` +
|
|
23
|
+
`AND NOT (coalesce(ce.visibility, '') IN (${sqlList(BUSY_ONLY_VISIBILITIES)}) AND trim(coalesce(ce.title, '')) = '' ` +
|
|
24
|
+
"AND trim(coalesce(ce.description, '')) = '' AND trim(coalesce(ce.location, '')) = '')";
|
|
25
|
+
/**
|
|
26
|
+
* Whether the event `event` (an SQL expression of its id) has a searchable calendar copy, and with `account` one in
|
|
27
|
+
* those accounts (an SQL condition over `c.account_id`). Only live copies count, unless none is: an event its
|
|
28
|
+
* calendars deleted keeps its past copies as history.
|
|
29
|
+
*/
|
|
30
|
+
export const searchableCopy = (event, account) => `EXISTS (SELECT 1 FROM calendar_entries ce${account ? " JOIN calendars c ON c.id = ce.calendar_id" : ""} ` +
|
|
31
|
+
`WHERE ce.event_id = ${event} AND ${SEARCHABLE_COPY}${account ? ` AND ${account}` : ""} ` +
|
|
32
|
+
`AND (ce.deleted_at IS NULL OR NOT EXISTS (SELECT 1 FROM calendar_entries l WHERE l.event_id = ${event} ` +
|
|
33
|
+
"AND l.deleted_at IS NULL)))";
|
|
34
|
+
/**
|
|
35
|
+
* Whether an event `e` is searched at all: by words, by meaning and in `search all`. The one place that names what
|
|
36
|
+
* is left out. An event no calendar holds (a Zoom meeting's own) is searched; a cancelled one is not.
|
|
37
|
+
*/
|
|
38
|
+
export const EVENT_LIVE = "(e.deleted_at IS NULL AND coalesce(e.status, '') <> 'cancelled' AND " +
|
|
39
|
+
`(NOT EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id) OR ${searchableCopy("e.id")}))`;
|
|
12
40
|
export const CORPORA = {
|
|
13
41
|
document: {
|
|
14
42
|
type: "document",
|
|
@@ -60,15 +88,11 @@ export const CORPORA = {
|
|
|
60
88
|
type: "event",
|
|
61
89
|
index: "event_index",
|
|
62
90
|
table: "events",
|
|
63
|
-
|
|
64
|
-
read: "SELECT e.title, trim(coalesce(e.description, '') || ' ' || coalesce(e.location, '') || ' ' || coalesce((" +
|
|
65
|
-
"SELECT group_concat(p.name, ' ') FROM calendar_entries ce JOIN calendar_entry_participants p ON p.entry_id = ce.id " +
|
|
66
|
-
"WHERE ce.event_id = e.id AND ce.deleted_at IS NULL AND p.resource = 0 AND p.name IS NOT NULL), '')) AS body, " +
|
|
67
|
-
"e.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id " +
|
|
68
|
-
"AND ce.deleted_at IS NULL AND ce.event_type IN ('workingLocation', 'focusTime', 'outOfOffice', 'birthday')) AS live, " +
|
|
91
|
+
read: `SELECT e.title, ${EVENT_TEXT} AS body, ${EVENT_LIVE} AS live, ` +
|
|
69
92
|
"coalesce((SELECT a.scope FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id " +
|
|
70
|
-
"JOIN accounts a ON a.id = c.account_id WHERE ce.event_id = e.id LIMIT 1), 'personal') AS scope
|
|
71
|
-
|
|
93
|
+
"JOIN accounts a ON a.id = c.account_id WHERE ce.event_id = e.id LIMIT 1), 'personal') AS scope, " +
|
|
94
|
+
"(SELECT c.account_id FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id " +
|
|
95
|
+
"WHERE ce.event_id = e.id AND ce.deleted_at IS NULL ORDER BY ce.id LIMIT 1) AS account_id FROM events e WHERE e.id = ?",
|
|
72
96
|
},
|
|
73
97
|
};
|
|
74
98
|
/** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
|