@wirecat/cli-core 0.451.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.
- package/CHANGELOG.md +68 -0
- 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/instructions.d.ts +1 -1
- package/dist/mcp/instructions.js +5 -5
- 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
|
@@ -44,6 +44,42 @@ const mailWith = async (store, key, address, limit, since) => {
|
|
|
44
44
|
return { items, hasMore: emails.length > limit };
|
|
45
45
|
};
|
|
46
46
|
const newestFirst = (a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp);
|
|
47
|
+
const iso = (at) => (at === null ? null : new Date(at).toISOString());
|
|
48
|
+
const contextEvent = (event) => ({
|
|
49
|
+
ref: `event:${event.id}`,
|
|
50
|
+
title: event.title,
|
|
51
|
+
startsAt: iso(event.startsAt),
|
|
52
|
+
endsAt: iso(event.endsAt),
|
|
53
|
+
allDay: event.allDay === true,
|
|
54
|
+
location: event.location,
|
|
55
|
+
status: event.status ?? null,
|
|
56
|
+
});
|
|
57
|
+
const EVENT_PAGE = 100;
|
|
58
|
+
/** A weekly meeting held for years is hundreds of occurrences; past this many rows the list stops, cut. */
|
|
59
|
+
const EVENT_ROWS = 1000;
|
|
60
|
+
/** Up to `limit` events of `filter`, in its order, the first occurrence of each series only and none cancelled. */
|
|
61
|
+
const eventsOnce = async (store, filter, limit) => {
|
|
62
|
+
const items = [];
|
|
63
|
+
const series = new Set();
|
|
64
|
+
for (let offset = 0; offset < EVENT_ROWS; offset += EVENT_PAGE) {
|
|
65
|
+
const page = await store.calendar.events({ ...filter, limit: EVENT_PAGE, offset });
|
|
66
|
+
for (const event of page) {
|
|
67
|
+
if (event.status === "cancelled")
|
|
68
|
+
continue;
|
|
69
|
+
if (event.eventSeriesId !== null) {
|
|
70
|
+
if (series.has(event.eventSeriesId))
|
|
71
|
+
continue;
|
|
72
|
+
series.add(event.eventSeriesId);
|
|
73
|
+
}
|
|
74
|
+
if (items.length === limit)
|
|
75
|
+
return { items, cut: true };
|
|
76
|
+
items.push(event);
|
|
77
|
+
}
|
|
78
|
+
if (page.length < EVENT_PAGE)
|
|
79
|
+
return { items, cut: false };
|
|
80
|
+
}
|
|
81
|
+
return { items, cut: true };
|
|
82
|
+
};
|
|
47
83
|
/**
|
|
48
84
|
* The accounts each identity is read in: for the messenger asked, only the account asked about — a
|
|
49
85
|
* second account of the same messenger is somebody else's view; for a linked messenger, every account
|
|
@@ -56,7 +92,7 @@ export const readsOf = (record, asked) => record.identities.flatMap((identity) =
|
|
|
56
92
|
* What the store knows about one person, across every identity linked to them. Reads the store
|
|
57
93
|
* only: nothing is fetched, nothing marked read. Identity is by id, never by name.
|
|
58
94
|
*/
|
|
59
|
-
export const personContext = async (store, asked, reference, { messages = CONTEXT_MESSAGES, bytes = CONTEXT_BYTES, since } = {}) => {
|
|
95
|
+
export const personContext = async (store, asked, reference, { messages = CONTEXT_MESSAGES, bytes = CONTEXT_BYTES, since, now = Date.now() } = {}) => {
|
|
60
96
|
const found = pickPerson(reference, await store.people.people(asked.provider, { account: asked.account }));
|
|
61
97
|
const record = await store.people.personOf({ provider: asked.provider, id: found.id });
|
|
62
98
|
if (!record)
|
|
@@ -121,6 +157,11 @@ export const personContext = async (store, asked, reference, { messages = CONTEX
|
|
|
121
157
|
.filter(recent));
|
|
122
158
|
cut ||= named.hasMore;
|
|
123
159
|
}
|
|
160
|
+
const personId = Number(record.uid);
|
|
161
|
+
const upcoming = await eventsOnce(store, { personId, from: now }, messages);
|
|
162
|
+
const upcomingIds = new Set(upcoming.items.map(({ id }) => id));
|
|
163
|
+
const past = await eventsOnce(store, { personId, to: now, newest: true, ...(since === undefined ? {} : { from: since }) }, messages);
|
|
164
|
+
cut ||= upcoming.cut || past.cut;
|
|
124
165
|
direct.sort(newestFirst);
|
|
125
166
|
const each = [...direct, ...beyond].sort(newestFirst);
|
|
126
167
|
theirs.sort(newestFirst);
|
|
@@ -145,6 +186,10 @@ export const personContext = async (store, asked, reference, { messages = CONTEX
|
|
|
145
186
|
},
|
|
146
187
|
recent: { direct: kept.direct, groups: kept.groups },
|
|
147
188
|
mentions: kept.mentions,
|
|
189
|
+
events: {
|
|
190
|
+
upcoming: upcoming.items.map(contextEvent),
|
|
191
|
+
recent: past.items.filter(({ id }) => !upcomingIds.has(id)).map(contextEvent),
|
|
192
|
+
},
|
|
148
193
|
limits: { messages, bytes },
|
|
149
194
|
hasMore: cut || kept.cut,
|
|
150
195
|
complete: notRead.length === 0,
|
|
@@ -3,7 +3,7 @@ import type { AccountKey, MessageStore } from "../store/store.js";
|
|
|
3
3
|
import type { SearchFound, SearchQuery } from "./messages.js";
|
|
4
4
|
import type { MeaningLane } from "./messages-discovery.js";
|
|
5
5
|
import { type SearchAllFound, type SearchAllItem, type SearchAllMessenger, type SearchAllRequest, type Skipped } from "./search-all.js";
|
|
6
|
-
export declare const RESOURCES_SEARCHED_WITH_MEETINGS: readonly ["messages", "mail", "notes", "meetings"];
|
|
6
|
+
export declare const RESOURCES_SEARCHED_WITH_MEETINGS: readonly ["messages", "mail", "notes", "events", "meetings"];
|
|
7
7
|
export type ResourceWithMeetings = (typeof RESOURCES_SEARCHED_WITH_MEETINGS)[number];
|
|
8
8
|
export interface MeetingSearchItem {
|
|
9
9
|
kind: "meeting";
|
|
@@ -131,7 +131,7 @@ export const searchAllIncludingMeetings = async (store, account, request, messen
|
|
|
131
131
|
const only = request.only ?? RESOURCES_SEARCHED_WITH_MEETINGS;
|
|
132
132
|
if (!only.length ||
|
|
133
133
|
only.some((resource) => !RESOURCES_SEARCHED_WITH_MEETINGS.includes(resource)))
|
|
134
|
-
throw new CliError("validation_error", "only takes messages, mail, notes and meetings");
|
|
134
|
+
throw new CliError("validation_error", "only takes messages, mail, notes, events and meetings");
|
|
135
135
|
if (request.meetingCursor) {
|
|
136
136
|
if (only.length !== 1 || only[0] !== "meetings")
|
|
137
137
|
throw new CliError("validation_error", "meeting cursor requires only: [meetings]");
|
|
@@ -261,7 +261,12 @@ const searchAllOfSource = async (store, account, source, request, messenger, sea
|
|
|
261
261
|
const found = await searchAllIncludingMeetings(store, account, {
|
|
262
262
|
...request,
|
|
263
263
|
only: read,
|
|
264
|
-
scope: {
|
|
264
|
+
scope: {
|
|
265
|
+
messages: named("messages"),
|
|
266
|
+
mail: named("mail"),
|
|
267
|
+
notes: named("notes"),
|
|
268
|
+
events: named("events"),
|
|
269
|
+
},
|
|
265
270
|
...(meetings[0] ? { meetingAccount: meetings[0] } : {}),
|
|
266
271
|
}, messenger, searchMessages, meaning);
|
|
267
272
|
const unnamed = only
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|
package/dist/store/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/store/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|