import { el, emit, fmt, labelsFor, num, plural, uid, uiLocaleOf, type Labels } from './labels' export const TRANSFER_TAG = 'golem15-seo-transfer' /** The largest file the screen previews (UI-SPEC Q-8 assumption). */ export const IMPORT_LIMIT = 60 * 1024 /** The preview rows the screen shows. */ export const ROW_CAP = 200 type Dataset = 'redirects' | 'rules' | 'records' /** One preview row of the seo_transfer action. */ export interface PreviewRow { line: number change: 'new' | 'changed' | 'unchanged' | 'refused' address: string field: string before: string after: string note: string warning?: boolean } /** The preview answer. */ export interface PreviewData { op: 'preview' token: string dataset: Dataset summary: { new: number; changed: number; unchanged: number; refused: number; warning: number } rows: PreviewRow[] total: number count: number } /** Every answer of the action: an op's data or an op's refusal. */ type Answer = | { op: 'export'; filename: string; csv: string; error?: undefined } | (PreviewData & { error?: undefined }) | { op: 'apply'; applied: boolean; count: number; batch_id: string; error?: undefined } | { op: 'export' | 'preview' | 'apply'; error: string; message: string } type Payload = | { op: 'export'; dataset: Dataset } | { op: 'preview'; dataset: Dataset; csv: string } | { op: 'apply'; token: string } /** The apply button's state: why it is disabled, if it is. */ export function applyState(p: PreviewData): 'refused' | 'nothing' | 'ready' { if (p.summary.refused > 0) { return 'refused' } return p.count > 0 ? 'ready' : 'nothing' } /** The rows the table shows: refused first, at most ROW_CAP. */ export function shownRows(rows: PreviewRow[]): PreviewRow[] { const refused = rows.filter((r) => r.change === 'refused') const others = rows.filter((r) => r.change !== 'refused') return [...refused, ...others].slice(0, ROW_CAP) } /** Saves text as a file through a Blob link. */ function download(filename: string, text: string): void { const blob = new Blob([text], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename a.rel = 'noopener' document.body.append(a) a.click() a.remove() setTimeout(() => URL.revokeObjectURL(url), 0) } /** The History list of the admin, next to this screen's route. */ function historyHref(): string { const path = window.location.pathname return path.replace(/\/transfer(\/.*)?$/, '/history') } /** * E6 golem15-seo-transfer (UI-SPEC C13): an export section (dataset and * Download CSV) and an import section (dataset, the file, Preview import, * the preview table and Apply). Every server call is the seo_transfer * action through the widget; the file is read in the browser and refused * over 60 KB before the action is called. Apply is enabled only after a * fresh preview with no refused row and at least one change; a stale * preview keeps the table and shows the server's message above the * actions. */ export class SeoTransferElement extends HTMLElement { static observedAttributes = ['busy', 'state', 'ui-locale', 'disabled', 'label', 'busy-label'] private connected = false private pending: Payload['op'] | null = null private exportDataset: Dataset = 'redirects' private importDataset: Dataset = 'redirects' private fileName = '' private csv = '' private fileError = '' private importError = '' private exportError = '' private applyError = '' private preview: PreviewData | null = null private applied = false private readonly onResult = (ev: Event): void => { const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined const data = detail?.data as Answer | undefined this.pending = null if (!data) { this.render() return } if (data.error !== undefined) { if (data.op === 'export') { this.exportError = data.message } else if (data.op === 'preview') { this.importError = data.message this.preview = null } else { this.applyError = data.message } } else if (data.op === 'export') { this.exportError = '' download(data.filename, data.csv) } else if (data.op === 'preview') { this.importError = '' this.applyError = '' this.applied = false this.preview = data } else if (data.op === 'apply') { this.preview = null this.applyError = '' this.applied = true } this.render() } connectedCallback(): void { this.connected = true this.addEventListener('summer-result', this.onResult) this.render() } disconnectedCallback(): void { this.connected = false this.removeEventListener('summer-result', this.onResult) } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { if (!this.connected) { return } if (name === 'state' && value === 'error') { this.pending = null } if (name === 'busy' && value === null) { this.pending = null } this.render() } private labels(): Labels { return labelsFor(uiLocaleOf(this)) } private request(payload: Payload): void { if (this.hasAttribute('disabled') || this.hasAttribute('busy') || this.pending) { return } this.pending = payload.op emit(this, 'summer-action', { payload }) this.render() } private busyLabel(op: Payload['op'], idle: string): string { if (this.pending === op) { return this.getAttribute('busy-label') || idle } return idle } private datasetSelect(labels: Labels, value: Dataset, onChange: (v: Dataset) => void): HTMLDivElement { const wrap = el('div', 'seo-transfer__field') const id = uid('seo-transfer-dataset') const label = el('label', 'seo-transfer__label', labels.dataset) label.htmlFor = id const select = el('select', 'seo-transfer__select') select.id = id const options: Array<[Dataset, string]> = [ ['redirects', labels.dsRedirects], ['rules', labels.dsRules], ['records', labels.dsRecords], ] for (const [v, text] of options) { const option = el('option', '', text) option.value = v select.append(option) } select.value = value select.disabled = this.hasAttribute('disabled') select.addEventListener('change', () => onChange(select.value as Dataset)) wrap.append(label, select) return wrap } private button(text: string, kind: 'outline' | 'ghost' | 'primary', onClick: () => void): HTMLButtonElement { const b = el('button', 'seo-transfer__button seo-transfer__button--' + kind, text) b.type = 'button' b.addEventListener('click', onClick) return b } private message(text: string, className: string): HTMLParagraphElement { const p = el('p', className, text) p.setAttribute('role', 'status') return p } private async chooseFile(input: HTMLInputElement): Promise { const file = input.files?.[0] this.preview = null this.applied = false this.importError = '' this.applyError = '' this.csv = '' if (!file) { this.fileName = '' this.fileError = '' this.render() return } this.fileName = file.name if (file.size > IMPORT_LIMIT) { this.fileError = fmt(this.labels().fileTooLarge, { limit: num(IMPORT_LIMIT / 1024, uiLocaleOf(this)) + ' KB' }) this.render() return } try { this.csv = await file.text() this.fileError = '' } catch { this.fileError = this.labels().readFailed } this.render() } private render(): void { const labels = this.labels() const locale = uiLocaleOf(this) const disabled = this.hasAttribute('disabled') const busy = this.hasAttribute('busy') || this.pending !== null const actionLabel = this.getAttribute('label') || labels.chooseFile // Export. const exportSection = el('section', 'seo-transfer__section') exportSection.append(el('h3', 'seo-transfer__heading', labels.exportHeading)) const exportRow = el('div', 'seo-transfer__row') exportRow.append(this.datasetSelect(labels, this.exportDataset, (v) => (this.exportDataset = v))) const downloadButton = this.button(this.busyLabel('export', labels.download), 'outline', () => this.request({ op: 'export', dataset: this.exportDataset })) downloadButton.disabled = busy downloadButton.hidden = disabled exportRow.append(downloadButton) exportSection.append(exportRow) if (this.exportError) { exportSection.append(this.message(this.exportError, 'seo-transfer__error')) } // Import. const importSection = el('section', 'seo-transfer__section seo-transfer__section--import') importSection.append(el('h3', 'seo-transfer__heading', labels.importHeading)) const importRow = el('div', 'seo-transfer__row') importRow.append( this.datasetSelect(labels, this.importDataset, (v) => { this.importDataset = v this.preview = null this.render() }), ) const input = el('input', 'seo-transfer__file') input.type = 'file' input.accept = '.csv,text/csv' input.hidden = true input.addEventListener('change', () => void this.chooseFile(input)) const choose = this.button(labels.chooseFile, 'outline', () => input.click()) choose.hidden = disabled choose.disabled = busy const name = el('span', this.fileName ? 'seo-transfer__filename' : 'seo-transfer__filename seo-transfer__filename--none', this.fileName || labels.noFile) const previewButton = this.button(this.busyLabel('preview', actionLabel), 'outline', () => this.request({ op: 'preview', dataset: this.importDataset, csv: this.csv }), ) previewButton.disabled = busy || this.csv === '' || this.fileError !== '' previewButton.hidden = disabled importRow.append(input, choose, name, previewButton) importSection.append(importRow) if (this.fileError || this.importError) { importSection.append(this.message(this.fileError || this.importError, 'seo-transfer__error')) } if (this.applied) { const done = el('p', 'seo-transfer__done') const link = el('a', 'seo-transfer__link', labels.openHistory) link.href = historyHref() done.append(link) importSection.append(done) } if (this.preview) { importSection.append(this.renderPreview(labels, locale, busy, disabled)) } this.replaceChildren(exportSection, importSection) } private renderPreview(labels: Labels, locale: string, busy: boolean, disabled: boolean): HTMLDivElement { const p = this.preview as PreviewData const box = el('div', 'seo-transfer__preview') const s = p.summary box.append( this.message( fmt(labels.summary, { new: num(s.new, locale), changed: num(s.changed, locale), unchanged: num(s.unchanged, locale), refused: num(s.refused, locale) }), 'seo-transfer__summary', ), ) const wrap = el('div', 'summer-table-wrap seo-transfer__table-wrap') const table = el('table', 'summer-table seo-transfer__table') const head = el('tr') for (const text of [labels.colLine, labels.colChange, labels.colAddress, labels.colField, labels.colBefore, labels.colAfter, labels.colNote]) { head.append(el('th', '', text)) } const thead = el('thead') thead.append(head) const tbody = el('tbody') const badge: Record = { new: labels.bNew, changed: labels.bChanged, unchanged: labels.bUnchanged, refused: labels.bRefused } for (const row of shownRows(p.rows)) { const tr = el('tr', 'seo-transfer__tr--' + row.change) tr.append(el('td', 'seo-transfer__num', num(row.line, locale))) const change = el('td') change.append(el('span', 'seo-transfer__badge seo-transfer__badge--' + row.change, badge[row.change])) if (row.warning) { change.append(el('span', 'seo-transfer__badge seo-transfer__badge--warning', labels.bWarning)) } tr.append(change) tr.append(el('td', 'seo-transfer__mono', row.address)) tr.append(el('td', '', row.field)) tr.append(el('td', '', row.before)) tr.append(el('td', '', row.after)) tr.append(el('td', '', row.note)) tbody.append(tr) } table.append(thead, tbody) wrap.append(table) box.append(wrap) if (p.total > ROW_CAP || p.rows.length > ROW_CAP) { box.append(el('p', 'seo-transfer__note', fmt(labels.rowCap, { count: num(Math.max(p.total, p.rows.length), locale) }))) } const state = applyState(p) if (state === 'refused') { box.append(this.message(labels.refusedBlocks, 'seo-transfer__error')) } else if (state === 'nothing') { box.append(this.message(labels.nothing, 'seo-transfer__note')) } if (this.applyError) { box.append(this.message(this.applyError, 'seo-transfer__error')) } const actions = el('div', 'seo-transfer__actions') const discard = this.button(labels.discard, 'ghost', () => { this.preview = null this.applyError = '' this.render() }) discard.disabled = busy const apply = this.button(this.busyLabel('apply', plural(labels.applyCount, p.count, locale)), 'primary', () => this.request({ op: 'apply', token: p.token }), ) apply.disabled = busy || state !== 'ready' actions.append(discard, apply) actions.hidden = disabled box.append(actions) return box } }