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