@wirecat/cli-core 0.419.0 → 0.420.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 +36 -0
- 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/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/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/search/account-list.js +39 -7
- 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
|
@@ -191,8 +191,12 @@ export const event = v.strictObject({
|
|
|
191
191
|
starts_at: v.nullable(isoTime),
|
|
192
192
|
ends_at: v.nullable(isoTime),
|
|
193
193
|
timezone: v.nullable(text),
|
|
194
|
-
/** `auto`: made from a meeting; `owner`: made by hand. */
|
|
195
|
-
origin: v.picklist(["auto", "owner"]),
|
|
194
|
+
/** `auto`: made from a meeting; `owner`: made by hand; `calendar`: from a calendar's entries. */
|
|
195
|
+
origin: v.picklist(["auto", "owner", "calendar"]),
|
|
196
|
+
status: v.nullable(text),
|
|
197
|
+
all_day: v.boolean(),
|
|
198
|
+
/** When its calendar deleted it: shown as "deleted in Google", kept as history. */
|
|
199
|
+
source_deleted_at: v.nullable(isoTime),
|
|
196
200
|
deleted_at: v.nullable(isoTime),
|
|
197
201
|
created_at: isoTime,
|
|
198
202
|
updated_at: isoTime,
|
|
@@ -207,6 +211,9 @@ export const eventItem = (one) => ({
|
|
|
207
211
|
ends_at: iso(one.endsAt),
|
|
208
212
|
timezone: one.timezone,
|
|
209
213
|
origin: one.origin,
|
|
214
|
+
status: one.status ?? null,
|
|
215
|
+
all_day: one.allDay === true,
|
|
216
|
+
source_deleted_at: iso(one.sourceDeletedAt ?? null),
|
|
210
217
|
deleted_at: iso(one.deletedAt),
|
|
211
218
|
created_at: new Date(one.createdAt).toISOString(),
|
|
212
219
|
updated_at: new Date(one.updatedAt).toISOString(),
|
package/dist/meetings/store.d.ts
CHANGED
|
@@ -120,6 +120,8 @@ export interface MeetingStore {
|
|
|
120
120
|
setEventSeries(meetingSeriesId: number, eventSeriesId: number, now: number): Promise<void>;
|
|
121
121
|
/** Automatic linking must not replace an existing link. */
|
|
122
122
|
linkMeeting(id: number, eventId: number, now: number, mode?: "auto" | "owner"): Promise<void>;
|
|
123
|
+
/** Two or more events fit a meeting equally: no link, and the pair goes to the owner's review list. */
|
|
124
|
+
reviewMeetingLink?(meetingId: number, eventIds: number[], now: number): Promise<void>;
|
|
123
125
|
cursor(accountId: number): Promise<string | null>;
|
|
124
126
|
setCursor(accountId: number, value: string, now: number): Promise<void>;
|
|
125
127
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EventStore } from "../event-store.js";
|
|
2
|
+
import type { ContractCase } from "../store.js";
|
|
3
|
+
/** A fresh, empty store and one calendar account in it. */
|
|
4
|
+
export interface EventStoreFixture {
|
|
5
|
+
store: EventStore;
|
|
6
|
+
accountId: number;
|
|
7
|
+
}
|
|
8
|
+
/** "Now" in every case: entries before it have happened. */
|
|
9
|
+
export declare const CONTRACT_NOW: number;
|
|
10
|
+
export declare const eventStoreContract: (make: () => EventStoreFixture | Promise<EventStoreFixture>) => ContractCase[];
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
const HOUR = 3_600_000;
|
|
3
|
+
/** "Now" in every case: entries before it have happened. */
|
|
4
|
+
export const CONTRACT_NOW = 1_000 * HOUR;
|
|
5
|
+
const single = (externalId, at, extra = {}) => ({
|
|
6
|
+
externalId,
|
|
7
|
+
kind: "single",
|
|
8
|
+
status: "confirmed",
|
|
9
|
+
title: "Example review",
|
|
10
|
+
startsAt: at,
|
|
11
|
+
endsAt: at + HOUR,
|
|
12
|
+
...extra,
|
|
13
|
+
});
|
|
14
|
+
export const eventStoreContract = (make) => {
|
|
15
|
+
const test = (name, check) => ({
|
|
16
|
+
name,
|
|
17
|
+
async run() {
|
|
18
|
+
const fixture = await make();
|
|
19
|
+
const [calendar] = await fixture.store.saveCalendars(fixture.accountId, [{ externalId: "primary", title: "Work", timezone: "UTC", accessRole: "owner", primary: true }], 1);
|
|
20
|
+
await check({ ...fixture, calendar: calendar.id });
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
const second = async ({ store, accountId }, externalId = "personal") => (await store.saveCalendars(accountId, [{ externalId, title: externalId, timezone: "UTC", accessRole: "owner", primary: false }], 1))[0].id;
|
|
24
|
+
const later = CONTRACT_NOW + 24 * HOUR;
|
|
25
|
+
const earlier = CONTRACT_NOW - 24 * HOUR;
|
|
26
|
+
return [
|
|
27
|
+
test("a calendar the owner owns syncs; one shared with them is listed and does not", async ({ store, accountId, }) => {
|
|
28
|
+
await store.saveCalendars(accountId, [{ externalId: "team", title: "Team", timezone: null, accessRole: "reader", primary: false }], 2);
|
|
29
|
+
assert.deepEqual((await store.calendars(accountId)).map(({ externalId, synced }) => [externalId, synced]), [
|
|
30
|
+
["primary", true],
|
|
31
|
+
["team", false],
|
|
32
|
+
]);
|
|
33
|
+
}),
|
|
34
|
+
test("rule 1: the same calendar and id is the same entry; a change keeps the old values", async ({ store, calendar, }) => {
|
|
35
|
+
const [first] = await store.saveEntries(calendar, [single("e1", later)], 2);
|
|
36
|
+
const [again] = await store.saveEntries(calendar, [single("e1", later + HOUR, { title: "Moved review" })], 3);
|
|
37
|
+
assert.equal(again?.id, first?.id);
|
|
38
|
+
assert.equal(again?.eventId, first?.eventId);
|
|
39
|
+
const details = await store.event(first?.eventId);
|
|
40
|
+
assert.equal(details?.event.title, "Moved review");
|
|
41
|
+
assert.equal(details?.event.startsAt, later + HOUR);
|
|
42
|
+
assert.equal(details?.event.origin, "calendar");
|
|
43
|
+
assert.deepEqual(details?.revisions.map(({ title, startsAt }) => [title, startsAt]), [["Example review", later]]);
|
|
44
|
+
}),
|
|
45
|
+
test("rule 2: a plain occurrence and the exception of its slot are one entry, the series in any order", async ({ store, calendar, }) => {
|
|
46
|
+
const slot = later;
|
|
47
|
+
const occurrence = (externalId, extra = {}) => single(externalId, slot, { kind: "occurrence", seriesExternalId: "s1", originalStartsAt: slot, ...extra });
|
|
48
|
+
const [plain] = await store.saveEntries(calendar, [occurrence("s1_slot")], 2);
|
|
49
|
+
await store.saveEntries(calendar, [{ ...single("s1", earlier), kind: "series", recurrence: ["RRULE:FREQ=WEEKLY"], recurrenceEndsAt: null }], 3);
|
|
50
|
+
const [exception] = await store.saveEntries(calendar, [occurrence("s1_moved", { exception: true, startsAt: slot + HOUR, endsAt: slot + 2 * HOUR })], 4);
|
|
51
|
+
assert.equal(exception?.id, plain?.id);
|
|
52
|
+
assert.notEqual(exception?.seriesEntryId, null);
|
|
53
|
+
const details = await store.event(exception?.eventId);
|
|
54
|
+
assert.equal(details?.event.startsAt, slot + HOUR);
|
|
55
|
+
assert.notEqual(details?.event.eventSeriesId, null);
|
|
56
|
+
}),
|
|
57
|
+
test("rule 3: another calendar's copy with the same iCalUID is the same event", async (fixture) => {
|
|
58
|
+
const { store, calendar } = fixture;
|
|
59
|
+
const other = await second(fixture);
|
|
60
|
+
const [work] = await store.saveEntries(calendar, [single("w1", later, { icalUid: "uid-1@example.com" })], 2);
|
|
61
|
+
const [home] = await store.saveEntries(other, [single("p1", later, { icalUid: "uid-1@example.com" })], 3);
|
|
62
|
+
assert.equal(home?.eventId, work?.eventId);
|
|
63
|
+
assert.equal((await store.event(work?.eventId))?.entries.length, 2);
|
|
64
|
+
}),
|
|
65
|
+
test("rule 5: otherwise a new event of origin calendar; a slot cancelled before it was stored is none", async ({ store, calendar, }) => {
|
|
66
|
+
const [one, two, gone] = await store.saveEntries(calendar, [single("a", later), single("b", later), single("c", later, { status: "cancelled" })], 2);
|
|
67
|
+
assert.notEqual(one?.eventId, two?.eventId);
|
|
68
|
+
assert.equal(gone?.eventId, null);
|
|
69
|
+
assert.equal((await store.events({ from: later, to: later + HOUR })).length, 2);
|
|
70
|
+
}),
|
|
71
|
+
test("two candidates: no link and one review entry, kept through a second sync and an owner's decision", async (fixture) => {
|
|
72
|
+
const { store, calendar } = fixture;
|
|
73
|
+
const other = await second(fixture);
|
|
74
|
+
const third = await second(fixture, "third");
|
|
75
|
+
const [a] = await store.saveEntries(calendar, [single("a", later, { icalUid: "first" })], 2);
|
|
76
|
+
const [b] = await store.saveEntries(other, [single("b", later, { icalUid: "second" })], 2);
|
|
77
|
+
await store.saveEntries(calendar, [single("a", later, { icalUid: "shared" })], 3);
|
|
78
|
+
await store.saveEntries(other, [single("b", later, { icalUid: "shared" })], 3);
|
|
79
|
+
const [c] = await store.saveEntries(third, [single("c", later, { icalUid: "shared" })], 4);
|
|
80
|
+
assert.equal(c?.eventId, null);
|
|
81
|
+
await store.saveEntries(third, [single("c", later, { icalUid: "shared" })], 5);
|
|
82
|
+
const open = await store.reviews({ status: "open" });
|
|
83
|
+
assert.equal(open.length, 1);
|
|
84
|
+
assert.deepEqual(open[0]?.candidates, [a?.eventId, b?.eventId].sort((x, y) => x - y));
|
|
85
|
+
await store.resolveReview(open[0]?.id, { choose: b?.eventId }, 6);
|
|
86
|
+
await store.saveEntries(third, [single("c", later, { icalUid: "shared" })], 7);
|
|
87
|
+
const [after] = await store.saveEntries(third, [single("c", later, { icalUid: "shared" })], 8);
|
|
88
|
+
assert.equal(after?.eventId, b?.eventId);
|
|
89
|
+
assert.deepEqual((await store.reviews()).map(({ status }) => status), ["linked"]);
|
|
90
|
+
}),
|
|
91
|
+
test("the owner's unlink holds: no rule links the entry again", async ({ store, calendar }) => {
|
|
92
|
+
const [entry] = await store.saveEntries(calendar, [single("a", later)], 2);
|
|
93
|
+
await store.unlinkEntry(entry?.id, 3);
|
|
94
|
+
const [again] = await store.saveEntries(calendar, [single("a", later, { title: "Renamed" })], 4);
|
|
95
|
+
assert.equal(again?.eventId, null);
|
|
96
|
+
await store.linkEntry(entry?.id, entry?.eventId, 5);
|
|
97
|
+
const [linked] = await store.saveEntries(calendar, [single("a", later)], 6);
|
|
98
|
+
assert.equal(linked?.eventId, entry?.eventId);
|
|
99
|
+
}),
|
|
100
|
+
test("a deleted single event: past stays as history, future is cancelled", async ({ store, calendar }) => {
|
|
101
|
+
const [past, future] = await store.saveEntries(calendar, [single("p", earlier), single("f", later)], 2);
|
|
102
|
+
await store.saveEntries(calendar, [single("p", earlier, { status: "cancelled" }), single("f", later, { status: "cancelled" })], CONTRACT_NOW);
|
|
103
|
+
const happened = (await store.event(past?.eventId))?.event;
|
|
104
|
+
assert.equal(happened?.sourceDeletedAt, CONTRACT_NOW);
|
|
105
|
+
assert.notEqual(happened?.status, "cancelled");
|
|
106
|
+
assert.equal(happened?.title, "Example review");
|
|
107
|
+
const cancelled = (await store.event(future?.eventId))?.event;
|
|
108
|
+
assert.equal(cancelled?.status, "cancelled");
|
|
109
|
+
assert.equal(cancelled?.sourceDeletedAt, null);
|
|
110
|
+
}),
|
|
111
|
+
test("a whole series deleted: its entries cancel, past occurrences stay, future ones cancel", async ({ store, calendar, }) => {
|
|
112
|
+
const series = {
|
|
113
|
+
...single("s", earlier - HOUR),
|
|
114
|
+
kind: "series",
|
|
115
|
+
icalUid: "series@example.com",
|
|
116
|
+
recurrence: ["RRULE:FREQ=DAILY"],
|
|
117
|
+
};
|
|
118
|
+
const occurrence = (at) => single(`s_${at}`, at, { kind: "occurrence", seriesExternalId: "s", originalStartsAt: at });
|
|
119
|
+
const [, past, future] = await store.saveEntries(calendar, [series, occurrence(earlier), occurrence(later)], 2);
|
|
120
|
+
await store.saveEntries(calendar, [{ ...series, status: "cancelled" }], CONTRACT_NOW);
|
|
121
|
+
const before = await store.event(past?.eventId);
|
|
122
|
+
assert.equal(before?.event.sourceDeletedAt, CONTRACT_NOW);
|
|
123
|
+
assert.equal(before?.entries[0]?.status, "cancelled");
|
|
124
|
+
const after = await store.event(future?.eventId);
|
|
125
|
+
assert.equal(after?.event.status, "cancelled");
|
|
126
|
+
}),
|
|
127
|
+
test("a full list marks the entries it did not return, plain occurrences only inside the window", async ({ store, calendar, }) => {
|
|
128
|
+
const outside = later + 30 * 24 * HOUR;
|
|
129
|
+
const [kept, gone, far] = await store.saveEntries(calendar, [
|
|
130
|
+
single("kept", later),
|
|
131
|
+
single("gone", later),
|
|
132
|
+
single("far", outside, { kind: "occurrence", seriesExternalId: "x", originalStartsAt: outside }),
|
|
133
|
+
], 2);
|
|
134
|
+
await store.saveEntries(calendar, [single("kept", later)], 10);
|
|
135
|
+
assert.equal(await store.markUnseen(calendar, 10, { from: earlier, to: later + 24 * HOUR }, 11), 1);
|
|
136
|
+
const entries = new Map((await store.event(gone?.eventId))?.entries.map((entry) => [entry.externalId, entry]));
|
|
137
|
+
assert.equal(entries.get("gone")?.deletedAt, 11);
|
|
138
|
+
assert.notEqual(await store.event(gone?.eventId), null);
|
|
139
|
+
assert.equal((await store.event(kept?.eventId))?.entries[0]?.deletedAt, null);
|
|
140
|
+
assert.equal((await store.event(far?.eventId))?.entries[0]?.deletedAt, null);
|
|
141
|
+
}),
|
|
142
|
+
test("participants are kept by address; a room is listed and is no contact", async ({ store, calendar }) => {
|
|
143
|
+
const [entry] = await store.saveEntries(calendar, [
|
|
144
|
+
single("a", later, {
|
|
145
|
+
participants: [
|
|
146
|
+
{ email: "Alice@Example.com", name: "Alice Example", response: "accepted" },
|
|
147
|
+
{ email: "room-1@resource.example.com", name: "Room 1", resource: true },
|
|
148
|
+
],
|
|
149
|
+
}),
|
|
150
|
+
], 2);
|
|
151
|
+
const participants = (await store.event(entry?.eventId))?.participants ?? [];
|
|
152
|
+
assert.deepEqual(participants.map(({ email, resource, identityId }) => [email, resource, identityId === null]), [
|
|
153
|
+
["alice@example.com", false, false],
|
|
154
|
+
["room-1@resource.example.com", true, true],
|
|
155
|
+
]);
|
|
156
|
+
}),
|
|
157
|
+
test("an entry that ends before it starts is refused", async ({ store, calendar }) => {
|
|
158
|
+
await assert.rejects(store.saveEntries(calendar, [single("bad", later, { endsAt: later - 1 })], 2));
|
|
159
|
+
}),
|
|
160
|
+
];
|
|
161
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { meetingStoreContract } from "./contract.js";
|
|
2
|
+
export { CONTRACT_NOW, type EventStoreFixture, eventStoreContract } from "./event-store-contract.js";
|
|
2
3
|
export { type MeetingManagementContractFixture, meetingManagementContract } from "./management-contract.js";
|
|
3
4
|
export { memoryMeetingStore } from "./memory.js";
|
|
4
5
|
export { type RecordingSyncCheckpointFixture, recordingSyncCheckpointStoreContract, } from "./recording-sync-contract.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { meetingStoreContract } from "./contract.js";
|
|
2
|
+
export { CONTRACT_NOW, eventStoreContract } from "./event-store-contract.js";
|
|
2
3
|
export { meetingManagementContract } from "./management-contract.js";
|
|
3
4
|
export { memoryMeetingStore } from "./memory.js";
|
|
4
5
|
export { recordingSyncCheckpointStoreContract, } from "./recording-sync-contract.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import {
|
|
2
|
+
import { INTERNAL_NOTES, isDocumentAccountProvider, isFolderProvider, isKind, kindIn, kindOf, providerNamed, providers as registered, UNKNOWN_PROVIDER, } from "../domain/providers.js";
|
|
3
3
|
import { isBotProvider } from "./query.js";
|
|
4
4
|
/** Store-wide: every profile, tg, MAX and memo reads the same groups. */
|
|
5
5
|
export const ACCOUNT_GROUPS = "accountGroups";
|
|
@@ -29,13 +29,29 @@ const describe = (accounts) => accounts.map(({ provider, account, name }) => `${
|
|
|
29
29
|
const kindHere = (provider, running) => provider === running.provider ? kindOf(provider) : kindIn(provider);
|
|
30
30
|
const heldOf = (stored, running) => [
|
|
31
31
|
...stored
|
|
32
|
-
.filter(({ provider }) => kindIn(provider) !== "notes")
|
|
32
|
+
.filter(({ provider }) => kindIn(provider) !== "notes" && !isDocumentAccountProvider(provider))
|
|
33
33
|
.map(({ provider, account, name }) => ({ provider, account, name, kind: kindHere(provider, running) })),
|
|
34
34
|
{ provider: "notes", account: INTERNAL_NOTES, name: null, kind: "notes" },
|
|
35
35
|
...stored
|
|
36
|
-
.filter(({ provider }) => provider
|
|
37
|
-
.map(({ id, name }) => ({
|
|
36
|
+
.filter(({ provider }) => isFolderProvider(provider))
|
|
37
|
+
.map(({ id, name, provider }) => ({
|
|
38
|
+
provider: "notes",
|
|
39
|
+
account: String(id),
|
|
40
|
+
name,
|
|
41
|
+
kind: "notes",
|
|
42
|
+
folder: provider,
|
|
43
|
+
})),
|
|
44
|
+
...stored
|
|
45
|
+
.filter(({ provider }) => isDocumentAccountProvider(provider))
|
|
46
|
+
.map(({ id, provider, account, name }) => ({
|
|
47
|
+
provider: "notes",
|
|
48
|
+
account: String(id),
|
|
49
|
+
name,
|
|
50
|
+
kind: "notes",
|
|
51
|
+
stored: { provider, account },
|
|
52
|
+
})),
|
|
38
53
|
];
|
|
54
|
+
const storedAs = (held, key) => held.filter(({ stored }) => stored?.provider === key.provider && (key.account === "" || stored.account === key.account));
|
|
39
55
|
const registry = () => registered()
|
|
40
56
|
.map(({ name, aliases }) => (aliases.length ? `${name} (${aliases.join(", ")})` : name))
|
|
41
57
|
.join(", ");
|
|
@@ -44,7 +60,7 @@ const providerOf = (prefix, held, { running, profiles }) => {
|
|
|
44
60
|
if (wanted === running.command?.toLowerCase())
|
|
45
61
|
return running.provider;
|
|
46
62
|
const providers = [...new Set(held.map(({ provider }) => provider))];
|
|
47
|
-
if (providers.includes(wanted))
|
|
63
|
+
if (providers.includes(wanted) || isDocumentAccountProvider(wanted))
|
|
48
64
|
return wanted;
|
|
49
65
|
const named = providerNamed(wanted);
|
|
50
66
|
if (named)
|
|
@@ -57,6 +73,11 @@ const providerOf = (prefix, held, { running, profiles }) => {
|
|
|
57
73
|
const profilesOf = (provider, profiles) => Object.values(profiles).flatMap((named) => Object.entries(named).filter(([, key]) => key.provider === provider));
|
|
58
74
|
const accountOf = (prefix, ref, held, context) => {
|
|
59
75
|
const provider = providerOf(prefix, held, context);
|
|
76
|
+
if (isDocumentAccountProvider(provider) && !context.profiles[prefix.toLowerCase()]?.[ref]) {
|
|
77
|
+
const stored = storedAs(held, { provider, account: ref })[0];
|
|
78
|
+
if (stored)
|
|
79
|
+
return stored;
|
|
80
|
+
}
|
|
60
81
|
const ofProvider = held.filter((one) => one.provider === provider);
|
|
61
82
|
const byId = ofProvider.find(({ account }) => account === ref || account === ref.toLowerCase());
|
|
62
83
|
if (byId)
|
|
@@ -67,12 +88,18 @@ const accountOf = (prefix, ref, held, context) => {
|
|
|
67
88
|
kind: kindHere(key.provider, context.running),
|
|
68
89
|
};
|
|
69
90
|
const recorded = context.profiles[prefix.toLowerCase()]?.[ref];
|
|
70
|
-
if (recorded
|
|
91
|
+
if (recorded && isFolderProvider(recorded.provider)) {
|
|
71
92
|
const folder = held.find((one) => one.provider === "notes" && one.name === recorded.account);
|
|
72
93
|
if (folder)
|
|
73
94
|
return folder;
|
|
74
95
|
return fail(`no stored folder for ${prefix}:${ref}; sync that profile first`);
|
|
75
96
|
}
|
|
97
|
+
if (recorded && isDocumentAccountProvider(recorded.provider)) {
|
|
98
|
+
const account = storedAs(held, recorded)[0];
|
|
99
|
+
if (account)
|
|
100
|
+
return account;
|
|
101
|
+
return fail(`no stored documents for ${prefix}:${ref}; sync that profile first`);
|
|
102
|
+
}
|
|
76
103
|
if (recorded)
|
|
77
104
|
return keyed(recorded);
|
|
78
105
|
const byProfile = [
|
|
@@ -101,9 +128,14 @@ const accountsOfWord = (word, held, context) => {
|
|
|
101
128
|
if (isKind(wanted))
|
|
102
129
|
return held.filter(({ kind }) => kind === wanted);
|
|
103
130
|
const recorded = context.profiles[wanted];
|
|
104
|
-
if (recorded &&
|
|
131
|
+
if (recorded &&
|
|
132
|
+
Object.values(recorded).every((key) => isFolderProvider(key.provider) || isDocumentAccountProvider(key.provider)))
|
|
105
133
|
return Object.keys(recorded).map((profile) => accountOf(word, profile, held, context));
|
|
106
134
|
const provider = providerOf(word, held, context);
|
|
135
|
+
if (isDocumentAccountProvider(provider))
|
|
136
|
+
return storedAs(held, { provider, account: "" });
|
|
137
|
+
if (isFolderProvider(provider))
|
|
138
|
+
return held.filter(({ folder }) => folder === provider);
|
|
107
139
|
return held.filter((one) => one.provider === provider);
|
|
108
140
|
};
|
|
109
141
|
const keyOf = ({ provider, account }) => `${provider}\u0000${account}`;
|
package/dist/services/agenda.js
CHANGED
|
@@ -5,6 +5,7 @@ import { agenda } from "../entities/agenda.js";
|
|
|
5
5
|
import { AGENDA_COLUMNS, inboxKey, queryFilter, } from "../store/store.js";
|
|
6
6
|
import { CHAT_KINDS } from "./chats.js";
|
|
7
7
|
import { taskAccount } from "./task-rules.js";
|
|
8
|
+
import { taskTagFilters } from "./task-tag-filters.js";
|
|
8
9
|
const DAY = 86_400_000;
|
|
9
10
|
const CLAIMS = ["none", "mine", "others", "any"];
|
|
10
11
|
const TASK_FIELDS = new Set(["tag", "category", "claimed"]);
|
|
@@ -46,8 +47,9 @@ export const agendaService = (deps, now = Date.now) => ({
|
|
|
46
47
|
throw error;
|
|
47
48
|
});
|
|
48
49
|
const open = await find();
|
|
50
|
+
const tags = await taskTagFilters(store, parts.tasks);
|
|
49
51
|
const listed = parts.tasks
|
|
50
|
-
? await find(queryFilter(parts.tasks, taskColumns(holder, at), { command: "tasks agenda" }))
|
|
52
|
+
? await find(queryFilter(parts.tasks, taskColumns(holder, at, tags), { command: "tasks agenda" }))
|
|
51
53
|
: open;
|
|
52
54
|
const tasks = listed
|
|
53
55
|
.map((task) => ({ task, reasons: reasonsOf(task, at, dueWithin) }))
|
|
@@ -148,11 +150,9 @@ const split = (query) => {
|
|
|
148
150
|
};
|
|
149
151
|
return { tasks: of("tasks"), chats: of("chats") };
|
|
150
152
|
};
|
|
151
|
-
const TAGGED = "EXISTS (SELECT 1 FROM taggings g JOIN tags n ON n.id = g.tag_id " +
|
|
152
|
-
"WHERE g.taggable_type = 'task' AND g.taggable_id = t.id AND n.name = ?)";
|
|
153
153
|
/** A claim that lapsed holds nothing, as `tasks claim` reads it. */
|
|
154
|
-
const taskColumns = (holder, at) => ({
|
|
155
|
-
tag: { where: (node) => ({ sql:
|
|
154
|
+
const taskColumns = (holder, at, tags) => ({
|
|
155
|
+
tag: { where: (node) => tags.get(node.value) ?? { sql: "0", params: [] } },
|
|
156
156
|
category: { type: "kw", sql: "t.category" },
|
|
157
157
|
claimed: {
|
|
158
158
|
where: (node) => {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { Profiles } from "../search/account-list.js";
|
|
2
|
-
import type { Note,
|
|
3
|
-
/**
|
|
4
|
-
|
|
2
|
+
import type { Note, StoredAccount, StoredFile } from "../store/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Where a hit came from. The document's preferred file says it: its account's provider, its link and its edit time.
|
|
5
|
+
* A Drive document under a notes folder (before version 31 moves it) is still known by its front matter.
|
|
6
|
+
* Indexing time belongs to the local row.
|
|
7
|
+
*/
|
|
8
|
+
export declare const documentSource: (note: Note, accounts: readonly StoredAccount[], profiles: Profiles, file?: StoredFile) => {
|
|
5
9
|
kind: "document" | "note";
|
|
6
10
|
provider: string;
|
|
7
11
|
account: string;
|
|
@@ -1,18 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { isFolderProvider, kindIn } from "../domain/providers.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where a hit came from. The document's preferred file says it: its account's provider, its link and its edit time.
|
|
4
|
+
* A Drive document under a notes folder (before version 31 moves it) is still known by its front matter.
|
|
5
|
+
* Indexing time belongs to the local row.
|
|
6
|
+
*/
|
|
7
|
+
export const documentSource = (note, accounts, profiles, file) => {
|
|
3
8
|
const metadata = note.frontMatter;
|
|
4
|
-
const
|
|
5
|
-
const remote = note.source === "file" &&
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
const account = accounts.find(({ id }) => String(id) === note.folderId);
|
|
10
|
+
const remote = note.source === "file" &&
|
|
11
|
+
((account !== undefined && kindIn(account.provider) === "documents") || metadata?.source === "google-drive");
|
|
12
|
+
const profile = remote && account
|
|
13
|
+
? Object.entries(profiles)
|
|
14
|
+
.flatMap(([command, named]) => Object.entries(named).map(([name, key]) => ({ command, name, key })))
|
|
15
|
+
.find(({ key }) => (key.provider === account.provider && key.account === account.account) ||
|
|
16
|
+
(isFolderProvider(key.provider) && key.account === account.name))
|
|
8
17
|
: undefined;
|
|
18
|
+
const url = file?.url ?? (remote && typeof metadata?.sourceUrl === "string" ? metadata.sourceUrl : undefined);
|
|
19
|
+
const modified = file?.externalUpdatedAt ??
|
|
20
|
+
(remote && typeof metadata?.modifiedAt === "string" && Number.isFinite(Date.parse(metadata.modifiedAt))
|
|
21
|
+
? metadata.modifiedAt
|
|
22
|
+
: undefined);
|
|
9
23
|
return {
|
|
10
24
|
kind: remote ? "document" : "note",
|
|
11
|
-
provider: profile ?
|
|
12
|
-
account: profile ?? note.folderId ?? "internal",
|
|
13
|
-
...(remote &&
|
|
14
|
-
updated_at: remote &&
|
|
15
|
-
? metadata.modifiedAt
|
|
16
|
-
: note.updatedAt,
|
|
25
|
+
provider: profile ? profile.command : "notes",
|
|
26
|
+
account: profile?.name ?? note.folderId ?? "internal",
|
|
27
|
+
...(remote && url !== undefined ? { url } : {}),
|
|
28
|
+
updated_at: remote && modified !== undefined ? modified : note.updatedAt,
|
|
17
29
|
};
|
|
18
30
|
};
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { SourceContent, SourceFile } from "../../documents/model.js";
|
|
2
2
|
import type { CatalogInput, CatalogQuery, MessageStore } from "../../store/index.js";
|
|
3
3
|
export interface IngestionOptions<M extends SourceFile> {
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The documents account's provider (`google-drive`): each principal's documents live under
|
|
6
|
+
* `{ provider, account: principal }`. Without it they live in a notes folder named `collectionName(principal)`.
|
|
7
|
+
*/
|
|
8
|
+
provider?: string;
|
|
9
|
+
collectionName?(principal: string): string;
|
|
5
10
|
externalId(id: string): string;
|
|
6
11
|
catalogInput(file: M): CatalogInput;
|
|
7
12
|
frontMatter(content: SourceContent<M>): Record<string, unknown>;
|
|
@@ -2,6 +2,10 @@ import { CliError } from "@wirecat/cli-base";
|
|
|
2
2
|
import { walkQuery } from "@wirecat/cli-base/query";
|
|
3
3
|
export const documentIngestion = (store, options) => {
|
|
4
4
|
const folder = async (principal, create) => {
|
|
5
|
+
if (options.provider)
|
|
6
|
+
return store.notes.documentsAccount({ provider: options.provider, account: principal }, create);
|
|
7
|
+
if (!options.collectionName)
|
|
8
|
+
throw new CliError("configuration_error", "document ingestion needs a provider or a collection name");
|
|
5
9
|
const name = options.collectionName(principal);
|
|
6
10
|
const found = (await store.notes.folders()).find((item) => item.name === name);
|
|
7
11
|
return found ?? (create ? await store.notes.addFolder({ name, format: "markdown" }) : undefined);
|
|
@@ -295,7 +295,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
295
295
|
const hasMore = slice ? matched.hasMore : ordered.length > window || matched.hasMore || titled.hasMore;
|
|
296
296
|
const hits = [];
|
|
297
297
|
const counts = new Map();
|
|
298
|
-
const
|
|
298
|
+
const accounts = await store.accounts.storedAccounts();
|
|
299
299
|
const profiles = await store.accounts.profiles();
|
|
300
300
|
for (const [ref, { note, foundBy }] of page) {
|
|
301
301
|
const links = (await store.notes.links({ from: ref })).map(({ to, targetText, kind, anchor }) => ({
|
|
@@ -310,7 +310,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
310
310
|
folder_id: note.folderId,
|
|
311
311
|
path: note.path,
|
|
312
312
|
title: note.title,
|
|
313
|
-
...documentSource(note,
|
|
313
|
+
...documentSource(note, accounts, profiles, note.source === "file" ? (await store.files.ofDocument(note.ref))[0] : undefined),
|
|
314
314
|
excerpt: firstLine(note.text, words),
|
|
315
315
|
links,
|
|
316
316
|
found_by: foundBy,
|
|
@@ -374,6 +374,7 @@ export const searchNotesOf = async (store, account, text, { accounts: list, app,
|
|
|
374
374
|
const held = [
|
|
375
375
|
{ provider: "notes", account: INTERNAL_NOTES },
|
|
376
376
|
...(await store.notes.folders()).map(({ id }) => ({ provider: "notes", account: id })),
|
|
377
|
+
...(await store.notes.documentsAccounts()).map(({ id }) => ({ provider: "notes", account: id })),
|
|
377
378
|
];
|
|
378
379
|
const named = list === undefined
|
|
379
380
|
? { accounts: held, skipped: [] }
|
|
@@ -109,6 +109,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
|
|
|
109
109
|
const read = chosen?.map(({ account }) => account) ?? [
|
|
110
110
|
INTERNAL_NOTES,
|
|
111
111
|
...(await store.notes.folders()).map(({ id }) => id),
|
|
112
|
+
...(await store.notes.documentsAccounts()).map(({ id }) => id),
|
|
112
113
|
];
|
|
113
114
|
accounts.push(...read.map((account) => ({ resource, provider: "notes", account })));
|
|
114
115
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { walkQuery } from "@wirecat/cli-base/query";
|
|
2
|
+
export const taskTagFilters = async (store, query) => {
|
|
3
|
+
const tags = new Map();
|
|
4
|
+
for (const node of query ? walkQuery(query.root) : [])
|
|
5
|
+
if (node.field === "tag" && !tags.has(node.value))
|
|
6
|
+
tags.set(node.value, await store.tasks.tagWhere(node.value));
|
|
7
|
+
return tags;
|
|
8
|
+
};
|
package/dist/services/tasks.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createTaskService, TASK_KINDS, TASK_STATES, } from "../tasks/index.js";
|
|
|
9
9
|
import { storedChatId } from "./message-resolution.js";
|
|
10
10
|
import { categorize, taskCategoryOf } from "./task-categories.js";
|
|
11
11
|
import { taskAccount } from "./task-rules.js";
|
|
12
|
+
import { taskTagFilters } from "./task-tag-filters.js";
|
|
12
13
|
export { TASK_KINDS, TASK_STATES };
|
|
13
14
|
export const TASK_QUERY = {
|
|
14
15
|
entity: "task",
|
|
@@ -68,17 +69,11 @@ export const TASK_PAIR_QUERY = {
|
|
|
68
69
|
};
|
|
69
70
|
const TIME = (sql) => ({ type: "time", sql });
|
|
70
71
|
/** `chat:` names the chat as the messenger does; `groups` holds each name already resolved to its stored id. */
|
|
71
|
-
const taskColumns = (groups) => ({
|
|
72
|
+
const taskColumns = (groups, tags) => ({
|
|
72
73
|
id: { type: "kw", sql: "t.package_id" },
|
|
73
74
|
key: { type: "kw", sql: "t.key" },
|
|
74
75
|
category: { type: "kw", sql: "t.category" },
|
|
75
|
-
tag: {
|
|
76
|
-
where: (node) => ({
|
|
77
|
-
sql: "EXISTS (SELECT 1 FROM taggings g JOIN tags n ON n.id = g.tag_id " +
|
|
78
|
-
"WHERE g.taggable_type = 'task' AND g.taggable_id = t.id AND n.name = ?)",
|
|
79
|
-
params: [node.value.trim().toLowerCase()],
|
|
80
|
-
}),
|
|
81
|
-
},
|
|
76
|
+
tag: { where: (node) => tags.get(node.value) ?? { sql: "0", params: [] } },
|
|
82
77
|
title: { type: "kw", sql: "t.title_folded", folded: true },
|
|
83
78
|
kind: { type: "enum", sql: "t.type" },
|
|
84
79
|
state: { type: "enum", sql: "t.status" },
|
|
@@ -213,7 +208,8 @@ export const tasksService = (deps, categories) => {
|
|
|
213
208
|
for (const node of query ? walkQuery(query.root) : [])
|
|
214
209
|
if (node.field === "chat" && node.operator !== "wildcard" && !groups.has(node.value))
|
|
215
210
|
groups.set(node.value, await storedChatId(deps.messenger, node.value, store, account, { includeLeft: true }));
|
|
216
|
-
const
|
|
211
|
+
const tags = await taskTagFilters(store, query);
|
|
212
|
+
const where = query ? queryFilter(query, taskColumns(groups, tags), { command: "tasks list" }) : undefined;
|
|
217
213
|
const rows = await store.tasks.query(taskAccount(account), where, { limit: limit + 1, offset });
|
|
218
214
|
const shown = rows.slice(0, limit);
|
|
219
215
|
return {
|
|
@@ -7,6 +7,7 @@ import { planTransfer } from "../sqlite/document-transfer/plan.js";
|
|
|
7
7
|
import { transferSnapshot } from "../sqlite/document-transfer/snapshot.js";
|
|
8
8
|
import { openSqlite } from "../sqlite/open.js";
|
|
9
9
|
import { openStoreContext } from "../sqlite/open-store.js";
|
|
10
|
+
import { assertReadCompatible } from "../sqlite/read-compatibility.js";
|
|
10
11
|
import { digest } from "./digest.js";
|
|
11
12
|
const locations = (options) => {
|
|
12
13
|
for (const value of [options.source, options.destination, options.collection])
|
|
@@ -51,8 +52,17 @@ const openSource = async (path) => {
|
|
|
51
52
|
}
|
|
52
53
|
};
|
|
53
54
|
const openPreviewTarget = async (path) => {
|
|
54
|
-
if (statSync(path, { throwIfNoEntry: false }))
|
|
55
|
-
|
|
55
|
+
if (statSync(path, { throwIfNoEntry: false })) {
|
|
56
|
+
const opened = await openSource(path);
|
|
57
|
+
try {
|
|
58
|
+
assertReadCompatible(opened.database);
|
|
59
|
+
return opened;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
opened.database.close();
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
56
66
|
const target = await openSqlite(":memory:");
|
|
57
67
|
try {
|
|
58
68
|
migrate(target.database);
|
package/dist/store/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextEvidenceSender, ContextSource, } from "../context/evidence.js";
|
|
2
2
|
export { FACT_STATUSES, FACT_TYPES, MEMORY_KINDS, MEMORY_SCOPES, MEMORY_STATUSES, TASK_TYPES, WORK_TASK_TYPES, } from "../domain/knowledge.js";
|
|
3
|
-
export type { TagConsolidationOptions, TagConsolidationPreview, TagConsolidationStore, } from "../domain/tag-consolidation.js";
|
|
3
|
+
export type { TagConsolidationApplyOptions, TagConsolidationOptions, TagConsolidationPreview, TagConsolidationReceipt, TagConsolidationStore, } from "../domain/tag-consolidation.js";
|
|
4
4
|
export type { TagRedirectIndex, TagRedirectResolution, TagRedirectSnapshot } from "../domain/tag-redirects.js";
|
|
5
5
|
export { createTagRedirectIndex } from "../domain/tag-redirects.js";
|
|
6
6
|
export { note } from "../entities/note.js";
|
|
@@ -31,6 +31,7 @@ export { CATALOG_QUERY } from "./sqlite/catalog.js";
|
|
|
31
31
|
export type { ChatMetadata } from "./sqlite/chats.js";
|
|
32
32
|
export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
|
|
33
33
|
export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
|
|
34
|
+
export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
|
|
34
35
|
export { IMPORTED_DOCUMENT_QUERY, type ImportedDocuments } from "./sqlite/imported-documents.js";
|
|
35
36
|
export type { Involvement, InvolvementStore } from "./sqlite/involvements.js";
|
|
36
37
|
export type { Annotation, KnowledgeRelation, KnowledgeStore, KnowledgeTarget, LabelledType, Organization, Project, Reminder, } from "./sqlite/knowledge.js";
|
|
@@ -111,6 +111,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
|
|
|
111
111
|
orm.delete(accountIdentities).where(eq(accountIdentities.accountId, accountKey)).run();
|
|
112
112
|
// The inbox project and its tasks are the owner's; only the tie to the account goes.
|
|
113
113
|
orm.update(projects).set({ accountId: null }).where(eq(projects.accountId, accountKey)).run();
|
|
114
|
+
orm.run(sql `DELETE FROM files WHERE account_id = ${accountKey}`);
|
|
114
115
|
orm.delete(accounts).where(eq(accounts.id, accountKey)).run();
|
|
115
116
|
dropOrphanPeople(database, unseenBefore);
|
|
116
117
|
orm.run(sql `DELETE FROM embeddings WHERE content_hash IN (SELECT value FROM json_each(${JSON.stringify(hashes)}))
|