@wirecat/cli-core 0.488.0 → 0.490.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 +112 -0
- package/dist/calendar/cli/index.js +48 -13
- package/dist/calendar/index.d.ts +1 -1
- package/dist/calendar/mcp/index.d.ts +390 -0
- package/dist/calendar/mcp/index.js +50 -12
- package/dist/calendar/reads.d.ts +9 -2
- package/dist/calendar/reads.js +60 -5
- package/dist/calendar/recurrence.d.ts +20 -0
- package/dist/calendar/recurrence.js +74 -0
- package/dist/calendar/sync.d.ts +6 -0
- package/dist/calendar/sync.js +123 -41
- package/dist/calendar/testing/memory.js +90 -7
- package/dist/calendar/testing/sync-contract.js +90 -0
- package/dist/calendar/testing/writer-contract.d.ts +2 -6
- package/dist/calendar/testing/writer-contract.js +157 -29
- package/dist/calendar/testing/writer.d.ts +3 -1
- package/dist/calendar/testing/writer.js +11 -0
- package/dist/calendar/writer.d.ts +20 -3
- package/dist/calendar/writer.js +8 -4
- package/dist/calendar/writes.d.ts +21 -12
- package/dist/calendar/writes.js +259 -20
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +5 -0
- package/dist/meetings/event-store.d.ts +80 -2
- package/dist/meetings/event-store.js +65 -1
- package/dist/meetings/index.d.ts +2 -1
- package/dist/meetings/index.js +1 -0
- package/dist/meetings/testing/event-store-contract.d.ts +1 -1
- package/dist/meetings/testing/event-store-contract.js +114 -0
- package/dist/store/drivers/node-sqlite.d.ts +9 -1
- package/dist/store/drivers/node-sqlite.js +30 -8
- package/dist/store/sqlite/calendar-events.d.ts +4 -0
- package/dist/store/sqlite/calendar-events.js +89 -0
- package/dist/store/sqlite/calendar.js +126 -94
- package/dist/store/sqlite/identities.d.ts +1 -1
- package/dist/store/sqlite/identities.js +4 -1
- package/dist/store/sqlite/knowledge.js +4 -2
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +10 -0
- package/dist/store/sqlite/note-index.d.ts +16 -1
- package/dist/store/sqlite/note-index.js +68 -3
- package/dist/store/sqlite/notes.js +7 -5
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { eventChange } from "../event-store.js";
|
|
2
3
|
const HOUR = 3_600_000;
|
|
3
4
|
/** "Now" in every case: entries before it have happened. */
|
|
4
5
|
export const CONTRACT_NOW = 1_000 * HOUR;
|
|
@@ -218,6 +219,119 @@ export const eventStoreContract = (make) => {
|
|
|
218
219
|
assert.equal((await store.event(kept?.eventId))?.entries[0]?.deletedAt, null);
|
|
219
220
|
assert.equal((await store.event(far?.eventId))?.entries[0]?.deletedAt, null);
|
|
220
221
|
}),
|
|
222
|
+
test("a full list marks nothing outside its window: a single, a moved slot, a series that ended", async ({ store, calendar, }) => {
|
|
223
|
+
const month = 30 * 24 * HOUR;
|
|
224
|
+
const until = new Date(earlier - month).toISOString().replace(/[-:]|\.\d{3}/gu, "");
|
|
225
|
+
await store.saveEntries(calendar, [
|
|
226
|
+
single("old", earlier - month),
|
|
227
|
+
single("next", later + month),
|
|
228
|
+
single("inside", later),
|
|
229
|
+
single("ended", earlier - 2 * month, { kind: "series", recurrence: [`RRULE:FREQ=WEEKLY;UNTIL=${until}`] }),
|
|
230
|
+
single("endless", earlier - 2 * month, { kind: "series", recurrence: ["RRULE:FREQ=WEEKLY"] }),
|
|
231
|
+
single("moved", earlier - month, {
|
|
232
|
+
kind: "occurrence",
|
|
233
|
+
exception: true,
|
|
234
|
+
seriesExternalId: "endless",
|
|
235
|
+
originalStartsAt: earlier - month,
|
|
236
|
+
}),
|
|
237
|
+
], 2);
|
|
238
|
+
assert.equal(await store.markUnseen(calendar, 10, { from: earlier, to: later + 24 * HOUR }, 11), 2);
|
|
239
|
+
const deleted = async (id) => (await store.entry(calendar, id))?.deletedAt;
|
|
240
|
+
assert.deepEqual(await Promise.all(["old", "next", "ended", "moved", "inside", "endless"].map(deleted)), [
|
|
241
|
+
null,
|
|
242
|
+
null,
|
|
243
|
+
null,
|
|
244
|
+
null,
|
|
245
|
+
11,
|
|
246
|
+
11,
|
|
247
|
+
]);
|
|
248
|
+
}),
|
|
249
|
+
test("entries marked outside the window of the list that marked them come back, with their events", async ({ store, calendar, }) => {
|
|
250
|
+
const month = 30 * 24 * HOUR;
|
|
251
|
+
const [old, next, inside] = await store.saveEntries(calendar, [single("old", earlier - month), single("next", later + month), single("inside", later)], 2);
|
|
252
|
+
assert.equal(await store.markUnseen(calendar, 10, { from: 0, to: later + 2 * month }, 11), 3);
|
|
253
|
+
assert.equal((await store.event(next?.eventId))?.event.status, "cancelled");
|
|
254
|
+
assert.equal(await store.restoreUnseen(calendar, () => ({ from: earlier, to: later + 24 * HOUR }), 12), 2);
|
|
255
|
+
const oldEvent = (await store.event(old?.eventId))?.event;
|
|
256
|
+
const nextEvent = (await store.event(next?.eventId))?.event;
|
|
257
|
+
assert.equal(oldEvent?.sourceDeletedAt, null);
|
|
258
|
+
assert.equal(nextEvent?.status, "confirmed");
|
|
259
|
+
assert.equal((await store.entry(calendar, "old"))?.deletedAt, null);
|
|
260
|
+
assert.equal((await store.entry(calendar, "inside"))?.deletedAt, 11);
|
|
261
|
+
assert.equal(await store.restoreUnseen(calendar, () => ({ from: earlier, to: later + 24 * HOUR }), 13), 0);
|
|
262
|
+
assert.equal((await store.event(inside?.eventId))?.event.status, "cancelled");
|
|
263
|
+
}),
|
|
264
|
+
test("a list by time finds a long event that started well before it, timed or all-day", async ({ store, calendar, }) => {
|
|
265
|
+
const day = 24 * HOUR;
|
|
266
|
+
await store.saveEntries(calendar, [
|
|
267
|
+
single("trip", later - 30 * day, { endsAt: later + 30 * day }),
|
|
268
|
+
single("leave", later - 30 * day, {
|
|
269
|
+
allDay: true,
|
|
270
|
+
startDate: new Date(later - 30 * day).toISOString().slice(0, 10),
|
|
271
|
+
endDate: new Date(later + 30 * day).toISOString().slice(0, 10),
|
|
272
|
+
endsAt: later + 30 * day,
|
|
273
|
+
}),
|
|
274
|
+
single("today", later),
|
|
275
|
+
], 2);
|
|
276
|
+
const day0 = new Date(later).toISOString().slice(0, 10);
|
|
277
|
+
const day1 = new Date(later + day).toISOString().slice(0, 10);
|
|
278
|
+
for (const filter of [
|
|
279
|
+
{ from: later, to: later + day },
|
|
280
|
+
{ from: later, to: later + day, fromDate: day0, toDate: day1 },
|
|
281
|
+
])
|
|
282
|
+
assert.deepEqual((await store.events(filter)).map(({ title }) => title).length, 3, JSON.stringify(filter));
|
|
283
|
+
}),
|
|
284
|
+
test("a list of changes since an instant finds events new, moved or cancelled since, and says which", async ({ store, calendar, }) => {
|
|
285
|
+
await store.saveEntries(calendar, ["same", "moved", "back", "cancelled"].map((id) => single(id, later)), 2);
|
|
286
|
+
await store.saveEntries(calendar, [
|
|
287
|
+
single("moved", later + HOUR),
|
|
288
|
+
single("back", later + HOUR),
|
|
289
|
+
single("cancelled", later, { status: "cancelled" }),
|
|
290
|
+
single("new", later),
|
|
291
|
+
], 10);
|
|
292
|
+
await store.saveEntries(calendar, [single("back", later)], 11);
|
|
293
|
+
const changed = async (since) => {
|
|
294
|
+
const events = await store.events({ changedSince: since });
|
|
295
|
+
const { entries, revisions } = await store.historyOf(events.map(({ id }) => id), [calendar], since);
|
|
296
|
+
return Object.fromEntries(events.map((event) => {
|
|
297
|
+
const [copy] = entries.filter(({ eventId }) => eventId === event.id);
|
|
298
|
+
return [
|
|
299
|
+
copy?.externalId,
|
|
300
|
+
eventChange(event, entries.filter(({ eventId }) => eventId === event.id), revisions, since),
|
|
301
|
+
];
|
|
302
|
+
}));
|
|
303
|
+
};
|
|
304
|
+
assert.deepEqual(await changed(5), {
|
|
305
|
+
moved: { change: "moved", at: 10, was: { startsAt: later, endsAt: later + HOUR } },
|
|
306
|
+
cancelled: { change: "cancelled", at: 10 },
|
|
307
|
+
new: { change: "new", at: 10 },
|
|
308
|
+
});
|
|
309
|
+
assert.deepEqual(Object.keys(await changed(12)), []);
|
|
310
|
+
assert.equal((await changed(1)).same?.change, "new");
|
|
311
|
+
}),
|
|
312
|
+
test("a list of overlapping events holds timed events that clash and take the owner's time", async ({ store, calendar, }) => {
|
|
313
|
+
const at = later;
|
|
314
|
+
await store.saveEntries(calendar, [
|
|
315
|
+
single("first", at),
|
|
316
|
+
single("second", at + HOUR / 2),
|
|
317
|
+
single("alone", at + 3 * HOUR),
|
|
318
|
+
single("declined", at + 3 * HOUR + HOUR / 2, { ownResponse: "declined" }),
|
|
319
|
+
single("busy", at + 5 * HOUR),
|
|
320
|
+
single("free", at + 5 * HOUR, { transparency: "transparent" }),
|
|
321
|
+
single("cancelled", at + 3 * HOUR, { status: "cancelled" }),
|
|
322
|
+
single("all-day", at, {
|
|
323
|
+
allDay: true,
|
|
324
|
+
startDate: new Date(at).toISOString().slice(0, 10),
|
|
325
|
+
endDate: new Date(at + 24 * HOUR).toISOString().slice(0, 10),
|
|
326
|
+
endsAt: at + 24 * HOUR,
|
|
327
|
+
}),
|
|
328
|
+
], 2);
|
|
329
|
+
const found = await store.events({ overlapping: true, from: at - HOUR, to: at + 8 * HOUR });
|
|
330
|
+
assert.deepEqual(found.map(({ title }) => title).length, 2);
|
|
331
|
+
const ids = new Set(found.map(({ id }) => id));
|
|
332
|
+
for (const externalId of ["first", "second"])
|
|
333
|
+
assert.ok(ids.has((await store.entry(calendar, externalId))?.eventId), externalId);
|
|
334
|
+
}),
|
|
221
335
|
test("participants are kept by address; a room is listed and is no contact", async ({ store, calendar }) => {
|
|
222
336
|
const [entry] = await store.saveEntries(calendar, [
|
|
223
337
|
single("a", later, {
|
|
@@ -9,4 +9,12 @@ export declare const openWithNodeSqlite: (path: string) => Promise<CacheDatabase
|
|
|
9
9
|
export declare const nodeClient: (path: string, { readOnly }?: {
|
|
10
10
|
readOnly?: boolean;
|
|
11
11
|
}) => Promise<DatabaseSync>;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Preparing is the expensive part of a call: the sync write path builds the same SQL text thousands of times, so each
|
|
14
|
+
* connection keeps its statements, least recently used out first. Node's own `createTagStore` would do it, but it needs
|
|
15
|
+
* Node 24.9 and template tags, not SQL text. An evicted statement is not finalized, because a caller may still hold
|
|
16
|
+
* it; `close()` finalizes everything left. The seam must never offer `iterate`: callers share a cached statement, and
|
|
17
|
+
* reusing a statement invalidates its open iterator.
|
|
18
|
+
*/
|
|
19
|
+
export declare const STATEMENT_CACHE_SIZE = 1000;
|
|
20
|
+
export declare const cacheOverNodeSqlite: (database: DatabaseSync, cacheSize?: number) => CacheDatabase;
|
|
@@ -19,17 +19,39 @@ export const nodeClient = async (path, { readOnly = false } = {}) => {
|
|
|
19
19
|
throw error;
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Preparing is the expensive part of a call: the sync write path builds the same SQL text thousands of times, so each
|
|
24
|
+
* connection keeps its statements, least recently used out first. Node's own `createTagStore` would do it, but it needs
|
|
25
|
+
* Node 24.9 and template tags, not SQL text. An evicted statement is not finalized, because a caller may still hold
|
|
26
|
+
* it; `close()` finalizes everything left. The seam must never offer `iterate`: callers share a cached statement, and
|
|
27
|
+
* reusing a statement invalidates its open iterator.
|
|
28
|
+
*/
|
|
29
|
+
export const STATEMENT_CACHE_SIZE = 1000;
|
|
30
|
+
export const cacheOverNodeSqlite = (database, cacheSize = STATEMENT_CACHE_SIZE) => {
|
|
31
|
+
const statements = new Map();
|
|
32
|
+
const prepare = (sql) => {
|
|
33
|
+
const statement = database.prepare(sql);
|
|
34
|
+
return {
|
|
35
|
+
run: (...parameters) => ({ changes: Number(statement.run(...parameters).changes) }),
|
|
36
|
+
get: (...parameters) => statement.get(...parameters),
|
|
37
|
+
all: (...parameters) => statement.all(...parameters),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
23
40
|
return {
|
|
24
41
|
exec: (sql) => database.exec(sql),
|
|
25
42
|
prepare: (sql) => {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
const cached = statements.get(sql);
|
|
44
|
+
if (cached)
|
|
45
|
+
statements.delete(sql);
|
|
46
|
+
const statement = cached ?? prepare(sql);
|
|
47
|
+
statements.set(sql, statement);
|
|
48
|
+
if (statements.size > cacheSize)
|
|
49
|
+
statements.delete(statements.keys().next().value);
|
|
50
|
+
return statement;
|
|
51
|
+
},
|
|
52
|
+
close: () => {
|
|
53
|
+
statements.clear();
|
|
54
|
+
database.close();
|
|
32
55
|
},
|
|
33
|
-
close: () => database.close(),
|
|
34
56
|
};
|
|
35
57
|
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Event, EventFilter } from "../../meetings/index.js";
|
|
2
|
+
import type { CacheDatabase } from "../driver.js";
|
|
3
|
+
/** The events of a filter, earliest first (`EventStore.events`). */
|
|
4
|
+
export declare const listEvents: (database: CacheDatabase, filter: EventFilter) => Event[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { eventOf } from "./events.js";
|
|
3
|
+
const DAY = 86_400_000;
|
|
4
|
+
/** The events of a filter, earliest first (`EventStore.events`). */
|
|
5
|
+
export const listEvents = (database, filter) => {
|
|
6
|
+
const clauses = ["e.deleted_at IS NULL"];
|
|
7
|
+
const params = [];
|
|
8
|
+
const entries = (extra) => `EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id AND ${extra})`;
|
|
9
|
+
const dated = (column, date, instant, at) => {
|
|
10
|
+
if (at === undefined)
|
|
11
|
+
return;
|
|
12
|
+
if (date === undefined) {
|
|
13
|
+
clauses.push(instant);
|
|
14
|
+
params.push(at);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
clauses.push(`(CASE WHEN e.all_day = 1 THEN EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id AND ce.${column}) ELSE ${instant} END)`);
|
|
18
|
+
params.push(date, at);
|
|
19
|
+
};
|
|
20
|
+
dated("end_date > ?", filter.fromDate, "coalesce(e.ends_at, e.starts_at + 1) > ?", filter.from);
|
|
21
|
+
dated("start_date < ?", filter.toDate, "e.starts_at < ?", filter.to);
|
|
22
|
+
// Bounds on starts_at alone let SQLite seek its index; the CASE above cannot. They are wider than the CASE: by the
|
|
23
|
+
// longest stored event back, and by a day each way for an all-day event's zone.
|
|
24
|
+
const longest = () => Number(database.prepare("SELECT coalesce(max(ends_at - starts_at), 0) AS ms FROM events WHERE deleted_at IS NULL").get()
|
|
25
|
+
?.ms ?? 0);
|
|
26
|
+
if (filter.from !== undefined) {
|
|
27
|
+
clauses.push("e.starts_at >= ?");
|
|
28
|
+
params.push(filter.from - longest() - DAY);
|
|
29
|
+
}
|
|
30
|
+
if (filter.to !== undefined && filter.toDate !== undefined) {
|
|
31
|
+
clauses.push("e.starts_at < ?");
|
|
32
|
+
params.push(filter.to + DAY);
|
|
33
|
+
}
|
|
34
|
+
if (filter.calendarId !== undefined) {
|
|
35
|
+
clauses.push(entries("ce.calendar_id = ?"));
|
|
36
|
+
params.push(filter.calendarId);
|
|
37
|
+
}
|
|
38
|
+
if (filter.accountId !== undefined) {
|
|
39
|
+
clauses.push(entries("c.account_id = ?"));
|
|
40
|
+
params.push(filter.accountId);
|
|
41
|
+
}
|
|
42
|
+
if (filter.personId !== undefined) {
|
|
43
|
+
clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
|
|
44
|
+
"WHERE p.entry_id = ce.id AND il.person_id = ?)"));
|
|
45
|
+
params.push(filter.personId);
|
|
46
|
+
}
|
|
47
|
+
const account = (alias) => {
|
|
48
|
+
if (filter.accountId === undefined)
|
|
49
|
+
return "";
|
|
50
|
+
params.push(filter.accountId);
|
|
51
|
+
return ` AND ${alias}.account_id = ?`;
|
|
52
|
+
};
|
|
53
|
+
if (filter.changedSince !== undefined) {
|
|
54
|
+
// The SQL form of eventChange: first stored since, cancelled since, or a live copy's time changed since.
|
|
55
|
+
const since = filter.changedSince;
|
|
56
|
+
const own = account("c");
|
|
57
|
+
params.push(since);
|
|
58
|
+
const older = account("oc");
|
|
59
|
+
params.push(since, since, since, since);
|
|
60
|
+
clauses.push(`EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id${own}
|
|
61
|
+
AND ((ce.created_at >= ? AND NOT EXISTS (SELECT 1 FROM calendar_entries o JOIN calendars oc
|
|
62
|
+
ON oc.id = o.calendar_id WHERE o.event_id = e.id${older} AND o.created_at < ?))
|
|
63
|
+
OR ((e.status = 'cancelled' OR e.source_deleted_at IS NOT NULL) AND (ce.deleted_at >= ?
|
|
64
|
+
OR (ce.status = 'cancelled' AND EXISTS (SELECT 1 FROM calendar_entry_revisions r WHERE r.entry_id = ce.id
|
|
65
|
+
AND r.created_at >= ? AND r.status IS NOT 'cancelled'))))
|
|
66
|
+
OR (ce.deleted_at IS NULL AND ce.status <> 'cancelled' AND (SELECT r.starts_at IS NOT ce.starts_at
|
|
67
|
+
OR r.ends_at IS NOT ce.ends_at FROM calendar_entry_revisions r WHERE r.entry_id = ce.id AND r.created_at >= ?
|
|
68
|
+
ORDER BY r.created_at, r.id LIMIT 1) = 1)))`);
|
|
69
|
+
}
|
|
70
|
+
if (filter.overlapping) {
|
|
71
|
+
const busy = (event) => `${event}.deleted_at IS NULL AND ${event}.status <> 'cancelled' AND ${event}.all_day = 0
|
|
72
|
+
AND ${event}.starts_at IS NOT NULL AND ${event}.ends_at IS NOT NULL AND EXISTS (SELECT 1 FROM calendar_entries
|
|
73
|
+
bce JOIN calendars bc ON bc.id = bce.calendar_id WHERE bce.event_id = ${event}.id${account("bc")}
|
|
74
|
+
AND bce.deleted_at IS NULL AND bce.status <> 'cancelled' AND coalesce(bce.own_response, '') <> 'declined'
|
|
75
|
+
AND coalesce(bce.transparency, '') <> 'transparent')`;
|
|
76
|
+
const own = busy("e");
|
|
77
|
+
params.push(longest());
|
|
78
|
+
clauses.push(`${own} AND EXISTS (SELECT 1 FROM events e2 WHERE e2.id <> e.id AND e2.starts_at < e.ends_at
|
|
79
|
+
AND e2.ends_at > e.starts_at AND e2.starts_at >= e.starts_at - ? AND ${busy("e2")})`);
|
|
80
|
+
}
|
|
81
|
+
const limit = filter.limit ?? 100;
|
|
82
|
+
const offset = filter.offset ?? 0;
|
|
83
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
|
|
84
|
+
throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
|
|
85
|
+
return database
|
|
86
|
+
.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 ?`)
|
|
87
|
+
.all(...params, limit, offset)
|
|
88
|
+
.map(eventOf);
|
|
89
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import { calendarText, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
|
|
2
|
+
import { calendarText, entryInWindow, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
|
|
3
|
+
import { listEvents } from "./calendar-events.js";
|
|
3
4
|
import { bool, eventOf, fromJson, int, str, toJson } from "./events.js";
|
|
4
5
|
import { driveDocumentOf, driveFileTarget, logFileLink } from "./files.js";
|
|
5
6
|
import { identityOf } from "./identities.js";
|
|
@@ -51,6 +52,8 @@ const entryOf = (row) => ({
|
|
|
51
52
|
etag: str(row.etag),
|
|
52
53
|
seenAt: int(row.seen_at),
|
|
53
54
|
deletedAt: int(row.deleted_at),
|
|
55
|
+
createdAt: Number(row.created_at),
|
|
56
|
+
transparency: str(row.transparency),
|
|
54
57
|
});
|
|
55
58
|
const participantOf = (row) => ({
|
|
56
59
|
entryId: Number(row.entry_id),
|
|
@@ -79,6 +82,7 @@ const reviewOf = (row) => ({
|
|
|
79
82
|
id: Number(row.id),
|
|
80
83
|
linkableType: String(row.linkable_type),
|
|
81
84
|
linkableId: Number(row.linkable_id),
|
|
85
|
+
calendarId: int(row.calendar_id),
|
|
82
86
|
entityType: String(row.entity_type),
|
|
83
87
|
candidates: fromJson(row.candidates),
|
|
84
88
|
reason: String(row.reason),
|
|
@@ -94,6 +98,8 @@ const attachmentOf = (row) => ({
|
|
|
94
98
|
title: str(fromJson(row.metadata)?.title),
|
|
95
99
|
documentId: int(row.to_id),
|
|
96
100
|
});
|
|
101
|
+
const REVIEWS = "SELECT r.*, CASE WHEN r.linkable_type = 'calendar_entry' THEN " +
|
|
102
|
+
"(SELECT calendar_id FROM calendar_entries WHERE id = r.linkable_id) END AS calendar_id FROM link_reviews r";
|
|
97
103
|
const TRACKED = ["title", "starts_at", "ends_at", "location", "status", "recurrence", "recurrence_ends_at"];
|
|
98
104
|
const flag = (value) => (value ? 1 : 0);
|
|
99
105
|
const cursorKey = (calendarId) => `calendar:${calendarId}`;
|
|
@@ -448,6 +454,56 @@ export const eventStoreOver = (context) => {
|
|
|
448
454
|
refreshEvent(Number(row.event_id), now);
|
|
449
455
|
}
|
|
450
456
|
};
|
|
457
|
+
const saveAll = (calendarId, entries, now) => {
|
|
458
|
+
calendarRow(calendarId);
|
|
459
|
+
// A series first, so an occurrence in the same page finds it.
|
|
460
|
+
const ordered = [...entries].sort((a, b) => Number(b.kind === "series") - Number(a.kind === "series"));
|
|
461
|
+
const ids = new Map();
|
|
462
|
+
const saved = ordered.map((input) => {
|
|
463
|
+
const entry = saveEntry(calendarId, input, now);
|
|
464
|
+
ids.set(input, entry.id);
|
|
465
|
+
return entry;
|
|
466
|
+
});
|
|
467
|
+
for (const entry of saved)
|
|
468
|
+
if (entry.kind === "series" && entry.status === "cancelled")
|
|
469
|
+
cancelOccurrences(entry.id, now);
|
|
470
|
+
const current = saved.map(({ id }) => entryOf(entryRow(id)));
|
|
471
|
+
for (const entry of current)
|
|
472
|
+
link(entry, now);
|
|
473
|
+
refreshAround(current.map(({ id }) => id), now);
|
|
474
|
+
return entries.map((input) => entryOf(entryRow(ids.get(input))));
|
|
475
|
+
};
|
|
476
|
+
const removeAll = (calendarId, externalIds, now) => {
|
|
477
|
+
calendarRow(calendarId);
|
|
478
|
+
const removed = database
|
|
479
|
+
.prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id IN (SELECT value FROM json_each(?))
|
|
480
|
+
AND status <> 'cancelled' AND deleted_at IS NULL`)
|
|
481
|
+
.all(calendarId, JSON.stringify(externalIds));
|
|
482
|
+
for (const row of removed) {
|
|
483
|
+
cancel(row, now);
|
|
484
|
+
database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?").run(now, Number(row.id));
|
|
485
|
+
if (row.kind === "series")
|
|
486
|
+
cancelOccurrences(Number(row.id), now);
|
|
487
|
+
}
|
|
488
|
+
refreshAround(removed.map((row) => Number(row.id)), now);
|
|
489
|
+
return removed.length;
|
|
490
|
+
};
|
|
491
|
+
const putCursor = (calendarId, value, now) => {
|
|
492
|
+
const accountId = Number(calendarRow(calendarId).account_id);
|
|
493
|
+
if (value === null)
|
|
494
|
+
database
|
|
495
|
+
.prepare("DELETE FROM sync_cursors WHERE account_id = ? AND key = ?")
|
|
496
|
+
.run(accountId, cursorKey(calendarId));
|
|
497
|
+
else
|
|
498
|
+
database
|
|
499
|
+
.prepare(`INSERT INTO sync_cursors (account_id, key, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
|
|
500
|
+
ON CONFLICT (account_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
|
501
|
+
.run(accountId, cursorKey(calendarId), value, now, now);
|
|
502
|
+
};
|
|
503
|
+
const slotRows = (calendarId, series, window) => database
|
|
504
|
+
.prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND kind = 'occurrence' AND series_external_id = ?
|
|
505
|
+
AND original_starts_at >= ? AND original_starts_at < ? AND deleted_at IS NULL ORDER BY original_starts_at`)
|
|
506
|
+
.all(calendarId, series, window.from, window.to);
|
|
451
507
|
return {
|
|
452
508
|
saveCalendars: async (accountId, calendars, now) => write(database, () => {
|
|
453
509
|
const upsert = database.prepare(`INSERT INTO calendars (account_id, external_id, title, timezone, access_role, primary_calendar, synced, seen_at,
|
|
@@ -468,115 +524,72 @@ export const eventStoreOver = (context) => {
|
|
|
468
524
|
.prepare("UPDATE calendars SET synced = ?, updated_at = ? WHERE id = ?")
|
|
469
525
|
.run(flag(synced), now, calendarId);
|
|
470
526
|
}),
|
|
471
|
-
saveEntries: async (calendarId, entries, now) => write(database, () =>
|
|
472
|
-
|
|
473
|
-
// A series first, so an occurrence in the same page finds it.
|
|
474
|
-
const ordered = [...entries].sort((a, b) => Number(b.kind === "series") - Number(a.kind === "series"));
|
|
475
|
-
const ids = new Map();
|
|
476
|
-
const saved = ordered.map((input) => {
|
|
477
|
-
const entry = saveEntry(calendarId, input, now);
|
|
478
|
-
ids.set(input, entry.id);
|
|
479
|
-
return entry;
|
|
480
|
-
});
|
|
481
|
-
for (const entry of saved)
|
|
482
|
-
if (entry.kind === "series" && entry.status === "cancelled")
|
|
483
|
-
cancelOccurrences(entry.id, now);
|
|
484
|
-
const current = saved.map(({ id }) => entryOf(entryRow(id)));
|
|
485
|
-
for (const entry of current)
|
|
486
|
-
link(entry, now);
|
|
487
|
-
refreshAround(current.map(({ id }) => id), now);
|
|
488
|
-
return entries.map((input) => entryOf(entryRow(ids.get(input))));
|
|
489
|
-
}),
|
|
490
|
-
removeEntries: async (calendarId, externalIds, now) => write(database, () => {
|
|
491
|
-
calendarRow(calendarId);
|
|
492
|
-
const removed = database
|
|
493
|
-
.prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id IN (SELECT value FROM json_each(?))
|
|
494
|
-
AND status <> 'cancelled' AND deleted_at IS NULL`)
|
|
495
|
-
.all(calendarId, JSON.stringify(externalIds));
|
|
496
|
-
for (const row of removed) {
|
|
497
|
-
cancel(row, now);
|
|
498
|
-
database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?").run(now, Number(row.id));
|
|
499
|
-
if (row.kind === "series")
|
|
500
|
-
cancelOccurrences(Number(row.id), now);
|
|
501
|
-
}
|
|
502
|
-
refreshAround(removed.map((row) => Number(row.id)), now);
|
|
503
|
-
return removed.length;
|
|
504
|
-
}),
|
|
527
|
+
saveEntries: async (calendarId, entries, now) => write(database, () => saveAll(calendarId, entries, now)),
|
|
528
|
+
removeEntries: async (calendarId, externalIds, now) => write(database, () => removeAll(calendarId, externalIds, now)),
|
|
505
529
|
markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => {
|
|
506
530
|
const marked = database
|
|
507
|
-
.prepare(`
|
|
508
|
-
AND (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
.
|
|
531
|
+
.prepare(`SELECT * FROM calendar_entries WHERE calendar_id = ? AND deleted_at IS NULL AND (seen_at IS NULL OR seen_at < ?)
|
|
532
|
+
AND min(coalesce(starts_at, original_starts_at), coalesce(original_starts_at, starts_at)) < ? AND (kind = 'series'
|
|
533
|
+
OR max(coalesce(ends_at, 0), coalesce(starts_at, 0) + 1, coalesce(original_starts_at, 0)) >= ?)`)
|
|
534
|
+
.all(calendarId, seenBefore, window.to, window.from)
|
|
535
|
+
.filter((row) => entryInWindow(entryOf(row), window));
|
|
536
|
+
const mark = database.prepare("UPDATE calendar_entries SET deleted_at = ?, updated_at = ? WHERE id = ?");
|
|
537
|
+
for (const row of marked)
|
|
538
|
+
mark.run(now, now, Number(row.id));
|
|
512
539
|
for (const eventId of new Set(marked.map((row) => int(row.event_id))))
|
|
513
540
|
if (eventId !== null)
|
|
514
541
|
refreshEvent(eventId, now);
|
|
515
542
|
return marked.length;
|
|
516
543
|
}),
|
|
544
|
+
restoreUnseen: async (calendarId, windowAt, now) => write(database, () => {
|
|
545
|
+
const restored = database
|
|
546
|
+
.prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND deleted_at IS NOT NULL")
|
|
547
|
+
.all(calendarId)
|
|
548
|
+
.map(entryOf)
|
|
549
|
+
.filter((entry) => !entryInWindow(entry, windowAt(entry.deletedAt)));
|
|
550
|
+
const restore = database.prepare("UPDATE calendar_entries SET deleted_at = NULL, updated_at = ? WHERE id = ?");
|
|
551
|
+
for (const { id } of restored)
|
|
552
|
+
restore.run(now, id);
|
|
553
|
+
refreshAround(restored.map(({ id }) => id), now);
|
|
554
|
+
return restored.length;
|
|
555
|
+
}),
|
|
517
556
|
entry: async (calendarId, externalId) => read(database, () => {
|
|
518
557
|
const row = database
|
|
519
558
|
.prepare("SELECT * FROM calendar_entries WHERE calendar_id = ? AND external_id = ?")
|
|
520
559
|
.get(calendarId, externalId);
|
|
521
560
|
return row ? entryOf(row) : null;
|
|
522
561
|
}),
|
|
562
|
+
slotsOf: async (calendarId, seriesExternalId, window) => read(database, () => slotRows(calendarId, seriesExternalId, window).map(entryOf)),
|
|
563
|
+
saveSlots: async (calendarId, groups, window, cursor, now) => write(database, () => {
|
|
564
|
+
let [saved, removed] = [0, 0];
|
|
565
|
+
const seen = database.prepare("UPDATE calendar_entries SET seen_at = ? WHERE id = ?");
|
|
566
|
+
for (const { series, slots } of groups) {
|
|
567
|
+
const held = slotRows(calendarId, series, window);
|
|
568
|
+
if (slots === null) {
|
|
569
|
+
for (const row of held)
|
|
570
|
+
seen.run(now, Number(row.id));
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (slots.length)
|
|
574
|
+
saved += saveAll(calendarId, slots, now).length;
|
|
575
|
+
const given = new Set(slots.map(({ externalId }) => externalId));
|
|
576
|
+
const stale = held
|
|
577
|
+
.filter((row) => Number(row.exception) !== 1 && row.status !== "cancelled" && !given.has(String(row.external_id)))
|
|
578
|
+
.map((row) => String(row.external_id));
|
|
579
|
+
if (stale.length)
|
|
580
|
+
removed += removeAll(calendarId, stale, now);
|
|
581
|
+
}
|
|
582
|
+
putCursor(calendarId, cursor, now);
|
|
583
|
+
return { saved, removed };
|
|
584
|
+
}),
|
|
523
585
|
cursor: async (calendarId) => read(database, () => {
|
|
524
586
|
const { account_id } = calendarRow(calendarId);
|
|
525
587
|
return str(database
|
|
526
588
|
.prepare("SELECT value FROM sync_cursors WHERE account_id = ? AND key = ?")
|
|
527
589
|
.get(Number(account_id), cursorKey(calendarId))?.value);
|
|
528
590
|
}),
|
|
529
|
-
setCursor: async (calendarId, value, now) => write(database, () =>
|
|
530
|
-
|
|
531
|
-
if (value === null)
|
|
532
|
-
database
|
|
533
|
-
.prepare("DELETE FROM sync_cursors WHERE account_id = ? AND key = ?")
|
|
534
|
-
.run(accountId, cursorKey(calendarId));
|
|
535
|
-
else
|
|
536
|
-
database
|
|
537
|
-
.prepare(`INSERT INTO sync_cursors (account_id, key, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
|
|
538
|
-
ON CONFLICT (account_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
|
539
|
-
.run(accountId, cursorKey(calendarId), value, now, now);
|
|
540
|
-
}),
|
|
541
|
-
events: async (filter = {}) => {
|
|
542
|
-
const clauses = ["e.deleted_at IS NULL"];
|
|
543
|
-
const params = [];
|
|
544
|
-
const entries = (extra) => `EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id AND ${extra})`;
|
|
545
|
-
const dated = (column, date, instant, at) => {
|
|
546
|
-
if (at === undefined)
|
|
547
|
-
return;
|
|
548
|
-
if (date === undefined) {
|
|
549
|
-
clauses.push(instant);
|
|
550
|
-
params.push(at);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
clauses.push(`(CASE WHEN e.all_day = 1 THEN EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id AND ce.${column}) ELSE ${instant} END)`);
|
|
554
|
-
params.push(date, at);
|
|
555
|
-
};
|
|
556
|
-
dated("end_date > ?", filter.fromDate, "coalesce(e.ends_at, e.starts_at + 1) > ?", filter.from);
|
|
557
|
-
dated("start_date < ?", filter.toDate, "e.starts_at < ?", filter.to);
|
|
558
|
-
if (filter.calendarId !== undefined) {
|
|
559
|
-
clauses.push(entries("ce.calendar_id = ?"));
|
|
560
|
-
params.push(filter.calendarId);
|
|
561
|
-
}
|
|
562
|
-
if (filter.accountId !== undefined) {
|
|
563
|
-
clauses.push(entries("c.account_id = ?"));
|
|
564
|
-
params.push(filter.accountId);
|
|
565
|
-
}
|
|
566
|
-
if (filter.personId !== undefined) {
|
|
567
|
-
clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
|
|
568
|
-
"WHERE p.entry_id = ce.id AND il.person_id = ?)"));
|
|
569
|
-
params.push(filter.personId);
|
|
570
|
-
}
|
|
571
|
-
const limit = filter.limit ?? 100;
|
|
572
|
-
const offset = filter.offset ?? 0;
|
|
573
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
|
|
574
|
-
throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
|
|
575
|
-
return database
|
|
576
|
-
.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 ?`)
|
|
577
|
-
.all(...params, limit, offset)
|
|
578
|
-
.map(eventOf);
|
|
579
|
-
},
|
|
591
|
+
setCursor: async (calendarId, value, now) => write(database, () => putCursor(calendarId, value, now)),
|
|
592
|
+
events: async (filter = {}) => listEvents(database, filter),
|
|
580
593
|
event: async (id) => read(database, () => {
|
|
581
594
|
const row = database.prepare("SELECT * FROM events WHERE id = ?").get(id);
|
|
582
595
|
if (!row)
|
|
@@ -605,6 +618,25 @@ export const eventStoreOver = (context) => {
|
|
|
605
618
|
.map(attachmentOf),
|
|
606
619
|
};
|
|
607
620
|
}),
|
|
621
|
+
historyOf: async (eventIds, calendarIds, since) => read(database, () => {
|
|
622
|
+
const entries = database
|
|
623
|
+
.prepare(`SELECT * FROM calendar_entries WHERE event_id IN (SELECT value FROM json_each(?))
|
|
624
|
+
AND calendar_id IN (SELECT value FROM json_each(?)) ORDER BY id`)
|
|
625
|
+
.all(JSON.stringify(eventIds), JSON.stringify(calendarIds))
|
|
626
|
+
.map(entryOf);
|
|
627
|
+
const revisions = database
|
|
628
|
+
.prepare(`SELECT * FROM calendar_entry_revisions WHERE entry_id IN (SELECT value FROM json_each(?)) AND created_at >= ?
|
|
629
|
+
ORDER BY created_at, id`)
|
|
630
|
+
.all(JSON.stringify(entries.map(({ id }) => id)), since)
|
|
631
|
+
.map(revisionOf);
|
|
632
|
+
return { entries, revisions };
|
|
633
|
+
}),
|
|
634
|
+
participantsOf: async (eventIds, calendarIds) => read(database, () => database
|
|
635
|
+
.prepare(`SELECT p.*, ce.event_id FROM calendar_entry_participants p JOIN calendar_entries ce ON ce.id = p.entry_id
|
|
636
|
+
WHERE ce.event_id IN (SELECT value FROM json_each(?)) AND ce.calendar_id IN (SELECT value FROM json_each(?))
|
|
637
|
+
ORDER BY ce.event_id, p.entry_id, p.email`)
|
|
638
|
+
.all(JSON.stringify(eventIds), JSON.stringify(calendarIds))
|
|
639
|
+
.map((row) => ({ ...participantOf(row), eventId: Number(row.event_id) }))),
|
|
608
640
|
linkEntry: async (entryId, eventId, now) => write(database, () => {
|
|
609
641
|
const row = database.prepare("SELECT * FROM calendar_entries WHERE id = ?").get(entryId);
|
|
610
642
|
if (!row)
|
|
@@ -636,11 +668,11 @@ export const eventStoreOver = (context) => {
|
|
|
636
668
|
});
|
|
637
669
|
}),
|
|
638
670
|
reviews: async (filter = {}) => database
|
|
639
|
-
.prepare(
|
|
671
|
+
.prepare(`${REVIEWS}${filter.status ? " WHERE r.status = ?" : ""} ORDER BY r.created_at, r.id`)
|
|
640
672
|
.all(...(filter.status ? [filter.status] : []))
|
|
641
673
|
.map(reviewOf),
|
|
642
674
|
resolveReview: async (id, decision, now) => write(database, () => {
|
|
643
|
-
const row = database.prepare(
|
|
675
|
+
const row = database.prepare(`${REVIEWS} WHERE r.id = ?`).get(id);
|
|
644
676
|
if (!row)
|
|
645
677
|
throw new CliError("not_found", `no review ${id} in the local store`);
|
|
646
678
|
const review = reviewOf(row);
|
|
@@ -710,7 +742,7 @@ export const eventStoreOver = (context) => {
|
|
|
710
742
|
database
|
|
711
743
|
.prepare("UPDATE link_reviews SET status = ?, chosen_id = ?, decided_by = 'owner', decided_at = ?, updated_at = ? WHERE id = ?")
|
|
712
744
|
.run(chosen === null ? "apart" : "linked", chosen, now, now, id);
|
|
713
|
-
return reviewOf(database.prepare(
|
|
745
|
+
return reviewOf(database.prepare(`${REVIEWS} WHERE r.id = ?`).get(id));
|
|
714
746
|
}),
|
|
715
747
|
};
|
|
716
748
|
};
|
|
@@ -28,7 +28,7 @@ export declare const people: ({ orm, database }: StoreContext, provider: Provide
|
|
|
28
28
|
export interface ContactFilter {
|
|
29
29
|
/** Three letters or more of the name, the @username or the local alias. */
|
|
30
30
|
query?: string;
|
|
31
|
-
/**
|
|
31
|
+
/** Three characters or more of one of the owner's private notes about them, anywhere, in any letter case. */
|
|
32
32
|
notes?: string;
|
|
33
33
|
}
|
|
34
34
|
export declare const contacts: ({ orm }: StoreContext, accountKey: number, key: AccountKey, { order, limit, offset, ...filter }: {
|
|
@@ -2,7 +2,9 @@ import { CliError } from "@wirecat/cli-base";
|
|
|
2
2
|
import { fold } from "../normalize.js";
|
|
3
3
|
import { and, asc, desc, eq, inArray, isNotNull, isNull, ne, sql } from "./drizzle/core.js";
|
|
4
4
|
import { folded } from "./folded.js";
|
|
5
|
+
import { holdingText } from "./note-index.js";
|
|
5
6
|
import { resolvePersonLinks } from "./person-resolution.js";
|
|
7
|
+
import { bound } from "./query-filter.js";
|
|
6
8
|
import { accountIdentities, accounts, aliases, chatMembers, chats, identities, identityLinkEvents, identityLinks, identityRevisions, messages, persons, } from "./schema.js";
|
|
7
9
|
import { toIso } from "./values.js";
|
|
8
10
|
const flag = (value) => (value === undefined || value === null ? null : Number(value));
|
|
@@ -209,13 +211,14 @@ const contactsWhere = (accountKey, key, { query, notes }) => {
|
|
|
209
211
|
if (query !== undefined && query.trim().length < 3) {
|
|
210
212
|
throw new CliError("validation_error", `a contact search takes at least 3 characters, got "${query}"`);
|
|
211
213
|
}
|
|
214
|
+
const noted = notes === undefined ? undefined : holdingText("note", "n.id", notes);
|
|
212
215
|
return and(eq(accountIdentities.accountId, accountKey), ne(identities.externalId, key.account), sql `EXISTS (SELECT 1 FROM ${chatMembers} JOIN ${chats} ON ${chats.id} = ${chatMembers.chatId}
|
|
213
216
|
WHERE ${chatMembers.identityId} = ${identities.id} AND ${chats.accountId} = ${accountIdentities.accountId}
|
|
214
217
|
AND ${chats.kind} = 'dialog')`, query === undefined
|
|
215
218
|
? undefined
|
|
216
219
|
: sql `(${identities.id} IN (SELECT rowid FROM identities_fts WHERE identities_fts MATCH ${`"${query.trim().replaceAll('"', '""')}"`}) OR EXISTS (SELECT 1 FROM aliases ca WHERE ca.aliasable_type='identity' AND ca.display=1 AND ca.account_id=${accountKey} AND ca.aliasable_id=${identities.id} AND instr(ca.name_folded, ${fold(query.trim())}) > 0))`, notes === undefined
|
|
217
220
|
? undefined
|
|
218
|
-
: sql `EXISTS (SELECT 1 FROM notes n WHERE n.notable_type='identity' AND n.notable_id=${identities.id} AND n.deleted_at IS NULL AND
|
|
221
|
+
: sql `EXISTS (SELECT 1 FROM notes n WHERE n.notable_type='identity' AND n.notable_id=${identities.id} AND n.deleted_at IS NULL${noted ? sql ` AND ${bound(noted)}` : sql ``})`);
|
|
219
222
|
};
|
|
220
223
|
export const contacts = ({ orm }, accountKey, key, { order, limit, offset = 0, ...filter }) => {
|
|
221
224
|
const byName = [
|
|
@@ -10,6 +10,7 @@ import { ulid } from "../ulid.js";
|
|
|
10
10
|
import { ownerPerson } from "./actors.js";
|
|
11
11
|
import { linkKind, RELATION_KINDS } from "./link-kinds.js";
|
|
12
12
|
import { meetingProjectsStoreOver } from "./meeting-projects.js";
|
|
13
|
+
import { holdingText } from "./note-index.js";
|
|
13
14
|
import { insertLink, linkOf } from "./notes.js";
|
|
14
15
|
import { queryFilter } from "./query-filter.js";
|
|
15
16
|
import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, liveTagging, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
|
|
@@ -314,10 +315,11 @@ export const knowledgeStoreOver = (context) => {
|
|
|
314
315
|
const thing = options.target ? resolve(key, options.target).thing : undefined;
|
|
315
316
|
if (options.target && !thing)
|
|
316
317
|
return { items: [], hasMore: false };
|
|
318
|
+
const text = options.search === undefined ? undefined : holdingText("note", "id", options.search);
|
|
317
319
|
const rows = database
|
|
318
320
|
.prepare("SELECT * FROM notes WHERE deleted_at IS NULL AND (? IS NULL OR (notable_type = ? AND notable_id = ?)) " +
|
|
319
|
-
|
|
320
|
-
.all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null,
|
|
321
|
+
`${text ? `AND ${text.sql} ` : ""}ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`)
|
|
322
|
+
.all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null, ...(text?.params ?? []), limit + 1, offset);
|
|
321
323
|
const items = rows.slice(0, limit).flatMap((row) => annotationOf(row) ?? []);
|
|
322
324
|
return { items, hasMore: rows.length > limit };
|
|
323
325
|
},
|