Files
sm-seo-plugin/assets/src/elements/transfer.ts
Jakub Zych 6f69031c73 feat: import and export element (E6)
- golem15-seo-transfer: export by dataset with a Blob download, the
  import file read in the browser and refused over 60 KB, the preview
  table (refused first, 200 rows, scrolling wrapper), Apply gated on
  refused and empty previews, a stale preview kept with the message
- en and pl copy; assets/dist rebuilt
2026-10-27 12:35:26 +01:00

383 lines
13 KiB
TypeScript

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<void> {
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<PreviewRow['change'], string> = { 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
}
}