mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 4 - office, ebooks, data, archives (14 tools) (#224)
This commit is contained in:
@@ -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],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user