document to markdown

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-18 11:40:47 +08:00
parent 941590f51a
commit b241220c15
8 changed files with 343 additions and 11 deletions
+94
View File
@@ -9,6 +9,7 @@ const s3 = require("../../services/s3.service");
const mediaToken = require("../../services/mediaToken.service");
const uploadProgress = require("../../services/uploadProgress.service");
const { extractVideoMeta } = require("../../services/ffprobe.service");
const documentConversion = require("../../services/documentConversion.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
@@ -60,6 +61,15 @@ function resolveChecksum(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
}
function resolveResolution(width, height) {
if (!width || !height) return null;
const h = Math.min(width, height);
@@ -524,6 +534,12 @@ exports.streamUploadProgress = (req, res) => {
uploadProgress.subscribe(req.params.uploadId, res);
};
// Same broadcaster, reused as-is for document-conversion job progress — it's
// just a generic string-keyed SSE channel, nothing upload-specific about it.
exports.streamConvertProgress = (req, res) => {
uploadProgress.subscribe(req.params.jobId, res);
};
// ─── UPLOAD (batch) ─────────────────────────────────────────────────────────
//
// Accepts multiple files under the "files" field in one multipart request,
@@ -573,6 +589,84 @@ exports.uploadAssetsBatch = async (req, res) => {
return R.success(res, `${createdCount} of ${files.length} asset(s) uploaded.`, { results }, 201);
};
// ─── CONVERT TO MARKDOWN ────────────────────────────────────────────────────
//
// PDF/PPTX -> Markdown, text only (see services/documentConversion.service.js
// for the compile/validate/automate stages and why OCR/images are out of
// scope). Nothing here is written to the database — the caller (the Lesson
// block builder) treats the response as a draft and only persists it if/when
// the admin explicitly inserts it into a block and saves the lesson page.
//
// Mirrors the upload flow's SSE progress pattern exactly: the actual work
// runs synchronously inside this request (uploadProgress.service.js is
// reused as-is, keyed by a client-generated jobId) while stage transitions
// are published for a live "Compiling / Validating / Generating" UI, same as
// AddAsset.jsx already renders for uploads.
//
const CONVERTIBLE_EXTENSIONS = new Set(["pdf", "pptx"]);
const MAX_CONVERT_SIZE_BYTES = 25 * 1024 * 1024; // 25MB — keeps this comfortably synchronous
// compile() calls the MarkItDown sidecar over HTTP now instead of running
// officeparser in-process, so this leaves a bit more room than the original
// 45s for network/queueing overhead — MarkItDown's own parsing is plain
// CPU-bound work, not ML inference, so it doesn't need much more than that.
const CONVERT_TIMEOUT_MS = 60_000;
exports.convertAssetToMarkdown = async (req, res) => {
const { jobId } = req.body;
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404);
const extension = (asset.extension || "").toLowerCase();
if (!CONVERTIBLE_EXTENSIONS.has(extension)) {
return R.error(res, "Only PDF and PPTX documents can be converted to Markdown.", 400);
}
if (asset.storage_provider !== "s3" || !asset.storage_key) {
return R.error(res, "This asset has no stored file to convert.", 400);
}
if (Number(asset.file_size) > MAX_CONVERT_SIZE_BYTES) {
return R.error(res, "File is too large to convert (25MB max).", 400);
}
const publish = (phase) => { if (jobId) uploadProgress.publish(jobId, { phase }); };
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), CONVERT_TIMEOUT_MS);
let markdown, warnings, stats;
try {
publish("compiling");
const { stream } = await s3.getObjectStream(asset.storage_key);
const buffer = await streamToBuffer(stream);
const ast = await documentConversion.compile(buffer, extension, { signal: abortController.signal });
publish("validating");
const validated = documentConversion.validate(ast);
publish("generating");
const generated = await documentConversion.automate(ast, { signal: abortController.signal });
markdown = generated.markdown;
warnings = [...validated.warnings, ...generated.messages];
stats = validated.stats;
} finally {
clearTimeout(timeout);
}
if (jobId) uploadProgress.complete(jobId, { phase: "done" });
return R.success(res, "Document converted.", { markdown, warnings, stats });
} catch (err) {
if (jobId) uploadProgress.complete(jobId, { phase: "error", message: err.message });
console.error("[ASSET][CONVERT TO MARKDOWN]", err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, "Internal server error.", 500);
}
};
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => {