@wirecat/cli-core 0.382.0 → 0.384.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 +35 -0
- package/dist/attachments/documents.d.ts +3 -0
- package/dist/attachments/documents.js +17 -0
- package/dist/cli/bot/moderation.js +4 -3
- package/dist/cli/bot/store.js +5 -4
- package/dist/cli/list-query.d.ts +11 -1
- package/dist/cli/list-query.js +11 -1
- package/dist/cli/meeting-batch.js +2 -1
- package/dist/cli/messenger/admin-moderation-command.js +4 -3
- package/dist/cli/messenger/archive-commands.js +5 -3
- package/dist/cli/messenger/backfill-command.js +9 -4
- package/dist/cli/messenger/catch-up-answers.d.ts +5 -2
- package/dist/cli/messenger/catch-up-answers.js +11 -6
- package/dist/cli/messenger/chat-lists.js +11 -6
- package/dist/cli/messenger/chats-stats-command.js +7 -7
- package/dist/cli/messenger/contacts-command.js +4 -3
- package/dist/cli/messenger/conversation-answers.d.ts +2 -2
- package/dist/cli/messenger/conversation-answers.js +5 -2
- package/dist/cli/messenger/conversations-command.js +7 -3
- package/dist/cli/messenger/inbox.js +2 -2
- package/dist/cli/messenger/replies-command.js +4 -3
- package/dist/cli/messenger/review.js +2 -1
- package/dist/cli/messenger/stats-answers.d.ts +1 -1
- package/dist/cli/messenger/stats-answers.js +4 -3
- package/dist/cli/runs/search.d.ts +7 -0
- package/dist/cli/runs/search.js +11 -3
- package/dist/documents/blocks.d.ts +45 -0
- package/dist/documents/blocks.js +48 -0
- package/dist/documents/index.d.ts +5 -0
- package/dist/documents/index.js +4 -0
- package/dist/documents/markdown-blocks.d.ts +3 -0
- package/dist/documents/markdown-blocks.js +123 -0
- package/dist/documents/push-model.d.ts +89 -0
- package/dist/documents/push-model.js +1 -0
- package/dist/documents.d.ts +1 -0
- package/dist/documents.js +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/meetings/answers.d.ts +4 -2
- package/dist/meetings/answers.js +2 -1
- package/dist/meetings/cli/index.d.ts +1 -1
- package/dist/meetings/cli/index.js +1 -1
- package/dist/meetings/cli/search.d.ts +5 -0
- package/dist/meetings/cli/search.js +23 -5
- package/dist/meetings/mcp/search.d.ts +3 -1
- package/dist/meetings/mcp/search.js +5 -4
- package/dist/services/admin-statistics.d.ts +3 -0
- package/dist/services/admin-statistics.js +10 -7
- package/dist/services/documents/block-diff.d.ts +24 -0
- package/dist/services/documents/block-diff.js +91 -0
- package/dist/services/documents/push.d.ts +60 -0
- package/dist/services/documents/push.js +218 -0
- package/dist/services/retention.d.ts +7 -0
- package/dist/services/retention.js +10 -5
- package/package.json +5 -1
|
@@ -2,6 +2,7 @@ import type { AdminOptions, AdminReport } from "../domain/admin-statistics.js";
|
|
|
2
2
|
import type { AdminStoreResult, MessageStore } from "../store/store.js";
|
|
3
3
|
import type { ServiceDeps } from "./deps.js";
|
|
4
4
|
import type { SearchQuery } from "./messages.js";
|
|
5
|
+
import { type DateWindow } from "./moment.js";
|
|
5
6
|
import { type RankingSelection } from "./rankings-selection.js";
|
|
6
7
|
export type AdminQuery = SearchQuery & {
|
|
7
8
|
answerers?: string[];
|
|
@@ -28,6 +29,8 @@ export interface AdminSelection {
|
|
|
28
29
|
}
|
|
29
30
|
export interface AdminFound extends AdminStoreResult {
|
|
30
31
|
report: AdminReport;
|
|
32
|
+
/** How the typed join dates were read, for the answer's `meta`. */
|
|
33
|
+
timeMeta?: DateWindow["meta"];
|
|
31
34
|
detectorVersion: 1;
|
|
32
35
|
items: (AdminStoreResult["items"][number] & {
|
|
33
36
|
drilldown: {
|
|
@@ -2,7 +2,7 @@ import { CliError, singleLine } from "@wirecat/cli-base";
|
|
|
2
2
|
import { parseLocator } from "../domain/locator.js";
|
|
3
3
|
import { isId, pickPerson } from "../resolve.js";
|
|
4
4
|
import { prepareLucene, stemExecution } from "./messages-search.js";
|
|
5
|
-
import {
|
|
5
|
+
import { dateWindow } from "./moment.js";
|
|
6
6
|
import { rankingSelection, readRankingSelection } from "./rankings-selection.js";
|
|
7
7
|
import { searchRecordOf } from "./search-record.js";
|
|
8
8
|
function invalid(message) {
|
|
@@ -205,6 +205,12 @@ export const adminStatisticsService = (deps) => ({
|
|
|
205
205
|
if (pinned &&
|
|
206
206
|
(query.source || query.ast || query.exact || (query.timezone && query.timezone !== prepared.timezone)))
|
|
207
207
|
invalid("a saved report keeps its resolved query, accounts and timezone");
|
|
208
|
+
const window = dateWindow({
|
|
209
|
+
...(query.sinceTime ? { sinceTime: query.sinceTime } : {}),
|
|
210
|
+
...(query.untilTime ? { untilTime: query.untilTime } : {}),
|
|
211
|
+
zone: prepared.timezone,
|
|
212
|
+
now: cutoff,
|
|
213
|
+
});
|
|
208
214
|
const options = {
|
|
209
215
|
report,
|
|
210
216
|
cutoff,
|
|
@@ -212,12 +218,8 @@ export const adminStatisticsService = (deps) => ({
|
|
|
212
218
|
? pinned.options.olderThan
|
|
213
219
|
: duration(query.olderThan ?? "24h", "--older-than"),
|
|
214
220
|
within: query.within === undefined && pinned ? pinned.options.within : duration(query.within ?? "7d", "--within"),
|
|
215
|
-
joinSince:
|
|
216
|
-
|
|
217
|
-
: (pinned?.options.joinSince ?? cutoff - 30 * 86_400_000),
|
|
218
|
-
joinUntil: query.untilTime
|
|
219
|
-
? momentOf(query.untilTime, "--until-time", cutoff, { zone: prepared.timezone, end: true })
|
|
220
|
-
: (pinned?.options.joinUntil ?? cutoff),
|
|
221
|
+
joinSince: window.since ?? pinned?.options.joinSince ?? cutoff - 30 * 86_400_000,
|
|
222
|
+
joinUntil: window.until ?? pinned?.options.joinUntil ?? cutoff,
|
|
221
223
|
minViews: count(query.minViews ?? pinned?.options.minViews ?? 1, "--min-views"),
|
|
222
224
|
maxReplies: count(query.maxReplies ?? pinned?.options.maxReplies ?? 0, "--max-replies"),
|
|
223
225
|
answerers: query.answerers === undefined && pinned
|
|
@@ -252,6 +254,7 @@ export const adminStatisticsService = (deps) => ({
|
|
|
252
254
|
return {
|
|
253
255
|
...result,
|
|
254
256
|
report,
|
|
257
|
+
timeMeta: window.meta,
|
|
255
258
|
detectorVersion: 1,
|
|
256
259
|
items: result.items.map((original) => {
|
|
257
260
|
const identityKnown = known.has(JSON.stringify([original.account.provider, original.account.account, original.id]));
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Block } from "../../documents/blocks.js";
|
|
2
|
+
/** `base[from, to)` is replaced by `blocks`; an empty range is an insert, an empty `blocks` a delete. */
|
|
3
|
+
export interface BlockHunk {
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
blocks: Block[];
|
|
7
|
+
}
|
|
8
|
+
/** The edits that turn `base` into `next`, leaving every unchanged block in place. */
|
|
9
|
+
export declare const diffBlocks: (base: Block[], next: Block[]) => BlockHunk[];
|
|
10
|
+
export type Reconciled = {
|
|
11
|
+
status: "clean";
|
|
12
|
+
blocks: Block[];
|
|
13
|
+
ticksKept: number;
|
|
14
|
+
} | {
|
|
15
|
+
status: "conflict";
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* What a push may write. `recorded` is what the last push wrote and read back, `current` the remote
|
|
19
|
+
* now, `local` the file now and `lastLocal` the file as the last push read it. Any remote change other
|
|
20
|
+
* than a checklist tick is a conflict, so the push never overwrites someone's edit. A tick follows the
|
|
21
|
+
* side that changed it: the local file when its tick differs from `lastLocal`, the remote otherwise —
|
|
22
|
+
* judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
|
|
23
|
+
*/
|
|
24
|
+
export declare const reconcileBlocks: (recorded: Block[], current: Block[], local: Block[], lastLocal: Block[]) => Reconciled;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { blockKey, blockShape } from "../../documents/blocks.js";
|
|
2
|
+
const MAX_CELLS = 4_000_000;
|
|
3
|
+
/** Longest common subsequence: index pairs [inA, inB] of blocks with equal keys, in order. */
|
|
4
|
+
const matches = (a, b, keyOf) => {
|
|
5
|
+
const ka = a.map(keyOf);
|
|
6
|
+
const kb = b.map(keyOf);
|
|
7
|
+
let start = 0;
|
|
8
|
+
while (start < ka.length && start < kb.length && ka[start] === kb[start])
|
|
9
|
+
start++;
|
|
10
|
+
let endA = ka.length;
|
|
11
|
+
let endB = kb.length;
|
|
12
|
+
while (endA > start && endB > start && ka[endA - 1] === kb[endB - 1]) {
|
|
13
|
+
endA--;
|
|
14
|
+
endB--;
|
|
15
|
+
}
|
|
16
|
+
const rows = endA - start;
|
|
17
|
+
const cols = endB - start;
|
|
18
|
+
const pairs = [];
|
|
19
|
+
for (let i = 0; i < start; i++)
|
|
20
|
+
pairs.push([i, i]);
|
|
21
|
+
if (rows * cols > MAX_CELLS) {
|
|
22
|
+
// Too large to align: the changed middle is rewritten as one block of edits.
|
|
23
|
+
}
|
|
24
|
+
else if (rows && cols) {
|
|
25
|
+
const table = new Uint32Array((rows + 1) * (cols + 1));
|
|
26
|
+
for (let i = rows - 1; i >= 0; i--)
|
|
27
|
+
for (let j = cols - 1; j >= 0; j--)
|
|
28
|
+
table[i * (cols + 1) + j] =
|
|
29
|
+
ka[start + i] === kb[start + j]
|
|
30
|
+
? table[(i + 1) * (cols + 1) + j + 1] + 1
|
|
31
|
+
: Math.max(table[(i + 1) * (cols + 1) + j], table[i * (cols + 1) + j + 1]);
|
|
32
|
+
let i = 0;
|
|
33
|
+
let j = 0;
|
|
34
|
+
while (i < rows && j < cols) {
|
|
35
|
+
if (ka[start + i] === kb[start + j]) {
|
|
36
|
+
pairs.push([start + i, start + j]);
|
|
37
|
+
i++;
|
|
38
|
+
j++;
|
|
39
|
+
}
|
|
40
|
+
else if (table[(i + 1) * (cols + 1) + j] >= table[i * (cols + 1) + j + 1])
|
|
41
|
+
i++;
|
|
42
|
+
else
|
|
43
|
+
j++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (let offset = 0; offset < ka.length - endA; offset++)
|
|
47
|
+
pairs.push([endA + offset, endB + offset]);
|
|
48
|
+
return pairs;
|
|
49
|
+
};
|
|
50
|
+
/** The edits that turn `base` into `next`, leaving every unchanged block in place. */
|
|
51
|
+
export const diffBlocks = (base, next) => {
|
|
52
|
+
const hunks = [];
|
|
53
|
+
let a = 0;
|
|
54
|
+
let b = 0;
|
|
55
|
+
for (const [i, j] of [...matches(base, next, blockKey), [base.length, next.length]]) {
|
|
56
|
+
if (i > a || j > b)
|
|
57
|
+
hunks.push({ from: a, to: i, blocks: next.slice(b, j) });
|
|
58
|
+
a = i + 1;
|
|
59
|
+
b = j + 1;
|
|
60
|
+
}
|
|
61
|
+
return hunks;
|
|
62
|
+
};
|
|
63
|
+
const isCheck = (block) => block?.kind === "item" && block.list === "check";
|
|
64
|
+
/**
|
|
65
|
+
* What a push may write. `recorded` is what the last push wrote and read back, `current` the remote
|
|
66
|
+
* now, `local` the file now and `lastLocal` the file as the last push read it. Any remote change other
|
|
67
|
+
* than a checklist tick is a conflict, so the push never overwrites someone's edit. A tick follows the
|
|
68
|
+
* side that changed it: the local file when its tick differs from `lastLocal`, the remote otherwise —
|
|
69
|
+
* judged on every push, so a remote tick is not undone once the remote and `recorded` agree again.
|
|
70
|
+
*/
|
|
71
|
+
export const reconcileBlocks = (recorded, current, local, lastLocal) => {
|
|
72
|
+
const sameShape = recorded.length === current.length &&
|
|
73
|
+
recorded.every((block, index) => blockShape(block) === blockShape(current[index]));
|
|
74
|
+
if (!sameShape)
|
|
75
|
+
return { status: "conflict" };
|
|
76
|
+
const before = new Map(matches(local, lastLocal, blockShape));
|
|
77
|
+
const blocks = local.map((block) => ({ ...block }));
|
|
78
|
+
let ticksKept = 0;
|
|
79
|
+
for (const [i, j] of matches(current, local, blockShape)) {
|
|
80
|
+
const remote = current[i];
|
|
81
|
+
const mine = blocks[j];
|
|
82
|
+
const last = lastLocal[before.get(j) ?? -1];
|
|
83
|
+
if (!isCheck(remote) || !isCheck(mine) || !isCheck(last))
|
|
84
|
+
continue;
|
|
85
|
+
if (Boolean(mine.checked) === Boolean(last.checked) && Boolean(remote.checked) !== Boolean(mine.checked)) {
|
|
86
|
+
mine.checked = Boolean(remote.checked);
|
|
87
|
+
ticksKept++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { status: "clean", blocks, ticksKept };
|
|
91
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import type { DocumentTarget, LocalDocument, PushPlan, PushState, PushStatePort } from "../../documents/push-model.js";
|
|
3
|
+
export interface PushOptions<S> {
|
|
4
|
+
/** Absolute path of the local folder being mirrored. */
|
|
5
|
+
root: string;
|
|
6
|
+
/** Remote folder id the mirror writes into. */
|
|
7
|
+
folder: string;
|
|
8
|
+
target: DocumentTarget<S>;
|
|
9
|
+
state: PushStatePort<S>;
|
|
10
|
+
principal: string;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
/** Runs one remote write behind the provider's permission checks and write journal. */
|
|
14
|
+
guard<T>(attempt: {
|
|
15
|
+
action: "create" | "update" | "folder";
|
|
16
|
+
path: string;
|
|
17
|
+
operationId: string;
|
|
18
|
+
}, act: () => Promise<T>): Promise<T>;
|
|
19
|
+
operationId(): string;
|
|
20
|
+
errorOf(error: unknown): CliError;
|
|
21
|
+
assertHeld(): void;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
maxFiles?: number;
|
|
24
|
+
now?(): number;
|
|
25
|
+
}
|
|
26
|
+
export interface PushResult {
|
|
27
|
+
created: string[];
|
|
28
|
+
updated: string[];
|
|
29
|
+
unchanged: number;
|
|
30
|
+
conflicts: {
|
|
31
|
+
path: string;
|
|
32
|
+
url: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}[];
|
|
35
|
+
missing: string[];
|
|
36
|
+
orphans: {
|
|
37
|
+
path: string;
|
|
38
|
+
url: string;
|
|
39
|
+
}[];
|
|
40
|
+
errors: {
|
|
41
|
+
path: string;
|
|
42
|
+
code: string;
|
|
43
|
+
message: string;
|
|
44
|
+
}[];
|
|
45
|
+
unsupported: number;
|
|
46
|
+
plan?: {
|
|
47
|
+
create: string[];
|
|
48
|
+
update: string[];
|
|
49
|
+
orphans: string[];
|
|
50
|
+
};
|
|
51
|
+
stopped?: true;
|
|
52
|
+
}
|
|
53
|
+
/** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
|
|
54
|
+
export declare const scanLocal: (root: string, kindOf: (path: string) => string | undefined, maxFiles?: number) => Promise<{
|
|
55
|
+
files: LocalDocument[];
|
|
56
|
+
unsupported: number;
|
|
57
|
+
}>;
|
|
58
|
+
/** What a push would do, from the local files and the last push alone: no remote read. */
|
|
59
|
+
export declare const planPush: <S>(files: LocalDocument[], state: PushState<S>) => PushPlan;
|
|
60
|
+
export declare const pushDocuments: <S>(options: PushOptions<S>) => Promise<PushResult>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { join, posix } from "node:path";
|
|
5
|
+
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
6
|
+
const SKIPPED = /^(\.|node_modules$|~\$)|(~|\.swp|\.tmp)$/;
|
|
7
|
+
const hashFile = (path) => new Promise((done, fail) => {
|
|
8
|
+
const hash = createHash("sha256");
|
|
9
|
+
createReadStream(path)
|
|
10
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
11
|
+
.on("error", fail)
|
|
12
|
+
.on("end", () => done(hash.digest("hex")));
|
|
13
|
+
});
|
|
14
|
+
/** Files under `root` the target pushes. Hidden files, editor leftovers and symbolic links are never followed. */
|
|
15
|
+
export const scanLocal = async (root, kindOf, maxFiles = 10_000) => {
|
|
16
|
+
const files = [];
|
|
17
|
+
let unsupported = 0;
|
|
18
|
+
const walk = async (relative) => {
|
|
19
|
+
const entries = await readdir(join(root, relative), { withFileTypes: true });
|
|
20
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
21
|
+
if (SKIPPED.test(entry.name) || entry.isSymbolicLink())
|
|
22
|
+
continue;
|
|
23
|
+
const path = relative ? posix.join(relative, entry.name) : entry.name;
|
|
24
|
+
if (entry.isDirectory())
|
|
25
|
+
await walk(path);
|
|
26
|
+
else if (entry.isFile()) {
|
|
27
|
+
const kind = kindOf(path);
|
|
28
|
+
if (!kind) {
|
|
29
|
+
unsupported++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (files.length >= maxFiles)
|
|
33
|
+
throw new CliError("validation_error", `the folder holds more than ${maxFiles} files to push`, {
|
|
34
|
+
reason: "push.too-many-files",
|
|
35
|
+
});
|
|
36
|
+
const absolute = join(root, path);
|
|
37
|
+
files.push({ path, absolute, kind, hash: await hashFile(absolute) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
await walk("");
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (isCliError(error))
|
|
46
|
+
throw error;
|
|
47
|
+
throw new CliError("validation_error", "the folder to push could not be read", { reason: "push.unreadable" });
|
|
48
|
+
}
|
|
49
|
+
return { files, unsupported };
|
|
50
|
+
};
|
|
51
|
+
/** What a push would do, from the local files and the last push alone: no remote read. */
|
|
52
|
+
export const planPush = (files, state) => {
|
|
53
|
+
const plan = { create: [], update: [], unchanged: [], orphans: [] };
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
seen.add(file.path);
|
|
57
|
+
const entry = state.entries[file.path];
|
|
58
|
+
if (!entry)
|
|
59
|
+
plan.create.push(file);
|
|
60
|
+
else if (entry.hash !== file.hash)
|
|
61
|
+
plan.update.push(file);
|
|
62
|
+
else
|
|
63
|
+
plan.unchanged.push(file);
|
|
64
|
+
}
|
|
65
|
+
plan.orphans = Object.keys(state.entries).filter((path) => !seen.has(path));
|
|
66
|
+
return plan;
|
|
67
|
+
};
|
|
68
|
+
const originOf = (principal, root, folder, path) => createHash("sha256")
|
|
69
|
+
.update(JSON.stringify([principal, root, folder, path]))
|
|
70
|
+
.digest("hex")
|
|
71
|
+
.slice(0, 32);
|
|
72
|
+
export const pushDocuments = async (options) => {
|
|
73
|
+
const { root, folder, target, principal, force = false, signal } = options;
|
|
74
|
+
const now = options.now ?? Date.now;
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
throw new CliError("cancelled", "push was cancelled");
|
|
77
|
+
if ((await target.identity()) !== principal)
|
|
78
|
+
throw new CliError("authentication_error", "signed-in account does not match this profile", {
|
|
79
|
+
reason: "profile.wrong-account",
|
|
80
|
+
});
|
|
81
|
+
const state = options.state.read();
|
|
82
|
+
if (state.principal !== principal || state.root !== root || state.target !== folder)
|
|
83
|
+
throw new CliError("configuration_error", "push state belongs to another account or mirror");
|
|
84
|
+
const save = () => options.state.save(state);
|
|
85
|
+
if (!options.dryRun)
|
|
86
|
+
for (const [path, pending] of Object.entries(state.pending)) {
|
|
87
|
+
const found = await target.findByOrigin(pending.origin);
|
|
88
|
+
// An empty hash makes this pass update the found file from the local one.
|
|
89
|
+
if (found)
|
|
90
|
+
state.entries[path] = { ...found, kind: target.kindOf(path) ?? "", hash: "", pushedAt: "" };
|
|
91
|
+
delete state.pending[path];
|
|
92
|
+
save();
|
|
93
|
+
}
|
|
94
|
+
const { files, unsupported } = await scanLocal(root, (path) => target.kindOf(path), options.maxFiles);
|
|
95
|
+
const plan = planPush(files, state);
|
|
96
|
+
const result = {
|
|
97
|
+
created: [],
|
|
98
|
+
updated: [],
|
|
99
|
+
unchanged: plan.unchanged.length,
|
|
100
|
+
conflicts: [],
|
|
101
|
+
missing: [],
|
|
102
|
+
orphans: plan.orphans.map((path) => ({ path, url: state.entries[path]?.url ?? "" })),
|
|
103
|
+
errors: [],
|
|
104
|
+
unsupported,
|
|
105
|
+
};
|
|
106
|
+
if (options.dryRun) {
|
|
107
|
+
result.plan = {
|
|
108
|
+
create: plan.create.map((file) => file.path),
|
|
109
|
+
update: plan.update.map((file) => file.path),
|
|
110
|
+
orphans: plan.orphans,
|
|
111
|
+
};
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
const folderOf = async (path) => {
|
|
115
|
+
const directory = posix.dirname(path);
|
|
116
|
+
if (directory === ".")
|
|
117
|
+
return folder;
|
|
118
|
+
const known = state.folders[directory];
|
|
119
|
+
if (known)
|
|
120
|
+
return known;
|
|
121
|
+
const parent = await folderOf(directory);
|
|
122
|
+
const operationId = options.operationId();
|
|
123
|
+
options.assertHeld();
|
|
124
|
+
const id = await options.guard({ action: "folder", path: directory, operationId }, () => target.ensureFolder({
|
|
125
|
+
parent,
|
|
126
|
+
name: posix.basename(directory),
|
|
127
|
+
origin: originOf(principal, root, folder, `${directory}/`),
|
|
128
|
+
}));
|
|
129
|
+
state.folders[directory] = id;
|
|
130
|
+
save();
|
|
131
|
+
return id;
|
|
132
|
+
};
|
|
133
|
+
const create = async (file) => {
|
|
134
|
+
const parent = await folderOf(file.path);
|
|
135
|
+
const operationId = options.operationId();
|
|
136
|
+
const origin = originOf(principal, root, folder, file.path);
|
|
137
|
+
state.pending[file.path] = { origin, operationId };
|
|
138
|
+
save();
|
|
139
|
+
options.assertHeld();
|
|
140
|
+
const pushed = await options.guard({ action: "create", path: file.path, operationId }, () => target.create({
|
|
141
|
+
file,
|
|
142
|
+
name: posix.basename(file.path),
|
|
143
|
+
parent,
|
|
144
|
+
origin,
|
|
145
|
+
operationId,
|
|
146
|
+
...(signal ? { signal } : {}),
|
|
147
|
+
}));
|
|
148
|
+
state.entries[file.path] = { ...pushed, kind: file.kind, hash: file.hash, pushedAt: new Date(now()).toISOString() };
|
|
149
|
+
delete state.pending[file.path];
|
|
150
|
+
save();
|
|
151
|
+
result.created.push(file.path);
|
|
152
|
+
};
|
|
153
|
+
const work = [
|
|
154
|
+
...plan.create.map((file) => ({ file, create: true })),
|
|
155
|
+
...plan.update.map((file) => ({ file, create: false })),
|
|
156
|
+
];
|
|
157
|
+
for (const job of work.sort((a, b) => a.file.path.localeCompare(b.file.path))) {
|
|
158
|
+
if (signal?.aborted) {
|
|
159
|
+
result.stopped = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const { file } = job;
|
|
163
|
+
try {
|
|
164
|
+
const entry = state.entries[file.path];
|
|
165
|
+
if (job.create || !entry) {
|
|
166
|
+
await create(file);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const prepared = await target.prepare({
|
|
170
|
+
id: entry.id,
|
|
171
|
+
file,
|
|
172
|
+
recorded: entry.snapshot,
|
|
173
|
+
force,
|
|
174
|
+
...(signal ? { signal } : {}),
|
|
175
|
+
});
|
|
176
|
+
if ("conflict" in prepared) {
|
|
177
|
+
result.conflicts.push({ path: file.path, url: entry.url, reason: prepared.conflict });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if ("missing" in prepared) {
|
|
181
|
+
if (!force) {
|
|
182
|
+
result.missing.push(file.path);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
delete state.entries[file.path];
|
|
186
|
+
await create(file);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let pushed;
|
|
190
|
+
if ("unchanged" in prepared) {
|
|
191
|
+
pushed = prepared.unchanged;
|
|
192
|
+
result.unchanged++;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const operationId = options.operationId();
|
|
196
|
+
options.assertHeld();
|
|
197
|
+
pushed = await options.guard({ action: "update", path: file.path, operationId }, () => prepared.write());
|
|
198
|
+
result.updated.push(file.path);
|
|
199
|
+
}
|
|
200
|
+
state.entries[file.path] = {
|
|
201
|
+
...pushed,
|
|
202
|
+
kind: file.kind,
|
|
203
|
+
hash: file.hash,
|
|
204
|
+
pushedAt: new Date(now()).toISOString(),
|
|
205
|
+
};
|
|
206
|
+
save();
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
const failure = options.errorOf(error);
|
|
210
|
+
if (failure.code === "cancelled") {
|
|
211
|
+
result.stopped = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
result.errors.push({ path: file.path, code: failure.code, message: failure.message });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
};
|
|
@@ -75,6 +75,13 @@ export declare const retentionService: (deps: ServiceDeps) => {
|
|
|
75
75
|
}[];
|
|
76
76
|
account: import("../store/types.js").AccountKey;
|
|
77
77
|
report: "retention";
|
|
78
|
+
timeMeta: {
|
|
79
|
+
timezone?: string;
|
|
80
|
+
window?: {
|
|
81
|
+
since?: string;
|
|
82
|
+
until?: string;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
78
85
|
}>;
|
|
79
86
|
evidence: (target: "messages" | "contacts", reference: string, selection: unknown, query: {
|
|
80
87
|
component: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { timezoneOf } from "@wirecat/cli-base/query";
|
|
3
3
|
import { storedChatId } from "./message-resolution.js";
|
|
4
|
-
import {
|
|
4
|
+
import { dateWindow } from "./moment.js";
|
|
5
5
|
export const observationDuration = (text, flag) => {
|
|
6
6
|
const match = /^(\d+(?:\.\d+)?)([smhdw])$/.exec(text);
|
|
7
7
|
const units = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
@@ -14,11 +14,15 @@ export const retentionService = (deps) => ({
|
|
|
14
14
|
report: async (chat, query = {}) => {
|
|
15
15
|
const cutoff = Date.now();
|
|
16
16
|
const zone = timezoneOf(query.timezone);
|
|
17
|
+
const window = dateWindow({
|
|
18
|
+
...(query.sinceTime === undefined ? {} : { sinceTime: query.sinceTime }),
|
|
19
|
+
...(query.untilTime === undefined ? {} : { untilTime: query.untilTime }),
|
|
20
|
+
zone,
|
|
21
|
+
now: cutoff,
|
|
22
|
+
});
|
|
17
23
|
const options = {
|
|
18
|
-
since:
|
|
19
|
-
|
|
20
|
-
: momentOf(query.sinceTime, "--since-time", cutoff, { zone }),
|
|
21
|
-
until: query.untilTime === undefined ? cutoff : momentOf(query.untilTime, "--until-time", cutoff, { zone, end: true }),
|
|
24
|
+
since: window.since ?? cutoff - 90 * 86_400_000,
|
|
25
|
+
until: window.until ?? cutoff,
|
|
22
26
|
cutoff,
|
|
23
27
|
checkpoints: (query.checkpoints ?? "1d,7d,30d")
|
|
24
28
|
.split(",")
|
|
@@ -46,6 +50,7 @@ export const retentionService = (deps) => ({
|
|
|
46
50
|
})),
|
|
47
51
|
account,
|
|
48
52
|
report: "retention",
|
|
53
|
+
timeMeta: window.meta,
|
|
49
54
|
};
|
|
50
55
|
},
|
|
51
56
|
evidence: async (target, reference, selection, query) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.384.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",
|
|
@@ -182,6 +182,9 @@
|
|
|
182
182
|
"liquidjs": "10.30.0",
|
|
183
183
|
"lossless-json": "4.3.0",
|
|
184
184
|
"mammoth": "1.13.0",
|
|
185
|
+
"mdast-util-from-markdown": "2.1.0",
|
|
186
|
+
"mdast-util-gfm": "3.1.0",
|
|
187
|
+
"micromark-extension-gfm": "3.0.0",
|
|
185
188
|
"ogg-opus-decoder": "1.7.5",
|
|
186
189
|
"psl": "1.15.0",
|
|
187
190
|
"qrcode-generator": "2.0.4",
|
|
@@ -200,6 +203,7 @@
|
|
|
200
203
|
"@cspell/dict-ru_ru": "2.3.3",
|
|
201
204
|
"@modelcontextprotocol/client": "2.2.0",
|
|
202
205
|
"@napi-rs/canvas": "1.0.10",
|
|
206
|
+
"@types/mdast": "4.0.4",
|
|
203
207
|
"@types/node": "^22.20.4",
|
|
204
208
|
"@types/pngjs": "6.0.5",
|
|
205
209
|
"@vitest/coverage-v8": "^5.0.2",
|