slidemind 0.3.13 → 0.3.14
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 +86 -21
- package/README.md +14 -17
- package/bin/slidemind.mjs +65 -4969
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +113 -74
- package/skills/search/SKILL.md +25 -7
- package/src/bridge/build-and-push.mjs +108 -15
- package/src/bridge/bundle.mjs +69 -0
- package/src/bridge/capture.mjs +19 -0
- package/src/bridge/client/brands.mjs +622 -0
- package/src/bridge/client/engine-routing.mjs +237 -0
- package/src/bridge/client/engine-search.mjs +310 -0
- package/src/bridge/client/engine-slides.mjs +435 -0
- package/src/bridge/client/pane-ops.mjs +335 -0
- package/src/bridge/client/presentations.mjs +587 -0
- package/src/bridge/client/render.mjs +317 -0
- package/src/bridge/client/transport.mjs +695 -0
- package/src/bridge/client.mjs +80 -3452
- package/src/bridge/config.mjs +1 -1
- package/src/bridge/engine-download.mjs +15 -2
- package/src/builders/build.mjs +11 -804
- package/src/builders/draft-check.mjs +376 -0
- package/src/builders/lint.mjs +38 -2
- package/src/builders/preview.html +799 -0
- package/src/builders/review.mjs +23 -4
- package/src/builders/visual-families.mjs +6 -8
- package/src/cli/common.mjs +179 -0
- package/src/cli/concepts.mjs +310 -0
- package/src/cli/connection.mjs +147 -0
- package/src/cli/deck.mjs +608 -0
- package/src/cli/designs.mjs +113 -0
- package/src/cli/diagnose.mjs +141 -0
- package/src/cli/download-bundle.mjs +144 -0
- package/src/cli/engine-helpers.mjs +429 -0
- package/src/cli/engine-results.mjs +320 -0
- package/src/cli/help.mjs +201 -0
- package/src/cli/install.mjs +257 -0
- package/src/cli/read.mjs +120 -0
- package/src/cli/ready.mjs +805 -0
- package/src/cli/search.mjs +608 -0
- package/src/cli/session.mjs +177 -0
- package/src/cli/setup.mjs +282 -0
- package/src/cli/slide-reuse.mjs +334 -0
- package/src/cli/status.mjs +118 -0
- package/src/cli/workspace.mjs +302 -0
- package/src/installers/codex.mjs +1 -1
- package/src/update-check.mjs +231 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { envTag } from "../bridge/config.mjs";
|
|
6
|
+
import { fail, loadBridgeModule, SRC_DIR } from "./common.mjs";
|
|
7
|
+
|
|
8
|
+
// Turn the add-in's deliberately terse search failures into something actionable.
|
|
9
|
+
// It withholds detail on purpose: the raw message used to carry the built OData
|
|
10
|
+
// filter and physical index names, and "Engine not found" doubles as a permission
|
|
11
|
+
// denial so engine existence stays undisclosed. So the CLI can't diagnose — it can
|
|
12
|
+
// only name the next move.
|
|
13
|
+
export function searchFailureHint(error, engineId) {
|
|
14
|
+
const raw = error ?? "unknown error";
|
|
15
|
+
if (/engine not found/i.test(raw)) {
|
|
16
|
+
return (
|
|
17
|
+
`${raw} — '${engineId}' is unknown or not visible to you. ` +
|
|
18
|
+
`Re-run 'slidemind ls engines' (ids can be retired) and pick from the live list, ` +
|
|
19
|
+
`or search 'scope:all' to skip engines entirely.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (/^search failed$/i.test(raw)) {
|
|
23
|
+
return (
|
|
24
|
+
`${raw} — the add-in withholds the reason (it can leak index internals); ` +
|
|
25
|
+
`the detail is in the add-in's logs. Retry once, then try 'scope:all'.`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return raw;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Zero results is now a routine, ambiguous outcome: the add-in answers "success,
|
|
32
|
+
// nothing" for a corpus that isn't connected, one that is still embedding, and a
|
|
33
|
+
// folder filter that matches nothing — all indistinguishable on the wire. Say that
|
|
34
|
+
// instead of letting an empty list read as "this content does not exist".
|
|
35
|
+
export function writeEmptySearchDiagnostic(engineId, bundleMod) {
|
|
36
|
+
const isScope = bundleMod.isSearchScopeId(engineId);
|
|
37
|
+
const lines = [
|
|
38
|
+
` No results — this does NOT mean the content doesn't exist. Any of these looks identical here:`,
|
|
39
|
+
` · nothing is connected yet, so there is no corpus to search`,
|
|
40
|
+
` · the corpus is still indexing (content appears progressively)`,
|
|
41
|
+
];
|
|
42
|
+
if (!isScope) {
|
|
43
|
+
lines.push(
|
|
44
|
+
` · this engine is a saved FOLDER FILTER and the content sits outside it`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
lines.push(` Next:`);
|
|
48
|
+
if (!isScope) {
|
|
49
|
+
lines.push(
|
|
50
|
+
` · widen to the unfiltered corpus: slidemind search "<query>" --engine ${bundleMod.SCOPE_ALL} --result-type slide`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
lines.push(
|
|
54
|
+
` · re-word the query (content search matches phrasing, so a synonym can decide a hit)`,
|
|
55
|
+
` · slidemind ls engines — see every engine and scope you can reach`,
|
|
56
|
+
);
|
|
57
|
+
if (isScope) {
|
|
58
|
+
lines.push(
|
|
59
|
+
` · if the unfiltered corpus is also empty, report it as "not found" and name what you searched — don't author a replacement as if reuse had been tried`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Write the search's numbered collage to a file and tell the agent to look at it.
|
|
66
|
+
//
|
|
67
|
+
// The add-in composes ONE bounded 800x600 image showing every candidate with its
|
|
68
|
+
// selection number burned in — the same picture the in-app agent sees. That is the
|
|
69
|
+
// intended way to choose a visual result: one look at one file, rather than N
|
|
70
|
+
// per-result previews (which is also why the relay ack carries no per-result bytes
|
|
71
|
+
// — a corpus image is multi-megabyte and N of them can't cross the relay).
|
|
72
|
+
//
|
|
73
|
+
// Best-effort by design: a missing/…unparseable grid must never fail a search that
|
|
74
|
+
// otherwise returned usable results. Content-intent searches have no grid at all —
|
|
75
|
+
// their signal is the printed text.
|
|
76
|
+
export function writeSearchGrid(gridDataUri, searchId, resolveExt) {
|
|
77
|
+
if (typeof gridDataUri !== "string" || !gridDataUri.startsWith("data:")) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const m = /^data:([^;]+);base64,(.+)$/.exec(gridDataUri);
|
|
82
|
+
if (!m) return null;
|
|
83
|
+
const bytes = Buffer.from(m[2], "base64");
|
|
84
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-grid-"));
|
|
85
|
+
const target = path.join(dir, `grid-${searchId}.${resolveExt(m[1], bytes)}`);
|
|
86
|
+
fs.writeFileSync(target, bytes);
|
|
87
|
+
return target;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Where an engine op runs. Newer add-ins serve the engine surface off the CLI
|
|
94
|
+
// session itself (`/api/cli/engines`, `/api/cli/searches/*`), so discovery,
|
|
95
|
+
// search, download and extraction need no PowerPoint window at all; older ones
|
|
96
|
+
// relay every one of them through a task-pane webview, which is what
|
|
97
|
+
// requirePaneHost gates. The DIRECT path is preferred whenever it exists and the
|
|
98
|
+
// pane path stays as the compat fallback, so both add-in generations work.
|
|
99
|
+
//
|
|
100
|
+
// `--to <presId>` stops being a REQUIREMENT — the searchId is server-owned
|
|
101
|
+
// rather than bound to a window, so nothing has to be targeted — but it is still
|
|
102
|
+
// honoured, and naming a window means "relay through THAT pane". That is the
|
|
103
|
+
// escape hatch for the two things the pane-free routes refuse (`asset_needs_host`
|
|
104
|
+
// for SharePoint-hosted bytes, `recolor_needs_host` for an icon tint), both of
|
|
105
|
+
// which the pane can do; ignoring --to made their own remediation unreachable.
|
|
106
|
+
// `brandId` is the deck's bound design — the only thing that routes an engine op
|
|
107
|
+
// in a multi-environment session when no window is involved.
|
|
108
|
+
// No connected environment holds a server-owned search record. Authored once
|
|
109
|
+
// because every consumer of an opaque searchId reaches the same dead end and
|
|
110
|
+
// the same way out — the record's own environment is the only thing that can
|
|
111
|
+
// serve it, so a re-search is the fix regardless of who asked.
|
|
112
|
+
export function expiredSearchMessage(searchId) {
|
|
113
|
+
return (
|
|
114
|
+
`search ${searchId} was not found on any connected environment — server-side ` +
|
|
115
|
+
"search records expire after ~2 hours. Re-run 'slidemind search' and pick again."
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function resolveEngineRouting(client, { to = null, brandId = null, pick = "one", searchId = null } = {}) {
|
|
120
|
+
// An opaque (server-minted) searchId pins the route by itself: the record
|
|
121
|
+
// lives on the environment that minted it, and the pane relay can never
|
|
122
|
+
// serve it — its handler only knows its own per-window numeric ids. So the
|
|
123
|
+
// record's origin outranks the deck's design AND --to, and when no connected
|
|
124
|
+
// environment holds the record the only honest answer is "search again",
|
|
125
|
+
// not a pane fallback that is guaranteed to 4xx.
|
|
126
|
+
if (client.isOpaqueSearchId?.(searchId)) {
|
|
127
|
+
if (to) {
|
|
128
|
+
// Agents thread `--to` from a window-bound habit; on a server-held
|
|
129
|
+
// record it is meaningless, not wrong — say so once and carry on.
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
` note: --to ${to} ignored — searchId ${searchId} is held by the add-in, not by a window.\n`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
let origin = null;
|
|
135
|
+
try {
|
|
136
|
+
origin = await client.resolveSearchBackend(searchId);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// The probe distinguishes "no environment holds this record" from the
|
|
139
|
+
// failures that only LOOK like it — a dead session, a missing sign-in —
|
|
140
|
+
// and hands the latter back for their own remediation.
|
|
141
|
+
await failOnEngineBlocker(client, err, { to });
|
|
142
|
+
}
|
|
143
|
+
if (origin) {
|
|
144
|
+
return { direct: true, backendURL: origin, presId: null, registry: null, entry: null };
|
|
145
|
+
}
|
|
146
|
+
fail(expiredSearchMessage(searchId));
|
|
147
|
+
}
|
|
148
|
+
let backendURL = null;
|
|
149
|
+
if (!to) {
|
|
150
|
+
backendURL = await client.resolveEngineBackend({ brandId });
|
|
151
|
+
}
|
|
152
|
+
if (backendURL) {
|
|
153
|
+
return { direct: true, backendURL, presId: null, registry: null, entry: null };
|
|
154
|
+
}
|
|
155
|
+
const registry = await client.requirePaneHost();
|
|
156
|
+
const entry =
|
|
157
|
+
pick === "any"
|
|
158
|
+
? client.pickAnyPresentationEntry(registry, to)
|
|
159
|
+
: client.pickPresentationEntry(registry, to);
|
|
160
|
+
return {
|
|
161
|
+
direct: false,
|
|
162
|
+
backendURL: entry.backendURL ?? null,
|
|
163
|
+
presId: entry.presId,
|
|
164
|
+
registry,
|
|
165
|
+
entry,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The design a deck is bound to (presFP == brandId), or null when it isn't bound
|
|
170
|
+
// / isn't a deck. Routing state only — never printed.
|
|
171
|
+
export async function deckBrandId(deckDir) {
|
|
172
|
+
if (!deckDir) return null;
|
|
173
|
+
const bundleMod = await import(
|
|
174
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
175
|
+
);
|
|
176
|
+
return bundleMod.readDeckBundleRef(deckDir)?.presFP ?? null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A pane-ready window, or null — never a throw, and never a wait. Used by the
|
|
180
|
+
// login_required paths below, where an open pane is an OPTION rather than a
|
|
181
|
+
// precondition. `to` is honoured only when it names a window that is actually
|
|
182
|
+
// registered; a stale --to must not cost the caller its fallback.
|
|
183
|
+
export async function openPaneEntry(client, to = null) {
|
|
184
|
+
const registry = (await client.paneReadyPresentations?.()) ?? [];
|
|
185
|
+
if (registry.length === 0) return null;
|
|
186
|
+
const explicit = registry.some((p) => p.presId === to) ? to : null;
|
|
187
|
+
try {
|
|
188
|
+
return { registry, entry: client.pickAnyPresentationEntry(registry, explicit) };
|
|
189
|
+
} catch {
|
|
190
|
+
// Several windows and no way to choose — no automatic fallback from here.
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The ONE rendering of a corpus-identity refusal: the pane-free routes run as
|
|
196
|
+
// the USER and answer `login_required` without the delegated tokens `slidemind
|
|
197
|
+
// login` mints (the CLI attempts that sign-in itself before ever sending a
|
|
198
|
+
// token-less request, so reaching this means the browser step was refused or
|
|
199
|
+
// abandoned). The refusal backs the PANE-FREE path only: an open task pane
|
|
200
|
+
// signs every request with a live browser token, so when a pane-ready window
|
|
201
|
+
// is already open, that window can serve a fresh search right now — say so.
|
|
202
|
+
// Returns the message, or null for any other failure.
|
|
203
|
+
export async function loginRequiredMessage(client, err, { to = null } = {}) {
|
|
204
|
+
const blocker = client.loginRequiredBlocker?.(err);
|
|
205
|
+
if (!blocker) return null;
|
|
206
|
+
const pane = await openPaneEntry(client, to);
|
|
207
|
+
if (!pane) return blocker.message;
|
|
208
|
+
const env = envTag(pane.entry.env);
|
|
209
|
+
return (
|
|
210
|
+
`${blocker.message} A slidemind pane is already open${env} and signs each request live, so a fresh ` +
|
|
211
|
+
`search through that window works now: slidemind search "<query>" --to ${pane.entry.presId}.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Engine-route failures the USER has to clear get one actionable line instead of
|
|
216
|
+
// a relay error. `login_required` means the corpus routes want the user's own
|
|
217
|
+
// Microsoft 365 sign-in. Only corpus search stops; render and bundle don't.
|
|
218
|
+
// Anything else is re-thrown for the top-level handler.
|
|
219
|
+
export async function failOnEngineBlocker(client, err, { to = null } = {}) {
|
|
220
|
+
const message = await loginRequiredMessage(client, err, { to });
|
|
221
|
+
if (message) fail(message);
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A login_required refusal that an OPEN PANE can simply route around. Only an
|
|
226
|
+
// operation that can be replayed pane-side qualifies — creating a NEW search,
|
|
227
|
+
// whose id the pane mints itself. A record-bound op on an opaque searchId
|
|
228
|
+
// cannot: the pane's handler only understands its own per-window numeric ids,
|
|
229
|
+
// so those get loginRequiredMessage's extended blocker instead. Returns
|
|
230
|
+
// { registry, entry } or null.
|
|
231
|
+
export async function paneRelayForLoginRequired(client, err, { to = null } = {}) {
|
|
232
|
+
if (!client.loginRequiredBlocker?.(err)) return null;
|
|
233
|
+
return openPaneEntry(client, to);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The pane-free routes refuse exactly one thing outright: bytes that live in
|
|
237
|
+
// SharePoint (`asset_needs_host`, which needs a delegated token only a browser
|
|
238
|
+
// can mint). An open pane has one, so the remediation is the RELAY — name it,
|
|
239
|
+
// rather than echoing a 409 whose "retry" would take the identical route.
|
|
240
|
+
// (Icon tints are baked locally — the CLI never asks the pane-free route for
|
|
241
|
+
// one.) Returns null for anything else.
|
|
242
|
+
export function describeHostOnlyEngineFailure(err) {
|
|
243
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
244
|
+
if (code !== "asset_needs_host") return null;
|
|
245
|
+
const detail =
|
|
246
|
+
typeof err?.body?.error === "string" ? err.body.error : (err?.message ?? "");
|
|
247
|
+
return (
|
|
248
|
+
`${detail} Run 'slidemind search "<query>" --to <presId>' against an open window and ` +
|
|
249
|
+
"re-pick, then repeat this download with the same --to — that relays it through the pane."
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The other half of the same boundary: a SharePoint-backed SLIDE. The extract
|
|
254
|
+
// route fetches it with the caller's identity, so without a delegated token the
|
|
255
|
+
// add-in falls back to a managed identity that is not configured for the corpus
|
|
256
|
+
// site and the fetch dies inside the provider. That route answers a plain 400
|
|
257
|
+
// with no discrete code, so the provider text is the only signal there is.
|
|
258
|
+
export function isSharePointProviderFailure(err) {
|
|
259
|
+
const text = `${err?.body?.error ?? ""} ${err?.message ?? ""}`;
|
|
260
|
+
return /sharepoint provider/i.test(text);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// `slidemind login` as the WINDOW-FREE way past both — named only on a backend
|
|
264
|
+
// that honours the delegated token and only while no sign-in is stored, so the
|
|
265
|
+
// sentence never appears where following it would change nothing. Null
|
|
266
|
+
// otherwise, which keeps every pre-existing message byte-identical.
|
|
267
|
+
export async function graphLoginRemediation(client, backendURL) {
|
|
268
|
+
if (!backendURL) return null;
|
|
269
|
+
try {
|
|
270
|
+
if (!(await client.backendSupportsGraphDelegated?.(backendURL))) return null;
|
|
271
|
+
const graph = await loadBridgeModule("graph-auth");
|
|
272
|
+
if (graph.hasGraphSession(backendURL)) return null;
|
|
273
|
+
} catch {
|
|
274
|
+
// A capability probe that fails is not a reason to withhold the download's
|
|
275
|
+
// real error — just say nothing extra.
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
return (
|
|
279
|
+
"Or run 'slidemind login' once: it signs this CLI in to Microsoft 365 with your own " +
|
|
280
|
+
"identity, and SharePoint-backed content then downloads with no window at all."
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The user-facing text for an engine byte fetch that failed for want of an
|
|
285
|
+
// identity — either refusal shape. Returns null when the failure is something
|
|
286
|
+
// else entirely (the caller keeps its own message).
|
|
287
|
+
export async function describeEngineByteFailure(client, err, { backendURL = null } = {}) {
|
|
288
|
+
const paneAdvice = describeHostOnlyEngineFailure(err);
|
|
289
|
+
if (!paneAdvice && !isSharePointProviderFailure(err)) return null;
|
|
290
|
+
const login = await graphLoginRemediation(client, backendURL);
|
|
291
|
+
if (!login) return paneAdvice;
|
|
292
|
+
return paneAdvice ? `${paneAdvice} ${login}` : `${err.message} ${login}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// A searchId is a number when the pane minted it (the relay path) and an opaque
|
|
296
|
+
// server-minted id on the pane-free engine routes, where the search record lives
|
|
297
|
+
// on the backend. Accept both so a pick round-trips whichever search produced it.
|
|
298
|
+
export function parseSearchIdArg(raw, flag = "--search") {
|
|
299
|
+
if (!raw || raw.startsWith("-")) fail(`${flag} expects a searchId, got '${raw}'`);
|
|
300
|
+
return /^\d+$/.test(raw) ? parseInt(raw, 10) : raw;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function parsePicks(raw, flag = "--pick") {
|
|
304
|
+
if (!raw) fail(`${flag} expects a comma-separated list of selection numbers`);
|
|
305
|
+
// Tolerate a trailing/empty segment ("1," or "1,,2") by dropping blank parts
|
|
306
|
+
// rather than turning them into NaN that fails the whole call; dedupe so the
|
|
307
|
+
// same selection isn't extracted twice.
|
|
308
|
+
const segments = raw
|
|
309
|
+
.split(",")
|
|
310
|
+
.map((s) => s.trim())
|
|
311
|
+
.filter((s) => s.length > 0);
|
|
312
|
+
if (segments.length === 0) {
|
|
313
|
+
fail(`${flag} expects a comma-separated list of selection numbers`);
|
|
314
|
+
}
|
|
315
|
+
const picks = [...new Set(segments.map((s) => parseInt(s, 10)))];
|
|
316
|
+
if (picks.some((n) => !Number.isFinite(n) || n <= 0)) {
|
|
317
|
+
fail(`${flag} expects positive selection numbers, got '${raw}'`);
|
|
318
|
+
}
|
|
319
|
+
return picks;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Turn a live-insert failure into the action that actually fixes it. The client
|
|
324
|
+
* has already re-listed the registry and retried ONCE against the live window
|
|
325
|
+
* (a pane reconnect churns the presId), so a pane error that reaches here is one
|
|
326
|
+
* only the user can clear — and the way around it is the window-free fold.
|
|
327
|
+
*
|
|
328
|
+
* A login_required refusal is NOT among them: it is authored once, in
|
|
329
|
+
* loginRequiredMessage, which the caller consults first.
|
|
330
|
+
*/
|
|
331
|
+
|
|
332
|
+
export function describeInsertSlideFailure(err, searchId) {
|
|
333
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
334
|
+
if (code === "unknown_search") {
|
|
335
|
+
return `searchId ${searchId} is no longer available. Run 'slidemind search' again, then re-pick.`;
|
|
336
|
+
}
|
|
337
|
+
if (code === "searchId_required") {
|
|
338
|
+
// The pane was handed an id it cannot resolve — its search context is
|
|
339
|
+
// per-window and numeric, and a server-minted id was never in it.
|
|
340
|
+
return (
|
|
341
|
+
`searchId ${searchId} belongs to the add-in, not to that PowerPoint window. ` +
|
|
342
|
+
`Re-run 'slidemind search' (the CLI picks the reuse route from where the search ran), then re-pick.`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const paneAsleep =
|
|
346
|
+
code === "pane_suspended" ||
|
|
347
|
+
code === "pane_timeout" ||
|
|
348
|
+
// A relay 404 about the presId itself carries prose and no machine code,
|
|
349
|
+
// and bridgeFetch falls `code` back to that prose — so read the PARSED one.
|
|
350
|
+
(err?.status === 404 &&
|
|
351
|
+
(!err?.body?.code || err.body.code === "unknown_presentation"));
|
|
352
|
+
if (paneAsleep) {
|
|
353
|
+
return (
|
|
354
|
+
"the slidemind task pane in that PowerPoint window is not responding (already retried once). " +
|
|
355
|
+
"Foreground PowerPoint and click the slidemind pane to wake it, then re-run — or reuse the same " +
|
|
356
|
+
`result with no window at all: slidemind add-slide --search ${searchId} --pick <n> --deck <deck>.`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return `insert-slide failed: ${err?.message ?? "unknown error"}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// One line per written slide, plus the machine-readable twin. The searchId is
|
|
363
|
+
// echoed on purpose: an agent interrupted between picks can resume from the JSON
|
|
364
|
+
// without re-running the search (the record lives on the backend now).
|
|
365
|
+
export function writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides }) {
|
|
366
|
+
if (asJson) {
|
|
367
|
+
process.stdout.write(
|
|
368
|
+
`${JSON.stringify(
|
|
369
|
+
{
|
|
370
|
+
ok: true,
|
|
371
|
+
searchId,
|
|
372
|
+
deck: path.relative(process.cwd(), deckDir),
|
|
373
|
+
slides,
|
|
374
|
+
},
|
|
375
|
+
null,
|
|
376
|
+
2,
|
|
377
|
+
)}\n`,
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
process.stdout.write(
|
|
382
|
+
`Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
|
|
383
|
+
written.map((w) => ` ${w}`).join("\n") +
|
|
384
|
+
`\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Map a `--icon-color` value onto the `--pick` list. Accepts either one colour
|
|
389
|
+
// (applied to every pick) or exactly one per pick (positional); an empty
|
|
390
|
+
// segment means "no colour for this pick". Returns an array aligned to picks.
|
|
391
|
+
export function parseIconColors(raw, pickCount) {
|
|
392
|
+
if (!raw) return new Array(pickCount).fill(null);
|
|
393
|
+
const colors = raw
|
|
394
|
+
.split(",")
|
|
395
|
+
.map((s) => s.trim())
|
|
396
|
+
.map((s) => (s.length ? s : null));
|
|
397
|
+
for (const c of colors) {
|
|
398
|
+
if (c !== null && !/^#[0-9a-fA-F]{3,8}$/.test(c)) {
|
|
399
|
+
fail(`--icon-color expects hex colours like #0A7E8C, got '${c}'`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (colors.length === 1) return new Array(pickCount).fill(colors[0]);
|
|
403
|
+
if (colors.length !== pickCount) {
|
|
404
|
+
fail(
|
|
405
|
+
`--icon-color takes one colour (applied to all) or exactly ${pickCount} (one per --pick); got ${colors.length}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return colors;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function describeReadSourceFailure(err, searchId, pick) {
|
|
412
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
413
|
+
switch (code) {
|
|
414
|
+
case "unknown_search":
|
|
415
|
+
return `searchId ${searchId} is no longer available on that window. Run 'slidemind search' again, then re-pick.`;
|
|
416
|
+
case "wrong_result_type":
|
|
417
|
+
return `result ${pick} has no source presentation — only slide search results come from a deck (image and collection results do not).`;
|
|
418
|
+
case "access_denied":
|
|
419
|
+
return "you do not have access to that result's source presentation.";
|
|
420
|
+
case "no_indexed_content":
|
|
421
|
+
return "that presentation has no indexed slides yet. It may still be syncing — try again later.";
|
|
422
|
+
case "invalid_source":
|
|
423
|
+
return `result ${pick} carries no source file reference. Re-run the search, or pick a different result.`;
|
|
424
|
+
case "source_presentation_unsupported":
|
|
425
|
+
return err.message;
|
|
426
|
+
default:
|
|
427
|
+
return `read-source failed: ${err?.message ?? "unknown error"}`;
|
|
428
|
+
}
|
|
429
|
+
}
|