504 lines
15 KiB
JavaScript
504 lines
15 KiB
JavaScript
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()
|