Files
doc-refactor/dww11-article-importer.user.js
T
2026-10-31 14:47:15 +08:00

472 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name 广谈大湾网 · 本地文章目录导入
// @namespace local.dww11.article-importer
// @version 1.0.0
// @description 选择整个文章目录,导入 Markdown 标题、正文与本地配图;保留人工发布与程序接口。
// @match https://www.dww11.com/forum.php*
// @match https://dww11.com/forum.php*
// @run-at document-idle
// @grant none
// @noframes
// @license MIT
// ==/UserScript==
(function (root, factory) {
'use strict';
const core = factory();
if (typeof module === 'object' && module.exports) module.exports = core;
else core.mount(root);
})(typeof window === 'undefined' ? null : window, function () {
'use strict';
const VERSION = '1.0.0';
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
const IMAGE_EXT = /\.(?:jpe?g|png|gif)$/i;
function normalizePath(path) {
const parts = [];
const value = String(path).replace(/\\/g, '/').normalize('NFC');
if (/^(?:\/|[a-z]:|[a-z]+:)/i.test(value)) throw new Error('请使用目录内的相对路径:' + path);
for (const part of value.split('/')) {
if (!part || part === '.') continue;
if (part === '..') {
if (!parts.length) throw new Error('图片路径超出所选目录:' + path);
parts.pop();
} else parts.push(part);
}
return parts.join('/');
}
function imagePath(mdPath, target) {
if (/^(?:[a-z][\w+.-]*:|\/\/|\/)/i.test(target)) throw new Error('图片必须在所选目录内,请先下载:' + target);
let decoded;
try { decoded = decodeURIComponent(target); } catch { throw new Error('图片路径编码无效:' + target); }
return normalizePath(mdPath.slice(0, mdPath.lastIndexOf('/') + 1) + decoded);
}
// A small, intentionally bounded Markdown reader. Raw HTML is never executed.
// Links/images support balanced parentheses, angle destinations and optional titles.
function readLink(text, start) {
const image = text.startsWith('![', start);
if (!image && text[start] !== '[') return null;
const left = start + (image ? 2 : 1);
let end = left;
for (; end < text.length; end++) {
if (text[end] === '\\') { end++; continue; }
if (text[end] === ']') break;
}
if (text.slice(end, end + 2) !== '](') return null;
let pos = end + 2, depth = 1, quoted = '', angle = false;
for (; pos < text.length; pos++) {
const ch = text[pos];
if (ch === '\\') { pos++; continue; }
if (quoted) { if (ch === quoted) quoted = ''; continue; }
if (!angle && depth === 1 && /\s/.test(text[pos - 1]) && (ch === '"' || ch === "'")) { quoted = ch; continue; }
if (ch === '<') { angle = true; continue; }
if (ch === '>') { angle = false; continue; }
if (!angle && ch === '(') depth++;
if (!angle && ch === ')' && --depth === 0) break;
}
if (depth) return null;
let target = text.slice(end + 2, pos).trim();
const destination = target.match(/^<([^>]+)>\s*(?:["'][\s\S]*["'])?$/);
target = destination ? destination[1] : target.replace(/\s+["'][\s\S]*["']\s*$/, '');
target = target.replace(/\\([()\\ ])/g, '$1');
return { image, label: text.slice(left, end), target, end: pos + 1 };
}
function inline(text, onImage) {
let out = '';
for (let pos = 0; pos < text.length;) {
if (text[pos] === '\\' && pos + 1 < text.length) { out += text[pos + 1]; pos += 2; continue; }
const link = readLink(text, pos);
if (link) {
if (link.image) out += onImage(link.target, link.label);
else if (/^(https?:\/\/|mailto:)/i.test(link.target) && !/[\[\]\r\n"']/.test(link.target)) {
out += '[url=' + link.target + ']' + inline(link.label, onImage) + '[/url]';
} else out += link.label + ' (' + link.target + ')';
pos = link.end;
continue;
}
if (text[pos] === '`') {
const end = text.indexOf('`', pos + 1);
if (end > pos + 1) { out += text.slice(pos + 1, end); pos = end + 1; continue; }
}
let matched = false;
for (const [mark, tag] of [['**', 'b'], ['__', 'b'], ['~~', 's'], ['*', 'i'], ['_', 'i']]) {
if (!text.startsWith(mark, pos) || (mark === '_' && /\w/.test(text[pos - 1] || ''))) continue;
const end = text.indexOf(mark, pos + mark.length);
if (end <= pos + mark.length) continue;
out += '[' + tag + ']' + inline(text.slice(pos + mark.length, end), onImage) + '[/' + tag + ']';
pos = end + mark.length; matched = true; break;
}
if (!matched) out += text[pos++];
}
return out;
}
function markdownToBBCode(markdown, onImage) {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
const output = [];
let list = '', quote = false, code = false, fence = '';
const closeList = () => { if (list) output.push('[/list]'); list = ''; };
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const codeMark = line.match(/^\s*(`{3,}|~{3,})/);
if (codeMark && (!code || codeMark[1][0] === fence)) {
closeList();
output.push(code ? '[/code]' : '[code]');
code = !code; fence = codeMark[1][0]; continue;
}
if (code) { output.push(line.replace(/\[\/code\]/gi, '[/code]')); continue; }
const quoted = /^\s*>\s?/.test(line);
if (quoted !== quote) { closeList(); output.push(quoted ? '[quote]' : '[/quote]'); quote = quoted; }
const content = quoted ? line.replace(/^\s*>\s?/, '') : line;
const item = content.match(/^\s*(?:([-+*])|\d+[.)])\s+(.+)$/);
if (item) {
const type = item[1] ? '[list]' : '[list=1]';
if (list !== type) { closeList(); output.push(type); list = type; }
output.push('[*]' + inline(item[2], onImage)); continue;
}
closeList();
const heading = content.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*$/);
if (heading) output.push('[b]' + inline(heading[2], onImage) + '[/b]');
else if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(content)) output.push('[hr]');
else output.push(inline(content, onImage));
}
closeList();
if (quote) output.push('[/quote]');
if (code) output.push('[/code]');
return output.join('\n').trim();
}
function compile(markdown, mdPath, fileMap, titleOverride) {
let body = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const firstHeading = body.match(/^\s*#\s+([^\n]+)\n?/);
const title = String(titleOverride || firstHeading?.[1] || mdPath.split('/').pop().replace(/\.md$/i, '')).trim();
if (firstHeading) body = body.slice(firstHeading[0].length).trim();
if (!title || Array.from(title).length > 80) throw new Error('标题为空或超过 80 字,请修改 Markdown 标题后重试。');
if (!body.trim()) throw new Error('正文为空。');
const warnings = [];
if (/<\/?[a-z][^>]*>/i.test(body)) warnings.push('存在 HTML,将作为文字保留;请检查排版。');
if (/^\s*\|.*\|\s*$/m.test(body)) warnings.push('表格暂按原文保留,需手动检查。');
if (/!\[[^\]]*\]\s*\[[^\]]*\]/.test(body) || /^\s*\[[^\]]+\]:/m.test(body)) {
throw new Error('暂不支持引用式 Markdown 图片/链接,请改成 ![](图片路径) 或 [文字](网址)。');
}
const images = new Map();
// Validate every image before any network request; repeated references upload once.
markdownToBBCode(body, (target) => {
const path = imagePath(mdPath, target);
const file = fileMap.get(path);
if (!file) throw new Error('找不到配图:' + path);
if (!IMAGE_EXT.test(path)) throw new Error('网站仅支持 JPG、JPEG、PNG、GIF:' + path);
if (!file.size || file.size >= MAX_IMAGE_SIZE) throw new Error('配图为空或达到 20 MB:' + path);
images.set(path, file);
return '[图片:' + path + ']';
});
return { title, body, mdPath, images, warnings,
render(aids) {
return markdownToBBCode(body, target => {
const path = imagePath(mdPath, target);
const aid = aids.get(path);
if (!/^[1-9]\d*$/.test(String(aid))) throw new Error('配图尚未上传:' + path);
return '[attachimg]' + aid + '[/attachimg]';
});
}
};
}
async function prepareFiles(files, markdownPath) {
const fileMap = new Map();
for (const file of Array.from(files)) {
const path = normalizePath(file.webkitRelativePath || file.name);
if (fileMap.has(path)) throw new Error('文件路径重复:' + path);
fileMap.set(path, file);
}
const candidates = Array.from(fileMap.keys()).filter(p => /\.md$/i.test(p)).sort();
if (!candidates.length) throw new Error('所选目录中没有 .md 文件。');
if (!markdownPath && candidates.length > 1) return { candidates, files: Array.from(files) };
const mdPath = markdownPath || candidates[0];
const md = fileMap.get(mdPath);
if (!md) throw new Error('指定的 Markdown 文件不存在。');
return compile(await md.text(), mdPath, fileMap);
}
async function preparePackage(data) {
if (!data || data.schemaVersion !== 1 || typeof data.markdown !== 'string' || !Array.isArray(data.images)) {
throw new Error('文章包格式错误,需要 schemaVersion: 1、markdown 和 images。');
}
const map = new Map();
for (const item of data.images) {
const path = normalizePath(item.path);
if (map.has(path)) throw new Error('文章包图片路径重复:' + path);
let blob = item.blob;
if (!blob && typeof item.base64 === 'string') {
const binary = atob(item.base64);
blob = new Blob([Uint8Array.from(binary, ch => ch.charCodeAt(0))], { type: item.mime || 'application/octet-stream' });
}
if (!blob || typeof blob.arrayBuffer !== 'function') throw new Error('文章包缺少图片数据:' + path);
map.set(path, blob);
}
return compile(data.markdown, normalizePath(data.markdownPath || 'article.md'), map, data.title);
}
function parseAid(text) {
const value = String(text).trim();
// The site's actual WebUploader adapter uses a positive integer response.
if (!/^(?:[1-9]\d*|"[1-9]\d*")$/.test(value)) throw new Error('服务器未返回有效附件编号(可能登录过期、超出权限或上传失败)。');
return value.replace(/"/g, '');
}
function mount(win) {
const doc = win.document;
const params = new URLSearchParams(win.location.search);
if (params.get('mod') !== 'post' || params.get('action') !== 'newthread' || !doc.querySelector('#subject') || !doc.querySelector('#e_textarea')) return;
if (win.DWW11Importer) return;
let article = null, busy = false, candidates = null, lastResult = null, savedBackup = null, stageRevision = 0;
const cache = new Map(), urls = [], logs = [];
const host = doc.createElement('div');
host.id = 'dww11-importer';
host.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:2147483000';
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>
:host{all:initial}*{box-sizing:border-box}details{font:14px/1.6 system-ui,sans-serif;color:#203044;background:#fff;border:1px solid #d4dee8;border-radius:12px;box-shadow:0 6px 28px #0003;width:360px;max-width:92vw}summary{cursor:pointer;padding:12px 16px;font-weight:650}section{padding:0 16px 16px;max-height:70vh;overflow:auto}button,select{font:inherit;padding:7px 10px;border-radius:6px;border:1px solid #b8c9da;background:#f4f8fc;color:#203044;cursor:pointer;margin:4px 4px 4px 0}button.primary{background:#1767b3;color:white;border-color:#1767b3}button:disabled{opacity:.5;cursor:default}p{margin:8px 0}small{color:#617183}pre{font:12px/1.6 system-ui;white-space:pre-wrap;overflow-wrap:anywhere;max-height:170px;overflow:auto;background:#f4f6f9;padding:8px}img{max-width:90px;max-height:80px;margin:4px;object-fit:contain}.error{color:#b32424}.success{color:#18713e}select{max-width:100%}[hidden]{display:none!important}
</style><details open><summary>本地文章导入 · 1.0</summary><section>
<small>选择整个文章文件夹,检查后上传配图并填写。发布由你操作。网站自带的自动保存仍可能运行。</small>
<p><button id="choose" type="button">选择文章目录</button><button id="package" type="button">导入文章包</button></p>
<input id="folder" type="file" webkitdirectory multiple hidden><input id="json" type="file" accept=".json,application/json" hidden>
<select id="md" hidden aria-label="选择 Markdown"></select>
<p id="status" role="status" aria-live="polite">尚未选择目录</p><div id="preview" hidden><strong id="title"></strong><pre id="body"></pre><div id="images"></div><p id="warnings"></p></div>
<button id="import" class="primary" type="button" disabled>上传配图并填写</button>
<button id="diagnostics" type="button">导出诊断</button><pre id="log" hidden></pre>
</section></details>`;
doc.body.appendChild(host);
const $ = id => shadow.getElementById(id);
const summary = () => ({ version: VERSION, busy, ready: !!article, title: article?.title || null,
markdownPath: article?.mdPath || null, imageCount: article?.images.size || 0,
warnings: article?.warnings || [], result: lastResult });
function notify(phase, message, extra = {}) {
$('status').textContent = message;
$('status').className = phase === 'error' ? 'error' : phase === 'complete' ? 'success' : '';
logs.push({ time: new Date().toISOString(), phase, message, ...extra });
$('log').hidden = false;
$('log').textContent = logs.slice(-8).map(l => l.message).join('\n');
win.dispatchEvent(new CustomEvent('dww11-importer:progress', { detail: { phase, message, ...extra } }));
}
function releasePreviews() { while (urls.length) URL.revokeObjectURL(urls.pop()); }
function clearStage() {
article = null; lastResult = null; candidates = null; releasePreviews();
$('preview').hidden = true; $('md').hidden = true; $('import').disabled = true;
}
function showArticle(prepared) {
article = prepared; lastResult = null; releasePreviews();
$('preview').hidden = false;
$('title').textContent = article.title;
$('body').textContent = article.body;
$('warnings').textContent = article.warnings.join(';');
$('images').replaceChildren();
for (const [path, blob] of article.images) {
const img = doc.createElement('img');
img.src = URL.createObjectURL(blob); urls.push(img.src); img.alt = path; img.title = path;
$('images').appendChild(img);
}
$('import').disabled = false;
notify('ready', `已检查:${article.images.size} 张图片。点击“上传配图并填写”开始导入。`);
return summary();
}
async function stageFiles(files, options = {}) {
if (busy) throw new Error('正在导入,请稍候。');
clearStage();
const revision = ++stageRevision;
const list = Array.from(files);
const prepared = await prepareFiles(list, options.markdownPath);
if (revision !== stageRevision) return { cancelled: true };
if (prepared.candidates) {
candidates = prepared.files;
$('md').replaceChildren(new Option('请选择目录中的 Markdown 文件', ''));
for (const path of prepared.candidates) $('md').appendChild(new Option(path, path));
$('md').hidden = false;
notify('choose-markdown', '目录中有多个 Markdown 文件,请选择一篇。');
return { candidates: prepared.candidates };
}
return showArticle(prepared);
}
async function stagePackage(data) {
if (busy) throw new Error('正在导入,请稍候。');
clearStage();
const revision = ++stageRevision;
const prepared = await preparePackage(data);
if (revision !== stageRevision) return { cancelled: true };
return showArticle(prepared);
}
const snapshot = () => ({ title: doc.querySelector('#subject').value,
mode: Number(win.wysiwyg), text: win.getEditorContents() });
function assertEditor() {
for (const name of ['getEditorContents', 'switchEditor']) {
if (typeof win[name] !== 'function') throw new Error('未识别到网站编辑器:' + name + '。请刷新页面后重试。');
}
if (!win.imgUpload?.settings || typeof win.updateImageList !== 'function') throw new Error('网站图片上传组件尚未就绪,请刷新后重试。');
if (Number(win.allowbbcode) === 0 || Number(win.allowimgcode) === 0) throw new Error('当前版块或账号不支持正文格式/图片。');
}
async function uploadImage(blob, path, settings) {
const url = new URL(settings.upload_url, win.location.href);
if (url.origin !== win.location.origin || url.pathname !== '/misc.php' || url.searchParams.get('operation') !== 'upload') {
throw new Error('上传地址与预期不符,已停止。');
}
const hash = Array.from(new Uint8Array(await win.crypto.subtle.digest('SHA-256', await blob.arrayBuffer())), n => n.toString(16).padStart(2, '0')).join('');
const key = url.href + ':' + settings.post_params.uid + ':' + hash;
if (cache.has(key)) { notify('upload', '复用已上传图片:' + path); return cache.get(key); }
const data = new FormData();
for (const [key, value] of Object.entries(settings.post_params)) data.append(key, String(value));
data.set('type', 'image');
data.set('filetype', blob.type || (/\.png$/i.test(path) ? 'image/png' : /\.gif$/i.test(path) ? 'image/gif' : 'image/jpeg'));
data.append(settings.file_post_name || 'Filedata', blob, path.split('/').pop());
const control = new AbortController(), timer = setTimeout(() => control.abort(), 90000);
try {
const response = await win.fetch(url.href, { method: 'POST', body: data, credentials: 'same-origin', redirect: 'error', signal: control.signal });
if (!response.ok) throw new Error('图片上传请求失败:HTTP ' + response.status);
const aid = parseAid(await response.text());
cache.set(key, aid);
return aid;
} catch (error) {
if (error.name === 'AbortError') throw new Error('上传超时;该图片可能已到达服务器,请检查网站图片列表后再重试。');
throw error;
} finally { clearTimeout(timer); }
}
async function registerImages(aids) {
if (!aids.length) return;
const ids = new Set(aids);
doc.querySelectorAll('#imgattachlist [id^="image_td_"],#imgattachlist [id^="image_"]').forEach(el => {
const id = el.id.match(/^(?:image_td_|image_)(\d+)$/)?.[1]; if (id) ids.add(id);
});
const ready = () => aids.every(id => doc.getElementById('image_' + id));
if (ready()) return;
// Use the site's own list loader, which registers attachment form fields too.
win.updateImageList(1, Array.from(ids).join('|'));
await new Promise((resolve, reject) => {
const timer = setTimeout(() => { observer.disconnect(); reject(new Error('图片已上传,但网站图片列表加载超时。重试会复用本页已上传图片。')); }, 25000);
const check = () => { if (ready()) { clearTimeout(timer); observer.disconnect(); resolve(); } };
const observer = new MutationObserver(check);
observer.observe(doc.body, { childList: true, subtree: true }); check();
});
}
function setEditor(title, bbcode) {
win.switchEditor(0);
if (Number(win.wysiwyg) !== 0) throw new Error('无法切换编辑器为纯文本模式。');
const textarea = doc.querySelector('#e_textarea');
textarea.value = bbcode;
doc.querySelector('#subject').value = title;
for (const el of [textarea, doc.querySelector('#subject')]) {
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
}
// Let Discuz generate the WYSIWYG content with native attachment metadata.
win.switchEditor(1);
const switcher = doc.querySelector('#e_switchercheck');
if (switcher) switcher.checked = Number(win.wysiwyg) === 0;
}
async function importPrepared() {
if (busy) throw new Error('正在导入,请勿重复点击。');
if (!article) throw new Error('请先选择文章目录或文章包。');
assertEditor();
for (const id of ['bbcodeoff', 'htmlon']) {
if (doc.getElementById(id)?.checked) throw new Error('请先关闭“禁用 Discuz! 代码”和“HTML 代码”,再导入。');
}
const before = snapshot();
if ((before.title.trim() || before.text.replace(/<[^>]*>/g, '').trim() || /<img\b/i.test(before.text)) &&
!win.confirm('当前编辑器已有内容。导入将替换标题和正文,是否继续?')) return { cancelled: true };
const selected = article;
busy = true;
const controls = ['choose', 'package', 'import', 'md'].map($);
controls.forEach(el => { el.disabled = true; });
// Prevent accidental submission of a partly imported article.
const blockSubmit = event => { event.preventDefault(); event.stopImmediatePropagation(); notify('busy', '正在导入,请等待完成后再操作发布。'); };
const form = doc.querySelector('#postform');
form.addEventListener('submit', blockSubmit, true);
const submitButtons = Array.from(form.querySelectorAll('button[type="submit"],button[name],input[type="submit"]'));
const previousDisabled = submitButtons.map(el => el.disabled);
submitButtons.forEach(el => { el.disabled = true; });
let committed = false;
try {
const aids = new Map();
const settings = win.imgUpload.settings;
const max = Math.min(MAX_IMAGE_SIZE, Number(settings.file_size_limit) * 1024 || MAX_IMAGE_SIZE);
for (const [path, blob] of selected.images) if (blob.size >= max) throw new Error('配图超过当前网站限制:' + path);
let index = 0;
for (const [path, blob] of selected.images) {
notify('upload', `上传 ${++index}/${selected.images.size}:${path}`, { completed: index - 1, total: selected.images.size });
aids.set(path, await uploadImage(blob, path, settings));
}
notify('register', '正在加载网站图片列表…');
await registerImages(Array.from(new Set(aids.values())));
const now = snapshot();
if ((now.title !== before.title || now.text !== before.text) && !win.confirm('上传期间你修改了编辑器。是否用导入文章替换最新内容?')) {
notify('cancelled', '已保留当前正文;图片已上传,可稍后再次导入。'); return { cancelled: true };
}
const backup = snapshot();
const bbcode = selected.render(aids);
// Preserve a local, downloadable snapshot before changing title or body.
savedBackup = { title: backup.title, content: backup.text, mode: backup.mode, createdAt: new Date().toISOString() };
try {
for (const id of ['bbcodeoff', 'htmlon']) {
const el = doc.getElementById(id);
if (el && id === 'bbcodeoff' && el.checked) throw new Error('请先取消“禁用 Discuz! 代码”,再重试。');
if (el && id === 'htmlon' && el.checked) throw new Error('请先关闭“HTML 代码”,再重试。');
}
setEditor(selected.title, bbcode);
const raw = win.getEditorContents();
if (Number(win.wysiwyg) === 1) {
for (const id of new Set(aids.values())) if (!raw.includes('attachimg_' + id)) throw new Error('富文本图片转换未通过检查。');
} else if (raw !== bbcode) throw new Error('正文填写校验失败。');
if (doc.querySelector('#subject').value !== selected.title) throw new Error('标题填写校验失败。');
committed = true;
} catch (error) {
// Restore the exact editor representation saved immediately before filling.
win.switchEditor(backup.mode);
if (backup.mode) win.editdoc.body.innerHTML = backup.text;
else doc.querySelector('#e_textarea').value = backup.text;
doc.querySelector('#subject').value = backup.title;
const switcher = doc.querySelector('#e_switchercheck'); if (switcher) switcher.checked = !backup.mode;
throw error;
}
lastResult = { title: selected.title, images: Object.fromEntries(aids), completedAt: new Date().toISOString() };
notify('complete', `已填写标题、正文和 ${aids.size} 张配图。请检查排版,随后手动发表。`);
return lastResult;
} catch (error) {
notify('error', error.message + (committed ? '' : ' 标题和正文未替换;已上传的图片会保留。'));
throw error;
} finally {
busy = false; form.removeEventListener('submit', blockSubmit, true);
submitButtons.forEach((el, index) => { el.disabled = previousDisabled[index]; });
controls.forEach(el => { el.disabled = false; });
$('import').disabled = !article;
}
}
function downloadJSON(value, filename) {
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
const link = doc.createElement('a'); link.href = url; link.download = filename; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Public v1 interface. Staging is read-only; importPrepared is the explicit upload/fill step.
// No postMessage listener, localhost service, publishing endpoint or saved credentials.
const api = Object.freeze({ version: VERSION, schemaVersion: 1, stageFiles, stagePackage,
getState: summary, openPicker: () => { if (!busy) $('folder').click(); }, importPrepared,
getBackup: () => savedBackup });
win.DWW11Importer = api;
$('choose').onclick = () => { $('folder').value = ''; $('folder').click(); };
$('package').onclick = () => { $('json').value = ''; $('json').click(); };
const handleError = error => notify('error', error.message || String(error));
$('folder').onchange = () => stageFiles($('folder').files).catch(handleError);
$('json').onchange = async () => {
try { const file = $('json').files[0]; if (file) await stagePackage(JSON.parse(await file.text())); }
catch (error) { handleError(error); }
};
$('md').onchange = () => {
const files = candidates, path = $('md').value;
if (files && path) stageFiles(files, { markdownPath: path }).catch(handleError);
};
$('import').onclick = () => importPrepared().catch(handleError);
$('diagnostics').onclick = () => downloadJSON({ ...summary(), logs }, 'dww11-import-diagnostics.json');
win.addEventListener('pagehide', releasePreviews, { once: true });
}
return { VERSION, normalizePath, imagePath, readLink, markdownToBBCode, compile, prepareFiles, preparePackage, parseAid, mount };
});