// ==UserScript==
// @name 广谈大湾网 · 本地文章目录导入
// @namespace local.dww11.article-importer
// @version 1.0.1
// @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.1';
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, brackets = 1;
for (; end < text.length; end++) {
if (text[end] === '\\') { end++; continue; }
if (text[end] === '[') brackets++;
if (text[end] === ']' && --brackets === 0) 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++) {
let 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; }
// A linked image/caption may span multiple Markdown lines. Keep the complete
// construct together so its inner image is visited and uploaded exactly once.
const remainder = lines.slice(index).join('\n');
for (let pos = 0; pos < line.length; pos++) {
const link = readLink(remainder, pos);
if (!link) continue;
if (link.end > line.length) {
const endLine = remainder.slice(0, link.end).split('\n').length - 1;
line = lines.slice(index, index + endLine + 1).join('\n');
}
pos = link.end - 1;
}
index += line.split('\n').length - 1;
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 attachmentId(image) {
// The live Discuz editor uses aid="attachimg_123", not necessarily an id.
for (const value of [image.getAttribute('aid'), image.id]) {
const match = String(value || '').match(/^attachimg_([1-9]\d*)$/);
if (match) return match[1];
}
return null;
}
async function verifyEditorImages(body, expected, signal) {
signal?.throwIfAborted();
if (!body?.querySelectorAll) throw new Error('无法读取可视化编辑器图片');
const images = Array.from(body.querySelectorAll('img')).filter(image => attachmentId(image));
if (JSON.stringify(images.map(attachmentId)) !== JSON.stringify(expected.map(String))) {
throw new Error('正文配图数量或顺序不符,已停止;上传成功不代表正文插图成功。');
}
await Promise.all(images.map(image => new Promise((resolve, reject) => {
let timer;
const finish = error => {
clearTimeout(timer);
image.removeEventListener('load', onLoad);
image.removeEventListener('error', onError);
signal?.removeEventListener('abort', onAbort);
error ? reject(error) : resolve();
};
const onLoad = () => image.naturalWidth > 0 ? finish() : onError();
const onError = () => finish(new Error('网站附件图片无法显示(编号 ' + attachmentId(image) + ')。请核对附件缩略图及图片请求的 HTTP 错误;不会自动重传或发布。'));
const onAbort = () => finish(new Error('流程已终止'));
image.addEventListener('load', onLoad);
image.addEventListener('error', onError);
signal?.addEventListener('abort', onAbort, { once: true });
timer = setTimeout(() => finish(new Error('等待正文图片加载超时(编号 ' + attachmentId(image) + ')')), 25000);
if (signal?.aborted) onAbort();
else if (image.complete) onLoad();
})));
}
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 = `本地文章导入 · 1.0
`;
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, signal) {
signal?.throwIfAborted();
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 {
signal?.throwIfAborted();
signal?.addEventListener('abort', () => control.abort(), { once: true });
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, signal) {
signal?.throwIfAborted();
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);
signal?.addEventListener('abort', () => { clearTimeout(timer); observer.disconnect(); reject(new Error('流程已终止')); }, { once: true });
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(options = {}) {
const { signal, automatic = false } = options;
signal?.throwIfAborted();
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() || /
{ throw new Error('编辑器已有内容,请人工处理旧稿后重试'); })() : !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, signal));
}
notify('register', '正在加载网站图片列表…');
await registerImages(Array.from(new Set(aids.values())), signal);
signal?.throwIfAborted();
const now = snapshot();
if ((now.title !== before.title || now.text !== before.text) && (automatic ? (() => { throw new Error('上传期间编辑器被修改,已保留当前内容'); })() : !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 代码”,再重试。');
}
signal?.throwIfAborted();
setEditor(selected.title, bbcode);
const raw = win.getEditorContents();
if (Number(win.wysiwyg) === 1) {
const expected = Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]);
await verifyEditorImages(doc.querySelector('#e_iframe')?.contentDocument?.body || win.editdoc?.body, expected, signal);
} 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),
attachmentOrder: Array.from(bbcode.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g), match => match[1]),
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,
attachmentId, verifyEditorImages,
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, attachmentId, verifyEditorImages, mount };
});