const form = document.getElementById('export-form') const submitButton = document.getElementById('submit-button') const statusPanel = document.getElementById('status-panel') const statusTitle = document.getElementById('status-title') const statusEyebrow = document.getElementById('status-eyebrow') const statusBadge = document.getElementById('status-badge') const statusMessage = document.getElementById('status-message') const progressBar = document.querySelector('.progress') const progressFill = document.getElementById('progress-fill') const statusPages = document.getElementById('status-pages') const errorBox = document.getElementById('error-box') const errorMessage = document.getElementById('error-message') const resultBox = document.getElementById('result-box') const resultMeta = document.getElementById('result-meta') const downloadButton = document.getElementById('download-button') const warningsBox = document.getElementById('warnings') const warningsCount = document.getElementById('warnings-count') const warningsList = document.getElementById('warnings-list') const configNotice = document.getElementById('config-notice') const pageFilter = document.getElementById('page-filter') const startPageSelect = document.getElementById('start-page') const pageStatus = document.getElementById('page-status') const includeUnpublishedInput = document.getElementById('include-unpublished') const includeTocInput = document.getElementById('include-toc') const inlineImagesInput = document.getElementById('inline-images') const maxPagesInput = document.getElementById('max-pages') let pollTimer = null let networkFailures = 0 let activeJobId = null let serverConfigured = true let allPages = [] let wikiBaseUrl = '' let savedStartPage = null let savedLocale = null function setSubmitBusy(busy, label = 'Create PDF') { submitButton.disabled = busy || !serverConfigured submitButton.textContent = busy ? 'Working…' : label } function showConfigNotice(message) { configNotice.hidden = false configNotice.textContent = message } function stagePercent(job) { if (job.status === 'done') return 100 const found = Number(job.pagesFound) || 0 const done = Number(job.pagesDone) || 0 switch (job.stage) { case 'queued': return 2 case 'connect': return 6 case 'list': return 12 case 'select': return 20 case 'fetch': return found > 0 ? 24 + Math.round((Math.min(done, found) / found) * 62) : 24 case 'build': return 89 case 'render': return 95 case 'done': return 100 default: return job.status === 'running' ? 12 : 0 } } function renderJob(job) { statusPanel.hidden = false statusPanel.dataset.status = job.status activeJobId = job.id const titles = { queued: 'Export queued', running: 'Exporting…', done: 'PDF ready', error: 'Export failed', } statusTitle.textContent = titles[job.status] || 'Export' statusBadge.textContent = job.status === 'running' ? 'working' : job.status statusMessage.textContent = job.message || '' statusEyebrow.textContent = job.request?.startPage ? `Starting page · ${job.request.startPage}` : 'Export' const percent = stagePercent(job) progressFill.style.width = `${percent}%` progressBar.setAttribute('aria-valuenow', String(percent)) if (job.pagesFound > 0) { const warningText = job.warnings?.length ? ` · ${job.warnings.length} warning${job.warnings.length === 1 ? '' : 's'}` : '' statusPages.textContent = `Pages: ${job.pagesDone} / ${job.pagesFound}${warningText}` } else { statusPages.textContent = '' } if (job.status === 'error' && job.error) { errorBox.hidden = false errorMessage.textContent = job.error.code ? `${job.error.message} (${job.error.code})` : job.error.message } else { errorBox.hidden = true errorMessage.textContent = '' } if (job.status === 'done' && job.result) { resultBox.hidden = false const result = job.result resultMeta.textContent = `${result.pageCount} page${result.pageCount === 1 ? '' : 's'} · locale ${result.locale} · starting at ${result.startPath}` downloadButton.href = result.downloadUrl downloadButton.setAttribute('download', result.filename) } else { resultBox.hidden = true downloadButton.removeAttribute('href') } if (job.warnings?.length) { warningsBox.hidden = false warningsCount.textContent = String(job.warnings.length) warningsList.replaceChildren() for (const warning of job.warnings) { const item = document.createElement('li') item.textContent = warning warningsList.append(item) } } else { warningsBox.hidden = true warningsList.replaceChildren() } const finished = job.status === 'done' || job.status === 'error' setSubmitBusy(!finished) } function showRequestError(message) { statusPanel.hidden = false statusPanel.dataset.status = 'error' statusTitle.textContent = 'Export failed' statusBadge.textContent = 'error' statusMessage.textContent = message statusEyebrow.textContent = 'Export' progressFill.style.width = '0%' progressBar.setAttribute('aria-valuenow', '0') statusPages.textContent = '' resultBox.hidden = true errorBox.hidden = false errorMessage.textContent = message setSubmitBusy(false) } function stopPolling() { if (pollTimer) { clearTimeout(pollTimer) pollTimer = null } } async function pollJob(jobId) { stopPolling() const tick = async () => { try { const response = await fetch(`/api/exports/${encodeURIComponent(jobId)}`, { cache: 'no-store', }) if (response.status === 404) { showRequestError('The export job disappeared. It may have expired. Start a new export.') return } if (!response.ok) { let message = `The server answered with HTTP ${response.status}.` try { const payload = await response.json() if (payload.error) message = payload.error } catch { // Keep the status message. } throw new Error(message) } networkFailures = 0 const payload = await response.json() renderJob(payload.job) if (payload.job.status === 'done' || payload.job.status === 'error') { return } pollTimer = setTimeout(tick, 1000) } catch (error) { networkFailures += 1 if (networkFailures >= 10) { showRequestError(`Lost contact with the server: ${error.message}`) return } statusMessage.textContent = `Connection problem, retrying… (${error.message})` pollTimer = setTimeout(tick, 2000) } } tick() } function readSessionForm() { try { const saved = JSON.parse(sessionStorage.getItem('wikiPrintForm') || 'null') if (!saved) return if (saved.startPage) { savedStartPage = saved.startPage savedLocale = saved.locale || '' } if (saved.maxPages) maxPagesInput.value = saved.maxPages if (typeof saved.includeUnpublished === 'boolean') { includeUnpublishedInput.checked = saved.includeUnpublished } if (typeof saved.includeToc === 'boolean') { includeTocInput.checked = saved.includeToc } if (typeof saved.inlineImages === 'boolean') { inlineImagesInput.checked = saved.inlineImages } } catch { // Ignore malformed session storage. } } function saveSessionForm(payload) { try { sessionStorage.setItem( 'wikiPrintForm', JSON.stringify({ startPage: payload.startPage, locale: payload.locale, maxPages: payload.maxPages, includeUnpublished: payload.includeUnpublished, includeToc: payload.includeToc, inlineImages: payload.inlineImages, }), ) } catch { // Session storage is optional. } } function pageMatches(page, query) { if (!query) return true const haystack = `${page.title || ''} ${page.path} ${page.locale}`.toLowerCase() return haystack.includes(query) } function appendPageOption(parent, page) { const option = document.createElement('option') option.value = page.path option.dataset.locale = page.locale const title = String(page.title || '').trim() option.textContent = title && title !== page.path ? `${title} — ${page.path}` : page.path if (!page.isPublished) option.textContent += ' (unpublished)' parent.append(option) } function renderPageOptions() { const query = pageFilter.value.trim().toLowerCase() const includeUnpublished = includeUnpublishedInput.checked const previous = startPageSelect.selectedOptions[0] const previousPath = previous?.value || '' const previousLocale = previous?.dataset.locale || '' const filteredPages = allPages.filter( (page) => (includeUnpublished || page.isPublished) && pageMatches(page, query), ) startPageSelect.replaceChildren() const placeholder = document.createElement('option') placeholder.value = '' placeholder.textContent = filteredPages.length ? 'Select a starting page…' : 'No matching pages' placeholder.disabled = true placeholder.selected = true startPageSelect.append(placeholder) const locales = [...new Set(filteredPages.map((page) => page.locale))] if (locales.length > 1) { for (const locale of locales) { const group = document.createElement('optgroup') group.label = locale for (const page of filteredPages.filter((candidate) => candidate.locale === locale)) { appendPageOption(group, page) } startPageSelect.append(group) } } else { for (const page of filteredPages) appendPageOption(startPageSelect, page) } let restore = null if (savedStartPage) { restore = { path: savedStartPage, locale: savedLocale } savedStartPage = null savedLocale = null } else if (previousPath) { restore = { path: previousPath, locale: previousLocale } } if (restore) { const options = [...startPageSelect.options] const match = options.find( (option) => option.value === restore.path && (!restore.locale || option.dataset.locale === restore.locale), ) if (match) startPageSelect.selectedIndex = options.indexOf(match) } startPageSelect.disabled = false pageFilter.disabled = false pageStatus.classList.toggle('error', filteredPages.length === 0) const host = (() => { try { return new URL(wikiBaseUrl).host } catch { return 'Wiki.js' } })() if (filteredPages.length === 0) { pageStatus.textContent = allPages.length ? 'No pages match this filter.' : 'This wiki returned no pages.' return } const hidden = includeUnpublished ? '' : ' · unpublished pages hidden' pageStatus.textContent = `Showing ${filteredPages.length} of ${allPages.length} pages from ${host}${hidden}` } async function loadPages() { pageStatus.classList.remove('error') pageStatus.textContent = 'Loading the page list from Wiki.js…' pageFilter.disabled = true startPageSelect.disabled = true try { const response = await fetch('/api/pages', { cache: 'no-store' }) let payload = null try { payload = await response.json() } catch { // Keep the HTTP status as the error message. } if (!response.ok) { const error = new Error( payload?.error || `The server answered with HTTP ${response.status}.`, ) error.code = payload?.code throw error } allPages = Array.isArray(payload.pages) ? payload.pages : [] wikiBaseUrl = payload.baseUrl || '' renderPageOptions() } catch (error) { allPages = [] pageFilter.disabled = true startPageSelect.disabled = true startPageSelect.replaceChildren(new Option('Could not load pages', '')) pageStatus.classList.add('error') pageStatus.textContent = error.message if (error.code === 'WIKI_URL_MISSING' || error.code === 'WIKI_API_KEY_MISSING') { serverConfigured = false showConfigNotice(error.message) setSubmitBusy(false) } } } async function loadConfig() { try { const response = await fetch('/api/config', { cache: 'no-store' }) if (!response.ok) return true const config = await response.json() if (config.maxPages) { maxPagesInput.value = String(config.maxPages) maxPagesInput.max = String(Math.max(5000, config.maxPages)) } const missing = [] if (!config.wikiUrlConfigured) missing.push('WIKI_URL') if (!config.apiKeyConfigured) missing.push('WIKI_API_KEY') if (missing.length > 0) { serverConfigured = false showConfigNotice( `No wiki connection is configured on this server. Set ${missing.join(' and ')} and restart the container, then reload this page.`, ) setSubmitBusy(false) return false } return true } catch { // Let the page-list request report the actual connection problem. return true } } pageFilter.addEventListener('input', renderPageOptions) includeUnpublishedInput.addEventListener('change', renderPageOptions) form.addEventListener('submit', async (event) => { event.preventDefault() stopPolling() if (!form.reportValidity()) return const selected = startPageSelect.selectedOptions[0] const startPage = selected?.value?.trim() || '' const locale = selected?.dataset.locale || '' const payload = { startPage, locale, maxPages: Number(maxPagesInput.value) || 500, includeUnpublished: includeUnpublishedInput.checked, includeToc: includeTocInput.checked, inlineImages: inlineImagesInput.checked, } if (!serverConfigured) { showRequestError( 'The wiki connection is not configured on the server. Set WIKI_URL and WIKI_API_KEY, then restart the container.', ) return } if (!startPage) { showRequestError('Select a starting page from the list.') return } saveSessionForm(payload) errorBox.hidden = true resultBox.hidden = true warningsBox.hidden = true warningsList.replaceChildren() statusPanel.hidden = false statusPanel.dataset.status = 'queued' statusTitle.textContent = 'Starting export…' statusBadge.textContent = 'queued' statusMessage.textContent = 'Sending the request to the server…' statusPages.textContent = '' progressFill.style.width = '2%' progressBar.setAttribute('aria-valuenow', '2') setSubmitBusy(true) try { const response = await fetch('/api/exports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) let body = null try { body = await response.json() } catch { // Ignore non-JSON error bodies. } if (!response.ok) { throw new Error(body?.error || `The server answered with HTTP ${response.status}.`) } renderJob(body.job) networkFailures = 0 pollJob(body.job.id) } catch (error) { showRequestError(error.message) } }) async function init() { readSessionForm() const configured = await loadConfig() if (configured) await loadPages() } init()