pi-quiver 5.4.0 → 6.0.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 +12 -0
- package/README.md +12 -11
- package/dist/bin/pi-quiver.js +121 -56
- package/extensions/doc_to_md.ts +1 -1
- package/extensions/slack.ts +7 -5
- package/lib/doc-to-md-bundle.ts +46 -25
- package/lib/doc-to-md-core.ts +75 -38
- package/lib/doc-to-md-handle.ts +14 -3
- package/lib/doc-to-md-options.ts +3 -5
- package/lib/extension-config.ts +3 -3
- package/lib/slack-core.ts +66 -2
- package/package.json +1 -1
- package/scripts/doc_to_md.py +279 -89
package/lib/doc-to-md-bundle.ts
CHANGED
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bundle protocol: a call owns `<stem>` for its whole duration via `<stem>.md.lock`;
|
|
3
|
-
* children stage images under `images/.stage-<lockId>/p<N>/` and
|
|
4
|
-
* Node publishes
|
|
3
|
+
* children stage images under `images/.stage-<lockId>/p<N>/` and CSVs under `sheets/.stage-<lockId>/s<idx>-<slug>.csv`;
|
|
4
|
+
* Node publishes them to `images/<stem>-p<N>-<n>.<ext>` and `sheets/<stem>-s<idx>-<slug>.csv`, and records every file it
|
|
5
5
|
* wrote in a manifest, and commits `<stem>.md` atomically (tmp + rename).
|
|
6
6
|
*/
|
|
7
|
-
import fs, { closeSync, existsSync, mkdirSync, openSync, readdirSync,
|
|
7
|
+
import fs, { closeSync, existsSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { extname, join, resolve } from "node:path";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
|
|
12
12
|
export interface Bundle {
|
|
13
13
|
root: string; stem: string; mdPath: string; lockPath: string; imagesDir: string; stagingDir: string; lockId: string;
|
|
14
|
+
sheetsDir: string; sheetsStagingDir: string;
|
|
14
15
|
manifest: Set<string>;
|
|
16
|
+
csvManifest: Set<string>;
|
|
15
17
|
sourceMap: Map<string, string>;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
21
|
+
|
|
18
22
|
export function ownedPattern(stem: string): RegExp {
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
return new RegExp(`^${escRe(stem)}-(p|s)\\d+(-\\d+)?\\.[a-z0-9]+$`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ownedCsvPattern(stem: string): RegExp {
|
|
27
|
+
return new RegExp(`^${escRe(stem)}-s\\d+-[a-z0-9-]+\\.csv$`);
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
const SHEET_LINK_RE = /\[[^\]]*\]\(\s*(sheets\/[^)\s]+)\s*\)/g;
|
|
23
31
|
const IMG_LINK_RE = /!\[[^\]]*\]\(\s*(?:<([^>]*)>|([^)]*?))\s*\)|<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/gi;
|
|
24
32
|
|
|
25
33
|
function imageTarget(m: RegExpMatchArray): string {
|
|
@@ -28,17 +36,6 @@ function imageTarget(m: RegExpMatchArray): string {
|
|
|
28
36
|
return m[2] === undefined ? destination : destination.replace(/\s+(?:"[^"]*"|'[^']*'|\([^)]*\))$/, "");
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
function linkedFiles(md: string): string[] {
|
|
32
|
-
const out: string[] = [];
|
|
33
|
-
for (const m of md.matchAll(IMG_LINK_RE)) {
|
|
34
|
-
const target = imageTarget(m);
|
|
35
|
-
if (!target.startsWith("images/")) continue;
|
|
36
|
-
const file = target.slice("images/".length);
|
|
37
|
-
if (file && file !== "." && file !== ".." && /^[^/\\]+$/.test(file)) out.push(file);
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
39
|
export function openBundle(root: string, stem: string, overwrite: boolean): Bundle {
|
|
43
40
|
mkdirSync(root, { recursive: true });
|
|
44
41
|
const mdPath = join(root, `${stem}.md`);
|
|
@@ -51,18 +48,20 @@ export function openBundle(root: string, stem: string, overwrite: boolean): Bund
|
|
|
51
48
|
}
|
|
52
49
|
closeSync(fd);
|
|
53
50
|
const imagesDir = join(root, "images");
|
|
51
|
+
const sheetsDir = join(root, "sheets");
|
|
54
52
|
try {
|
|
55
53
|
if (existsSync(mdPath)) {
|
|
56
54
|
if (!overwrite) throw new Error(`Output exists: ${mdPath} (pass overwrite)`);
|
|
57
|
-
const owned = ownedPattern(stem);
|
|
58
|
-
const linked = new Set(linkedFiles(readFileSync(mdPath, "utf8")));
|
|
55
|
+
const owned = ownedPattern(stem), ownedCsv = ownedCsvPattern(stem);
|
|
59
56
|
unlinkSync(mdPath);
|
|
60
|
-
if (existsSync(imagesDir)) for (const f of readdirSync(imagesDir)) if (owned.test(f)
|
|
57
|
+
if (existsSync(imagesDir)) for (const f of readdirSync(imagesDir)) if (owned.test(f)) rmSync(join(imagesDir, f), { force: true });
|
|
58
|
+
if (existsSync(sheetsDir)) for (const f of readdirSync(sheetsDir)) if (ownedCsv.test(f)) rmSync(join(sheetsDir, f), { force: true });
|
|
61
59
|
}
|
|
62
60
|
const lockId = randomBytes(6).toString("hex");
|
|
63
61
|
const stagingDir = join(imagesDir, `.stage-${lockId}`);
|
|
62
|
+
const sheetsStagingDir = join(sheetsDir, `.stage-${lockId}`);
|
|
64
63
|
mkdirSync(stagingDir, { recursive: true });
|
|
65
|
-
return { root, stem, mdPath, lockPath, imagesDir, stagingDir, lockId, manifest: new Set(), sourceMap: new Map() };
|
|
64
|
+
return { root, stem, mdPath, lockPath, imagesDir, stagingDir, lockId, sheetsDir, sheetsStagingDir, manifest: new Set(), csvManifest: new Set(), sourceMap: new Map() };
|
|
66
65
|
} catch (e) { rmSync(lockPath, { force: true }); throw e; }
|
|
67
66
|
}
|
|
68
67
|
|
|
@@ -91,10 +90,10 @@ export function publishStaged(b: Bundle): Map<number, string[]> {
|
|
|
91
90
|
return out;
|
|
92
91
|
}
|
|
93
92
|
|
|
94
|
-
/** Excel:
|
|
93
|
+
/** Excel: `s<idx>-<n>.<ext>` (embedded) and `s<idx>.<fmt>` (rendered view) staged flat -> `images/<stem>-<file>`. */
|
|
95
94
|
export function publishSheetImages(b: Bundle): void {
|
|
96
95
|
for (const f of readdirSync(b.stagingDir).sort()) {
|
|
97
|
-
if (!/^s\d
|
|
96
|
+
if (!/^s\d+(-\d+)?\.[a-z0-9]+$/i.test(f)) continue;
|
|
98
97
|
const name = `${b.stem}-${f.toLowerCase()}`;
|
|
99
98
|
renameSync(join(b.stagingDir, f), join(b.imagesDir, name));
|
|
100
99
|
b.manifest.add(name);
|
|
@@ -102,19 +101,38 @@ export function publishSheetImages(b: Bundle): void {
|
|
|
102
101
|
}
|
|
103
102
|
}
|
|
104
103
|
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
/** Excel: `sheetsStagingDir/s<idx>-<slug>.csv` -> `sheets/<stem>-s<idx>-<slug>.csv`; `sheets/` is created only when a CSV exists. */
|
|
105
|
+
export function publishSheetCsvs(b: Bundle): void {
|
|
106
|
+
if (!existsSync(b.sheetsStagingDir)) return;
|
|
107
|
+
for (const f of readdirSync(b.sheetsStagingDir).sort()) {
|
|
108
|
+
if (!/^s\d+-[a-z0-9-]+\.csv$/.test(f)) continue;
|
|
109
|
+
const name = `${b.stem}-${f}`;
|
|
110
|
+
renameSync(join(b.sheetsStagingDir, f), join(b.sheetsDir, name));
|
|
111
|
+
b.csvManifest.add(name);
|
|
112
|
+
b.sourceMap.set(`sheets/${f}`, `sheets/${name}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function rewriteLinks(md: string, sourceMap: Map<string, string>): string {
|
|
117
|
+
const images = md.replace(IMG_LINK_RE, (whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted) => {
|
|
107
118
|
const target = imageTarget([whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted] as unknown as RegExpMatchArray);
|
|
108
119
|
const dest = sourceMap.get(target) ?? sourceMap.get(target.replace(/^\.\//, ""));
|
|
109
120
|
return dest ? whole.replace(mdAngle === undefined ? target : `<${mdAngle}>`, dest) : whole;
|
|
110
121
|
});
|
|
122
|
+
return images.replace(SHEET_LINK_RE, (whole, target: string) => {
|
|
123
|
+
const dest = sourceMap.get(target);
|
|
124
|
+
return dest ? whole.split(target).join(dest) : whole;
|
|
125
|
+
});
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
export function validateImageLinks(md: string, manifest: Set<string>): void {
|
|
128
|
+
export function validateImageLinks(md: string, manifest: Set<string>, csvManifest: Set<string> = new Set()): void {
|
|
114
129
|
for (const m of md.matchAll(IMG_LINK_RE)) {
|
|
115
130
|
const target = imageTarget(m);
|
|
116
131
|
if (!target.startsWith("images/") || !manifest.has(target.slice("images/".length))) throw new Error(`unexpected image reference in output: ${target}`);
|
|
117
132
|
}
|
|
133
|
+
for (const m of md.matchAll(SHEET_LINK_RE)) {
|
|
134
|
+
if (!csvManifest.has(m[1].slice("sheets/".length))) throw new Error(`unexpected sheet reference in output: ${m[1]}`);
|
|
135
|
+
}
|
|
118
136
|
}
|
|
119
137
|
|
|
120
138
|
export function commitBundle(b: Bundle, markdown: string): void {
|
|
@@ -122,13 +140,16 @@ export function commitBundle(b: Bundle, markdown: string): void {
|
|
|
122
140
|
writeFileSync(tmp, markdown, "utf8");
|
|
123
141
|
renameSync(tmp, b.mdPath);
|
|
124
142
|
try { fs.rmSync(b.stagingDir, { recursive: true, force: true }); } catch { /* Markdown is published; cleanup is best-effort. */ }
|
|
143
|
+
try { fs.rmSync(b.sheetsStagingDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
125
144
|
try { fs.rmSync(b.lockPath, { force: true }); } catch { /* Markdown is published; cleanup is best-effort. */ }
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
export function abortBundle(b: Bundle): void {
|
|
129
148
|
for (const f of b.manifest) rmSync(join(b.imagesDir, f), { force: true });
|
|
149
|
+
for (const f of b.csvManifest) rmSync(join(b.sheetsDir, f), { force: true });
|
|
130
150
|
rmSync(`${b.mdPath}.tmp`, { force: true });
|
|
131
151
|
rmSync(b.stagingDir, { recursive: true, force: true });
|
|
152
|
+
rmSync(b.sheetsStagingDir, { recursive: true, force: true });
|
|
132
153
|
rmSync(b.lockPath, { force: true });
|
|
133
154
|
}
|
|
134
155
|
|
package/lib/doc-to-md-core.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type ChildProcess, spawn } from "node:child_process";
|
|
|
12
12
|
import { homedir, tmpdir } from "node:os";
|
|
13
13
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
-
import { type Bundle, abortBundle, commitBundle, openBundle, publishSheetImages, publishStaged,
|
|
15
|
+
import { type Bundle, abortBundle, commitBundle, openBundle, publishSheetCsvs, publishSheetImages, publishStaged, rewriteLinks, tempBundleRoot, validateImageLinks } from "./doc-to-md-bundle.ts";
|
|
16
16
|
import { type Engine, type HandleData, type InfoData, type Tier, formatHandle, formatInfoHandle, scanOutline, type SheetInfo, type TocEntry } from "./doc-to-md-handle.ts";
|
|
17
17
|
import { type DocToMdOptions, type InputType, TUNABLE_DEFAULTS, classifyInput, sanitizeStem } from "./doc-to-md-options.ts";
|
|
18
18
|
|
|
@@ -28,6 +28,7 @@ export const VENV_DIR_NAME = "doc-to-md-venv-v2";
|
|
|
28
28
|
export const LEGACY_VENV_DIR_NAME = "pymupdf-venv";
|
|
29
29
|
const STDERR_CAP = 1_000_000;
|
|
30
30
|
export const OUTPUT_MAX_BYTES = 20_000_000;
|
|
31
|
+
export const EXCEL_PDF_FILTER = 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":"true"}}';
|
|
31
32
|
|
|
32
33
|
export interface BackendConfig { pymupdfVersion: string; warmTimeoutMs: number; }
|
|
33
34
|
|
|
@@ -59,12 +60,12 @@ export function uvChildArgs(cfg: BackendConfig, script: string, mode: string): s
|
|
|
59
60
|
|
|
60
61
|
export function pythonChildArgs(script: string, mode: string): string[] { return [script, mode]; }
|
|
61
62
|
|
|
62
|
-
export function soffArgs(src: string, profileDir: string, outDir: string): string[] {
|
|
63
|
+
export function soffArgs(src: string, profileDir: string, outDir: string, filter = "pdf"): string[] {
|
|
63
64
|
return [
|
|
64
65
|
"--headless", "--invisible", "--nocrashreport", "--nodefault", "--nofirststartwizard",
|
|
65
66
|
"--nolockcheck", "--nologo", "--norestore", "--quickstart=no",
|
|
66
67
|
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
|
67
|
-
"--convert-to",
|
|
68
|
+
"--convert-to", filter, "--outdir", outDir, src,
|
|
68
69
|
];
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -367,37 +368,37 @@ export function resetBackendCacheForTests(): void {
|
|
|
367
368
|
backendPromise = null;
|
|
368
369
|
}
|
|
369
370
|
|
|
370
|
-
export
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
): Promise<{ pdfPath: string; cleanup: () => void }> {
|
|
371
|
+
export type OfficeResult =
|
|
372
|
+
| { ok: true; pdfPath: string; cleanup: () => void }
|
|
373
|
+
| { ok: false; kind: "missing" | "timeout" | "exit" | "no-pdf"; code: number | null; timedOut: boolean; stderr: string };
|
|
374
|
+
|
|
375
|
+
export async function tryConvertOffice(sofficeTimeoutMs: number, src: string, signal?: AbortSignal, run: RunFn = runCapped, filter = "pdf"): Promise<OfficeResult> {
|
|
376
376
|
const profileDir = mkdtempSync(join(tmpdir(), "pi-doc-soffice-prof-"));
|
|
377
377
|
const outDir = mkdtempSync(join(tmpdir(), "pi-doc-soffice-out-"));
|
|
378
|
-
const cleanup = () => {
|
|
379
|
-
for (const d of [profileDir, outDir]) {
|
|
380
|
-
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
381
|
-
}
|
|
382
|
-
};
|
|
378
|
+
const cleanup = () => { for (const d of [profileDir, outDir]) try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } };
|
|
383
379
|
try {
|
|
384
380
|
const env = { ...process.env, SAL_USE_VCLPLUGIN: "svp", OOO_DISABLE_RECOVERY: "1", SAL_NO_MOUSEGRABS: "1" };
|
|
385
|
-
const r = await run("soffice", soffArgs(src, profileDir, outDir), { timeoutMs: sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
|
|
381
|
+
const r = await run("soffice", soffArgs(src, profileDir, outDir, filter), { timeoutMs: sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
|
|
386
382
|
if (signal?.aborted) throw new Error("aborted");
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (r.
|
|
391
|
-
const
|
|
392
|
-
const pdfPath = join(outDir, `${base}.pdf`);
|
|
383
|
+
const fail = (kind: "missing" | "timeout" | "exit" | "no-pdf"): OfficeResult => { cleanup(); return { ok: false, kind, code: r.code, timedOut: r.timedOut, stderr: r.stderr.slice(0, 500) }; };
|
|
384
|
+
if (r.code === null && !r.timedOut) return fail("missing");
|
|
385
|
+
if (r.timedOut) return fail("timeout");
|
|
386
|
+
if (r.code !== 0) return fail("exit");
|
|
387
|
+
const pdfPath = join(outDir, `${basename(src).replace(/\.[^.]+$/, "")}.pdf`);
|
|
393
388
|
const st = statSync(pdfPath, { throwIfNoEntry: false });
|
|
394
|
-
if (!st || !st.isFile() || st.size === 0)
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
return { pdfPath, cleanup };
|
|
389
|
+
if (!st || !st.isFile() || st.size === 0) return fail("no-pdf");
|
|
390
|
+
return { ok: true, pdfPath, cleanup };
|
|
398
391
|
} catch (e) { cleanup(); throw e; }
|
|
399
392
|
}
|
|
400
393
|
|
|
394
|
+
export async function convertOffice(sofficeTimeoutMs: number, src: string, signal?: AbortSignal, run: RunFn = runCapped): Promise<{ pdfPath: string; cleanup: () => void }> {
|
|
395
|
+
const r = await tryConvertOffice(sofficeTimeoutMs, src, signal, run);
|
|
396
|
+
if (r.ok) return r;
|
|
397
|
+
if (r.kind === "missing") throw new Error("LibreOffice (soffice) is required to convert .docx/.pptx but was not found on PATH. Install LibreOffice or convert the file to PDF first.");
|
|
398
|
+
if (r.kind === "no-pdf") throw new Error("LibreOffice (soffice) ran but produced no usable PDF for this file. Ensure LibreOffice can open the document, or convert it to PDF manually first.");
|
|
399
|
+
throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
401
402
|
|
|
402
403
|
// --- Conversion tiers and bundle orchestration ---
|
|
403
404
|
|
|
@@ -405,13 +406,14 @@ export const DEGRADED_TEXT = "PyMuPDF text extraction - layout/tables not preser
|
|
|
405
406
|
export const DEGRADED_UNPDF = "unpdf text extraction - structure not preserved";
|
|
406
407
|
export const EXCEL_REMEDY = "Remedy: install uv, or pip install openpyxl xlrd pillow";
|
|
407
408
|
|
|
408
|
-
export type Mode = "info" | "pdf-primary" | "pdf-fallback" | "xlsx" | "pdf-text";
|
|
409
|
-
export interface TierJson { markdown?: string; pages?: number[]; pageCount?: number; emptyPages?: number[]; failedPages?: { page: number; error: string }[]; notes?: string[]; images?: { sheetIndex: number; file: string }[]; metadata?: Record<string, string>; toc?: [number, string, number][]; sheets?: SheetInfo[]; }
|
|
409
|
+
export type Mode = "info" | "pdf-primary" | "pdf-fallback" | "xlsx" | "pdf-text" | "render-pages";
|
|
410
|
+
export interface TierJson { markdown?: string; pages?: number[]; pageCount?: number; emptyPages?: number[]; failedPages?: { page: number; error: string }[]; notes?: string[]; images?: { sheetIndex: number; file: string }[]; metadata?: Record<string, string>; toc?: [number, string, number][]; sheets?: SheetInfo[]; renderPages?: number[]; sheetCount?: number; ok?: boolean; reason?: string; rendered?: { idx: number; file: string; dpi: number }[]; failed?: { idx: number; reason: string }[]; }
|
|
410
411
|
export type TierResult = { ok: true; json: TierJson } | { ok: false; reason: string; detail?: string } | { ok: false; userError: string; pageCount?: number };
|
|
411
412
|
|
|
412
413
|
export interface PipelineSeams {
|
|
413
414
|
backend: (cfg: BackendConfig) => Promise<Backend>;
|
|
414
415
|
runTier: (mode: Mode, childOptions: Record<string, unknown>, bundle: Pick<Bundle, "stagingDir">, signal: AbortSignal | undefined, timeoutMs: number, backend: Backend) => Promise<TierResult>;
|
|
416
|
+
office: typeof tryConvertOffice;
|
|
415
417
|
}
|
|
416
418
|
|
|
417
419
|
export function resolveUnpdfWorker(candidates: string[], exists: (path: string) => boolean): string {
|
|
@@ -463,8 +465,19 @@ function detailSuffix(r: Extract<TierResult, { ok: false; reason: string }>): st
|
|
|
463
465
|
return r.detail ? ` (${r.detail})` : "";
|
|
464
466
|
}
|
|
465
467
|
|
|
468
|
+
export function reconcileRenderMarkers(md: string, renderPages: number[], fmt: string, sourceMap: Map<string, string>, reason: (idx: number) => string): string {
|
|
469
|
+
for (const idx of renderPages) {
|
|
470
|
+
const file = `s${idx}.${fmt}`;
|
|
471
|
+
const ok = sourceMap.has(file);
|
|
472
|
+
md = md.replace(`<!--rv:${idx}-->`, ok ? `Rendered view: ` : `Rendered view: unavailable (${reason(idx)})`);
|
|
473
|
+
md = md.replace(`<!--rvs:${idx}-->`, ok ? "yes" : "no");
|
|
474
|
+
}
|
|
475
|
+
if (/<!--rvs?:\d+-->/.test(md)) throw new Error("internal: unresolved render marker");
|
|
476
|
+
return md;
|
|
477
|
+
}
|
|
478
|
+
|
|
466
479
|
export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, seams?: Partial<PipelineSeams>): Promise<ConvertOutcome> {
|
|
467
|
-
const s: PipelineSeams = { backend: (c) => getBackend(c, undefined, signal), runTier: runTierReal, ...seams };
|
|
480
|
+
const s: PipelineSeams = { backend: (c) => getBackend(c, undefined, signal), runTier: runTierReal, office: tryConvertOffice, ...seams };
|
|
468
481
|
const inputPath = resolve(o.path);
|
|
469
482
|
const st = statSync(inputPath, { throwIfNoEntry: false });
|
|
470
483
|
if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
|
|
@@ -479,17 +492,40 @@ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, s
|
|
|
479
492
|
try {
|
|
480
493
|
let pdfPath = inputPath;
|
|
481
494
|
if (type === "docx" || type === "pptx") { office = await convertOffice(o.sofficeTimeoutMs, inputPath, signal); pdfPath = office.pdfPath; }
|
|
482
|
-
const base = { path: pdfPath, pages: o.pages, stagingDir: b.stagingDir,
|
|
495
|
+
const base = { path: pdfPath, pages: o.pages, stagingDir: b.stagingDir, sheetsStagingDir: b.sheetsStagingDir, imageDpi: o.imageDpi, imageFormat: o.imageFormat, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion };
|
|
483
496
|
let tier: Tier, engine: Engine, json: TierJson, degraded: string | null = null, fallbackReason: string | null = null;
|
|
497
|
+
let notes: string[] = [];
|
|
484
498
|
if (isExcel) {
|
|
485
499
|
const r = await s.runTier("xlsx", base, b, signal, o.excelTimeoutMs, backend);
|
|
486
500
|
if (!r.ok) {
|
|
487
501
|
if ("userError" in r) throw new Error(r.userError);
|
|
488
|
-
const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs
|
|
502
|
+
const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs" : "";
|
|
489
503
|
throw new Error(`Excel conversion failed: ${r.reason}${detailSuffix(r)}${remedy}`);
|
|
490
504
|
}
|
|
491
|
-
publishSheetImages(b);
|
|
492
|
-
tier = "excel"; engine = type === "xls" ? "xlrd" : "openpyxl"; json = r.json;
|
|
505
|
+
publishSheetImages(b); publishSheetCsvs(b);
|
|
506
|
+
tier = "excel"; engine = type === "xls" ? "xlrd" : "openpyxl"; json = r.json; notes = [...(json.notes ?? [])];
|
|
507
|
+
const renderPages = json.renderPages ?? [];
|
|
508
|
+
let skip: string | null = null;
|
|
509
|
+
const perSheet = new Map<number, string>();
|
|
510
|
+
if (renderPages.length) {
|
|
511
|
+
const off = await s.office(o.sofficeTimeoutMs, inputPath, signal, runCapped, EXCEL_PDF_FILTER);
|
|
512
|
+
if (!off.ok) skip = off.kind === "missing" ? "LibreOffice not found" : off.kind === "timeout" ? `soffice failed: timeout after ${o.sofficeTimeoutMs}ms` : off.kind === "exit" ? `soffice failed: exit ${off.code}` : "soffice produced no PDF";
|
|
513
|
+
else {
|
|
514
|
+
try {
|
|
515
|
+
const rp = await s.runTier("render-pages", { path: off.pdfPath, sheetIndices: renderPages, expectedPages: json.sheetCount, imageDpi: o.imageDpi, imageFormat: o.imageFormat, stagingDir: b.stagingDir, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, b, signal, o.fallbackTimeoutMs, backend);
|
|
516
|
+
if (!rp.ok) skip = `render failed: ${"userError" in rp ? rp.userError : rp.reason}`;
|
|
517
|
+
else if (rp.json.ok === false) skip = rp.json.reason ?? "render failed";
|
|
518
|
+
else {
|
|
519
|
+
publishSheetImages(b);
|
|
520
|
+
for (const f of rp.json.failed ?? []) perSheet.set(f.idx, f.reason);
|
|
521
|
+
for (const d of rp.json.rendered ?? []) if (d.dpi < o.imageDpi) notes.push(`Rendered view s${d.idx}: rendered at ${d.dpi} dpi`);
|
|
522
|
+
}
|
|
523
|
+
} finally { off.cleanup(); }
|
|
524
|
+
}
|
|
525
|
+
if (skip) notes.push(`Rendered views skipped: ${skip}`);
|
|
526
|
+
else if (perSheet.size) notes.push(`Rendered views: ${perSheet.size} of ${renderPages.length} unavailable`);
|
|
527
|
+
}
|
|
528
|
+
json = { ...json, markdown: reconcileRenderMarkers(json.markdown ?? "", renderPages, o.imageFormat, b.sourceMap, (idx) => perSheet.get(idx) ?? skip ?? "render failed") };
|
|
493
529
|
} else if (backend.kind === "none") {
|
|
494
530
|
const r = await s.runTier("pdf-text", base, b, signal, o.primaryTimeoutMs, backend);
|
|
495
531
|
if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: unpdf ${r.reason}${detailSuffix(r)}`);
|
|
@@ -508,25 +544,26 @@ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, s
|
|
|
508
544
|
tier = "fallback"; engine = "pymupdf-text"; json = f.json; degraded = DEGRADED_TEXT; fallbackReason = `primary ${p.reason}`;
|
|
509
545
|
}
|
|
510
546
|
}
|
|
511
|
-
|
|
512
|
-
|
|
547
|
+
if (!isExcel) notes = json.notes ?? [];
|
|
548
|
+
const body = rewriteLinks(json.markdown ?? "", b.sourceMap);
|
|
549
|
+
validateImageLinks(body, b.manifest, b.csvManifest);
|
|
513
550
|
const head: string[] = [];
|
|
514
551
|
if (degraded) head.push(`Degraded: ${degraded}`);
|
|
515
552
|
if (fallbackReason) head.push(`Fallback-Reason: ${fallbackReason}`);
|
|
516
553
|
if (json.failedPages?.length) head.push(`Failed pages: ${json.failedPages.map((f) => `${f.page} (${f.error})`).join("; ")}`);
|
|
517
554
|
if (json.emptyPages?.length) head.push(`Empty pages: ${json.emptyPages.join(", ")}`);
|
|
518
|
-
for (const n of
|
|
555
|
+
for (const n of notes) head.push(`Notes: ${n}`);
|
|
519
556
|
const markdown = (head.length ? `${head.join("\n")}\n\n` : "") + body;
|
|
520
557
|
commitBundle(b, markdown);
|
|
521
558
|
const outline = scanOutline(markdown, o.outlineMaxEntries);
|
|
522
|
-
const details: DocToMdDetails = { path: inputPath, backend: backend.kind, pymupdfVersion: o.pymupdfVersion, inputType: type, file: b.mdPath, outputDir: b.root, savedTo: b.mdPath, imagesDir: b.imagesDir, type, engine, tier, pageCount: json.pageCount ?? null, pages: o.pages, imageCount: b.manifest.size, bytes: Buffer.byteLength(markdown, "utf8"), lines: markdown.split("\n").length, degraded, fallbackReason, failedPages: (json.failedPages ?? []).map((f) => f.page), emptyPages: json.emptyPages ?? [], notes
|
|
559
|
+
const details: DocToMdDetails = { path: inputPath, backend: backend.kind, pymupdfVersion: o.pymupdfVersion, inputType: type, file: b.mdPath, outputDir: b.root, savedTo: b.mdPath, imagesDir: b.imagesDir, sheetsDir: b.csvManifest.size ? b.sheetsDir : null, type, engine, tier, pageCount: json.pageCount ?? null, pages: o.pages, imageCount: b.manifest.size, bytes: Buffer.byteLength(markdown, "utf8"), lines: markdown.split("\n").length, degraded, fallbackReason, failedPages: (json.failedPages ?? []).map((f) => f.page), emptyPages: json.emptyPages ?? [], notes, outline: outline.entries, outlineTotal: outline.total };
|
|
523
560
|
return { output: formatHandle(details), details };
|
|
524
561
|
} catch (e) { abortBundle(b); throw e; }
|
|
525
562
|
finally { office?.cleanup(); }
|
|
526
563
|
}
|
|
527
564
|
|
|
528
565
|
export async function inspectDocument(o: DocToMdOptions, signal?: AbortSignal, seams?: Partial<PipelineSeams>): Promise<{ output: string; details: InfoData }> {
|
|
529
|
-
const s: PipelineSeams = { backend: (c) => getBackend(c, undefined, signal), runTier: runTierReal, ...seams };
|
|
566
|
+
const s: PipelineSeams = { backend: (c) => getBackend(c, undefined, signal), runTier: runTierReal, office: tryConvertOffice, ...seams };
|
|
530
567
|
const inputPath = resolve(o.path);
|
|
531
568
|
const st = statSync(inputPath, { throwIfNoEntry: false });
|
|
532
569
|
if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
|
|
@@ -541,7 +578,7 @@ export async function inspectDocument(o: DocToMdOptions, signal?: AbortSignal, s
|
|
|
541
578
|
const r = await s.runTier("info", { path, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, { stagingDir: "" }, signal, isExcel ? o.excelTimeoutMs : o.fallbackTimeoutMs, backend);
|
|
542
579
|
if (!r.ok) {
|
|
543
580
|
if ("userError" in r) throw new Error(r.userError);
|
|
544
|
-
if (isExcel && (r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes")) throw new Error(`Excel inspection failed: ${r.reason}. Remedy: raise excelTimeoutMs
|
|
581
|
+
if (isExcel && (r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes")) throw new Error(`Excel inspection failed: ${r.reason}. Remedy: raise excelTimeoutMs${detailSuffix(r)}`);
|
|
545
582
|
throw new Error(`Inspection failed: ${r.reason}${detailSuffix(r)}`);
|
|
546
583
|
}
|
|
547
584
|
const toc: TocEntry[] = (r.json.toc ?? []).map(([level, title, page]) => ({ level, title, page }));
|
package/lib/doc-to-md-handle.ts
CHANGED
|
@@ -6,10 +6,20 @@ export type BackendKind = "uv" | "python" | "venv" | "none";
|
|
|
6
6
|
|
|
7
7
|
export interface OutlineEntry { line: number; level: number; title: string; }
|
|
8
8
|
export interface TocEntry { level: number; title: string; page: number; }
|
|
9
|
-
export interface SheetInfo {
|
|
9
|
+
export interface SheetInfo {
|
|
10
|
+
index: number;
|
|
11
|
+
name: string;
|
|
12
|
+
kind: "worksheet" | "chartsheet";
|
|
13
|
+
hidden: boolean;
|
|
14
|
+
rows: number | null; cols: number | null;
|
|
15
|
+
hiddenRows: number; hiddenCols: number;
|
|
16
|
+
charts: number; images: number;
|
|
17
|
+
rendered: boolean;
|
|
18
|
+
csv: string | null;
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
export interface HandleData {
|
|
12
|
-
savedTo: string; imagesDir: string | null; type: InputType; engine: Engine; tier: Tier;
|
|
22
|
+
savedTo: string; imagesDir: string | null; sheetsDir: string | null; type: InputType; engine: Engine; tier: Tier;
|
|
13
23
|
pageCount: number | null; pages: number[] | null; imageCount: number; bytes: number; lines: number;
|
|
14
24
|
degraded: string | null; fallbackReason: string | null; failedPages: number[]; emptyPages: number[];
|
|
15
25
|
notes: string[]; outline: OutlineEntry[]; outlineTotal: number;
|
|
@@ -73,6 +83,7 @@ function outlineLines(entries: OutlineEntry[], total: number): string[] {
|
|
|
73
83
|
export function formatHandle(h: HandleData): string {
|
|
74
84
|
const lines = [`Saved-To: ${h.savedTo}`];
|
|
75
85
|
if (h.imagesDir && h.imageCount > 0) lines.push(`Images-Dir: ${h.imagesDir}`);
|
|
86
|
+
if (h.sheetsDir) lines.push(`Sheets-Dir: ${h.sheetsDir}`);
|
|
76
87
|
lines.push(`Type: ${h.type} Engine: ${h.engine} Tier: ${h.tier}`);
|
|
77
88
|
lines.push(`Page-Count: ${h.pageCount ?? "?"} Pages: ${h.pages ? compactRanges(h.pages) : "all"} Images: ${h.imageCount} Size: ${formatSize(h.bytes)} / ${h.lines} lines`);
|
|
78
89
|
if (h.degraded) lines.push(`Degraded: ${h.degraded}`);
|
|
@@ -90,7 +101,7 @@ export function formatInfoHandle(i: InfoData, max: number): string {
|
|
|
90
101
|
if (i.sheets) {
|
|
91
102
|
const lines = [`Type: ${i.type} Sheets: ${i.sheetsTotal}`];
|
|
92
103
|
for (const s of i.sheets.slice(0, max)) {
|
|
93
|
-
const dims =
|
|
104
|
+
const dims = `${s.kind} rows=${s.rows ?? "-"} cols=${s.cols ?? "-"} charts=${s.charts} images=${s.images}`;
|
|
94
105
|
const hidden = s.hiddenRows || s.hiddenCols ? ` hiddenRows=${s.hiddenRows} hiddenCols=${s.hiddenCols}` : "";
|
|
95
106
|
lines.push(` ${trunc(s.name, TITLE_MAX)} ${s.hidden ? "hidden " : ""}${dims}${hidden}`);
|
|
96
107
|
}
|
package/lib/doc-to-md-options.ts
CHANGED
|
@@ -16,7 +16,6 @@ export interface Tunables {
|
|
|
16
16
|
pymupdfVersion: string;
|
|
17
17
|
imageDpi: number;
|
|
18
18
|
imageFormat: ImageFormat;
|
|
19
|
-
maxCellsPerSheet: number;
|
|
20
19
|
maxOutputBytes: number;
|
|
21
20
|
outlineMaxEntries: number;
|
|
22
21
|
}
|
|
@@ -63,14 +62,13 @@ export const DOC_TO_MD_OPTIONS: readonly OptionDescriptor[] = [
|
|
|
63
62
|
{ key: "outputDir", flag: "--output-dir", type: "string", default: null, settable: false, help: "Bundle root for <stem>.md + images/; default a per-call temp dir" },
|
|
64
63
|
{ key: "overwrite", flag: "--overwrite", type: "bool", default: false, settable: false, help: "Replace an existing completed <stem>.md bundle" },
|
|
65
64
|
{ key: "primaryTimeoutMs", flag: "--primary-timeout", type: "int", default: 60000, settable: true, env: "PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", help: "pymupdf4llm tier; also the unpdf tier" },
|
|
66
|
-
{ key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 30000, settable: true, help: "PyMuPDF get_text tier; also info on PDF" },
|
|
67
|
-
{ key: "sofficeTimeoutMs", flag: "--soffice-timeout", type: "int", default: 120000, settable: true, env: "PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", help: "DOCX/PPTX -> PDF via LibreOffice" },
|
|
65
|
+
{ key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 30000, settable: true, help: "PyMuPDF get_text tier; also info on PDF and Excel rendered views" },
|
|
66
|
+
{ key: "sofficeTimeoutMs", flag: "--soffice-timeout", type: "int", default: 120000, settable: true, env: "PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", help: "DOCX/PPTX -> PDF via LibreOffice; also Excel rendered views" },
|
|
68
67
|
{ key: "excelTimeoutMs", flag: "--excel-timeout", type: "int", default: 60000, settable: true, help: "Excel child (both openpyxl loads); also info on Excel" },
|
|
69
68
|
{ key: "warmTimeoutMs", flag: "--warm-timeout", type: "int", default: 120000, settable: true, env: "PI_DOC_TO_MD_WARM_TIMEOUT_MS", help: "Absolute backend discovery/bootstrap deadline (first call per process)" },
|
|
70
69
|
{ key: "pymupdfVersion", flag: "--pymupdf-version", type: "version", default: "1.27.2.3", settable: true, env: "PI_DOC_TO_MD_PYMUPDF_VERSION", help: "pymupdf4llm pin (>= 1.27.0)" },
|
|
71
|
-
{ key: "imageDpi", flag: "--image-dpi", type: "int", default: 150, settable: true, help: "Render DPI for page images" },
|
|
70
|
+
{ key: "imageDpi", flag: "--image-dpi", type: "int", default: 150, settable: true, help: "Render DPI for page images and Excel rendered views" },
|
|
72
71
|
{ key: "imageFormat", flag: "--image-format", type: "enum", default: "png", settable: true, enumValues: ["png", "jpg"], help: "Rendered image format (embedded images keep their native extension)" },
|
|
73
|
-
{ key: "maxCellsPerSheet", flag: "--max-cells-per-sheet", type: "int", default: 50000, settable: true, help: "rows x cols budget per worksheet" },
|
|
74
72
|
{ key: "maxOutputBytes", flag: "--max-output-bytes", type: "int", default: 20000000, settable: true, help: "Child stdout cap in bytes" },
|
|
75
73
|
{ key: "outlineMaxEntries", flag: "--outline-max-entries", type: "int", default: 40, settable: true, help: "Heading outline / TOC / sheet inventory cap in the handle" },
|
|
76
74
|
];
|
package/lib/extension-config.ts
CHANGED
|
@@ -47,7 +47,7 @@ export const QUIVER_CONFIG_KEYS: Record<string, readonly string[]> = {
|
|
|
47
47
|
sessionAutoName: ["enabled", "ghosttyTab", "herdrTab", "rules", "deny", "revisitFirstTurn", "revisitEveryTurns"],
|
|
48
48
|
swordHeader: ["enabled"],
|
|
49
49
|
providerStallWatchdog: ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries"],
|
|
50
|
-
slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "botTokenEnv", "uploadThresholdChars"],
|
|
50
|
+
slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "userTokenCommand", "userTokenCommandTimeoutSeconds", "botTokenEnv", "uploadThresholdChars"],
|
|
51
51
|
docToMd: DOC_TO_MD_OPTIONS.filter((o) => o.settable).map((o) => o.key),
|
|
52
52
|
};
|
|
53
53
|
|
|
@@ -104,7 +104,7 @@ export function resolveConfig<T extends object>(
|
|
|
104
104
|
cwd: string,
|
|
105
105
|
key: string,
|
|
106
106
|
defaults: T,
|
|
107
|
-
coerce: (raw: unknown) => Partial<T> | undefined,
|
|
107
|
+
coerce: (raw: unknown, warn?: (message: string) => void) => Partial<T> | undefined,
|
|
108
108
|
warn?: (message: string) => void,
|
|
109
109
|
): T {
|
|
110
110
|
const cfg: T = { ...defaults };
|
|
@@ -119,7 +119,7 @@ export function resolveConfig<T extends object>(
|
|
|
119
119
|
const hasFlat = Object.hasOwn(settings, key);
|
|
120
120
|
if (!hasNested && !(hasFlat && LEGACY_FLAT_KEYS.has(key))) continue;
|
|
121
121
|
const candidate = hasNested ? nested![key] : settings[key];
|
|
122
|
-
const patch = coerce(candidate);
|
|
122
|
+
const patch = coerce(candidate, (message) => emitWarning(warn, message));
|
|
123
123
|
if (patch) Object.assign(cfg, patch);
|
|
124
124
|
else emitWarning(warn, `pi-quiver: "${key}" in ${path} has an unrecognized value; ignored.`);
|
|
125
125
|
}
|
package/lib/slack-core.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* slack-local `PI_QUIVER_SLACK_*` env-var overlay applied on top.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
@@ -21,6 +21,8 @@ export interface SlackConfig {
|
|
|
21
21
|
cachePath: string | undefined;
|
|
22
22
|
policyPath: string | undefined;
|
|
23
23
|
userTokenEnv: string;
|
|
24
|
+
userTokenCommand: string[] | undefined;
|
|
25
|
+
userTokenCommandTimeoutSeconds: number;
|
|
24
26
|
botTokenEnv: string;
|
|
25
27
|
uploadThresholdChars: number;
|
|
26
28
|
}
|
|
@@ -30,6 +32,8 @@ export const DEFAULT_SLACK_CONFIG: SlackConfig = {
|
|
|
30
32
|
cachePath: undefined,
|
|
31
33
|
policyPath: undefined,
|
|
32
34
|
userTokenEnv: "SLACK_USER_TOKEN",
|
|
35
|
+
userTokenCommand: undefined,
|
|
36
|
+
userTokenCommandTimeoutSeconds: 10,
|
|
33
37
|
botTokenEnv: "SLACK_BOT_TOKEN",
|
|
34
38
|
uploadThresholdChars: 4000,
|
|
35
39
|
};
|
|
@@ -44,7 +48,9 @@ export class SlackError extends Error {
|
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
const MAX_TIMER_SECONDS = 2_147_483.647;
|
|
52
|
+
|
|
53
|
+
export function coerce(raw: unknown, warn?: (message: string) => void): Partial<SlackConfig> | undefined {
|
|
48
54
|
if (typeof raw === "boolean") return { enabled: raw };
|
|
49
55
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
50
56
|
const o = raw as Record<string, unknown>;
|
|
@@ -53,6 +59,28 @@ export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
|
|
|
53
59
|
if (typeof o.cachePath === "string") patch.cachePath = o.cachePath;
|
|
54
60
|
if (typeof o.policyPath === "string") patch.policyPath = o.policyPath;
|
|
55
61
|
if (typeof o.userTokenEnv === "string") patch.userTokenEnv = o.userTokenEnv;
|
|
62
|
+
if (
|
|
63
|
+
Array.isArray(o.userTokenCommand) &&
|
|
64
|
+
typeof o.userTokenCommand[0] === "string" &&
|
|
65
|
+
o.userTokenCommand[0].length > 0 &&
|
|
66
|
+
o.userTokenCommand.every((part) => typeof part === "string")
|
|
67
|
+
) {
|
|
68
|
+
patch.userTokenCommand = [...o.userTokenCommand];
|
|
69
|
+
}
|
|
70
|
+
if (o.userTokenCommandTimeoutSeconds !== undefined) {
|
|
71
|
+
if (
|
|
72
|
+
typeof o.userTokenCommandTimeoutSeconds === "number" &&
|
|
73
|
+
Number.isFinite(o.userTokenCommandTimeoutSeconds) &&
|
|
74
|
+
o.userTokenCommandTimeoutSeconds > 0 &&
|
|
75
|
+
o.userTokenCommandTimeoutSeconds <= MAX_TIMER_SECONDS
|
|
76
|
+
) {
|
|
77
|
+
patch.userTokenCommandTimeoutSeconds = o.userTokenCommandTimeoutSeconds;
|
|
78
|
+
} else {
|
|
79
|
+
warn?.(
|
|
80
|
+
`pi-quiver: quiver.slack.userTokenCommandTimeoutSeconds must be a positive finite number no greater than ${MAX_TIMER_SECONDS}; ignored.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
56
84
|
if (typeof o.botTokenEnv === "string") patch.botTokenEnv = o.botTokenEnv;
|
|
57
85
|
if (typeof o.uploadThresholdChars === "number" && Number.isInteger(o.uploadThresholdChars) && o.uploadThresholdChars > 0) {
|
|
58
86
|
patch.uploadThresholdChars = o.uploadThresholdChars;
|
|
@@ -222,6 +250,42 @@ export function resolveToken(
|
|
|
222
250
|
);
|
|
223
251
|
}
|
|
224
252
|
|
|
253
|
+
function runCredentialCommand(command: string[], timeoutMs: number): Promise<string> {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const [file, ...args] = command;
|
|
256
|
+
execFile(file, args, { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 64 * 1024, encoding: "utf8" }, (err, stdout) => {
|
|
257
|
+
if (err) {
|
|
258
|
+
const childError = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string | null };
|
|
259
|
+
if (childError.killed) {
|
|
260
|
+
reject(new SlackError("credential_command_failed", `Slack user credential command timed out after ${timeoutMs}ms.`));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const status = typeof childError.code === "number" ? ` (status ${childError.code})` : "";
|
|
264
|
+
reject(new SlackError("credential_command_failed", `Slack user credential command failed${status}.`));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const token = stdout.trim();
|
|
268
|
+
if (!token) {
|
|
269
|
+
reject(new SlackError("missing_token", "Slack user credential command returned no token."));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
resolve(token);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function resolveCredential(
|
|
278
|
+
identity: "user" | "bot",
|
|
279
|
+
cfg: SlackConfig,
|
|
280
|
+
env: Record<string, string | undefined>,
|
|
281
|
+
repoRoot: string,
|
|
282
|
+
): Promise<string> {
|
|
283
|
+
if (identity === "user" && cfg.userTokenCommand) {
|
|
284
|
+
return runCredentialCommand(cfg.userTokenCommand, Math.ceil(cfg.userTokenCommandTimeoutSeconds * 1000));
|
|
285
|
+
}
|
|
286
|
+
return resolveToken(identity, cfg, env, repoRoot);
|
|
287
|
+
}
|
|
288
|
+
|
|
225
289
|
/**
|
|
226
290
|
* Data-plane transport: every Slack API call and file upload funnels through
|
|
227
291
|
* these two injectable functions so tests script responses without network.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|