slidemind 0.3.20 → 0.4.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// Remote link intake (contracts §1/§3/§7): a pasted link becomes a JOB that
|
|
2
|
+
// resolves to one of four things — a PPTX item, a shared plan record, a folder
|
|
3
|
+
// (a SELECTION the user picks from, never a guessed child) — or a blocker the
|
|
4
|
+
// user can act on. The browser and the CLI call the same functions; the job
|
|
5
|
+
// file under `<sessionConfigDir>/workspace/jobs/` is what either reads back.
|
|
6
|
+
//
|
|
7
|
+
// Nothing here replaces local work: a link opened INTO a registered deck that
|
|
8
|
+
// already holds another item, or carries unpublished edits, is refused with
|
|
9
|
+
// `already_bound` / `dirty_target`. A deck already bound to the linked item is
|
|
10
|
+
// offered as-is (`existing:true`). Opening a PPTX or a record into a fresh deck
|
|
11
|
+
// reuses the existing `slidemind open` / `slidemind record open` commands as a
|
|
12
|
+
// subprocess — the adopt/bind/hydrate gates stay where they are.
|
|
13
|
+
//
|
|
14
|
+
// `deps` injects the network (a signed-in provider client, the record service)
|
|
15
|
+
// and the two open workflows so the suite runs against a mock host.
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
import { BIN_PATH, resolveDecksDir } from "../cli/common.mjs";
|
|
23
|
+
import { resolveWorkspace } from "../lib/deck-paths.mjs";
|
|
24
|
+
import { getRecord } from "../lib/record-client.mjs";
|
|
25
|
+
import { readRecordState } from "../lib/record-state.mjs";
|
|
26
|
+
import { readRemote } from "../lib/remote.mjs";
|
|
27
|
+
import { revisionStatus } from "../lib/revision.mjs";
|
|
28
|
+
import { jobPath, jobsDir, readJob } from "./discovery.mjs";
|
|
29
|
+
import { addDeck, coded, getDeck, listDecks, listRoots, writeJsonAtomic } from "./registry.mjs";
|
|
30
|
+
|
|
31
|
+
export const SELECTION_TTL_MS = 15 * 60 * 1000;
|
|
32
|
+
const PPTX_RE = /\.(pptx|pptm|potx|potm|ppsx|ppsm)$/i;
|
|
33
|
+
const JSON_RE = /\.json$/i;
|
|
34
|
+
// The user can act on these; everything else is an operational failure.
|
|
35
|
+
const BLOCKED = new Set([
|
|
36
|
+
"backend_ambiguous", "select_deck", "dirty_target", "already_bound", "login_required",
|
|
37
|
+
"access_denied", "name_exists", "selection_expired", "unsupported_link", "not_a_deck",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const now = () => new Date().toISOString();
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// defaults: sessions, provider client, record service, open workflows
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** Which paired backend serves this link: explicit scope > hosted-page origin > the only one > prod > the default; else ambiguous. */
|
|
47
|
+
export function pickBackend(sessions, { backendURL = null, hint = null, fallback = null } = {}) {
|
|
48
|
+
const match = (want) =>
|
|
49
|
+
sessions.filter((s) => s.backendURL === want || s.label === want || s.backendURL.includes(want));
|
|
50
|
+
if (backendURL) {
|
|
51
|
+
const hits = match(backendURL);
|
|
52
|
+
if (hits.length === 1) return hits[0].backendURL;
|
|
53
|
+
throw coded("backend_ambiguous", `--backend ${backendURL} names ${hits.length} paired environment(s)`, {
|
|
54
|
+
choices: sessions.map((s) => ({ label: s.label, backendURL: s.backendURL })),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (hint) {
|
|
58
|
+
const hits = sessions.filter((s) => safeOrigin(s.backendURL) === hint);
|
|
59
|
+
if (hits.length === 1) return hits[0].backendURL;
|
|
60
|
+
}
|
|
61
|
+
if (sessions.length === 1) return sessions[0].backendURL;
|
|
62
|
+
// The browser has no --backend to type, so an ambiguity here is a dead end.
|
|
63
|
+
// Prod serves the real tenant a share link belongs to, so it wins; otherwise
|
|
64
|
+
// the configured default decides.
|
|
65
|
+
const auto = sessions.find((s) => s.label === "prod") ?? sessions.find((s) => s.backendURL === fallback);
|
|
66
|
+
if (auto) return auto.backendURL;
|
|
67
|
+
throw coded("backend_ambiguous", "several environments are connected; name one with --backend <env>", {
|
|
68
|
+
choices: sessions.map((s) => ({ label: s.label, backendURL: s.backendURL })),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeOrigin(url) {
|
|
73
|
+
try {
|
|
74
|
+
return new URL(url).origin;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function defaultSession({ backendURL, hint }) {
|
|
81
|
+
const { sessionsInScope } = await import("../bridge/client/transport.mjs");
|
|
82
|
+
const { getDefaultBackend } = await import("../bridge/config.mjs");
|
|
83
|
+
const { getSharePointToken, sharePointResource } = await import("../bridge/graph-auth.mjs");
|
|
84
|
+
const provider = await import("../bridge/sharepoint.mjs");
|
|
85
|
+
const chosen = pickBackend(sessionsInScope(), { backendURL, hint, fallback: getDefaultBackend() });
|
|
86
|
+
const origin = await sharePointResource(chosen);
|
|
87
|
+
if (!origin) throw coded("unsupported_link", "this deployment publishes no SharePoint origin");
|
|
88
|
+
const token = await getSharePointToken(chosen);
|
|
89
|
+
if (!token) throw coded("login_required", "your Microsoft 365 sign-in is missing or expired — run `slidemind login`");
|
|
90
|
+
return { backendURL: chosen, provider, client: provider.createClient({ origin, token }) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `slidemind <args>` as a child, one JSON value expected on stdout. */
|
|
94
|
+
export function runCli(args, { env = {} } = {}) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const child = spawn(process.execPath, [BIN_PATH, ...args, "--json"], {
|
|
97
|
+
env: { ...process.env, ...env },
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
99
|
+
});
|
|
100
|
+
let out = "";
|
|
101
|
+
let err = "";
|
|
102
|
+
child.stdout.on("data", (c) => (out += c));
|
|
103
|
+
child.stderr.on("data", (c) => (err += c));
|
|
104
|
+
child.on("close", (status) => {
|
|
105
|
+
let json = null;
|
|
106
|
+
try {
|
|
107
|
+
json = JSON.parse(out);
|
|
108
|
+
} catch {
|
|
109
|
+
// not JSON: the error text is on stderr
|
|
110
|
+
}
|
|
111
|
+
resolve({ status, json, stderr: err });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decksDir() {
|
|
117
|
+
return listRoots()[0] ?? resolveDecksDir();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A fresh deck directory for a link opened without a target: the item's stem,
|
|
121
|
+
// suffixed only when that name is taken.
|
|
122
|
+
function freshDeckPath(name) {
|
|
123
|
+
const stem = String(name ?? "deck").replace(/\.[^.]+$/, "").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "deck";
|
|
124
|
+
const base = decksDir();
|
|
125
|
+
let candidate = path.join(base, stem);
|
|
126
|
+
for (let n = 2; fs.existsSync(candidate); n++) candidate = path.join(base, `${stem}-${n}`);
|
|
127
|
+
return candidate;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function defaultOpenPptx({ link, deckPath, backendURL }) {
|
|
131
|
+
const r = await runCli(["open", link, "--deck", deckPath], { env: { SLIDEMIND_BACKEND: backendURL, SLIDEMIND_DECKS: decksDir() } });
|
|
132
|
+
if (r.status !== 0) throw coded("open_failed", r.stderr.trim().split("\n").pop() || `slidemind open exited ${r.status}`);
|
|
133
|
+
return deckPath;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function defaultOpenRecord({ link, deckPath, backendURL }) {
|
|
137
|
+
const file = path.join(deckPath, ".slidemind", "plan.json");
|
|
138
|
+
const r = await runCli(["record", "open", link, "--deck", deckPath, "--file", file], { env: { SLIDEMIND_BACKEND: backendURL, SLIDEMIND_DECKS: decksDir() } });
|
|
139
|
+
if (r.status !== 0) throw coded(r.json?.error ?? "open_failed", r.json?.message ?? r.stderr.trim().split("\n").pop() ?? `record open exited ${r.status}`);
|
|
140
|
+
return deckPath;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const defaultDeps = {
|
|
144
|
+
session: defaultSession,
|
|
145
|
+
getRecord,
|
|
146
|
+
openPptx: defaultOpenPptx,
|
|
147
|
+
openRecord: defaultOpenRecord,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// jobs
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function newJob(kind, input) {
|
|
155
|
+
const job = { schemaVersion: 1, id: randomUUID(), kind, state: "running", input, result: null, error: null, selection: null, startedAt: now(), finishedAt: null };
|
|
156
|
+
writeJsonAtomic(jobPath(job.id), job);
|
|
157
|
+
return job;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function settle(job, fn) {
|
|
161
|
+
return fn(job).then(
|
|
162
|
+
(result) => {
|
|
163
|
+
job.state = "completed";
|
|
164
|
+
job.result = result ?? job.result;
|
|
165
|
+
job.finishedAt = now();
|
|
166
|
+
writeJsonAtomic(jobPath(job.id), job);
|
|
167
|
+
return job;
|
|
168
|
+
},
|
|
169
|
+
(err) => {
|
|
170
|
+
const code = err?.code ?? "internal";
|
|
171
|
+
job.state = BLOCKED.has(code) ? "blocked" : "failed";
|
|
172
|
+
job.error = { code, message: err?.message ?? String(err), ...(err?.choices ? { choices: err.choices } : {}), ...(err?.details ? { details: err.details } : {}) };
|
|
173
|
+
job.finishedAt = now();
|
|
174
|
+
writeJsonAtomic(jobPath(job.id), job);
|
|
175
|
+
return job;
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// link classification
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** A hosted APP page that names a record (`…/<page>.html?record=<webUrl>`) → its record link + origin hint. */
|
|
185
|
+
export function parseHostedLink(url) {
|
|
186
|
+
let u;
|
|
187
|
+
try {
|
|
188
|
+
u = new URL(url);
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const record = u.searchParams.get("record");
|
|
193
|
+
if (!/\.html$/i.test(u.pathname) || !record) return null;
|
|
194
|
+
return { record, hint: u.origin };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A registered deck already bound to this item (PPTX binding or record binding).
|
|
198
|
+
function boundDeck(match) {
|
|
199
|
+
for (const d of listDecks({ hidden: true, limit: 10_000 }).decks) {
|
|
200
|
+
if (d.missing) continue;
|
|
201
|
+
let ws;
|
|
202
|
+
try {
|
|
203
|
+
ws = resolveWorkspace(d.path);
|
|
204
|
+
} catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (match(ws)) return d;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const samePptx = (item) => (ws) => {
|
|
213
|
+
try {
|
|
214
|
+
const r = readRemote(ws);
|
|
215
|
+
return Boolean(r && r.driveId === item.driveId && r.itemId === item.itemId);
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const sameRecord = (record) => (ws) => {
|
|
221
|
+
const s = readRecordState(ws);
|
|
222
|
+
if (!s) return false;
|
|
223
|
+
if (s.driveId && s.itemId && record.identity) return s.driveId === record.identity.driveId && s.itemId === record.identity.itemId;
|
|
224
|
+
return Boolean(s.url && s.url === record.url);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
function targetDeck(deckId) {
|
|
228
|
+
const deck = getDeck(deckId);
|
|
229
|
+
if (!deck) throw coded("unknown_deck", `no registered deck ${deckId}`);
|
|
230
|
+
if (!fs.existsSync(deck.path)) throw coded("missing_path", `${deck.path} is not there any more`);
|
|
231
|
+
return { ...deck, workspaceDir: resolveWorkspace(deck.path) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Where a PPTX may land. Never replaces: another binding → already_bound,
|
|
235
|
+
// unpublished local edits (or any adopted generation) → dirty_target.
|
|
236
|
+
function pptxTarget(deckId, item) {
|
|
237
|
+
if (!deckId) return { deckPath: freshDeckPath(item.name), fresh: true };
|
|
238
|
+
const deck = targetDeck(deckId);
|
|
239
|
+
let bound = null;
|
|
240
|
+
try {
|
|
241
|
+
bound = readRemote(deck.workspaceDir);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
throw coded("dirty_target", `${deck.name} has an unfinished workspace transaction: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
if (bound && bound.driveId === item.driveId && bound.itemId === item.itemId) return { deckPath: deck.path, existing: deck };
|
|
246
|
+
if (bound) throw coded("already_bound", `${deck.name} publishes to ${bound.name ?? bound.documentInstance}; open the link into another deck`, { deckId: deck.id });
|
|
247
|
+
const status = revisionStatus(deck.workspaceDir);
|
|
248
|
+
if (status.adopted) {
|
|
249
|
+
throw coded("dirty_target", `${deck.name} already holds an adopted deck${status.localDirty ? ` with ${status.dirtySlides.length} unpublished slide edit(s)` : ""}; open the link into another deck`, { deckId: deck.id });
|
|
250
|
+
}
|
|
251
|
+
return { deckPath: deck.path, existing: null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function recordTarget(deckId, record) {
|
|
255
|
+
if (!deckId) return { deckPath: freshDeckPath(record.name ?? "plan"), fresh: true };
|
|
256
|
+
const deck = targetDeck(deckId);
|
|
257
|
+
const state = readRecordState(deck.workspaceDir);
|
|
258
|
+
if (state && sameRecord(record)(deck.workspaceDir)) return { deckPath: deck.path, existing: deck };
|
|
259
|
+
if (state?.url) throw coded("already_bound", `${deck.name} already shares ${state.name ?? state.url}`, { deckId: deck.id });
|
|
260
|
+
const planFile = path.join(deck.workspaceDir, "plan.json");
|
|
261
|
+
if (fs.existsSync(planFile)) throw coded("dirty_target", `${deck.name} already has a local plan; open the record into another deck`, { deckId: deck.id });
|
|
262
|
+
return { deckPath: deck.path, existing: null };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const supported = (child) => child.kind === "folder" || (child.kind === "file" && (PPTX_RE.test(child.name ?? "") || JSON_RE.test(child.name ?? "")));
|
|
266
|
+
|
|
267
|
+
async function openSelection(job, { provider, client, backendURL }, folder) {
|
|
268
|
+
const page = await provider.listChildren(client, { driveId: folder.driveId, itemId: folder.itemId });
|
|
269
|
+
job.selection = {
|
|
270
|
+
selectionId: randomUUID(),
|
|
271
|
+
expiresAt: new Date(Date.now() + SELECTION_TTL_MS).toISOString(),
|
|
272
|
+
backendURL,
|
|
273
|
+
folder: { driveId: folder.driveId, itemId: folder.itemId, name: folder.name ?? null, webUrl: folder.webUrl ?? null },
|
|
274
|
+
choices: page.items.filter(supported),
|
|
275
|
+
cursor: page.nextCursor,
|
|
276
|
+
};
|
|
277
|
+
return { type: "folder", selectionId: job.selection.selectionId, choices: job.selection.choices, nextCursor: page.nextCursor };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The one open path for both entry points (a pasted link, a selected child).
|
|
281
|
+
async function openItem(job, s, { item, link, deckId }) {
|
|
282
|
+
const { provider, client, backendURL } = s;
|
|
283
|
+
if (item.kind === "folder") return openSelection(job, s, item);
|
|
284
|
+
if (item.kind !== "file") throw coded("unsupported_link", `${item.name ?? "that item"} is neither a file nor a folder`);
|
|
285
|
+
if (JSON_RE.test(item.name ?? "")) {
|
|
286
|
+
// A JSON file is a plan only if the record service says so.
|
|
287
|
+
const record = await s.deps.getRecord(backendURL, { url: link });
|
|
288
|
+
const already = boundDeck(sameRecord(record));
|
|
289
|
+
if (already && (!deckId || already.id === deckId)) return { type: "record", deckId: already.id, existing: true, name: record.name ?? null };
|
|
290
|
+
const target = recordTarget(deckId, record);
|
|
291
|
+
if (target.existing) return { type: "record", deckId: target.existing.id, existing: true, name: record.name ?? null };
|
|
292
|
+
const opened = await s.deps.openRecord({ link, deckPath: target.deckPath, backendURL, record });
|
|
293
|
+
return { type: "record", deckId: addDeck(opened).id, existing: false, name: record.name ?? null };
|
|
294
|
+
}
|
|
295
|
+
if (!PPTX_RE.test(item.name ?? "")) throw coded("unsupported_link", `${item.name} is not a presentation, a plan record or a folder`);
|
|
296
|
+
const already = boundDeck(samePptx(item));
|
|
297
|
+
if (already && (!deckId || already.id === deckId)) return { type: "pptx", deckId: already.id, existing: true, name: item.name };
|
|
298
|
+
const target = pptxTarget(deckId, item);
|
|
299
|
+
if (target.existing) return { type: "pptx", deckId: target.existing.id, existing: true, name: item.name };
|
|
300
|
+
const opened = await s.deps.openPptx({ link, deckPath: target.deckPath, backendURL, item });
|
|
301
|
+
return { type: "pptx", deckId: addDeck(opened).id, existing: false, name: item.name };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function resolveItem(s, link) {
|
|
305
|
+
try {
|
|
306
|
+
return await s.provider.resolveLink(s.client, link);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err?.code === "identity_unresolved" || err?.status === 400 || err?.status === 404) {
|
|
309
|
+
throw coded("unsupported_link", "that link does not name a SharePoint file or folder (a site or list link cannot be opened)");
|
|
310
|
+
}
|
|
311
|
+
if (err?.status === 403) throw coded("access_denied", "you cannot reach that item");
|
|
312
|
+
if (err?.code === "bridge_timeout" || /ECONNREFUSED|fetch failed/i.test(String(err?.message))) throw coded("offline", "SharePoint could not be reached");
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Resolve a pasted link. Returns the settled job: `completed` with
|
|
319
|
+
* `result.type` pptx | record | folder (a selection), `blocked` with an
|
|
320
|
+
* actionable `error.code`, or `failed`.
|
|
321
|
+
*/
|
|
322
|
+
export async function resolveRemoteLink({ url, backendURL = null, deckId = null }, overrides = {}) {
|
|
323
|
+
const d = { ...defaultDeps, ...overrides };
|
|
324
|
+
const job = newJob("remote-resolve", { url, backendURL, deckId });
|
|
325
|
+
return settle(job, async () => {
|
|
326
|
+
if (typeof url !== "string" || !/^https?:\/\//i.test(url)) throw coded("validation_failed", "a remote link starts with https://");
|
|
327
|
+
const hosted = parseHostedLink(url);
|
|
328
|
+
const s = { ...(await d.session({ backendURL, hint: hosted?.hint ?? null })), deps: d };
|
|
329
|
+
const link = hosted ? hosted.record : url;
|
|
330
|
+
const item = await resolveItem(s, link);
|
|
331
|
+
return openItem(job, s, { item, link, deckId });
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// selections
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/** The job carrying a live selection, or null. ponytail: scans the jobs dir; index it if jobs pile up. */
|
|
340
|
+
export function findSelection(selectionId) {
|
|
341
|
+
let names = [];
|
|
342
|
+
try {
|
|
343
|
+
names = fs.readdirSync(jobsDir());
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
for (const n of names) {
|
|
348
|
+
if (!n.endsWith(".json")) continue;
|
|
349
|
+
const job = readJob(n.slice(0, -5));
|
|
350
|
+
if (job?.selection?.selectionId === selectionId) return job;
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function liveSelection(job, selectionId) {
|
|
356
|
+
if (!job?.selection || job.selection.selectionId !== selectionId) throw coded("selection_expired", "that selection is not one this workspace issued");
|
|
357
|
+
if (Date.parse(job.selection.expiresAt) < Date.now()) throw coded("selection_expired", "that selection has expired — resolve the link again");
|
|
358
|
+
return job.selection;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The next page of a selection's folder, appended to the choices the selection may later accept. */
|
|
362
|
+
export async function listSelectionChildren({ selectionId, cursor = null }, overrides = {}) {
|
|
363
|
+
const d = { ...defaultDeps, ...overrides };
|
|
364
|
+
const job = findSelection(selectionId);
|
|
365
|
+
const sel = liveSelection(job, selectionId);
|
|
366
|
+
const s = await d.session({ backendURL: sel.backendURL });
|
|
367
|
+
const page = await s.provider.listChildren(s.client, sel.folder, { cursor: cursor ?? sel.cursor });
|
|
368
|
+
const items = page.items.filter(supported);
|
|
369
|
+
const known = new Set(sel.choices.map((c) => c.id));
|
|
370
|
+
for (const c of items) if (!known.has(c.id)) sel.choices.push(c);
|
|
371
|
+
sel.cursor = page.nextCursor;
|
|
372
|
+
writeJsonAtomic(jobPath(job.id), job);
|
|
373
|
+
return { items, nextCursor: page.nextCursor };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Resume a selection: the chosen child is re-resolved (identity and access are
|
|
378
|
+
* checked again, never trusted from the stored page) and opened exactly like a
|
|
379
|
+
* pasted link. A folder choice yields the next selection on the same job.
|
|
380
|
+
*/
|
|
381
|
+
export async function selectChoice(jobId, { selectionId, choiceId, localTarget = null }, overrides = {}) {
|
|
382
|
+
const d = { ...defaultDeps, ...overrides };
|
|
383
|
+
const job = readJob(jobId);
|
|
384
|
+
if (!job) throw coded("unknown_job", `no job ${jobId}`);
|
|
385
|
+
const sel = liveSelection(job, selectionId);
|
|
386
|
+
const choice = sel.choices.find((c) => c.id === choiceId);
|
|
387
|
+
if (!choice) throw coded("validation_failed", "that choice is not part of this selection");
|
|
388
|
+
job.state = "running";
|
|
389
|
+
job.selection = null;
|
|
390
|
+
job.finishedAt = null;
|
|
391
|
+
writeJsonAtomic(jobPath(job.id), job);
|
|
392
|
+
return settle(job, async () => {
|
|
393
|
+
const s = { ...(await d.session({ backendURL: sel.backendURL })), deps: d };
|
|
394
|
+
const item = await s.provider.getItem(s.client, { driveId: choice.driveId, itemId: choice.id });
|
|
395
|
+
return openItem(job, s, { item, link: item.webUrl, deckId: localTarget });
|
|
396
|
+
});
|
|
397
|
+
}
|