Initial commit

This commit is contained in:
juergen committed 2026-11-13 11:50:48 +01:00
commit 2683eae6b3
36 files changed
+7369

No files matched your search

+503
View File
@@ -0,0 +1,503 @@
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()