@wirecat/cli-core 0.547.0 → 0.548.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 +21 -0
- package/dist/calendar/cli/answers.d.ts +7 -2
- package/dist/calendar/cli/answers.js +12 -4
- package/dist/calendar/cli/index.js +9 -8
- package/dist/calendar/feeds.d.ts +19 -0
- package/dist/calendar/feeds.js +52 -9
- package/dist/calendar/source.d.ts +10 -2
- package/dist/calendar/sync.d.ts +2 -0
- package/dist/calendar/sync.js +10 -6
- package/dist/calendar/testing/memory.js +39 -0
- package/dist/meetings/event-store.d.ts +10 -0
- package/dist/meetings/testing/event-store-contract.js +36 -0
- package/dist/store/sqlite/accounts.d.ts +8 -0
- package/dist/store/sqlite/accounts.js +2 -2
- package/dist/store/sqlite/calendar-forget.d.ts +17 -0
- package/dist/store/sqlite/calendar-forget.js +68 -0
- package/dist/store/sqlite/calendar.js +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.548.0 — 02.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A feed is asked whether it changed.** `sync` sends a feed the last answer's `ETag` / `Last-Modified` back as
|
|
15
|
+
`If-None-Match` / `If-Modified-Since`, and a `304` keeps every stored event without reading the feed again
|
|
16
|
+
(`feeds[].unchanged` in the answer). The validators are the feed calendar's sync token in the store, valid for one
|
|
17
|
+
window: the first sync of a new day, and `sync --rescan`, read the feed in full. `fetchFeedIfChanged` is exported;
|
|
18
|
+
a `fullListOnly` source now gets its last token in `changes()`, and a page may say `unchanged: true`.
|
|
19
|
+
- **`calendars add` warns once on stderr for an `http://` feed**: the feed and any token in its address travel
|
|
20
|
+
unencrypted. It is still allowed; `webcal://` and `https://` do not warn.
|
|
21
|
+
|
|
22
|
+
### Changed — may break callers
|
|
23
|
+
|
|
24
|
+
- **`calendars remove` deletes a feed's events instead of cancelling them**, with the calendar row, its cursor, the
|
|
25
|
+
events and series only it held, their links, tags, search words and vectors, and people only it had seen. An event
|
|
26
|
+
another calendar also holds stays with that copy. The answer's `events` counts the events deleted. A new
|
|
27
|
+
`EventStore.forgetCalendar(calendarId, now)` does it: an `EventStore` written outside cli-core must add it
|
|
28
|
+
(`memoryEventStore` and the SQLite store have it, and `eventStoreContract` checks it). `calendars unsync` is
|
|
29
|
+
unchanged.
|
|
30
|
+
|
|
10
31
|
## 0.547.0 — 02.11.2026
|
|
11
32
|
|
|
12
33
|
### Added
|
|
@@ -24,7 +24,7 @@ export declare const calendarFeed: v.StrictObjectSchema<{
|
|
|
24
24
|
readonly events: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
25
25
|
readonly hint: v.StringSchema<undefined>;
|
|
26
26
|
}, undefined>;
|
|
27
|
-
/** `calendars remove`: the feed's calendar,
|
|
27
|
+
/** `calendars remove`: the feed's calendar, gone, how many stored entries were deleted, and how many events with them. */
|
|
28
28
|
export declare const calendarFeedRemoval: v.StrictObjectSchema<{
|
|
29
29
|
readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
|
|
30
30
|
readonly external_id: v.StringSchema<undefined>;
|
|
@@ -35,6 +35,7 @@ export declare const calendarFeedRemoval: v.StrictObjectSchema<{
|
|
|
35
35
|
readonly synced: v.BooleanSchema<undefined>;
|
|
36
36
|
readonly seen_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
37
37
|
readonly removed: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
38
|
+
readonly events: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
38
39
|
}, undefined>;
|
|
39
40
|
export declare const calendarBusy: v.StrictObjectSchema<{
|
|
40
41
|
readonly start: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
|
|
@@ -100,11 +101,15 @@ export declare const calendarSync: v.StrictObjectSchema<{
|
|
|
100
101
|
readonly embedded: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
101
102
|
readonly left: v.BooleanSchema<undefined>;
|
|
102
103
|
}, undefined>, undefined>;
|
|
103
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Each feed's events as read, and how many it could not take (`syncWithFeeds`); `unchanged` when the feed answered
|
|
106
|
+
* 304 and the counts are those of the read before.
|
|
107
|
+
*/
|
|
104
108
|
readonly feeds: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
|
|
105
109
|
readonly calendar_id: v.NumberSchema<undefined>;
|
|
106
110
|
readonly events: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
107
111
|
readonly skipped: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
112
|
+
readonly unchanged: v.OptionalSchema<v.LiteralSchema<true, undefined>, undefined>;
|
|
108
113
|
}, undefined>, undefined>, undefined>;
|
|
109
114
|
}, undefined>;
|
|
110
115
|
export declare const calendarSyncProgress: v.StrictObjectSchema<{
|
|
@@ -21,8 +21,8 @@ export const calendar = v.strictObject({
|
|
|
21
21
|
});
|
|
22
22
|
/** `calendars add`: the feed's calendar, how many events the feed holds now, and the sync to run next. */
|
|
23
23
|
export const calendarFeed = v.strictObject({ ...calendar.entries, events: count, hint: text });
|
|
24
|
-
/** `calendars remove`: the feed's calendar,
|
|
25
|
-
export const calendarFeedRemoval = v.strictObject({ ...calendar.entries, removed: count });
|
|
24
|
+
/** `calendars remove`: the feed's calendar, gone, how many stored entries were deleted, and how many events with them. */
|
|
25
|
+
export const calendarFeedRemoval = v.strictObject({ ...calendar.entries, removed: count, events: count });
|
|
26
26
|
const busyRow = v.strictObject({
|
|
27
27
|
/** One of the account's calendars, by its id; or `person`, by the address asked. */
|
|
28
28
|
calendar: v.optional(id),
|
|
@@ -72,8 +72,16 @@ export const calendarSync = v.strictObject({
|
|
|
72
72
|
hint: v.optional(text),
|
|
73
73
|
/** What `sync --embed` embedded: `embedEvents`' answer. */
|
|
74
74
|
embeddings: v.optional(v.strictObject({ model: text, embedded: count, left: v.boolean() })),
|
|
75
|
-
/**
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Each feed's events as read, and how many it could not take (`syncWithFeeds`); `unchanged` when the feed answered
|
|
77
|
+
* 304 and the counts are those of the read before.
|
|
78
|
+
*/
|
|
79
|
+
feeds: v.optional(v.array(v.strictObject({
|
|
80
|
+
calendar_id: v.number(),
|
|
81
|
+
events: count,
|
|
82
|
+
skipped: count,
|
|
83
|
+
unchanged: v.optional(v.literal(true)),
|
|
84
|
+
}))),
|
|
77
85
|
});
|
|
78
86
|
export const calendarSyncProgress = v.strictObject({
|
|
79
87
|
calendar_id: id,
|
|
@@ -3,7 +3,7 @@ import { annotate, sharedCommand } from "@wirecat/cli-base/commands";
|
|
|
3
3
|
import { timezoneOf } from "@wirecat/cli-base/query";
|
|
4
4
|
import { clockMeta } from "@wirecat/cli-base/standards";
|
|
5
5
|
import { Argument, InvalidArgumentError, Option } from "commander";
|
|
6
|
-
import { environmentOf, runBudgetOf } from "../../cli/context.js";
|
|
6
|
+
import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
|
|
7
7
|
import { honoursDryRun } from "../../cli/dry-run-guard.js";
|
|
8
8
|
import { changesStore, ownerOnlyOption, writesFileOnlyWith } from "../../cli/effects.js";
|
|
9
9
|
import { queryTerms, withQuery } from "../../cli/list-query.js";
|
|
@@ -14,7 +14,7 @@ import { searchEvents } from "../../services/events-search.js";
|
|
|
14
14
|
import { dateWindow } from "../../services/moment.js";
|
|
15
15
|
import { linkPeople } from "../../services/people.js";
|
|
16
16
|
import { BUSY_QUERY, BUSY_QUERY_NOTE, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
|
|
17
|
-
import { isFeedCalendar, syncWithFeeds } from "../feeds.js";
|
|
17
|
+
import { isFeedCalendar, isPlainHttp, syncWithFeeds } from "../feeds.js";
|
|
18
18
|
import { eventPreparation, nextMeeting, PREPARE_MESSAGES } from "../prepare.js";
|
|
19
19
|
import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, EXPORT_MAX, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, eventsIcs, peopleAnswer, resolveAnswer, reviewsAnswer, sourceAccounts, weekFilter, } from "../reads.js";
|
|
20
20
|
import { REPEAT_HELP } from "../recurrence.js";
|
|
@@ -30,8 +30,6 @@ const REMOTE_READ = { remote: "read", local: "none" };
|
|
|
30
30
|
const LOCAL_OR_REMOTE_READ = { remote: "read", local: "read" };
|
|
31
31
|
const REMOTE_WRITE = { remote: "write", local: "write" };
|
|
32
32
|
const GROUP = { remote: "none", local: "none" };
|
|
33
|
-
/** Every time a store can hold, for marking all of a calendar's entries at once. */
|
|
34
|
-
const EVERYWHERE = { from: -8.64e15, to: 8.64e15 };
|
|
35
33
|
/** How many pieces one `sync --embed` embeds, as drive's. */
|
|
36
34
|
const EMBED_MAX = 600;
|
|
37
35
|
// A list's marker goes on each item: a host's list envelope is strict and `--jsonl` prints only the items.
|
|
@@ -130,6 +128,9 @@ const addCalendarCommandsDefinition = (program, deps) => {
|
|
|
130
128
|
if (command.optsWithGlobals().offline)
|
|
131
129
|
throw new CliError("permission_error", "calendars add reads the feed; leave out --offline");
|
|
132
130
|
const { calendar, events } = await feeds().add(url, opts.title === undefined ? {} : { title: opts.title });
|
|
131
|
+
if (isPlainHttp(url))
|
|
132
|
+
outputFor(command).renderer.warn("this feed's address starts with http://, so the feed and any token in its address travel unencrypted; " +
|
|
133
|
+
"use https:// if the server offers it");
|
|
133
134
|
const [saved] = await deps.store.saveCalendars(deps.accountId, [calendar], now());
|
|
134
135
|
print({
|
|
135
136
|
...calendarItem(saved),
|
|
@@ -138,7 +139,8 @@ const addCalendarCommandsDefinition = (program, deps) => {
|
|
|
138
139
|
}, command);
|
|
139
140
|
});
|
|
140
141
|
example(annotate(calendars.command("remove"), { effects: LOCAL_WRITE, mcp: false })
|
|
141
|
-
.description("stop following a feed calendars add added: its address is forgotten and its stored events
|
|
142
|
+
.description("stop following a feed calendars add added: its address is forgotten and its stored events are deleted, " +
|
|
143
|
+
"not cancelled. Only a feed; calendars unsync stops a provider's calendar")
|
|
142
144
|
.argument("<calendar>", "the feed calendar's id, its ics: id, or its title"), "calendars remove 7").action(async (reference, _opts, command) => {
|
|
143
145
|
const calendar = await calendarIn(deps.store, deps.accountId, reference);
|
|
144
146
|
if (!isFeedCalendar(calendar))
|
|
@@ -146,9 +148,8 @@ const addCalendarCommandsDefinition = (program, deps) => {
|
|
|
146
148
|
reason: "feed.not-a-feed",
|
|
147
149
|
});
|
|
148
150
|
await feeds().remove(calendar.externalId);
|
|
149
|
-
await deps.store.
|
|
150
|
-
|
|
151
|
-
print({ ...calendarItem({ ...calendar, synced: false }), removed }, command);
|
|
151
|
+
const { entries, events } = await deps.store.forgetCalendar(calendar.id, now());
|
|
152
|
+
print({ ...calendarItem({ ...calendar, synced: false }), removed: entries, events }, command);
|
|
152
153
|
});
|
|
153
154
|
}
|
|
154
155
|
if (deps.freeBusy)
|
package/dist/calendar/feeds.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface FeedSyncStats {
|
|
|
28
28
|
calendarId: number;
|
|
29
29
|
events: number;
|
|
30
30
|
skipped: number;
|
|
31
|
+
/** The feed answered 304: nothing was read again, and the counts are those of the read before. */
|
|
32
|
+
unchanged?: true;
|
|
31
33
|
}
|
|
32
34
|
/** The profile's iCalendar feeds: a source the engine expands locally, and how one is added and forgotten. */
|
|
33
35
|
export interface CalendarFeeds {
|
|
@@ -47,6 +49,8 @@ export interface CalendarFeeds {
|
|
|
47
49
|
}
|
|
48
50
|
/** `webcal://` is `https://`: calendar apps write it so a click opens them, and the feed itself is served over https. */
|
|
49
51
|
export declare const feedAddress: (text: string) => URL;
|
|
52
|
+
/** An `http://` address: allowed, but the feed and any token in its address travel unencrypted. */
|
|
53
|
+
export declare const isPlainHttp: (text: string) => boolean;
|
|
50
54
|
export interface FetchFeedOptions {
|
|
51
55
|
/** Names the feed in errors; the address never appears in one. */
|
|
52
56
|
name: string;
|
|
@@ -54,11 +58,26 @@ export interface FetchFeedOptions {
|
|
|
54
58
|
signal?: AbortSignal;
|
|
55
59
|
limits?: FeedLimits;
|
|
56
60
|
}
|
|
61
|
+
/** The last answer's `ETag` and `Last-Modified`, sent back so the server can say the feed has not changed. */
|
|
62
|
+
export interface FeedValidators {
|
|
63
|
+
etag: string | null;
|
|
64
|
+
lastModified: string | null;
|
|
65
|
+
}
|
|
57
66
|
/**
|
|
58
67
|
* The feed's text: http or https, each redirect followed by hand and only to https, the body cut off at its limit and
|
|
59
68
|
* the request at its time. Errors name the feed and the status, never the address, which may hold a token.
|
|
60
69
|
*/
|
|
61
70
|
export declare const fetchFeed: (address: string, options: FetchFeedOptions) => Promise<string>;
|
|
71
|
+
/**
|
|
72
|
+
* `fetchFeed` that sends `since` as `If-None-Match` and `If-Modified-Since`: `"unchanged"` when the server answers 304,
|
|
73
|
+
* otherwise the text and the validators to send next time.
|
|
74
|
+
*/
|
|
75
|
+
export declare const fetchFeedIfChanged: (address: string, options: FetchFeedOptions & {
|
|
76
|
+
since?: FeedValidators;
|
|
77
|
+
}) => Promise<{
|
|
78
|
+
text: string;
|
|
79
|
+
validators: FeedValidators;
|
|
80
|
+
} | "unchanged">;
|
|
62
81
|
/** One feed event in the neutral words, its text cleaned as an invitation's is: feed text is somebody else's. */
|
|
63
82
|
export declare const feedEntry: (event: IcsEvent, zone: string) => EntryInput;
|
|
64
83
|
export interface FeedRead {
|
package/dist/calendar/feeds.js
CHANGED
|
@@ -26,6 +26,8 @@ export const feedAddress = (text) => {
|
|
|
26
26
|
throw invalidAddress("must not hold a user name or a password");
|
|
27
27
|
return url;
|
|
28
28
|
};
|
|
29
|
+
/** An `http://` address: allowed, but the feed and any token in its address travel unencrypted. */
|
|
30
|
+
export const isPlainHttp = (text) => feedAddress(text).protocol === "http:";
|
|
29
31
|
const sameAddress = (a, b) => feedAddress(a).href === feedAddress(b).href;
|
|
30
32
|
const readBody = async (response, name, maxBytes) => {
|
|
31
33
|
const reader = response.body?.getReader();
|
|
@@ -58,8 +60,17 @@ const STATUS_ERRORS = {
|
|
|
58
60
|
* The feed's text: http or https, each redirect followed by hand and only to https, the body cut off at its limit and
|
|
59
61
|
* the request at its time. Errors name the feed and the status, never the address, which may hold a token.
|
|
60
62
|
*/
|
|
61
|
-
export const fetchFeed = async (address, options) =>
|
|
62
|
-
|
|
63
|
+
export const fetchFeed = async (address, options) => (await fetchFeedIfChanged(address, options)).text;
|
|
64
|
+
/**
|
|
65
|
+
* `fetchFeed` that sends `since` as `If-None-Match` and `If-Modified-Since`: `"unchanged"` when the server answers 304,
|
|
66
|
+
* otherwise the text and the validators to send next time.
|
|
67
|
+
*/
|
|
68
|
+
export const fetchFeedIfChanged = async (address, options) => {
|
|
69
|
+
const { name, limits = FEED_LIMITS, since } = options;
|
|
70
|
+
const conditional = {
|
|
71
|
+
...(since?.etag ? { "if-none-match": since.etag } : {}),
|
|
72
|
+
...(since?.lastModified ? { "if-modified-since": since.lastModified } : {}),
|
|
73
|
+
};
|
|
63
74
|
const run = options.fetch ?? fetch;
|
|
64
75
|
const timeout = AbortSignal.timeout(limits.timeoutMs);
|
|
65
76
|
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
@@ -69,8 +80,12 @@ export const fetchFeed = async (address, options) => {
|
|
|
69
80
|
const response = await run(url, {
|
|
70
81
|
redirect: "manual",
|
|
71
82
|
signal,
|
|
72
|
-
headers: { accept: "text/calendar, text/plain;q=0.5, */*;q=0.1" },
|
|
83
|
+
headers: { accept: "text/calendar, text/plain;q=0.5, */*;q=0.1", ...conditional },
|
|
73
84
|
});
|
|
85
|
+
if (response.status === 304 && Object.keys(conditional).length) {
|
|
86
|
+
await response.body?.cancel();
|
|
87
|
+
return "unchanged";
|
|
88
|
+
}
|
|
74
89
|
const location = response.headers.get("location");
|
|
75
90
|
if (response.status >= 300 && response.status < 400 && location) {
|
|
76
91
|
await response.body?.cancel();
|
|
@@ -95,7 +110,11 @@ export const fetchFeed = async (address, options) => {
|
|
|
95
110
|
retryable: response.status >= 500 || response.status === 429,
|
|
96
111
|
});
|
|
97
112
|
}
|
|
98
|
-
|
|
113
|
+
const text = await readBody(response, name, limits.maxBytes);
|
|
114
|
+
return {
|
|
115
|
+
text,
|
|
116
|
+
validators: { etag: response.headers.get("etag"), lastModified: response.headers.get("last-modified") },
|
|
117
|
+
};
|
|
99
118
|
}
|
|
100
119
|
}
|
|
101
120
|
catch (error) {
|
|
@@ -213,6 +232,19 @@ export const readFeed = (text, zone, window) => {
|
|
|
213
232
|
skipped,
|
|
214
233
|
};
|
|
215
234
|
};
|
|
235
|
+
const lastRead = (token, window) => {
|
|
236
|
+
if (token === null)
|
|
237
|
+
return null;
|
|
238
|
+
try {
|
|
239
|
+
const held = JSON.parse(token);
|
|
240
|
+
if (held?.v !== 1 || held.window?.from !== window.from || held.window.to !== window.to || !held.validators)
|
|
241
|
+
return null;
|
|
242
|
+
return held;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
216
248
|
const calendarOf = (feed, timezone = null) => ({
|
|
217
249
|
externalId: `${FEED_PREFIX}${feed.id}`,
|
|
218
250
|
title: feed.title,
|
|
@@ -236,8 +268,9 @@ export const icsFeeds = (options) => {
|
|
|
236
268
|
});
|
|
237
269
|
return feed;
|
|
238
270
|
};
|
|
239
|
-
const read = (url, name) =>
|
|
271
|
+
const read = (url, name, since) => fetchFeedIfChanged(url, {
|
|
240
272
|
name,
|
|
273
|
+
...(since ? { since } : {}),
|
|
241
274
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
242
275
|
...(options.signal ? { signal: options.signal } : {}),
|
|
243
276
|
...(options.limits ? { limits: options.limits } : {}),
|
|
@@ -248,9 +281,16 @@ export const icsFeeds = (options) => {
|
|
|
248
281
|
async calendars() {
|
|
249
282
|
return (await options.registry.read()).map((feed) => calendarOf(feed));
|
|
250
283
|
},
|
|
251
|
-
async changes(calendar, { window }) {
|
|
284
|
+
async changes(calendar, { cursor, window }) {
|
|
252
285
|
const feed = await feedOf(calendar.externalId);
|
|
253
|
-
const
|
|
286
|
+
const held = lastRead(cursor, window);
|
|
287
|
+
const got = await read(feed.url, `feed ${calendar.id}`, held?.validators);
|
|
288
|
+
if (got === "unchanged") {
|
|
289
|
+
const [events, skipped] = [held?.events ?? 0, held?.skipped ?? 0];
|
|
290
|
+
stats.set(calendar.id, { calendarId: calendar.id, events, skipped, unchanged: true });
|
|
291
|
+
return { entries: [], removed: [], cursor, unchanged: true };
|
|
292
|
+
}
|
|
293
|
+
const { text, validators } = got;
|
|
254
294
|
const found = readFeed(text, calendar.timezone ?? options.zone, window);
|
|
255
295
|
for (const entry of found.entries) {
|
|
256
296
|
const changed = found.overrides.get(entry.externalId);
|
|
@@ -258,7 +298,10 @@ export const icsFeeds = (options) => {
|
|
|
258
298
|
overridesOf.set(entry, changed);
|
|
259
299
|
}
|
|
260
300
|
stats.set(calendar.id, { calendarId: calendar.id, events: found.events, skipped: found.skipped });
|
|
261
|
-
|
|
301
|
+
const next = validators.etag || validators.lastModified
|
|
302
|
+
? { v: 1, validators, window, events: found.events, skipped: found.skipped }
|
|
303
|
+
: null;
|
|
304
|
+
return { entries: found.entries, removed: [], cursor: next && JSON.stringify(next) };
|
|
262
305
|
},
|
|
263
306
|
};
|
|
264
307
|
return {
|
|
@@ -271,7 +314,7 @@ export const icsFeeds = (options) => {
|
|
|
271
314
|
throw new CliError("validation_error", "this profile follows a feed at this address already", {
|
|
272
315
|
reason: "feed.duplicate",
|
|
273
316
|
});
|
|
274
|
-
const found = readFeed(await read(address, "the feed"), options.zone);
|
|
317
|
+
const found = readFeed((await read(address, "the feed")).text, options.zone);
|
|
275
318
|
const feed = {
|
|
276
319
|
id: randomBytes(6).toString("hex"),
|
|
277
320
|
url: address,
|
|
@@ -13,7 +13,10 @@ export interface CalendarSourceTraits {
|
|
|
13
13
|
expands: "on-request" | "in-changes" | "local";
|
|
14
14
|
/** The cursor encodes the window (Graph delta): a new window means a new full round. */
|
|
15
15
|
cursorHoldsWindow: boolean;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* No incremental cursor: every run is a full list (an ICS feed). The token the last list gave still reaches
|
|
18
|
+
* `changes()`, so the source can ask whether anything changed since.
|
|
19
|
+
*/
|
|
17
20
|
fullListOnly: boolean;
|
|
18
21
|
}
|
|
19
22
|
export interface ChangePage {
|
|
@@ -27,6 +30,11 @@ export interface ChangePage {
|
|
|
27
30
|
nextPage?: string;
|
|
28
31
|
/** On the last page: the cursor for the next run; `null` when the source has none. */
|
|
29
32
|
cursor?: string | null;
|
|
33
|
+
/**
|
|
34
|
+
* A full-list source's list is the one its token was given with (an ICS feed's 304): nothing was read, so nothing
|
|
35
|
+
* is saved and nothing stored is marked unseen. Only on a single page with no entries.
|
|
36
|
+
*/
|
|
37
|
+
unchanged?: true;
|
|
30
38
|
}
|
|
31
39
|
/** `reset`: the cursor is dead (Google 410, Graph `syncStateNotFound`, CalDAV `valid-sync-token`). */
|
|
32
40
|
export type ChangeResult = ChangePage | {
|
|
@@ -61,7 +69,7 @@ export interface CalendarSource {
|
|
|
61
69
|
readonly provider: string;
|
|
62
70
|
readonly traits: CalendarSourceTraits;
|
|
63
71
|
calendars(): Promise<CalendarInput[]>;
|
|
64
|
-
/** `cursor: null` asks for a full list of the window. */
|
|
72
|
+
/** `cursor: null` asks for a full list of the window; a `fullListOnly` source lists in full whatever it is given. */
|
|
65
73
|
changes(calendar: StoredCalendar, request: {
|
|
66
74
|
cursor: string | null;
|
|
67
75
|
page?: string;
|
package/dist/calendar/sync.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface PendingRound {
|
|
|
23
23
|
series: string[];
|
|
24
24
|
/** A full round asks every series it lists; otherwise only those that changed. Absent reads as true. */
|
|
25
25
|
everySeries?: boolean;
|
|
26
|
+
/** The source said its list is unchanged since `from`: the round marks nothing unseen. */
|
|
27
|
+
unchanged?: true;
|
|
26
28
|
}
|
|
27
29
|
/** What the engine keeps in `EventStore.cursor`. Version 1 had no `round`, `updatedAt` or `done`. */
|
|
28
30
|
export interface CalendarCursor {
|
package/dist/calendar/sync.js
CHANGED
|
@@ -29,7 +29,7 @@ const roundOf = (value) => {
|
|
|
29
29
|
round.series.some((id) => typeof id !== "string") ||
|
|
30
30
|
(round.everySeries !== undefined && typeof round.everySeries !== "boolean"))
|
|
31
31
|
return undefined;
|
|
32
|
-
const { full, from, window, startedAt, reset, page, listed, next, series, everySeries } = round;
|
|
32
|
+
const { full, from, window, startedAt, reset, page, listed, next, series, everySeries, unchanged } = round;
|
|
33
33
|
return {
|
|
34
34
|
full,
|
|
35
35
|
from,
|
|
@@ -41,6 +41,7 @@ const roundOf = (value) => {
|
|
|
41
41
|
next,
|
|
42
42
|
series,
|
|
43
43
|
...(everySeries === false ? { everySeries } : {}),
|
|
44
|
+
...(unchanged === true ? { unchanged } : {}),
|
|
44
45
|
};
|
|
45
46
|
};
|
|
46
47
|
export const parseCalendarCursor = (value) => {
|
|
@@ -87,9 +88,9 @@ export const calendarSyncWindow = (now) => {
|
|
|
87
88
|
return { from: Date.UTC(year - 1, month, date), to: Date.UTC(year, month + 3, date) };
|
|
88
89
|
};
|
|
89
90
|
const CONTINUE = "run sync again to continue";
|
|
90
|
-
const freshRound = (full, from, window, now, reset = false, everySeries = true) => ({
|
|
91
|
+
const freshRound = (full, from, window, now, reset = false, everySeries = true, keepToken = false) => ({
|
|
91
92
|
full,
|
|
92
|
-
from: full ? null : from,
|
|
93
|
+
from: full && !keepToken ? null : from,
|
|
93
94
|
window,
|
|
94
95
|
startedAt: now,
|
|
95
96
|
reset,
|
|
@@ -112,7 +113,7 @@ const roundFor = (source, stored, { window, now }) => {
|
|
|
112
113
|
// The daily full round asks only the series that changed, and every series once the window's end moved on far
|
|
113
114
|
// enough that their new slots matter (foundation §6.2 step 7).
|
|
114
115
|
const everySeries = !full || stored?.slotsTo === undefined || window.to - stored.slotsTo >= SLOTS_EVERY;
|
|
115
|
-
return freshRound(full, stored?.token ?? null, window, now, false, everySeries);
|
|
116
|
+
return freshRound(full, stored?.token ?? null, window, now, false, everySeries, source.traits.fullListOnly);
|
|
116
117
|
};
|
|
117
118
|
const deadList = (provider) => new CliError("invalid_response", `${provider} refused a full list as a dead cursor`);
|
|
118
119
|
/**
|
|
@@ -218,7 +219,9 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
|
|
|
218
219
|
}
|
|
219
220
|
if (page.removed.length)
|
|
220
221
|
result.removed += await store.removeEntries(calendar.id, page.removed, now);
|
|
221
|
-
if (page.nextPage === undefined)
|
|
222
|
+
if (page.unchanged && source.traits.fullListOnly && page.entries.length === 0 && page.nextPage === undefined)
|
|
223
|
+
round = { ...round, page: null, listed: true, next: page.cursor ?? null, unchanged: true };
|
|
224
|
+
else if (page.nextPage === undefined)
|
|
222
225
|
round = { ...round, page: null, listed: true, next: page.cursor ?? null };
|
|
223
226
|
else {
|
|
224
227
|
if (seen.has(page.nextPage))
|
|
@@ -260,7 +263,8 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
|
|
|
260
263
|
}
|
|
261
264
|
if (signal?.aborted)
|
|
262
265
|
return "cancelled";
|
|
263
|
-
result.unseen =
|
|
266
|
+
result.unseen =
|
|
267
|
+
round.full && !round.unchanged ? await store.markUnseen(calendar.id, round.startedAt, round.window, now) : 0;
|
|
264
268
|
await save({
|
|
265
269
|
token: round.next,
|
|
266
270
|
window: round.window,
|
|
@@ -343,6 +343,45 @@ export const memoryEventStore = () => {
|
|
|
343
343
|
async setCalendarSynced(calendarId, synced) {
|
|
344
344
|
calendarOf(calendarId).synced = synced;
|
|
345
345
|
},
|
|
346
|
+
async forgetCalendar(calendarId, now) {
|
|
347
|
+
const calendar = calendarOf(calendarId);
|
|
348
|
+
const mine = entries.filter((one) => one.calendarId === calendarId);
|
|
349
|
+
const ids = new Set(mine.map(({ id }) => id));
|
|
350
|
+
const eventIds = new Set(mine.flatMap(({ eventId }) => (eventId === null ? [] : [eventId])));
|
|
351
|
+
const seriesIds = new Set(mine.flatMap(({ eventSeriesId }) => (eventSeriesId === null ? [] : [eventSeriesId])));
|
|
352
|
+
for (let at = entries.length - 1; at >= 0; at--)
|
|
353
|
+
if (ids.has(entries[at].id))
|
|
354
|
+
entries.splice(at, 1);
|
|
355
|
+
for (let at = revisions.length - 1; at >= 0; at--)
|
|
356
|
+
if (ids.has(revisions[at].entryId))
|
|
357
|
+
revisions.splice(at, 1);
|
|
358
|
+
for (let at = reviews.length - 1; at >= 0; at--)
|
|
359
|
+
if (reviews[at].linkableType === "calendar_entry" &&
|
|
360
|
+
ids.has(reviews[at].linkableId))
|
|
361
|
+
reviews.splice(at, 1);
|
|
362
|
+
participants = participants.filter((one) => !ids.has(one.entryId));
|
|
363
|
+
attachments = attachments.filter((one) => !ids.has(one.entryId));
|
|
364
|
+
for (const id of ids)
|
|
365
|
+
unlinked.delete(id);
|
|
366
|
+
let gone = 0;
|
|
367
|
+
for (const id of eventIds)
|
|
368
|
+
if (entries.some((one) => one.eventId === id))
|
|
369
|
+
refreshEvent(id, now);
|
|
370
|
+
else if (events.delete(id))
|
|
371
|
+
gone++;
|
|
372
|
+
for (const id of seriesIds)
|
|
373
|
+
if (entries.some((one) => one.eventSeriesId === id))
|
|
374
|
+
refreshSeries(id);
|
|
375
|
+
else {
|
|
376
|
+
series.delete(id);
|
|
377
|
+
for (const event of events.values())
|
|
378
|
+
if (event.eventSeriesId === id)
|
|
379
|
+
event.eventSeriesId = null;
|
|
380
|
+
}
|
|
381
|
+
cursors.delete(calendarId);
|
|
382
|
+
calendars.splice(calendars.indexOf(calendar), 1);
|
|
383
|
+
return { entries: ids.size, events: gone };
|
|
384
|
+
},
|
|
346
385
|
async saveEntries(calendarId, list, now) {
|
|
347
386
|
calendarOf(calendarId);
|
|
348
387
|
for (const input of list)
|
|
@@ -253,6 +253,16 @@ export interface EventStore {
|
|
|
253
253
|
saveCalendars(accountId: number, calendars: CalendarInput[], now: number): Promise<StoredCalendar[]>;
|
|
254
254
|
calendars(accountId: number): Promise<StoredCalendar[]>;
|
|
255
255
|
setCalendarSynced(calendarId: number, synced: boolean, now: number): Promise<void>;
|
|
256
|
+
/**
|
|
257
|
+
* The calendar and everything stored from it go, for a calendar that is not the owner's data (a followed feed): its
|
|
258
|
+
* entries, the events and series only it held, what was built from them (links, tags, search pieces, people seen
|
|
259
|
+
* only there) and its cursor. An event another calendar also holds stays, with that copy's fields; a meeting linked
|
|
260
|
+
* to a gone event stays, unlinked. Notes the owner wrote stay.
|
|
261
|
+
*/
|
|
262
|
+
forgetCalendar(calendarId: number, now: number): Promise<{
|
|
263
|
+
entries: number;
|
|
264
|
+
events: number;
|
|
265
|
+
}>;
|
|
256
266
|
/** One sync page, matched by rules 1–5, in one transaction. */
|
|
257
267
|
saveEntries(calendarId: number, entries: EntryInput[], now: number): Promise<StoredEntry[]>;
|
|
258
268
|
/**
|
|
@@ -192,6 +192,42 @@ export const eventStoreContract = (make) => {
|
|
|
192
192
|
assert.equal(occurrence?.entries[0]?.deletedAt, null);
|
|
193
193
|
assert.equal(await store.removeEntries(calendar, ["p"], CONTRACT_NOW + 1), 0);
|
|
194
194
|
}),
|
|
195
|
+
test("a forgotten calendar goes with its entries and the events only it held; a shared event keeps the other copy", async (fixture) => {
|
|
196
|
+
const { store, calendar, accountId } = fixture;
|
|
197
|
+
const feed = await second(fixture, "ics:example");
|
|
198
|
+
await store.saveEntries(calendar, [single("w1", later, { icalUid: "shared@example.com", title: "Mine" })], 2);
|
|
199
|
+
const series = {
|
|
200
|
+
...single("weekly", later, { title: "Weekly" }),
|
|
201
|
+
kind: "series",
|
|
202
|
+
recurrence: ["RRULE:FREQ=WEEKLY"],
|
|
203
|
+
};
|
|
204
|
+
await store.saveEntries(feed, [
|
|
205
|
+
single("f1", later, { icalUid: "shared@example.com", title: "Theirs", description: "longer text" }),
|
|
206
|
+
single("f2", later + HOUR, { title: "Only in the feed", participants: [{ email: "guest@example.com" }] }),
|
|
207
|
+
series,
|
|
208
|
+
], 3);
|
|
209
|
+
await store.saveSlots(feed, [
|
|
210
|
+
{
|
|
211
|
+
series: "weekly",
|
|
212
|
+
slots: [
|
|
213
|
+
{
|
|
214
|
+
...single("weekly@1", later + 2 * HOUR),
|
|
215
|
+
kind: "occurrence",
|
|
216
|
+
seriesExternalId: "weekly",
|
|
217
|
+
originalStartsAt: later + 2 * HOUR,
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
], { from: earlier, to: later + 7 * 24 * HOUR }, "cursor", 3);
|
|
222
|
+
await store.setCursor(feed, "cursor", 3);
|
|
223
|
+
const forgotten = await store.forgetCalendar(feed, 4);
|
|
224
|
+
assert.deepEqual(forgotten, { entries: 4, events: 2 });
|
|
225
|
+
assert.deepEqual((await store.calendars(accountId)).map(({ externalId }) => externalId), ["primary"]);
|
|
226
|
+
const left = await store.events({ accountId });
|
|
227
|
+
assert.deepEqual(left.map(({ title, status }) => [title, status]), [["Mine", "confirmed"]]);
|
|
228
|
+
assert.equal((await store.event(left[0]?.id))?.entries.length, 1);
|
|
229
|
+
assert.equal(await store.entry(feed, "weekly"), null);
|
|
230
|
+
}),
|
|
195
231
|
test("a calendar's cursor is its own, and null forgets it", async (fixture) => {
|
|
196
232
|
const { store, calendar } = fixture;
|
|
197
233
|
const other = await second(fixture);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CacheDatabase } from "../driver.js";
|
|
1
2
|
import type { AccountKey, StoredAccount } from "../types.js";
|
|
2
3
|
import type { StoreContext } from "./open.js";
|
|
3
4
|
/** An account's row, made on first sight; a name only replaces what was known when there is one. */
|
|
@@ -10,3 +11,10 @@ export declare const storedAccounts: ({ orm }: StoreContext) => StoredAccount[];
|
|
|
10
11
|
export declare const storedAccount: ({ orm }: StoreContext, { provider, account }: AccountKey) => StoredAccount;
|
|
11
12
|
/** Everything the account holds, children before parents: the foreign keys are enforced. */
|
|
12
13
|
export declare const purgeAccount: ({ orm, database }: StoreContext, accountKey: number) => void;
|
|
14
|
+
export declare const unreferenced: (database: CacheDatabase, table: "identities") => Set<number>;
|
|
15
|
+
/**
|
|
16
|
+
* People only the purged account had seen go with it. An identity stays while anything else points at it
|
|
17
|
+
* (another account, a tag, a note, a link), or while its person is kept: the owner, a person something
|
|
18
|
+
* points at, or one linked to an identity that stays.
|
|
19
|
+
*/
|
|
20
|
+
export declare const dropOrphanPeople: (database: CacheDatabase, unseenBefore: Set<number>) => void;
|
|
@@ -132,7 +132,7 @@ const referencesTo = (database, table, skip) => {
|
|
|
132
132
|
const pointers = pointerPairs(database).map(({ table: tbl, pointer }) => `SELECT ${pointer}_id AS id FROM "${tbl}" WHERE ${pointer}_type = '${type}'`);
|
|
133
133
|
return [...keys, ...pointers].join(" UNION ");
|
|
134
134
|
};
|
|
135
|
-
const unreferenced = (database, table) => new Set(database
|
|
135
|
+
export const unreferenced = (database, table) => new Set(database
|
|
136
136
|
.prepare(`SELECT id FROM ${table} WHERE id NOT IN (SELECT id FROM (${referencesTo(database, table, OWN)}) WHERE id IS NOT NULL)`)
|
|
137
137
|
.all()
|
|
138
138
|
.map(({ id }) => Number(id)));
|
|
@@ -141,7 +141,7 @@ const unreferenced = (database, table) => new Set(database
|
|
|
141
141
|
* (another account, a tag, a note, a link), or while its person is kept: the owner, a person something
|
|
142
142
|
* points at, or one linked to an identity that stays.
|
|
143
143
|
*/
|
|
144
|
-
const dropOrphanPeople = (database, unseenBefore) => {
|
|
144
|
+
export const dropOrphanPeople = (database, unseenBefore) => {
|
|
145
145
|
const orphans = [...unreferenced(database, "identities")].filter((id) => !unseenBefore.has(id));
|
|
146
146
|
if (orphans.length === 0)
|
|
147
147
|
return;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CacheDatabase } from "../driver.js";
|
|
2
|
+
/**
|
|
3
|
+
* `EventStore.forgetCalendar` inside its transaction: children before parents, since the foreign keys are enforced.
|
|
4
|
+
* `refresh` gives a kept event or series its fields again from the copies left.
|
|
5
|
+
*/
|
|
6
|
+
export declare const forgetCalendarRows: (database: CacheDatabase, { calendarId, accountId, cursorKey, now }: {
|
|
7
|
+
calendarId: number;
|
|
8
|
+
accountId: number;
|
|
9
|
+
cursorKey: string;
|
|
10
|
+
now: number;
|
|
11
|
+
}, refresh: {
|
|
12
|
+
event: (id: number, now: number) => void;
|
|
13
|
+
series: (id: number, now: number) => void;
|
|
14
|
+
}) => {
|
|
15
|
+
entries: number;
|
|
16
|
+
events: number;
|
|
17
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { dropOrphanPeople, unreferenced } from "./accounts.js";
|
|
2
|
+
import { dropUnusedVectors } from "./vector-index.js";
|
|
3
|
+
const distinctIds = (rows) => [...new Set(rows.map((row) => Number(row.id)))];
|
|
4
|
+
/**
|
|
5
|
+
* `EventStore.forgetCalendar` inside its transaction: children before parents, since the foreign keys are enforced.
|
|
6
|
+
* `refresh` gives a kept event or series its fields again from the copies left.
|
|
7
|
+
*/
|
|
8
|
+
export const forgetCalendarRows = (database, { calendarId, accountId, cursorKey, now }, refresh) => {
|
|
9
|
+
const unseenBefore = unreferenced(database, "identities");
|
|
10
|
+
const ids = (sql) => distinctIds(database.prepare(sql).all(calendarId));
|
|
11
|
+
const entryIds = ids("SELECT id FROM calendar_entries WHERE calendar_id = ?");
|
|
12
|
+
const eventIds = ids("SELECT event_id AS id FROM calendar_entries WHERE calendar_id = ? AND event_id IS NOT NULL");
|
|
13
|
+
const seriesIds = ids("SELECT event_series_id AS id FROM calendar_entries WHERE calendar_id = ? AND event_series_id IS NOT NULL");
|
|
14
|
+
const hashes = [];
|
|
15
|
+
const dropAround = (type, list) => {
|
|
16
|
+
const json = JSON.stringify(list);
|
|
17
|
+
const inList = "IN (SELECT value FROM json_each(?))";
|
|
18
|
+
for (const row of database
|
|
19
|
+
.prepare(`SELECT DISTINCT content_hash FROM chunks WHERE chunkable_type = ? AND chunkable_id ${inList}`)
|
|
20
|
+
.all(type, json))
|
|
21
|
+
hashes.push(String(row.content_hash));
|
|
22
|
+
database.prepare(`DELETE FROM chunks WHERE chunkable_type = ? AND chunkable_id ${inList}`).run(type, json);
|
|
23
|
+
database
|
|
24
|
+
.prepare(`DELETE FROM links WHERE (from_type = ? AND from_id ${inList}) OR (to_type = ? AND to_id ${inList})`)
|
|
25
|
+
.run(type, json, type, json);
|
|
26
|
+
for (const [table, pointer] of [
|
|
27
|
+
["taggings", "taggable"],
|
|
28
|
+
["aliases", "aliasable"],
|
|
29
|
+
["attachments", "attachable"],
|
|
30
|
+
["link_reviews", "linkable"],
|
|
31
|
+
["entity_link_events", "linkable"],
|
|
32
|
+
])
|
|
33
|
+
database.prepare(`DELETE FROM ${table} WHERE ${pointer}_type = ? AND ${pointer}_id ${inList}`).run(type, json);
|
|
34
|
+
};
|
|
35
|
+
const entries = JSON.stringify(entryIds);
|
|
36
|
+
for (const table of ["calendar_entry_participants", "calendar_entry_revisions"])
|
|
37
|
+
database.prepare(`DELETE FROM ${table} WHERE entry_id IN (SELECT value FROM json_each(?))`).run(entries);
|
|
38
|
+
dropAround("calendar_entry", entryIds);
|
|
39
|
+
// Occurrences point at their series in the same table: the pointers go first, so the order of rows is free.
|
|
40
|
+
database.prepare("UPDATE calendar_entries SET series_entry_id = NULL WHERE calendar_id = ?").run(calendarId);
|
|
41
|
+
database.prepare("DELETE FROM calendar_entries WHERE calendar_id = ?").run(calendarId);
|
|
42
|
+
const held = (column, id) => database.prepare(`SELECT 1 FROM calendar_entries WHERE ${column} = ? LIMIT 1`).get(id) !== undefined;
|
|
43
|
+
const goneSeries = seriesIds.filter((id) => !held("event_series_id", id));
|
|
44
|
+
const goneEvents = eventIds.filter((id) => !held("event_id", id));
|
|
45
|
+
const gone = (list) => JSON.stringify(list);
|
|
46
|
+
database
|
|
47
|
+
.prepare("UPDATE meetings SET event_id = NULL, updated_at = ? WHERE event_id IN (SELECT value FROM json_each(?))")
|
|
48
|
+
.run(now, gone(goneEvents));
|
|
49
|
+
dropAround("event", goneEvents);
|
|
50
|
+
database.prepare("DELETE FROM events WHERE id IN (SELECT value FROM json_each(?))").run(gone(goneEvents));
|
|
51
|
+
for (const table of ["meeting_series", "events"])
|
|
52
|
+
database
|
|
53
|
+
.prepare(`UPDATE ${table} SET event_series_id = NULL, updated_at = ? WHERE event_series_id IN (SELECT value FROM json_each(?))`)
|
|
54
|
+
.run(now, gone(goneSeries));
|
|
55
|
+
dropAround("event_series", goneSeries);
|
|
56
|
+
database.prepare("DELETE FROM event_series WHERE id IN (SELECT value FROM json_each(?))").run(gone(goneSeries));
|
|
57
|
+
for (const id of seriesIds)
|
|
58
|
+
if (!goneSeries.includes(id))
|
|
59
|
+
refresh.series(id, now);
|
|
60
|
+
for (const id of eventIds)
|
|
61
|
+
if (!goneEvents.includes(id))
|
|
62
|
+
refresh.event(id, now);
|
|
63
|
+
database.prepare("DELETE FROM sync_cursors WHERE account_id = ? AND key = ?").run(accountId, cursorKey);
|
|
64
|
+
database.prepare("DELETE FROM calendars WHERE id = ?").run(calendarId);
|
|
65
|
+
dropOrphanPeople(database, unseenBefore);
|
|
66
|
+
dropUnusedVectors(database, hashes);
|
|
67
|
+
return { entries: entryIds.length, events: goneEvents.length };
|
|
68
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { calendarText, entryInWindow, entryMetadata, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
|
|
3
3
|
import { listEvents } from "./calendar-events.js";
|
|
4
|
+
import { forgetCalendarRows } from "./calendar-forget.js";
|
|
4
5
|
import { bool, eventOf, fromJson, int, str, toJson } from "./events.js";
|
|
5
6
|
import { driveDocumentOf, driveFileTarget, logFileLink } from "./files.js";
|
|
6
7
|
import { identityOf } from "./identities.js";
|
|
@@ -524,6 +525,13 @@ export const eventStoreOver = (context) => {
|
|
|
524
525
|
.prepare("UPDATE calendars SET synced = ?, updated_at = ? WHERE id = ?")
|
|
525
526
|
.run(flag(synced), now, calendarId);
|
|
526
527
|
}),
|
|
528
|
+
forgetCalendar: async (calendarId, now) => write(database, () => {
|
|
529
|
+
const accountId = Number(calendarRow(calendarId).account_id);
|
|
530
|
+
return forgetCalendarRows(database, { calendarId, accountId, cursorKey: cursorKey(calendarId), now }, {
|
|
531
|
+
event: refreshEvent,
|
|
532
|
+
series: refreshSeries,
|
|
533
|
+
});
|
|
534
|
+
}),
|
|
527
535
|
saveEntries: async (calendarId, entries, now) => write(database, () => saveAll(calendarId, entries, now)),
|
|
528
536
|
removeEntries: async (calendarId, externalIds, now) => write(database, () => removeAll(calendarId, externalIds, now)),
|
|
529
537
|
markUnseen: async (calendarId, seenBefore, window, now) => write(database, () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.548.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|