feat(tools): 2.0 phase 5 wave 4 - office, ebooks, data, archives (14 tools) (#224)

This commit is contained in:
SnapOtter
2026-06-13 10:19:11 +08:00
parent 638288e196
commit fc7c1f850e
93 changed files with 7636 additions and 817 deletions
@@ -0,0 +1,57 @@
import { writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
rtf: "application/rtf",
txt: "text/plain",
};
const settingsSchema = z.object({
format: z.enum(["docx", "odt", "rtf", "txt"]),
});
export function registerConvertDocument(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert-document",
settingsSchema,
process: async () => {
throw new Error("convert-document is v2-only");
},
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
@@ -0,0 +1,55 @@
import { writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
odp: "application/vnd.oasis.opendocument.presentation",
};
const settingsSchema = z.object({
format: z.enum(["pptx", "odp"]),
});
export function registerConvertPresentation(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert-presentation",
settingsSchema,
process: async () => {
throw new Error("convert-presentation is v2-only");
},
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
@@ -0,0 +1,56 @@
import { writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ods: "application/vnd.oasis.opendocument.spreadsheet",
csv: "text/csv",
};
const settingsSchema = z.object({
format: z.enum(["xlsx", "ods", "csv"]),
});
export function registerConvertSpreadsheet(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert-spreadsheet",
settingsSchema,
process: async () => {
throw new Error("convert-spreadsheet is v2-only");
},
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
// Reject same-format no-ops
const inputExt = extname(input.filename).toLowerCase();
if (inputExt === `.${settings.format}`) {
throw new InputValidationError("The file is already in that format", 422);
}
const base = input.filename.replace(/\.[^.]+$/, "");
// Preserve the real extension so LibreOffice can sniff the input format.
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, settings.format, {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.${settings.format}`,
contentType: CONTENT_TYPES[settings.format],
};
},
});
}
+62
View File
@@ -0,0 +1,62 @@
import { createWriteStream } from "node:fs";
import { extname, join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerCreateZip(app: FastifyInstance) {
createToolRoute(app, {
toolId: "create-zip",
maxInputs: 50,
settingsSchema,
process: async () => {
throw new Error("create-zip is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length < 2) {
throw new InputValidationError("Zipping needs at least two files");
}
// Deduplicate filenames: name-1.ext, name-2.ext on collision
const usedNames = new Map<string, number>();
const entryNames: string[] = [];
for (const input of ctx.inputs) {
const ext = extname(input.filename);
const base = input.filename.slice(0, input.filename.length - ext.length) || "file";
const key = input.filename.toLowerCase();
const count = usedNames.get(key) ?? 0;
if (count === 0) {
entryNames.push(input.filename);
} else {
entryNames.push(`${base}-${count}${ext}`);
}
usedNames.set(key, count + 1);
}
const zipPath = join(ctx.scratchDir, "archive.zip");
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 6 } });
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
archive.pipe(output);
for (let i = 0; i < ctx.inputs.length; i++) {
archive.append(ctx.inputs[i].buffer, { name: entryNames[i] });
const pct = Math.min(90, 10 + Math.round(((i + 1) / ctx.inputs.length) * 80));
ctx.report(pct, `Adding file ${i + 1} of ${ctx.inputs.length}`);
}
void archive.finalize();
});
return {
scratchPath: zipPath,
filename: "archive.zip",
contentType: "application/zip",
};
},
});
}
+65
View File
@@ -0,0 +1,65 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { htmlToPdfPy, runPandoc } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const CONTENT_TYPES: Record<string, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
html: "text/html",
md: "text/markdown",
};
const settingsSchema = z.object({
format: z.enum(["pdf", "docx", "html", "md"]),
});
export function registerEpubConvert(app: FastifyInstance) {
createToolRoute(app, {
toolId: "epub-convert",
settingsSchema,
process: async () => {
throw new Error("epub-convert is v2-only");
},
processV2: async (ctx) => {
const settings = ctx.settings as z.infer<typeof settingsSchema>;
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const { format } = settings;
ctx.report(10, "Converting");
if (format === "pdf") {
// Two-step chain: epub -> standalone HTML -> PDF via WeasyPrint.
// Resource embedding MUST stay OFF: pandoc --self-contained / --embed-resources
// would fetch remote refs server-side (SSRF). The weasyprint bridge pre-scan
// enforces remote-ref rejection so the PDF path fails safely on remote content.
const intermediateHtml = join(ctx.scratchDir, "book.html");
await runPandoc(inPath, intermediateHtml, { extraArgs: ["--standalone"] });
const outPath = join(ctx.scratchDir, `${base}.pdf`);
await htmlToPdfPy(intermediateHtml, outPath, "html");
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: CONTENT_TYPES.pdf,
};
}
// Direct pandoc conversion for docx, html, md
const outPath = join(ctx.scratchDir, `${base}.${format}`);
await runPandoc(inPath, outPath, format === "html" ? { extraArgs: ["--standalone"] } : {});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.${format}`,
contentType: CONTENT_TYPES[format],
};
},
});
}
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerExcelToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "excel-to-pdf",
settingsSchema,
process: async () => {
throw new Error("excel-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
// Sanitize the basename but keep the real extension so LibreOffice
// can sniff the input format (e.g. .xlsx vs .ods vs .csv).
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, "pdf", {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}
+196
View File
@@ -0,0 +1,196 @@
import { createWriteStream } from "node:fs";
import { basename, extname, join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { type Entry, fromBuffer, type ZipFile } from "yauzl";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
/** Promise wrapper for yauzl.fromBuffer with lazyEntries. */
function openZipBuffer(buffer: Buffer): Promise<ZipFile> {
return new Promise((resolve, reject) => {
fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (err, zipfile) => {
if (err) return reject(err);
if (!zipfile) return reject(new Error("Failed to open zip"));
resolve(zipfile);
});
});
}
const MAX_ENTRIES = 1000;
const MAX_ENTRY_SIZE = 200 * 1024 * 1024; // 200 MiB
const MAX_TOTAL_SIZE = 500 * 1024 * 1024; // 500 MiB
const MAX_RATIO = 100;
const PAYLOAD_ENTRY_CAP = 100;
const settingsSchema = z.object({});
/** Read all entries from a yauzl ZipFile (lazyEntries mode). */
function collectEntries(zipfile: ZipFile): Promise<Entry[]> {
return new Promise((resolve, reject) => {
const entries: Entry[] = [];
zipfile.on("entry", (entry: Entry) => {
entries.push(entry);
zipfile.readEntry();
});
zipfile.on("end", () => resolve(entries));
zipfile.on("error", (err: Error) => reject(err));
zipfile.readEntry();
});
}
/** Read an entry stream into a Buffer. */
function readEntryBuffer(zipfile: ZipFile, entry: Entry): Promise<Buffer> {
return new Promise((resolve, reject) => {
zipfile.openReadStream(entry, (err, stream) => {
if (err) return reject(err);
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", (e: Error) => reject(e));
});
});
}
/** Deduplicate basenames: name-1.ext, name-2.ext on collision. */
function deduplicateNames(names: string[]): string[] {
const usedNames = new Map<string, number>();
const result: string[] = [];
for (const raw of names) {
const name = basename(raw);
const ext = extname(name);
const base = name.slice(0, name.length - ext.length) || "file";
const key = name.toLowerCase();
const count = usedNames.get(key) ?? 0;
if (count === 0) {
result.push(name);
} else {
result.push(`${base}-${count}${ext}`);
}
usedNames.set(key, count + 1);
}
return result;
}
export function registerExtractZip(app: FastifyInstance) {
createToolRoute(app, {
toolId: "extract-zip",
settingsSchema,
process: async () => {
throw new Error("extract-zip is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const inputBase = input.filename.replace(/\.[^.]+$/, "") || "archive";
const zipSize = input.buffer.length;
ctx.report(5, "Reading archive");
const zipfile = await openZipBuffer(input.buffer);
const allEntries = await collectEntries(zipfile);
// Filter out directories and symlinks
const fileEntries: Entry[] = [];
for (const entry of allEntries) {
// Skip directory entries (name ends with /)
if (entry.fileName.endsWith("/")) continue;
// Skip symlinks: Unix external attrs mode check
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
if (mode === 0o120000) continue;
fileEntries.push(entry);
}
// Guard: entry count
if (fileEntries.length > MAX_ENTRIES) {
throw new InputValidationError("Too many entries");
}
// Guard: per-entry size, total size, path safety
let totalUncompressed = 0;
for (const entry of fileEntries) {
if (entry.uncompressedSize > MAX_ENTRY_SIZE) {
throw new InputValidationError("Archive expands too large");
}
totalUncompressed += entry.uncompressedSize;
if (totalUncompressed > MAX_TOTAL_SIZE) {
throw new InputValidationError("Archive expands too large");
}
// Path safety: reject absolute paths and ".." segments
const name = entry.fileName;
if (name.startsWith("/") || name.startsWith("\\")) {
throw new InputValidationError("Unsafe entry path");
}
const segments = name.split(/[/\\]/);
if (segments.some((s) => s === "..")) {
throw new InputValidationError("Unsafe entry path");
}
}
// Guard: compression ratio
if (zipSize > 0 && totalUncompressed / zipSize > MAX_RATIO) {
throw new InputValidationError("Suspicious compression ratio");
}
ctx.report(20, "Extracting files");
// Build the entries payload (capped at 100)
const entriesPayload = fileEntries.slice(0, PAYLOAD_ENTRY_CAP).map((e) => ({
name: basename(e.fileName),
size: e.uncompressedSize,
}));
// Re-open for streaming extraction (yauzl consumed the entries)
const zipfile2 = await openZipBuffer(input.buffer);
const allEntries2 = await collectEntries(zipfile2);
const fileEntries2 = allEntries2.filter((entry) => {
if (entry.fileName.endsWith("/")) return false;
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
if (mode === 0o120000) return false;
return true;
});
// Single file: output the bare file directly
if (fileEntries2.length === 1) {
const entry = fileEntries2[0];
const buf = await readEntryBuffer(zipfile2, entry);
ctx.report(90, "Done");
return {
buffer: buf,
filename: basename(entry.fileName),
contentType: "application/octet-stream",
resultPayload: { entries: entriesPayload },
};
}
// Multiple files: repackage flat
const dedupedNames = deduplicateNames(fileEntries2.map((e) => e.fileName));
const outPath = join(ctx.scratchDir, `${inputBase}_extracted.zip`);
const archive = archiver("zip", { zlib: { level: 6 } });
const output = createWriteStream(outPath);
archive.pipe(output);
for (let i = 0; i < fileEntries2.length; i++) {
const buf = await readEntryBuffer(zipfile2, fileEntries2[i]);
archive.append(buf, { name: dedupedNames[i] });
const pct = Math.min(85, 30 + Math.round(((i + 1) / fileEntries2.length) * 55));
ctx.report(pct, `Extracting file ${i + 1} of ${fileEntries2.length}`);
}
await new Promise<void>((resolve, reject) => {
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
void archive.finalize();
});
ctx.report(95, "Done");
return {
scratchPath: outPath,
filename: `${inputBase}_extracted.zip`,
contentType: "application/zip",
resultPayload: { entries: entriesPayload },
};
},
});
}
+28
View File
@@ -29,7 +29,11 @@ import { registerCompressVideo } from "./compress-video.js";
import { registerContentAwareResize } from "./content-aware-resize.js";
import { registerConvert } from "./convert.js";
import { registerConvertAudio } from "./convert-audio.js";
import { registerConvertDocument } from "./convert-document.js";
import { registerConvertPresentation } from "./convert-presentation.js";
import { registerConvertSpreadsheet } from "./convert-spreadsheet.js";
import { registerConvertVideo } from "./convert-video.js";
import { registerCreateZip } from "./create-zip.js";
import { registerCrop } from "./crop.js";
import { registerCropPdf } from "./crop-pdf.js";
import { registerCropVideo } from "./crop-video.js";
@@ -38,10 +42,13 @@ import { registerCsvJson } from "./csv-json.js";
import { registerEditMetadata } from "./edit-metadata.js";
import { registerEmbedSubtitles } from "./embed-subtitles.js";
import { registerEnhanceFaces } from "./enhance-faces.js";
import { registerEpubConvert } from "./epub-convert.js";
import { registerEraseObject } from "./erase-object.js";
import { registerExcelToPdf } from "./excel-to-pdf.js";
import { registerExtractAudio } from "./extract-audio.js";
import { registerExtractPages } from "./extract-pages.js";
import { registerExtractSubtitles } from "./extract-subtitles.js";
import { registerExtractZip } from "./extract-zip.js";
import { registerFadeAudio } from "./fade-audio.js";
import { registerFavicon } from "./favicon.js";
import { registerFindDuplicates } from "./find-duplicates.js";
@@ -58,9 +65,12 @@ import { registerImagesToVideo } from "./images-to-video.js";
import { registerInfo } from "./info.js";
import { registerJsonXml } from "./json-xml.js";
import { registerLinearizePdf } from "./linearize-pdf.js";
import { registerMarkdownToDocx } from "./markdown-to-docx.js";
import { registerMarkdownToHtml } from "./markdown-to-html.js";
import { registerMarkdownToPdf } from "./markdown-to-pdf.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerMergeAudio } from "./merge-audio.js";
import { registerMergeCsvs } from "./merge-csvs.js";
import { registerMergePdf } from "./merge-pdf.js";
import { registerMergeVideos } from "./merge-videos.js";
import { registerMuteVideo } from "./mute-video.js";
@@ -79,6 +89,7 @@ import { registerPdfToText } from "./pdf-to-text.js";
import { registerPdfToWord } from "./pdf-to-word.js";
import { registerPdfaConvert } from "./pdfa-convert.js";
import { registerPitchShift } from "./pitch-shift.js";
import { registerPowerpointToPdf } from "./powerpoint-to-pdf.js";
import { registerProtectPdf } from "./protect-pdf.js";
import { registerQrGenerate } from "./qr-generate.js";
import { registerRedEyeRemoval } from "./red-eye-removal.js";
@@ -109,6 +120,7 @@ import { registerStitch } from "./stitch.js";
import { registerStripMetadata } from "./strip-metadata.js";
import { registerSvgToRaster } from "./svg-to-raster.js";
import { registerTextOverlay } from "./text-overlay.js";
import { registerToEpub } from "./to-epub.js";
import { registerTransparencyFixer } from "./transparency-fixer.js";
import { registerTrimAudio } from "./trim-audio.js";
import { registerTrimVideo } from "./trim-video.js";
@@ -129,6 +141,8 @@ import { registerWatermarkText } from "./watermark-text.js";
import { registerWatermarkVideo } from "./watermark-video.js";
import { registerWaveformImage } from "./waveform-image.js";
import { registerWordToPdf } from "./word-to-pdf.js";
import { registerXmlToCsv } from "./xml-to-csv.js";
import { registerYamlJson } from "./yaml-json.js";
/**
* Registry that imports and registers all tool routes.
@@ -262,6 +276,11 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "waveform-image", register: registerWaveformImage },
// PDF & Documents
{ id: "convert-document", register: registerConvertDocument },
{ id: "convert-presentation", register: registerConvertPresentation },
{ id: "convert-spreadsheet", register: registerConvertSpreadsheet },
{ id: "epub-convert", register: registerEpubConvert },
{ id: "excel-to-pdf", register: registerExcelToPdf },
{ id: "merge-pdf", register: registerMergePdf },
{ id: "split-pdf", register: registerSplitPdf },
{ id: "compress-pdf", register: registerCompressPdf },
@@ -286,14 +305,23 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "pdf-to-text", register: registerPdfToText },
{ id: "pdf-to-word", register: registerPdfToWord },
{ id: "pdf-metadata", register: registerPdfMetadata },
{ id: "powerpoint-to-pdf", register: registerPowerpointToPdf },
{ id: "html-to-pdf", register: registerHtmlToPdf },
{ id: "markdown-to-docx", register: registerMarkdownToDocx },
{ id: "markdown-to-html", register: registerMarkdownToHtml },
{ id: "markdown-to-pdf", register: registerMarkdownToPdf },
{ id: "to-epub", register: registerToEpub },
// Data Files
{ id: "create-zip", register: registerCreateZip },
{ id: "csv-excel", register: registerCsvExcel },
{ id: "csv-json", register: registerCsvJson },
{ id: "extract-zip", register: registerExtractZip },
{ id: "json-xml", register: registerJsonXml },
{ id: "merge-csvs", register: registerMergeCsvs },
{ id: "split-csv", register: registerSplitCsv },
{ id: "xml-to-csv", register: registerXmlToCsv },
{ id: "yaml-json", register: registerYamlJson },
// AI Tools
{ id: "remove-background", register: registerRemoveBackground },
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { runPandoc } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMarkdownToDocx(app: FastifyInstance) {
createToolRoute(app, {
toolId: "markdown-to-docx",
settingsSchema,
process: async () => {
throw new Error("markdown-to-docx is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.docx`);
ctx.report(10, "Converting");
await runPandoc(inPath, outPath);
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.docx`,
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
};
},
});
}
@@ -0,0 +1,35 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { runPandoc } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMarkdownToHtml(app: FastifyInstance) {
createToolRoute(app, {
toolId: "markdown-to-html",
settingsSchema,
process: async () => {
throw new Error("markdown-to-html is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.html`);
ctx.report(10, "Converting");
await runPandoc(inPath, outPath, { extraArgs: ["--standalone"] });
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.html`,
contentType: "text/html",
};
},
});
}
+72
View File
@@ -0,0 +1,72 @@
import type { FastifyInstance } from "fastify";
import Papa from "papaparse";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerMergeCsvs(app: FastifyInstance) {
createToolRoute(app, {
toolId: "merge-csvs",
maxInputs: 20,
settingsSchema,
process: async () => {
throw new Error("merge-csvs is v2-only");
},
processV2: async (ctx) => {
if (ctx.inputs.length < 2) {
throw new InputValidationError("Merging needs at least two CSV files");
}
// Parse the first file to establish column order and delimiter
const firstText = ctx.inputs[0].buffer.toString("utf8");
const firstResult = Papa.parse<Record<string, unknown>>(firstText, {
header: true,
skipEmptyLines: true,
});
if (firstResult.errors.length > 0) {
throw new InputValidationError(`CSV parse failed: ${firstResult.errors[0].message}`);
}
const fields = firstResult.meta.fields ?? [];
const fieldSet = new Set(fields);
const detectedDelimiter = firstResult.meta.delimiter || ",";
const allRows: Record<string, unknown>[] = [...firstResult.data];
// Parse remaining files and validate headers match
for (let i = 1; i < ctx.inputs.length; i++) {
const input = ctx.inputs[i];
const text = input.buffer.toString("utf8");
const result = Papa.parse<Record<string, unknown>>(text, {
header: true,
skipEmptyLines: true,
});
if (result.errors.length > 0) {
throw new InputValidationError(
`CSV parse failed in ${input.filename}: ${result.errors[0].message}`,
);
}
const otherFields = new Set(result.meta.fields ?? []);
if (otherFields.size !== fieldSet.size || ![...otherFields].every((f) => fieldSet.has(f))) {
throw new InputValidationError(`${input.filename} has different columns`);
}
allRows.push(...result.data);
}
// Unparse preserving the first file's field order and delimiter
const merged = Papa.unparse(allRows, {
columns: fields,
delimiter: detectedDelimiter,
});
return {
buffer: Buffer.from(merged, "utf8"),
filename: "merged.csv",
contentType: "text/csv",
};
},
});
}
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { convertDocument } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../../config.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerPowerpointToPdf(app: FastifyInstance) {
createToolRoute(app, {
toolId: "powerpoint-to-pdf",
settingsSchema,
process: async () => {
throw new Error("powerpoint-to-pdf is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
// Sanitize the basename but keep the real extension so LibreOffice
// can sniff the input format (e.g. .pptx vs .ppt vs .odp).
const sanitized = input.filename.replace(/[^A-Za-z0-9._-]/g, "_");
const inPath = join(ctx.scratchDir, `in-${sanitized}`);
await writeFile(inPath, input.buffer);
ctx.report(10, "Converting");
const outPath = await convertDocument(inPath, ctx.scratchDir, "pdf", {
timeoutMs: (env.LIBREOFFICE_TIMEOUT_S || 120) * 1000,
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.pdf`,
contentType: "application/pdf",
};
},
});
}
+37
View File
@@ -0,0 +1,37 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { runPandoc } from "@snapotter/doc-engine";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
export function registerToEpub(app: FastifyInstance) {
createToolRoute(app, {
toolId: "to-epub",
settingsSchema,
process: async () => {
throw new Error("to-epub is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const inPath = join(ctx.scratchDir, `in-${input.filename.replace(/[^A-Za-z0-9._-]/g, "_")}`);
await writeFile(inPath, input.buffer);
const outPath = join(ctx.scratchDir, `${base}.epub`);
ctx.report(10, "Converting");
await runPandoc(inPath, outPath, {
extraArgs: ["--metadata", `title=${base}`],
});
ctx.report(90, "Done");
return {
scratchPath: outPath,
filename: `${base}.epub`,
contentType: "application/epub+zip",
};
},
});
}
+90
View File
@@ -0,0 +1,90 @@
import { XMLParser } from "fast-xml-parser";
import type { FastifyInstance } from "fastify";
import Papa from "papaparse";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
const TEN_MIB = 10 * 1024 * 1024;
/**
* Walk the parsed XML tree depth-first and return the first value that
* is an array of objects (the "repeating elements" suitable for tabulation).
*/
function findFirstArray(node: unknown): Record<string, unknown>[] | null {
if (Array.isArray(node)) {
if (node.length > 0 && typeof node[0] === "object" && node[0] !== null) {
return node as Record<string, unknown>[];
}
return null;
}
if (typeof node === "object" && node !== null) {
for (const val of Object.values(node)) {
const found = findFirstArray(val);
if (found) return found;
}
}
return null;
}
/**
* Flatten one level: nested objects and arrays become JSON strings in the cell.
*/
function flattenRow(row: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(row)) {
if (val !== null && typeof val === "object") {
out[key] = JSON.stringify(val);
} else {
out[key] = val;
}
}
return out;
}
export function registerXmlToCsv(app: FastifyInstance) {
createToolRoute(app, {
toolId: "xml-to-csv",
settingsSchema,
process: async () => {
throw new Error("xml-to-csv is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
if (input.buffer.length > TEN_MIB) {
throw new InputValidationError("File too large for conversion (10 MB limit)");
}
const text = input.buffer.toString("utf8");
// Mirror json-xml.ts parser options
const parser = new XMLParser({ ignoreAttributes: false });
let parsed: unknown;
try {
parsed = parser.parse(text);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new InputValidationError(`XML parse failed: ${msg.split("\n")[0]}`);
}
const rows = findFirstArray(parsed);
if (!rows || rows.length === 0) {
throw new InputValidationError("No repeating elements found to tabulate");
}
const flattened = rows.map(flattenRow);
const csv = Papa.unparse(flattened);
return {
buffer: Buffer.from(csv, "utf8"),
filename: `${base}.csv`,
contentType: "text/csv",
resultPayload: { rows: flattened.length },
};
},
});
}
+62
View File
@@ -0,0 +1,62 @@
import type { FastifyInstance } from "fastify";
import jsYaml from "js-yaml";
import { z } from "zod";
import { InputValidationError } from "../../modality/contract.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({});
const TEN_MIB = 10 * 1024 * 1024;
export function registerYamlJson(app: FastifyInstance) {
createToolRoute(app, {
toolId: "yaml-json",
settingsSchema,
process: async () => {
throw new Error("yaml-json is v2-only");
},
processV2: async (ctx) => {
const input = ctx.inputs[0];
const base = input.filename.replace(/\.[^.]+$/, "");
const lower = input.filename.toLowerCase();
if (input.buffer.length > TEN_MIB) {
throw new InputValidationError("File too large for conversion (10 MB limit)");
}
const text = input.buffer.toString("utf8");
if (lower.endsWith(".json")) {
// JSON -> YAML
let data: unknown;
try {
data = JSON.parse(text);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new InputValidationError(`Not valid JSON: ${msg.split("\n")[0]}`);
}
const yaml = jsYaml.dump(data);
return {
buffer: Buffer.from(yaml, "utf8"),
filename: `${base}.yaml`,
contentType: "text/yaml",
};
}
// YAML -> JSON (handles .yaml and .yml)
let parsed: unknown;
try {
parsed = jsYaml.load(text);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new InputValidationError(`Not valid YAML: ${msg.split("\n")[0]}`);
}
const json = JSON.stringify(parsed, null, 2);
return {
buffer: Buffer.from(json, "utf8"),
filename: `${base}.json`,
contentType: "application/json",
};
},
});
}