#!/usr/bin/env node "use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const { request } = require("playwright"); const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const ROOT_PATH = process.env.MNOTE_WOLAI_PDF_SMOKE_ROOT || "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space"; const LIMIT = Number(process.env.MNOTE_WOLAI_PDF_SMOKE_LIMIT || 10); const USERNAME = process.env.MNOTE_WOLAI_PDF_SMOKE_USER || "mnote.e2e@example.com"; const PASSWORD = process.env.MNOTE_WOLAI_PDF_SMOKE_PASSWORD || "MnoteE2E123!"; const OUT_DIR = path.join(process.cwd(), "tmp", "task802-pdf-attachments-restore-smoke"); const RESULT_PATH = path.join(OUT_DIR, "result.json"); function fileUrl(localPath) { return `file://${localPath}`; } function encodeLocalIdSegment(value) { const bytes = Buffer.from(String(value || ""), "utf8"); let encoded = ""; for (const byte of bytes) { const character = String.fromCharCode(byte); if ( (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || character === "." || character === "_" || character === "-" ) { encoded += character; } else { encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`; } } return encoded; } function markdownDocumentId(relativePath) { return `local-md:${encodeLocalIdSegment(relativePath)}`; } function walkMarkdownFiles(dir, out = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === ".assets" || entry.name === ".git" || entry.name === "node_modules") { continue; } const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { walkMarkdownFiles(fullPath, out); } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) { out.push(fullPath); } } return out; } function rootRelativePath(filePath) { return path.relative(ROOT_PATH, filePath).split(path.sep).join("/"); } function normalizeHref(href) { return String(href || "") .trim() .replace(/^<|>$/g, "") .replace(/\\([\\`*_{}\[\]()#+\-.!|>])/g, "$1"); } function pdfHrefs(markdown) { const refs = []; const source = String(markdown || ""); const linkPattern = /\[[^\]]*?\.pdf[^\]]*?\]\(\s*(?:<([^>]+?\.pdf)>|([^)\s]+?\.pdf))(?:\s+["'][^"']*["'])?\s*\)/gi; for (const match of source.matchAll(linkPattern)) { const href = normalizeHref(match[1] || match[2] || ""); if (!href || /^https?:\/\//i.test(href) || href.startsWith("#") || href.startsWith("mailto:")) { continue; } refs.push(href); } return refs; } function collectExistingPdfAttachments() { const candidates = []; for (const markdownPath of walkMarkdownFiles(ROOT_PATH)) { const markdown = fs.readFileSync(markdownPath, "utf8"); const markdownRelativePath = rootRelativePath(markdownPath); for (const href of pdfHrefs(markdown)) { if (!href.includes(".assets/")) { continue; } const assetRelativePath = path.posix.normalize(path.posix.join( path.posix.dirname(markdownRelativePath), href, )); const assetPath = path.join(ROOT_PATH, ...assetRelativePath.split("/")); if (!fs.existsSync(assetPath)) { continue; } const bytes = fs.statSync(assetPath).size; if (bytes <= 0) { continue; } candidates.push({ markdownRelativePath, href, assetRelativePath, assetPath, bytes, }); if (candidates.length >= LIMIT) { return candidates; } } } return candidates; } async function signIn(api) { const response = await api.post("/api/auth", { headers: { "content-type": "application/json", accept: "application/json" }, data: { action: "auth:signIn", args: { provider: "password", params: { account: USERNAME, password: PASSWORD, flow: "signIn", }, }, }, }); assert.equal(response.status(), 200, `登录失败: ${response.status()} ${await response.text()}`); } function flattenBlockDocumentBlocks(blockDocument) { const direct = Array.isArray(blockDocument?.blocks) ? blockDocument.blocks : []; return direct; } function pageHasMediaSource(pageAggregate, href, assetRelativePath) { const content = Array.isArray(pageAggregate?.body?.content) ? pageAggregate.body.content : []; if (content.some((block) => block?.type === "media" && block?.props?.sourcePath === href)) { return true; } const blocks = flattenBlockDocumentBlocks(pageAggregate?.body?.blockDocument); return blocks.some((block) => ( block?.type === "media" && (block?.attrs?.sourcePath === href || block?.attrs?.sourcePath === assetRelativePath) )); } async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); const candidates = collectExistingPdfAttachments(); assert.ok(candidates.length >= Math.min(5, LIMIT), `可验证 PDF 附件不足: ${candidates.length}`); const api = await request.newContext({ baseURL: BASE_URL, extraHTTPHeaders: { accept: "application/json" }, }); try { await signIn(api); const checked = []; for (const candidate of candidates) { const documentId = markdownDocumentId(candidate.markdownRelativePath); const aggregateUrl = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, BASE_URL); aggregateUrl.searchParams.set("sourceKind", "local_folder"); aggregateUrl.searchParams.set("rootUri", fileUrl(ROOT_PATH)); const response = await api.get(`${aggregateUrl.pathname}${aggregateUrl.search}`); assert.equal( response.status(), 200, `page aggregate 失败 ${candidate.markdownRelativePath}: ${response.status()} ${await response.text()}`, ); const payload = await response.json(); const aggregate = payload.result; const attachmentRefs = Array.isArray(aggregate?.body?.attachmentRefs) ? aggregate.body.attachmentRefs : []; const ref = attachmentRefs.find((item) => ( item?.relativePath === candidate.assetRelativePath || item?.normalizedHref === candidate.href || item?.rawHref === candidate.href )); assert.ok(ref, `aggregate 缺少 PDF attachmentRef: ${candidate.assetRelativePath}`); assert.equal(String(ref.ext || "").toLowerCase(), "pdf", JSON.stringify(ref)); assert.equal(ref.exists, true, JSON.stringify(ref)); assert.equal(ref.authorized, true, JSON.stringify(ref)); assert.equal(ref.fileSize, candidate.bytes, JSON.stringify({ ref, candidate })); assert.ok( pageHasMediaSource(aggregate, candidate.href, candidate.assetRelativePath), `aggregate 未还原 PDF media block: ${candidate.assetRelativePath}`, ); checked.push({ markdownRelativePath: candidate.markdownRelativePath, assetRelativePath: candidate.assetRelativePath, bytes: candidate.bytes, label: ref.label, }); } const result = { ok: true, checked }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } finally { await api.dispose(); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });