Initial commit
This commit is contained in:
commit
2683eae6b3
36 files changed
+7369
No files matched your search
+503
@@ -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()
|
||||
Reference in new issue
Block a user