跳转到内容

User:Sekidoki/Scripts/FileUploadWizard-dev.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// <nowiki>
/* Upload Wizard UI. Copyright rules and wikitext live in FileUploadWizard-core.js;
   interface text in both scripts lives in FileUploadWizard-i18n.js. */
(function () {
    'use strict';
    const DEV_MODE = true;
    const ALLOW_REAL_UPLOAD = false;
    const PAGE = 'Template:Upload Wizard Page';
    const DRAFT_KEY = 'fuw-draft-v1';
    // Raised when the saved fields change, so an older draft is dropped instead of half restored.
    const DRAFT_VERSION = 2;
    if (String(mw.config.get('wgPageName')).replace(/_/g, ' ') !== PAGE || mw.config.get('wgAction') !== 'view' || new URLSearchParams(location.search).get('fuwdev') !== '1') return;
    function start() {
        const root = document.getElementById('fuw-dev-root');
        if (!root || root.dataset.initialized) return;
        root.dataset.initialized = 'true';
        const C = window.FileUploadWizardCore, I18n = window.FileUploadWizardI18n;
        if (!C || !I18n) { root.appendChild(document.createTextNode('核心程式未載入/核心程序未加载。')); return; }
        // Unconverted 'zh' follows the landing page, which is written in Traditional characters.
        const variant = mw.config.get('wgUserVariant') || mw.config.get('wgContentLanguage') || 'zh';
        const simplified = I18n.isSimplified(variant);
        // Interface text lives in FileUploadWizard-i18n.js: t(key, ...values for $1, $2).
        const t = I18n.translator(variant);
        const commonsUpload = 'https://commons.wikimedia.org/wiki/Special:UploadWizard?uselang=' + (simplified ? 'zh-hans' : 'zh-hant');
        const helpDesk = mw.util.getUrl('Wikipedia:互助客棧/求助');
        const vrtHelp = 'https://commons.wikimedia.org/wiki/Commons:Volunteer_Response_Team/zh';
        const api = new mw.Api();
        const s = C.createState();
        const draft = readDraft();
        if (draft) restoreState(draft.state);
        // A link's values are applied once: after that the draft, with any corrections, wins on reload.
        const linked = C.createState();
        const prefillKey = C.applyPrefill(linked, Object.fromEntries(new URLSearchParams(location.search)));
        if (prefillKey && (!draft || draft.prefill !== prefillKey)) C.applyPrefill(s, Object.fromEntries(new URLSearchParams(location.search)));
        s.uploader = mw.config.get('wgUserName') || '';
        const session = new C.UploadSession(api, { allowPublish: !DEV_MODE && ALLOW_REAL_UPLOAD });
        let stepId = 'copyright', busy = false, limits = null, capabilities = 'loading', canUpload = false, objectURL = null, lastWarnings = {};
        let previewRequest = 0, lastAnnounced = '', saveTimer = null, fileChecked = false, fileDate = '';
        const fields = {}, groups = {}, sections = {};

        /* ---------- DOM helpers ---------- */
        const node = (tag, text, attrs = {}) => {
            const el = document.createElement(tag);
            if (text) el.textContent = text;
            for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
            return el;
        };
        const add = (parent, tag, text, attrs) => { const el = node(tag, text, attrs); parent.appendChild(el); return el; };
        // External and policy links open in a new tab so the wizard keeps its place.
        const link = (parent, text, href, newTab = true) => {
            const el = add(parent, 'a', text, { href });
            if (newTab) {
                el.target = '_blank'; el.rel = 'noopener';
                add(el, 'span', t('opens-new-tab'), { class: 'fuw-sr' });
            }
            return el;
        };
        const button = (parent, text, fn, kind = '') => {
            const el = add(parent, 'button', text, { type: 'button', class: 'fuw-button' + (kind ? ' fuw-' + kind : '') });
            el.addEventListener('click', fn); return el;
        };
        const list = (parent, items) => { const ul = add(parent, 'ul'); items.forEach(item => add(ul, 'li', item)); return ul; };
        const get = path => path.split('.').reduce((value, key) => value[key], s);
        const set = (path, value) => { const parts = path.split('.'), key = parts.pop(); parts.reduce((v, k) => v[k], s)[key] = value; };
        function visible(el, show) {
            el.hidden = !show;
            el.querySelectorAll('input, select, textarea, button').forEach(control => { control.disabled = !show; });
        }

        /* ---------- Draft persistence (survives login and reloads; the file itself cannot) ---------- */
        function readDraft() {
            try { const value = JSON.parse(sessionStorage.getItem(DRAFT_KEY)); return value && value.v === DRAFT_VERSION ? value : null; }
            catch (_) { return null; }
        }
        function restoreState(saved) {
            if (!saved) return;
            for (const key of ['answers', 'metadata', 'license', 'jurisdiction', 'usage']) Object.assign(s[key], saved[key] || {});
            const { rationale, ...nonfree } = saved.nonfree || {};
            Object.assign(s.nonfree, nonfree);
            for (const [id, values] of Object.entries(rationale || {})) if (s.nonfree.rationale[id]) Object.assign(s.nonfree.rationale[id], values);
            if (saved.file && saved.file.name) s.file.name = saved.file.name;
        }
        function saveDraft() {
            clearTimeout(saveTimer);
            try {
                const { rationale, ...nonfree } = s.nonfree;
                sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ v: DRAFT_VERSION, step: stepId, prefill: prefillKey, state: {
                    answers: s.answers, metadata: s.metadata, license: s.license, jurisdiction: s.jurisdiction,
                    usage: s.usage, nonfree: { ...nonfree, rationale }, file: { name: s.file.name } } }));
            } catch (_) { /* Private mode or full storage: the wizard still works without drafts. */ }
        }
        const saveSoon = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveDraft, 400); };
        function clearDraft() { try { sessionStorage.removeItem(DRAFT_KEY); } catch (_) { /* ignore */ } }

        /* ---------- Page frame ---------- */
        const mount = root.querySelector('.fuw-mount') || add(root, 'div', '', { class: 'fuw-mount' });
        for (const selector of ['.fuw-intro', '.fuw-help-panel']) { const el = root.querySelector(selector); if (el) el.hidden = true; }
        add(mount, 'h2', t('title'), { class: 'fuw-title' });
        add(mount, 'p', t('lead'), { class: 'fuw-lead' });
        if (DEV_MODE || !ALLOW_REAL_UPLOAD) add(mount, 'p', t('dev-note'), { class: 'fuw-devnote' });
        const progress = add(mount, 'ol', '', { class: 'fuw-steps', 'aria-label': t('steps-label') });
        const announce = add(mount, 'div', '', { class: 'fuw-sr', role: 'status' });
        const status = add(mount, 'p', '', { class: 'fuw-status', role: 'status' });
        const errorBox = add(mount, 'div', '', { class: 'fuw-errorsummary', tabindex: '-1', 'aria-labelledby': 'fuw-errorsummary-title', hidden: '' });
        const form = add(mount, 'form', '', { novalidate: '' });
        // Navigation is explicit through type=button controls; Enter must not jump steps.
        form.addEventListener('submit', e => { e.preventDefault(); });
        const workspace = add(form, 'fieldset', '', { class: 'fuw-workspace' });
        const STEP_TITLES = {
            copyright: t('step-copyright'), file: t('step-file'), 'nf-basics': t('step-nf-basics'), 'nf-need': t('step-nf-need'),
            'nf-minimal': t('step-nf-minimal'), details: t('step-details'), review: t('step-review')
        };
        for (const [id, title] of Object.entries(STEP_TITLES)) {
            const section = add(workspace, 'section', '', { 'aria-labelledby': 'fuw-step-' + id, hidden: '' });
            add(section, 'h3', title, { id: 'fuw-step-' + id, tabindex: '-1' });
            sections[id] = section;
        }
        function stepIds() {
            if (!C.LOCAL_ROUTES.includes(s.route)) return ['copyright'];
            // The file comes right after the route is known: its format and size decide later questions.
            return s.route === 'nonfree' ? ['copyright', 'file', 'nf-basics', 'nf-need', 'nf-minimal', 'details', 'review'] : ['copyright', 'file', 'details', 'review'];
        }
        function group(parent, name, title) {
            const el = add(parent, 'div', '', { class: 'fuw-group' });
            if (title) add(el, 'h4', title);
            groups[name] = el; return el;
        }

        /* ---------- Fields ---------- */
        // o: { required, optional, multiline, choices, radios: [[value, label, desc]], check, date, help, short }
        function field(parent, path, label, o = {}) {
            const id = 'fuw-' + path.replace(/\./g, '-');
            const wrap = add(parent, o.radios ? 'fieldset' : 'div', '', { class: 'fuw-field' + (o.check ? ' fuw-check' : '') + (o.radios ? ' fuw-choice' : '') });
            const mark = o.required ? t('mark-required') : o.optional ? t('mark-optional') : '';
            const record = { path, wrap, label, short: o.short || label, kind: o.radios ? 'radios' : o.check ? 'check' : o.choices ? 'select' : 'text', inputs: [] };
            const describe = [];
            const caption = node(o.radios ? 'legend' : 'label', label, o.radios ? {} : { for: id });
            if (mark) add(caption, 'span', mark, { class: 'fuw-mark' });
            let help = null;
            if (o.help) { help = node('p', o.help, { id: id + '-help', class: 'fuw-help' }); describe.push(id + '-help'); }
            if (o.radios) {
                wrap.appendChild(caption);
                if (help) wrap.appendChild(help);
                const options = add(wrap, 'div', '', { class: 'fuw-options' });
                record.options = {};
                o.radios.forEach(([value, text, desc], index) => {
                    const row = add(options, 'label', '', { class: 'fuw-option', for: id + '-' + index });
                    const input = add(row, 'input', '', { type: 'radio', name: id, id: id + '-' + index, value });
                    const body = add(row, 'span', '', { class: 'fuw-option-body' });
                    add(body, 'span', text, { class: 'fuw-option-label' });
                    if (desc) add(body, 'span', desc, { class: 'fuw-option-desc' });
                    input.checked = get(path) === value;
                    record.inputs.push(input); record.options[value] = row;
                });
            } else {
                let el;
                if (o.choices) {
                    el = node('select', '', { id });
                    o.choices.forEach(([value, text]) => add(el, 'option', text, { value }));
                } else if (o.multiline) el = node('textarea', '', { id, rows: '4' });
                else el = node('input', '', { id, type: o.check ? 'checkbox' : 'text' });
                if (o.date) el.setAttribute('autocomplete', 'off');
                if (o.check) { wrap.append(el, caption); el.checked = !!get(path); }
                else { wrap.append(caption, el); el.value = get(path); }
                if (help) wrap.appendChild(help);
                record.inputs.push(el);
            }
            record.error = add(wrap, 'p', '', { id: id + '-error', class: 'fuw-field-error', hidden: '' });
            record.focusTarget = () => record.inputs.find(input => input.checked) || record.inputs[0];
            record.value = () => record.kind === 'radios' ? (record.inputs.find(input => input.checked) || {}).value || '' : record.kind === 'check' ? record.inputs[0].checked : record.inputs[0].value;
            record.setValue = value => {
                if (record.kind === 'radios') record.inputs.forEach(input => { input.checked = input.value === value; });
                else if (record.kind === 'check') record.inputs[0].checked = !!value;
                else record.inputs[0].value = value;
            };
            record.describe = describe;
            record.refreshDescribedBy = () => {
                const ids = [...record.describe, ...(record.error.hidden ? [] : [record.error.id])];
                const targets = record.kind === 'radios' ? [wrap] : record.inputs;
                targets.forEach(target => { if (ids.length) target.setAttribute('aria-describedby', ids.join(' ')); else target.removeAttribute('aria-describedby'); });
            };
            record.setError = message => {
                record.error.textContent = message || '';
                record.error.hidden = !message;
                record.inputs.forEach(input => { if (message) input.setAttribute('aria-invalid', 'true'); else input.removeAttribute('aria-invalid'); });
                record.refreshDescribedBy();
            };
            record.refreshDescribedBy();
            const onChange = () => {
                set(path, record.value());
                record.setError('');
                session.invalidate();
                onFieldChange(path);
                if (record.kind === 'text') saveSoon(); else saveDraft();
            };
            record.inputs.forEach(input => input.addEventListener(record.kind === 'text' ? 'input' : 'change', onChange));
            fields[path] = record;
            return record;
        }
        const choice = (value, key) => [value, t(key)];
        const radio = (value, key, descKey) => [value, t(key), descKey ? t(descKey) : ''];
        const empty = choice('', 'choose');
        const OWN_SOURCES = ['own', 'own-published'];
        function onFieldChange(path) {
            const a = s.answers;
            if (path === 'answers.sourceKind') {
                if (a.evidenceKind === 'own' && !OWN_SOURCES.includes(a.sourceKind)) { a.evidenceKind = ''; fields['answers.evidenceKind'].setValue(''); }
                a.ownConfirmed = false; fields['answers.ownConfirmed'].setValue(false);
            }
            if (path === 'answers.evidenceKind') { a.ownConfirmed = false; fields['answers.ownConfirmed'].setValue(false); }
            if (path === 'nonfree.article') { s.nonfree.validatedArticle = ''; articleStatus.textContent = ''; }
            if (path === 'file.name') validateFile();
            if (fields[path].kind !== 'text' || ['nonfree.article', 'jurisdiction.otherOrigin', 'file.name'].includes(path)) sync();
        }

        /* ---------- Step: source and licence ---------- */
        const S0 = sections.copyright;
        field(S0, 'answers.sourceKind', t('source-question'), { short: t('source-short'), radios: [
            radio('own', 'source-own', 'source-own-desc'),
            radio('own-published', 'source-own-published', 'source-own-published-desc'),
            radio('online', 'source-online', 'source-online-desc'),
            radio('scan', 'source-scan', 'source-scan-desc'),
            radio('government', 'source-government'),
            radio('other', 'source-other'),
            radio('unknown', 'source-unknown', 'source-unknown-desc')
        ] });
        const sourceTip = add(fields['answers.sourceKind'].wrap, 'p', '', { class: 'fuw-tip' });
        const evidence = group(S0, 'evidence');
        field(evidence, 'answers.evidenceKind', t('evidence-question'), { short: t('evidence-short'), radios: [
            radio('own', 'evidence-own'),
            radio('free-license', 'evidence-free-license', 'evidence-free-license-desc'),
            radio('pd', 'evidence-pd', 'evidence-pd-desc'),
            radio('restricted', 'evidence-restricted', 'evidence-restricted-desc'),
            radio('copyrighted', 'evidence-copyrighted'),
            radio('unknown', 'evidence-unknown')
        ] });
        const myths = node('div', '', { class: 'fuw-help', id: 'fuw-evidence-myths' });
        add(myths, 'p', t('myths-intro'));
        list(myths, [t('myths-public'), t('myths-no-notice'),
            t('myths-reposting'), t('myths-noncommercial')]);
        fields['answers.evidenceKind'].wrap.insertBefore(myths, fields['answers.evidenceKind'].wrap.querySelector('.fuw-options'));
        fields['answers.evidenceKind'].describe.push(myths.id);
        fields['answers.evidenceKind'].refreshDescribedBy();
        field(evidence, 'answers.ownConfirmed', t('own-confirm'), { check: true, short: t('own-confirm-short') });
        const pd = group(S0, 'pd', t('pd-heading'));
        const regionChoices = [empty, ...['TW', 'CN', 'HK', 'MO', 'SG', 'MY', 'US'].map(code => [code, t('region-' + code)]),
            choice('OTHER', 'region-OTHER'), choice('unknown', 'unsure')];
        field(pd, 'jurisdiction.origin', t('origin-question'), { choices: regionChoices, help: t('origin-help') });
        const other = group(pd, 'other');
        // A list, not free text: typing 美國 or 臺灣 here would skip the checks those origins get.
        const regionLocale = simplified ? 'zh-Hans' : 'zh-Hant';
        const otherRegions = C.OTHER_REGIONS.map(code => [code, C.regionName(code, regionLocale)]).sort((a, b) => a[1].localeCompare(b[1], regionLocale));
        field(other, 'jurisdiction.otherOrigin', t('other-origin'), { choices: [empty, ...otherRegions], help: t('other-origin-help') });
        const statusRadios = [radio('public-domain', 'status-public-domain'), radio('copyrighted', 'status-copyrighted'), radio('unknown', 'unsure')];
        const originStatus = group(pd, 'originStatus');
        field(originStatus, 'jurisdiction.originCopyrightStatus', t('origin-status'), { radios: statusRadios });
        field(pd, 'jurisdiction.usCopyrightStatus', t('us-status'), { radios: statusRadios, help: t('us-status-help') });
        const use = group(S0, 'use', t('use-heading'));
        field(use, 'answers.purpose', t('use-question'), { radios: [
            radio('general', 'use-general'),
            radio('userspace', 'use-userspace'),
            radio('esoteric', 'use-esoteric', 'use-esoteric-desc')
        ] });
        const localOnly = group(use, 'localOnly');
        field(localOnly, 'answers.localOnly', t('local-only-confirm'), { check: true });
        field(use, 'answers.commonsUnavailable', t('commons-unavailable'), { check: true, help: t('commons-unavailable-help') });
        const result = add(S0, 'div', '', { class: 'fuw-result', hidden: '' });

        const SOURCE_TIPS = {
            'own-published': t('source-tip-own-published'),
            online: t('source-tip-online'),
            scan: t('source-tip-scan'),
            government: t('source-tip-government'),
            other: t('source-tip-other')
        };

        /* ---------- Step: non-free basics ---------- */
        const NF_TYPE_LABELS = {
            logo: t('nf-type-logo'), cover: t('nf-type-cover'), excerpt: t('nf-type-excerpt'),
            portrait: t('nf-type-portrait'), art: t('nf-type-art'), historic: t('nf-type-historic'), other: t('nf-type-other')
        };
        const NF_LICENSE_LABELS = {
            'Non-free logo': t('nf-license-logo'), 'Non-free book cover': t('nf-license-book-cover'), 'Non-free album cover': t('nf-license-album-cover'),
            'Non-free game cover': t('nf-license-game-cover'), 'Non-free video cover': t('nf-license-video-cover'), 'Non-free film screenshot': t('nf-license-film-screenshot'),
            'Non-free television screenshot': t('nf-license-television-screenshot'), 'Non-free game screenshot': t('nf-license-game-screenshot'), 'Non-free audio sample': t('nf-license-audio-sample'),
            'Non-free video sample': t('nf-license-video-sample'), 'Non-free biog-pic': t('nf-license-biog-pic'), 'Non-free 2D art': t('nf-license-2d-art'),
            'Non-free 3D art': t('nf-license-3d-art'), 'Non-free historic image': t('nf-license-historic-image'), 'Non-free fair use in': t('nf-license-fair-use-in')
        };
        const nfLicenseText = tag => (NF_LICENSE_LABELS[tag] || tag) + '(' + tag + ')';
        const S1 = sections['nf-basics'];
        field(S1, 'nonfree.article', t('article-question'), { required: true, short: t('article-short'), help: t('article-help') });
        const articleRow = add(S1, 'div', '', { class: 'fuw-inline-action' });
        button(articleRow, t('article-confirm'), () => run(checkArticle));
        const articleStatus = add(articleRow, 'p', '', { role: 'status', class: 'fuw-inline-status' });
        field(S1, 'nonfree.type', t('nf-type'), { required: true, choices: [empty, ...Object.entries(NF_TYPE_LABELS)] });
        field(S1, 'nonfree.license', t('nf-license'), { required: true, choices: [empty] });
        const rationale = group(S1, 'rationale');
        const useLabels = {
            Infobox: t('use-at-infobox'), Header: t('use-at-header'), Section: t('use-at-section'),
            Org: t('use-at-org'), Brand: t('use-at-brand'), Product: t('use-at-product'),
            'Public facility': t('use-at-public-facility'), 'Public authority': t('use-at-public-authority'),
            Author: t('use-at-author'), Artist: t('use-at-artist'),
            Technique: t('use-at-technique'), Work: t('use-at-work'), Other: t('use-at-other')
        };
        // A template's own wording for a row comes first, then the wording shared by all templates.
        const rationaleLabel = (profile, item) => t(t.has('rationale-' + profile.id + '-' + item.key) ? 'rationale-' + profile.id + '-' + item.key : 'rationale-' + item.key);
        for (const profile of Object.values(C.RATIONALE_PROFILES)) {
            const extra = group(rationale, 'rationale-' + profile.id);
            const prefix = 'nonfree.rationale.' + profile.id + '.';
            if (profile.uses.length) field(extra, prefix + 'use', t('use-at-question'), { required: true, short: t('use-at-short'), choices: [empty, ...profile.uses.map(value => [value, useLabels[value]])] });
            for (const item of profile.fields) field(extra, prefix + item.key, rationaleLabel(profile, item), { optional: true, date: item.date, help: item.date ? t('rationale-date-help') : '' });
        }
        const rationaleDoc = add(rationale, 'p', '', { class: 'fuw-help' });

        /* ---------- Step: why it is needed ---------- */
        // Shown under each question as a starting point; never copied into the answer.
        const NF_EXAMPLES = {
            logo: {
                purpose: t('example-logo-purpose'),
                replaceability: t('example-logo-replaceability'),
                text: t('example-logo-text'),
                minimality: t('example-logo-minimality'),
                commercial: t('example-logo-commercial')
            },
            cover: {
                purpose: t('example-cover-purpose'),
                replaceability: t('example-cover-replaceability'),
                text: t('example-cover-text'),
                minimality: t('example-cover-minimality'),
                commercial: t('example-cover-commercial')
            },
            excerpt: {
                purpose: t('example-excerpt-purpose'),
                replaceability: t('example-excerpt-replaceability'),
                text: t('example-excerpt-text'),
                minimality: t('example-excerpt-minimality'),
                commercial: t('example-excerpt-commercial')
            },
            sample: {
                purpose: t('example-sample-purpose'),
                replaceability: t('example-sample-replaceability'),
                text: t('example-sample-text'),
                minimality: t('example-sample-minimality'),
                commercial: t('example-sample-commercial')
            },
            portrait: {
                purpose: t('example-portrait-purpose'),
                replaceability: t('example-portrait-replaceability'),
                text: t('example-portrait-text'),
                minimality: t('example-portrait-minimality'),
                commercial: t('example-portrait-commercial')
            },
            art: {
                purpose: t('example-art-purpose'),
                replaceability: t('example-art-replaceability'),
                text: t('example-art-text'),
                minimality: t('example-art-minimality'),
                commercial: t('example-art-commercial')
            },
            historic: {
                purpose: t('example-historic-purpose'),
                replaceability: t('example-historic-replaceability'),
                text: t('example-historic-text'),
                minimality: t('example-historic-minimality'),
                commercial: t('example-historic-commercial')
            },
            other: {
                purpose: t('example-other-purpose'),
                replaceability: t('example-other-replaceability'),
                text: t('example-other-text'),
                minimality: t('example-other-minimality'),
                commercial: t('example-other-commercial')
            }
        };
        // Questions follow the rows of Template:Non-free use rationale, and each short label is that
        // row's heading, so error messages and the summary use the words shown on the file page.
        const S2 = sections['nf-need'];
        field(S2, 'nonfree.purpose', t('nf-purpose'), { required: true, multiline: true, short: t('nf-purpose-short') });
        field(S2, 'nonfree.significance', t('nf-significance'), { required: true, multiline: true, short: t('nf-significance-short'), help: ' ' });
        field(S2, 'nonfree.replaceability', t('nf-replaceability'), { required: true, multiline: true, short: t('nf-replaceability-short'), help: ' ' });
        field(S2, 'nonfree.replaceabilityText', t('nf-replaceability-text'), { optional: true, multiline: true, short: t('nf-replaceability-text-short'), help: ' ' });

        /* ---------- Step: minimal use ---------- */
        const S3 = sections['nf-minimal'];
        field(S3, 'nonfree.minimality', t('nf-minimality'), { required: true, multiline: true, short: t('nf-minimality-short'), help: ' ' });
        const sizeGuide = add(fields['nonfree.minimality'].wrap, 'p', t('nf-size-guide'), { class: 'fuw-help', id: 'fuw-nonfree-minimality-guide' });
        fields['nonfree.minimality'].wrap.insertBefore(sizeGuide, fields['nonfree.minimality'].error);
        fields['nonfree.minimality'].describe.push(sizeGuide.id);
        fields['nonfree.minimality'].refreshDescribedBy();
        // Shown only when the stashed image is over the size guide.
        const reduce = group(S3, 'reduce');
        const reduceNote = add(reduce, 'div', '', { class: 'fuw-note fuw-note-warning' });
        const reduceSize = add(reduceNote, 'p');
        add(reduceNote, 'p', t('nf-reduce-note'));
        field(reduce, 'nonfree.noReduce', t('nf-no-reduce'), { check: true, short: t('nf-no-reduce-short') });
        const noReduceReason = group(reduce, 'noReduceReason');
        field(noReduceReason, 'nonfree.noReduceReason', t('nf-no-reduce-reason'), { required: true, multiline: true, short: t('nf-no-reduce-reason-short'), help: t('nf-no-reduce-help') });
        field(S3, 'nonfree.commercial', t('nf-commercial'), { optional: true, multiline: true, short: t('nf-commercial-short'), help: ' ' });
        field(S3, 'nonfree.otherInformation', t('nf-other-information'), { optional: true, multiline: true, short: t('nf-other-information-short') });
        field(S3, 'nonfree.policy', t('nf-policy'), { check: true, required: true, short: t('nf-policy-short') });
        link(add(S3, 'p', '', { class: 'fuw-help' }), t('nf-policy-link'), mw.util.getUrl('Wikipedia:非自由内容使用准则'));

        /* ---------- Step: description and source ---------- */
        const S4 = sections.details;
        field(S4, 'metadata.description', t('description'), { required: true, multiline: true, help: t('description-help') });
        const ownAuthor = add(S4, 'p', '', { class: 'fuw-help fuw-own-author' });
        const author = group(S4, 'author');
        field(author, 'metadata.author', t('author'), { required: true, help: ' ' });
        const source = group(S4, 'source');
        field(source, 'metadata.source', t('source'), { required: true, multiline: true, help: t('source-help') });
        const publication = group(S4, 'publication');
        field(publication, 'metadata.publication', t('publication'), { optional: true, short: t('publication-short'), help: t('publication-help') });
        field(S4, 'metadata.publicationDate', t('date'), { date: true, optional: true, help: t('date-help') });
        // Shown while the date is still the one read from the file, so it is checked rather than assumed.
        const dateTip = add(fields['metadata.publicationDate'].wrap, 'p', t('date-from-file'), { class: 'fuw-tip', id: 'fuw-date-from-file', hidden: '' });
        fields['metadata.publicationDate'].describe.push(dateTip.id);
        fields['metadata.publicationDate'].refreshDescribedBy();
        const published = group(S4, 'published');
        field(published, 'nonfree.published', t('published'), { check: true, required: true, short: t('published-short') });
        const license = group(S4, 'license');
        field(license, 'license.type', t('license'), { required: true, choices: [empty] });
        const customLicense = group(license, 'customLicense');
        field(customLicense, 'license.customDescription', t('custom-license'), { required: true, multiline: true, help: t('custom-license-help') });
        const licenseEvidence = group(license, 'licenseEvidence');
        field(licenseEvidence, 'license.evidence', t('license-evidence'), { required: true, multiline: true, help: t('license-evidence-help') });
        link(add(licenseEvidence, 'p', '', { class: 'fuw-help' }), t('vrt-link'), vrtHelp);
        const pdEvidence = group(S4, 'pdEvidence');
        field(pdEvidence, 'jurisdiction.legalBasis', t('legal-basis'), { required: true, multiline: true, help: t('legal-basis-help') });
        const pdTag = group(pdEvidence, 'pdTag');
        field(pdTag, 'license.pdTemplate', t('pd-template'), { required: true, help: t('pd-template-help') });
        const usOnly = group(S4, 'usOnly', t('us-only-heading'));
        add(usOnly, 'p', t('us-only-note'), { class: 'fuw-note' });
        field(usOnly, 'usage.page', t('usage-page'), { optional: true });
        field(usOnly, 'usage.purpose', t('usage-purpose'), { optional: true, multiline: true });
        field(usOnly, 'usage.necessity', t('usage-necessity'), { optional: true, multiline: true });

        /* ---------- Step: choose file ---------- */
        const S5 = sections.file;
        const capability = add(S5, 'p', t('capability-loading'), { class: 'fuw-help' });
        const formats = add(S5, 'details', '', { class: 'fuw-formats', hidden: '' });
        add(formats, 'summary', t('formats-summary'));
        const formatList = add(formats, 'p', '', { class: 'fuw-help' });
        const capabilityRetry = button(S5, t('capability-retry'), () => run(loadCapabilities));
        capabilityRetry.hidden = true;
        const drop = add(S5, 'div', '', { class: 'fuw-drop' });
        add(drop, 'label', t('drop-label'), { for: 'fuw-file-input', class: 'fuw-drop-label' });
        const fileInput = add(drop, 'input', '', { id: 'fuw-file-input', type: 'file', 'aria-describedby': 'fuw-file-error' });
        const preview = add(drop, 'div', '', { class: 'fuw-preview' });
        const fileError = add(drop, 'p', '', { id: 'fuw-file-error', class: 'fuw-field-error', hidden: '' });
        fileInput.addEventListener('change', () => selectFile(fileInput.files[0]));
        drop.addEventListener('dragover', e => { e.preventDefault(); if (!busy) drop.classList.add('fuw-drag'); });
        drop.addEventListener('dragleave', () => drop.classList.remove('fuw-drag'));
        drop.addEventListener('drop', e => {
            e.preventDefault(); drop.classList.remove('fuw-drag');
            if (busy) return;
            if (e.dataTransfer.files.length !== 1) { setFileError(t('drop-one-file')); return; }
            try { fileInput.files = e.dataTransfer.files; } catch (_) { /* Older browsers keep the old label. */ }
            selectFile(e.dataTransfer.files[0]);
        });
        field(S5, 'file.name', t('file-name'), { required: true, help: t('file-name-help') });
        const nameWarning = add(fields['file.name'].wrap, 'p', '', { class: 'fuw-tip', hidden: '' });
        // Warnings from the draft upload (duplicates, earlier deletions) are shown before any questions.
        const fileWarnings = add(S5, 'div', '', { class: 'fuw-warnings', tabindex: '-1' });
        const uploadProgress = add(S5, 'div', '', { class: 'fuw-progress', hidden: '' });
        const progressLabel = add(uploadProgress, 'label', '', { for: 'fuw-upload-progress' });
        const progressBar = add(uploadProgress, 'progress', '', { id: 'fuw-upload-progress', max: '100', value: '0' });
        add(S5, 'p', t('stash-note'), { class: 'fuw-help' });

        /* ---------- Step: review ---------- */
        const S6 = sections.review;
        const review = add(S6, 'div', '', { class: 'fuw-review' });
        const warningBox = add(S6, 'div', '', { class: 'fuw-warnings' });
        add(S6, 'h4', t('preview-heading'));
        const renderedStatus = add(S6, 'p', '', { role: 'status', class: 'fuw-help', id: 'fuw-rendered-status' });
        const rendered = add(S6, 'iframe', '', {
            id: 'fuw-rendered-preview', class: 'fuw-rendered-preview', title: t('preview-heading'),
            sandbox: 'allow-same-origin allow-popups allow-popups-to-escape-sandbox', hidden: ''
        });
        rendered.addEventListener('load', () => {
            const doc = rendered.contentDocument;
            if (doc && doc.body && !rendered.hidden) rendered.style.height = Math.min(900, Math.max(240, doc.documentElement.scrollHeight, doc.body.scrollHeight)) + 'px';
        });
        const retryPreview = button(S6, t('preview-retry'), () => run(loadRenderedPreview));
        retryPreview.hidden = true;
        const advanced = add(S6, 'details', '', { class: 'fuw-advanced' });
        add(advanced, 'summary', t('wikitext-summary'));
        add(advanced, 'label', t('wikitext-label'), { for: 'fuw-output', class: 'fuw-sr' });
        const output = add(advanced, 'textarea', '', { id: 'fuw-output', readonly: '', rows: '12', class: 'fuw-output' });
        button(advanced, t('wikitext-copy'), () => copyText(output));
        const finalConfirm = add(S6, 'div', '', { class: 'fuw-field fuw-check' });
        const confirmInput = add(finalConfirm, 'input', '', { type: 'checkbox', id: 'fuw-final-confirm' });
        add(finalConfirm, 'label', t('final-confirm'), { for: 'fuw-final-confirm' });
        const publish = button(S6, t('publish'), () => run(publishFile), 'primary');
        if (DEV_MODE || !ALLOW_REAL_UPLOAD) add(S6, 'p', t('dev-publish-note'), { class: 'fuw-help' });

        /* ---------- Navigation ---------- */
        const nav = add(workspace, 'div', '', { class: 'fuw-nav' });
        const back = button(nav, t('back'), () => { session.invalidate(); go(-1); });
        const nextButton = button(nav, t('continue'), next, 'primary');
        const reset = button(nav, t('reset'), () => toggleReset(), 'quiet');
        const resetBox = add(workspace, 'div', '', { class: 'fuw-note', id: 'fuw-reset', hidden: '', tabindex: '-1' });
        reset.setAttribute('aria-controls', 'fuw-reset'); reset.setAttribute('aria-expanded', 'false');
        add(resetBox, 'p', t('reset-warning'));
        const resetActions = add(resetBox, 'div', '', { class: 'fuw-actions' });
        button(resetActions, t('reset-confirm'), () => { clearDraft(); location.reload(); });
        button(resetActions, t('cancel'), () => toggleReset(false), 'quiet');
        function toggleReset(open = resetBox.hidden) {
            resetBox.hidden = !open;
            reset.setAttribute('aria-expanded', String(open));
            (open ? resetBox : reset).focus();
        }

        /* ---------- Messages ---------- */
        const messages = {
            copyright: t('error-copyright'),
            'article-check': t('error-article-check'),
            'article-missing': t('error-article-missing'),
            file: t('error-file'),
            filename: t('error-filename'),
            siteinfo: t('error-siteinfo'),
            extension: t('error-extension'),
            'extension-change': t('error-extension-change'),
            size: t('error-size'),
            prototype: t('error-prototype'),
            review: t('error-review'),
            warnings: t('error-warnings'),
            exists: t('error-exists'),
            'title-blacklisted': t('error-title-blacklisted'),
            'title-protected': t('error-title-protected'),
            template: t('error-template'),
            rights: t('error-rights'),
            'own-uploader': t('error-own-uploader'),
            'stash-expired': t('error-stash-expired'),
            assertuserfailed: t('error-assertuserfailed'),
            busy: t('error-busy')
        };
        messages.notloggedin = messages.assertuserfailed;
        function errorText(code, detail, server) {
            const record = fields[code];
            if (record) {
                const value = record.value();
                if (record.kind === 'check') return t('error-check', record.short);
                if (record.kind === 'radios' || record.kind === 'select') return value ? t('error-reselect', record.short) : t('error-select', record.short);
                if (value && /date|birth|death/i.test(code)) return t('error-date', record.short);
                return t('error-fill', record.short);
            }
            if (code === 'template') return messages.template + (detail || []).join('、');
            if (messages[code]) return messages[code];
            // No wording of our own: the server's explanation (an abuse filter, a captcha) says more than the code.
            return server ? t('error-server', server, code) : t('error-unknown', code);
        }
        // Each item links to its field so keyboard and screen reader users land on the problem.
        function showErrors(items) {
            errorBox.replaceChildren();
            add(errorBox, 'h4', t('error-summary', items.length), { id: 'fuw-errorsummary-title' });
            const ul = add(errorBox, 'ul');
            for (const item of items) {
                const li = add(ul, 'li');
                const target = item.field && item.field.focusTarget();
                if (target && !item.field.wrap.closest('[hidden]')) {
                    const a = add(li, 'a', item.text, { href: '#' + target.id });
                    a.addEventListener('click', e => { e.preventDefault(); target.focus(); target.scrollIntoView({ block: 'center' }); });
                } else li.textContent = item.text;
            }
            errorBox.hidden = false; errorBox.focus();
        }
        function clearErrors() { errorBox.hidden = true; errorBox.replaceChildren(); }
        function fail(code, detail) { return Object.assign(new Error(code), { detail }); }

        /* ---------- Validation per step ---------- */
        const STEP_CODES = { 'article-check': 'nf-basics', 'own-uploader': 'details', copyright: 'copyright' };
        function stepOf(code) {
            if (STEP_CODES[code]) return STEP_CODES[code];
            const record = fields[code];
            if (!record) return null;
            return Object.keys(sections).find(id => sections[id].contains(record.wrap)) || null;
        }
        function validateStep(id) {
            const problems = C.detailErrors(s).filter(code => stepOf(code) === id);
            Object.values(fields).forEach(record => { if (sections[id].contains(record.wrap)) record.setError(''); });
            if (!problems.length) return true;
            showErrors(problems.map(code => {
                const text = errorText(code);
                if (fields[code]) fields[code].setError(text);
                return { text, field: fields[code] || (code === 'article-check' ? fields['nonfree.article'] : null) };
            }));
            return false;
        }

        /* ---------- Result card for step one ---------- */
        const ROUTE_TITLES = {
            'free-commons': t('route-free-commons'),
            'free-local-access': t('route-free-local-access'),
            'free-userspace': t('route-free-userspace'),
            'free-esoteric': t('route-free-esoteric'),
            'local-pd-usonly': t('route-local-pd-usonly'),
            nonfree: t('route-nonfree'),
            'copyright-check': t('route-copyright-check')
        };
        const REVIEW_ROUTES = {
            'free-local-access': t('review-route-free-local-access'),
            'free-userspace': t('review-route-free-userspace'),
            'free-esoteric': t('review-route-free-esoteric'),
            'local-pd-usonly': t('review-route-local-pd-usonly'),
            nonfree: t('review-route-nonfree')
        };
        const MISSING_TEXT = {
            'source-unknown': t('missing-source-unknown'),
            'evidence-unknown': t('missing-evidence-unknown'),
            'own-published': t('missing-own-published'),
            'origin-unknown': t('missing-origin-unknown'),
            'originStatus-unknown': t('missing-origin-status-unknown'),
            'usStatus-unknown': t('missing-us-status-unknown')
        };
        const answerLabel = path => { const record = fields[path], value = get(path); const row = record.options && record.options[value]; return row ? row.querySelector('.fuw-option-label').textContent : ''; };
        function renderResult() {
            const a = s.answers, missing = C.missingForDecision(s);
            const blocking = missing.filter(code => MISSING_TEXT[code]);
            const show = !!a.sourceKind && (a.sourceKind === 'unknown' || !!a.evidenceKind) && (s.route !== 'copyright-check' || blocking.length > 0);
            result.hidden = !show;
            result.replaceChildren();
            if (!show) { lastAnnounced = ''; return; }
            const kind = s.route === 'copyright-check' ? 'stop' : s.route === 'free-commons' ? 'commons' : 'local';
            result.className = 'fuw-result fuw-result-' + kind;
            const title = ROUTE_TITLES[s.route];
            add(result, 'h4', title);
            if (title !== lastAnnounced) { announce.textContent = t('result-prefix') + title; lastAnnounced = title; }
            const actions = node('div', '', { class: 'fuw-actions' });
            if (kind === 'stop') {
                add(result, 'p', t('stop-intro'));
                list(result, blocking.map(code => MISSING_TEXT[code]));
                add(result, 'h5', t('help-heading'));
                add(result, 'p', t('help-intro'));
                const lines = [t('help-draft-opening'),
                    t('help-draft-source') + answerLabel('answers.sourceKind'), t('help-draft-evidence') + answerLabel('answers.evidenceKind'),
                    t('help-draft-origin'), t('help-draft-unsure'), ...blocking.map(code => '* ' + MISSING_TEXT[code]), '~~~~'];
                add(result, 'label', t('help-draft-label'), { for: 'fuw-help-draft', class: 'fuw-sr' });
                const draftText = add(result, 'textarea', '', { id: 'fuw-help-draft', readonly: '', rows: String(lines.length + 1), class: 'fuw-output' });
                draftText.value = lines.join('\n');
                result.appendChild(actions);
                button(actions, t('help-draft-copy'), () => copyText(draftText));
                link(actions, t('help-desk-link'), helpDesk).className = 'fuw-button';
                return;
            }
            if (kind === 'commons') {
                add(result, 'p', t('commons-intro'));
                add(result, 'p', t('commons-prepare'));
                const prepare = { own: t('commons-prepare-own'),
                    'free-license': t('commons-prepare-free-license'),
                    pd: t('commons-prepare-pd') };
                list(result, [prepare[a.evidenceKind] || prepare['free-license'], t('commons-prepare-description')]);
                result.appendChild(actions);
                link(actions, t('commons-link'), commonsUpload).className = 'fuw-button fuw-primary';
                add(result, 'p', t('commons-fallback'), { class: 'fuw-help' });
                return;
            }
            if (s.route === 'nonfree') {
                if (a.evidenceKind === 'pd') add(result, 'p', t('nonfree-from-pd'));
                add(result, 'p', t('nonfree-intro'));
                list(result, [t('nonfree-no-replaceable'),
                    t('nonfree-no-agency'), t('nonfree-no-decoration')]);
            } else if (s.route === 'local-pd-usonly') {
                add(result, 'p', t('us-only-result'));
            } else add(result, 'p', t('local-next'));
            renderRights(result, s.route === 'nonfree' || s.route === 'local-pd-usonly');
            result.appendChild(actions);
            button(actions, t('continue'), next, 'primary');
        }
        // Shown before any form filling, so nobody completes a long form they cannot submit.
        function renderRights(parent, localOnlyRoute) {
            if (capabilities !== 'ok' || canUpload) return;
            const box = add(parent, 'div', '', { class: 'fuw-note fuw-note-warning' });
            if (!s.uploader) {
                add(box, 'p', t('rights-login'));
                link(add(box, 'p'), t('login'), mw.util.getUrl('Special:UserLogin', { returnto: PAGE, returntoquery: 'fuwdev=1' }), false).addEventListener('click', saveDraft);
                return;
            }
            add(box, 'p', t('rights-account'));
            if (localOnlyRoute) link(add(box, 'p'), t('rights-help-desk'), helpDesk);
            else link(add(box, 'p'), t('rights-commons'), commonsUpload);
        }

        /* ---------- Sync visibility with state ---------- */
        function sync() {
            C.setRoute(s);
            const a = s.answers, kind = C.copyrightKind(s), isNF = s.route === 'nonfree', isPD = kind === 'pd' && !isNF, own = C.usesOwnSource(s);
            const oversize = isNF ? C.nonfreeResolutionWarning(s.file.info) : '';
            visible(reduce, !!oversize);
            if (oversize) reduceSize.textContent = oversize === 'side' ? t('nf-size-side') : t('nf-size-pixels', Math.round(s.file.info.width * s.file.info.height / 10000));
            visible(noReduceReason, !!oversize && s.nonfree.noReduce);
            sourceTip.textContent = SOURCE_TIPS[a.sourceKind] || '';
            sourceTip.hidden = !sourceTip.textContent;
            visible(groups.evidence, !!a.sourceKind && a.sourceKind !== 'unknown');
            const ownOption = fields['answers.evidenceKind'].options.own;
            visible(ownOption, OWN_SOURCES.includes(a.sourceKind));
            visible(fields['answers.ownConfirmed'].wrap, !groups.evidence.hidden && a.sourceKind === 'own' && a.evidenceKind === 'own');
            visible(groups.pd, !groups.evidence.hidden && kind === 'pd');
            visible(groups.other, !groups.pd.hidden && s.jurisdiction.origin === 'OTHER');
            visible(groups.originStatus, !groups.pd.hidden && s.jurisdiction.origin !== 'US');
            visible(groups.use, (kind === 'license' || kind === 'pd') && !['nonfree', 'local-pd-usonly', 'copyright-check'].includes(s.route));
            visible(groups.localOnly, !groups.use.hidden && a.purpose !== 'general');
            renderResult();
            // Description and source
            ownAuthor.replaceChildren();
            ownAuthor.hidden = !own;
            if (own) {
                if (s.uploader) { ownAuthor.append(t('own-author')); link(ownAuthor, s.uploader, mw.util.getUrl('User:' + s.uploader)); }
                else ownAuthor.textContent = t('error-own-uploader');
            }
            dateTip.hidden = !(fileDate && s.metadata.publicationDate === fileDate);
            visible(groups.author, !own);
            visible(groups.source, !own);
            // A non-free rationale names the author only where known; a free licence cannot be checked without one.
            const authorField = fields['metadata.author'];
            authorField.wrap.querySelector('label .fuw-mark').textContent = isNF ? t('mark-optional') : t('mark-required');
            authorField.wrap.querySelector('.fuw-help').textContent = t('author-help') + (isNF ? t('author-help-optional') : '');
            visible(groups.publication, isNF);
            visible(groups.published, isNF);
            visible(groups.license, kind === 'license' && !isNF);
            const licenseSelect = fields['license.type'].inputs[0], choices = C.licenseChoices(s);
            if (licenseSelect.dataset.choices !== choices.join('|')) {
                licenseSelect.replaceChildren(node('option', empty[1], { value: '' }), ...choices.map(value => node('option', (LICENSE_LABELS[value] || value) + (own && value === 'CC-BY-SA-4.0' ? t('license-recommended') : ''), { value })));
                if (!choices.includes(s.license.type)) s.license.type = '';
                licenseSelect.value = s.license.type;
                licenseSelect.dataset.choices = choices.join('|');
            }
            fields['license.type'].wrap.querySelector('label').firstChild.textContent = own ? t('license-own') : t('license-source');
            licenseHelp.textContent = own ? t('license-own-help') : t('license-source-help');
            visible(groups.customLicense, !groups.license.hidden && s.license.type === 'custom');
            visible(groups.licenseEvidence, !groups.license.hidden && !own);
            visible(groups.pdEvidence, isPD);
            visible(groups.pdTag, isPD && s.route !== 'local-pd-usonly');
            visible(groups.usOnly, s.route === 'local-pd-usonly');
            // Non-free
            const nfSelect = fields['nonfree.license'].inputs[0];
            const allowed = C.NONFREE_LICENSES[s.nonfree.type] || [];
            if (nfSelect.dataset.type !== s.nonfree.type) {
                nfSelect.replaceChildren(node('option', empty[1], { value: '' }));
                allowed.forEach(name => nfSelect.appendChild(node('option', nfLicenseText(name), { value: name })));
                if (allowed.length === 1) s.nonfree.license = allowed[0];
                if (!allowed.includes(s.nonfree.license)) s.nonfree.license = '';
                nfSelect.value = s.nonfree.license;
                nfSelect.dataset.type = s.nonfree.type;
            }
            visible(fields['nonfree.license'].wrap, !!s.nonfree.type);
            const profile = C.rationaleProfile(s);
            visible(groups.rationale, !!s.nonfree.license);
            for (const item of Object.values(C.RATIONALE_PROFILES)) visible(groups['rationale-' + item.id], !groups.rationale.hidden && item.id === profile.id);
            rationaleDoc.replaceChildren();
            link(rationaleDoc, t('rationale-doc'), mw.util.getUrl('Template:' + profile.template));
            visible(fields['nonfree.purpose'].wrap, C.needsPurpose(s));
            // Without a Use list, the one Purpose answer also says where the file goes.
            fields['nonfree.significance'].wrap.querySelector('label').firstChild.textContent = profile.uses.length
                ? t('nf-significance') : t('nf-significance-with-place');
            const example = ['Non-free audio sample', 'Non-free video sample'].includes(s.nonfree.license) ? NF_EXAMPLES.sample : NF_EXAMPLES[s.nonfree.type] || NF_EXAMPLES.other;
            for (const [key, text] of Object.entries({ significance: example.purpose, replaceability: example.replaceability, replaceabilityText: example.text, minimality: example.minimality, commercial: example.commercial })) {
                fields['nonfree.' + key].wrap.querySelector('.fuw-help').textContent = text;
            }
            // Navigation
            const ids = stepIds(), index = ids.indexOf(stepId);
            nextButton.hidden = stepId === 'copyright' || stepId === 'review';
            nextButton.textContent = stepId === 'file' ? t('next-stash') : stepId === 'details' ? t('next-review') : t('continue');
            back.hidden = index <= 0;
            publish.disabled = DEV_MODE || !ALLOW_REAL_UPLOAD || session.completed;
            renderProgress();
        }
        const LICENSE_LABELS = {
            'CC-BY-SA-4.0': 'CC BY-SA 4.0', 'CC-BY-4.0': 'CC BY 4.0', 'Cc-zero': t('license-cc-zero'),
            'CC-BY-SA-3.0': 'CC BY-SA 3.0', 'CC-BY-3.0': 'CC BY 3.0', 'CC-BY-SA-2.5': 'CC BY-SA 2.5', 'CC-BY-2.5': 'CC BY 2.5',
            'CC-BY-SA-2.0': 'CC BY-SA 2.0', 'CC-BY-2.0': 'CC BY 2.0', custom: t('license-custom')
        };
        const licenseHelp = add(fields['license.type'].wrap, 'p', '', { class: 'fuw-help', id: 'fuw-license-type-help' });
        fields['license.type'].describe.push(licenseHelp.id);
        fields['license.type'].refreshDescribedBy();

        function renderProgress() {
            const ids = stepIds();
            progress.hidden = ids.length < 2;
            progress.replaceChildren();
            const current = ids.indexOf(stepId);
            ids.forEach((id, i) => {
                const li = add(progress, 'li');
                add(li, 'span', String(i + 1), { class: 'fuw-step-num' });
                add(li, 'span', '/' + ids.length, { class: 'fuw-step-total' });
                add(li, 'span', STEP_TITLES[id], { class: 'fuw-step-title' });
                if (i === current) li.setAttribute('aria-current', 'step');
                if (i < current) { li.classList.add('fuw-done'); add(li, 'span', t('step-done'), { class: 'fuw-sr' }); }
            });
        }
        function showStep(id, focus = true) {
            stepId = id;
            if (id !== 'review') { previewRequest++; rendered.hidden = true; rendered.removeAttribute('srcdoc'); }
            for (const [key, section] of Object.entries(sections)) section.hidden = key !== id;
            clearErrors(); status.textContent = '';
            confirmInput.checked = false;
            sync();
            saveDraft();
            if (focus) sections[id].querySelector('h3').focus();
        }
        function go(delta) {
            const ids = stepIds(), index = Math.max(0, ids.indexOf(stepId));
            showStep(ids[Math.min(ids.length - 1, Math.max(0, index + delta))]);
        }
        // Keeps focus on the control that started the action; disabling the fieldset would drop it to <body>.
        async function run(fn) {
            if (busy) return;
            const active = document.activeElement;
            busy = true; workspace.disabled = true; mount.setAttribute('aria-busy', 'true'); clearErrors();
            status.textContent = t('working');
            try { await fn(); }
            catch (error) { showErrors([{ text: errorText(error.message || String(error), error.detail, C.serverMessage(error.response)) }]); }
            finally {
                busy = false; workspace.disabled = false; mount.removeAttribute('aria-busy');
                if (status.textContent === t('working')) status.textContent = '';
                const focused = document.activeElement;
                if ((!focused || focused === document.body) && active && active.isConnected && !active.disabled && !active.closest('[hidden]')) active.focus();
            }
        }
        function next() {
            run(async () => {
                C.setRoute(s);
                if (stepId === 'copyright') {
                    if (!C.LOCAL_ROUTES.includes(s.route)) throw fail('copyright');
                } else if (stepId === 'file') {
                    if (!await prepareFile()) return;
                } else if (!validateStep(stepId)) return;
                if (stepId === 'details') { await prepareReview(); return; }
                go(1);
            });
        }

        /* ---------- Server checks ---------- */
        async function checkArticle() {
            const title = s.nonfree.article.trim();
            s.nonfree.validatedArticle = '';
            fields['nonfree.article'].setError('');
            if (!title || /[\[\]{}|#<>]/.test(title) || /^https?:/i.test(title)) { fields['nonfree.article'].setError(messages['article-missing']); throw fail('article-missing'); }
            const response = await C.request(api.get({ action: 'query', formatversion: 2, titles: title, redirects: true, converttitles: true, prop: 'pageprops', ppprop: 'disambiguation' }));
            let resolved;
            try { resolved = C.articleFromResponse(response); }
            catch (_) { fields['nonfree.article'].setError(messages['article-missing']); throw fail('article-missing'); }
            s.nonfree.article = resolved; s.nonfree.validatedArticle = resolved;
            fields['nonfree.article'].setValue(resolved);
            articleStatus.textContent = t('article-confirmed') + resolved;
            saveDraft();
            sync();
        }
        async function checkTemplates() {
            const names = C.requiredTemplates(s);
            const response = await C.request(api.get({ action: 'query', formatversion: 2, titles: names.map(name => 'Template:' + name).join('|'), redirects: true }));
            const pages = (response.query && response.query.pages) || [];
            const missing = pages.filter(page => page.missing || page.invalid || page.ns !== 10).map(page => String(page.title || '').replace(/^[^:]+:/, ''));
            if (!pages.length || missing.length) {
                if (C.copyrightKind(s) === 'pd' && missing.some(name => name.toLowerCase() === s.license.pdTemplate.toLowerCase())) fields['license.pdTemplate'].setError(messages.template + s.license.pdTemplate);
                throw fail('template', missing.length ? missing : names);
            }
        }
        async function loadCapabilities() {
            limits = null; canUpload = false; capabilities = 'loading';
            try {
                const response = await C.request(api.get({ action: 'query', formatversion: 2, meta: 'siteinfo|userinfo', siprop: 'general|fileextensions', uiprop: 'rights|blockinfo' }));
                const q = response.query || {}, user = q.userinfo || {}, general = q.general || {};
                const uploader = user.id > 0 ? user.name || '' : '';
                if (uploader !== s.uploader) { s.uploader = uploader; session.invalidate(); }
                const found = { maxSize: Number(general.maxuploadsize), extensions: [...new Set((q.fileextensions || []).map(item => item.ext.toLowerCase()))] };
                if (!found.extensions.length || !Number.isFinite(found.maxSize) || found.maxSize <= 0 || general.uploadsenabled === false) throw fail('siteinfo');
                limits = found;
                canUpload = user.id > 0 && (user.rights || []).includes('upload') && !user.blockid && !user.blockedby;
                capabilities = 'ok';
                const size = limits.maxSize >= 1024 ** 3 ? (limits.maxSize / 1024 ** 3).toFixed(0) + ' GiB' : (limits.maxSize / 1024 ** 2).toFixed(0) + ' MiB';
                capability.textContent = t('capability-ok', size);
                formatList.textContent = limits.extensions.sort().join('、');
                formats.hidden = false; capabilityRetry.hidden = true;
                fileInput.accept = limits.extensions.map(ext => '.' + ext).join(',');
                validateFile();
            } catch (error) {
                capabilities = 'error';
                capability.textContent = messages.siteinfo;
                capabilityRetry.hidden = false;
                if (error.message !== 'siteinfo') throw error;
            } finally { sync(); }
        }

        /* ---------- File selection ---------- */
        function setFileError(message) { fileError.textContent = message || ''; fileError.hidden = !message; }
        function validateFile() {
            const name = s.file.name;
            nameWarning.hidden = !(name && C.genericFilename(name));
            nameWarning.textContent = t('file-name-generic');
            if (!s.file.object) { setFileError(''); fields['file.name'].setError(''); return []; }
            const problems = C.fileErrors(s.file.object, name, limits).filter(code => code !== 'siteinfo' || capabilities === 'error');
            const nameProblems = problems.filter(code => ['filename', 'extension', 'extension-change'].includes(code));
            fields['file.name'].setError(nameProblems.map(code => errorText(code)).join(' '));
            setFileError(problems.filter(code => !nameProblems.includes(code)).map(code => code === 'size' && limits ? errorText(code) + '(' + (limits.maxSize / 1024 ** 2).toFixed(0) + ' MiB)' : errorText(code)).join(' '));
            return problems;
        }
        function selectFile(file) {
            if (!file || busy) return;
            session.dropStash();
            if (objectURL) URL.revokeObjectURL(objectURL);
            objectURL = null; preview.replaceChildren();
            lastWarnings = {}; fileChecked = false; fileDate = ''; fileWarnings.replaceChildren();
            // A name typed before a reload is kept when the reselected file has the same format.
            const ext = name => name.split('.').pop().toLowerCase();
            const name = s.file.name && !s.file.object && ext(s.file.name) === ext(file.name) ? s.file.name : file.name;
            s.file = { object: file, filekey: null, name, size: file.size, type: file.type, info: null };
            fields['file.name'].setValue(name);
            add(preview, 'p', file.name + ' · ' + (file.size >= 1024 ** 2 ? (file.size / 1024 ** 2).toFixed(1) + ' MiB' : (file.size / 1024).toFixed(1) + ' KiB'));
            if (['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type)) {
                objectURL = URL.createObjectURL(file);
                add(preview, 'img', '', { src: objectURL, alt: t('thumbnail-alt') });
            }
            validateFile();
            saveDraft();
        }
        async function checkFilename() {
            const title = 'File:' + s.file.name;
            const [response, blacklist] = await Promise.all([
                C.request(api.get({ action: 'query', formatversion: 2, titles: title, prop: 'imageinfo|info', iiprop: 'size|sha1', intestactions: 'upload', intestactionsdetail: 'full', redirects: true })),
                // The blacklist is an extension; a wiki without it still gets the checks above.
                C.request(api.get({ action: 'titleblacklist', formatversion: 2, tbtitle: title, tbaction: 'upload' })).catch(() => null)
            ]);
            const problem = C.filenameProblem(response, blacklist);
            if (problem) { fields['file.name'].setError(messages[problem]); throw fail(problem); }
        }
        // Stashes the file so the server can report its real format and size before the questions that
        // depend on them. Returns false when the file step itself still has problems.
        async function prepareFile() {
            if (!s.file.object) throw fail('file');
            const problems = validateFile();
            if (problems.length) {
                showErrors(problems.map(code => ({ text: errorText(code), field: ['filename', 'extension', 'extension-change'].includes(code) ? fields['file.name'] : null })));
                return false;
            }
            if (!canUpload) throw fail('rights');
            await checkFilename();
            // Only a draft whose checks all ran, and whose warnings were shown, may be passed on a second press.
            if (session.hasStash(s.file.object, s.file.name) && s.file.info && fileChecked) return true;
            if (!session.hasStash(s.file.object, s.file.name)) {
                fileChecked = false;
                uploadProgress.hidden = false;
                const update = fraction => { const percent = Math.round(fraction * 100); progressBar.value = percent; progressLabel.textContent = t('stash-progress') + percent + '%'; };
                update(0);
                try {
                    const upload = await session.stash(s.file.object, s.file.name, update);
                    lastWarnings = upload.warnings || {};
                } finally { uploadProgress.hidden = true; }
            }
            s.file.filekey = session.filekey;
            const info = await C.request(api.get({ action: 'query', formatversion: 2, prop: 'stashimageinfo', siifilekey: session.filekey, siiprop: 'size|mime|sha1|commonmetadata' }));
            s.file.info = info.query && info.query.stashimageinfo && info.query.stashimageinfo[0];
            if (!s.file.info) { session.dropStash(); throw fail('stash-expired'); }
            // A photographer's own file usually knows when it was taken; an empty date field takes that.
            fileDate = C.usesOwnSource(s) ? C.dateFromMetadata(s.file.info) : '';
            if (fileDate && !s.metadata.publicationDate.trim()) {
                s.metadata.publicationDate = fileDate;
                fields['metadata.publicationDate'].setValue(fileDate);
                saveDraft();
            }
            if (s.file.info.sha1) {
                const duplicates = await C.request(api.get({ action: 'query', formatversion: 2, list: 'allimages', aisha1: s.file.info.sha1, ailimit: 10 }));
                const matches = duplicates.query && duplicates.query.allimages;
                if (!Array.isArray(matches)) throw fail('api');
                if (matches.length) lastWarnings.duplicate = matches.map(file => file.name);
            }
            renderWarnings(lastWarnings, false, fileWarnings);
            // Publishing cannot go past these, so the name or the file has to change here, before the questions.
            if (Object.keys(lastWarnings).some(code => !C.CONFIRMABLE_WARNINGS.includes(code))) {
                add(fileWarnings, 'p', t('file-warnings-blocked'), { class: 'fuw-help' });
                fileWarnings.focus();
                return false;
            }
            fileChecked = true;
            if (Object.keys(lastWarnings).length) {
                // Stay here once so the warnings are read; pressing the button again goes on with the same draft.
                add(fileWarnings, 'p', t('file-warnings-continue'), { class: 'fuw-help' });
                fileWarnings.focus();
                return false;
            }
            return true;
        }
        async function prepareReview() {
            if (C.detailErrors(s).length) { const first = C.detailErrors(s).map(stepOf).find(Boolean); if (first) { showStep(first); validateStep(first); } return; }
            // A reload or an expired draft leaves answers without a file; the file step puts it back.
            if (!session.hasStash(s.file.object, s.file.name) || !s.file.info) { showStep('file'); throw fail('stash-expired'); }
            await checkTemplates();
            session.setReview(s);
            renderReview();
            showStep('review');
            await loadRenderedPreview();
        }

        /* ---------- Review ---------- */
        async function loadRenderedPreview() {
            const requestId = ++previewRequest, text = output.value, filename = s.file.name;
            const current = () => requestId === previewRequest && stepId === 'review' && output.value === text && s.file.name === filename;
            rendered.hidden = true;
            rendered.removeAttribute('srcdoc');
            renderedStatus.textContent = t('preview-loading');
            retryPreview.hidden = true;
            try {
                // action=parse is read-only; POST avoids URL length limits for full rationales.
                const response = await C.request(api.post({ action: 'parse', formatversion: 2, title: 'File:' + filename,
                    text, contentmodel: 'wikitext', prop: 'text|headhtml', preview: true,
                    disableeditsection: true, disablelimitreport: true, uselang: variant, variant,
                    useskin: mw.config.get('skin') || 'vector-2022' }));
                if (!current()) return;
                if (!response.parse || typeof response.parse.text !== 'string' || !response.parse.text.trim()) throw fail('parse');
                const parsed = response.parse;
                const doc = new DOMParser().parseFromString((parsed.headhtml || '<!doctype html><html><head></head><body>') + parsed.text + '</body></html>', 'text/html');
                // Keep server-rendered markup and styles isolated from the wizard. No scripts run.
                doc.querySelectorAll('script, iframe, object, embed, form, base, meta[http-equiv], link:not([rel="stylesheet"])').forEach(el => el.remove());
                doc.querySelectorAll('*').forEach(el => {
                    for (const attr of [...el.attributes]) if (/^on/i.test(attr.name)) el.removeAttribute(attr.name);
                });
                const base = doc.createElement('base');
                base.href = new URL(mw.util.getUrl('File:' + filename), location.href).href;
                base.target = '_blank'; doc.head.prepend(base);
                doc.querySelectorAll('a').forEach(el => { el.target = '_blank'; el.rel = 'noopener noreferrer'; });
                doc.documentElement.lang = variant;
                doc.body.classList.add('mw-body-content');
                const style = doc.createElement('style');
                style.textContent = 'html,body{margin:0;min-width:0}body{padding:1rem;box-sizing:border-box;overflow-wrap:anywhere}.mw-parser-output{max-width:100%}img{max-width:100%;height:auto}';
                doc.head.appendChild(style);
                rendered.srcdoc = '<!doctype html>\n' + doc.documentElement.outerHTML;
                rendered.hidden = false;
                renderedStatus.textContent = t('preview-ready');
            } catch (_) {
                if (!current()) return;
                renderedStatus.textContent = t('preview-failed');
            } finally {
                if (current()) retryPreview.hidden = false;
            }
        }
        const plainLabel = path => fields[path] ? fields[path].short : path;
        const optionText = (path, value) => {
            const record = fields[path];
            if (!record) return value;
            if (record.options && record.options[value]) return record.options[value].querySelector('.fuw-option-label').textContent;
            const option = record.kind === 'select' && [...record.inputs[0].options].find(item => item.value === value);
            return option ? option.textContent : value;
        };
        function renderReview() {
            review.replaceChildren();
            add(review, 'p', t('review-route-prefix') + REVIEW_ROUTES[s.route], { class: 'fuw-review-route' });
            const dl = add(review, 'dl');
            const entry = (label, value) => { add(dl, 'dt', label); return add(dl, 'dd', value || t('review-empty')); };
            entry(t('file-name'), s.file.name);
            const info = s.file.info;
            if (info) entry(t('review-file-info'), [info.mime, info.width && info.height ? info.width + ' × ' + info.height + t('review-pixels') : '', (info.size / 1024).toFixed(0) + ' KiB'].filter(Boolean).join(' · '));
            entry(plainLabel('metadata.description'), s.metadata.description);
            if (C.usesOwnSource(s)) {
                const authorCell = entry(t('review-author'), s.uploader);
                authorCell.replaceChildren();
                link(authorCell, s.uploader, mw.util.getUrl('User:' + s.uploader));
                entry(t('review-source'), t('review-own-work'));
            } else {
                if (s.route !== 'nonfree' || s.metadata.author.trim()) entry(plainLabel('metadata.author'), s.metadata.author);
                entry(plainLabel('metadata.source'), s.metadata.source);
            }
            if (s.route === 'nonfree' && s.metadata.publication.trim()) entry(plainLabel('metadata.publication'), s.metadata.publication);
            if (s.metadata.publicationDate) entry(plainLabel('metadata.publicationDate'), s.metadata.publicationDate);
            if (s.route === 'nonfree') {
                const profile = C.rationaleProfile(s), extra = s.nonfree.rationale[profile.id];
                entry(plainLabel('nonfree.article'), s.nonfree.validatedArticle);
                entry(plainLabel('nonfree.type'), NF_TYPE_LABELS[s.nonfree.type]);
                entry(plainLabel('nonfree.license'), nfLicenseText(s.nonfree.license));
                if (profile.uses.length) entry(t('use-at-short'), useLabels[extra.use]);
                for (const item of profile.fields) if (extra[item.key].trim()) entry(rationaleLabel(profile, item), extra[item.key]);
                for (const key of ['purpose', 'significance', 'replaceability', 'replaceabilityText', 'minimality', 'commercial', 'otherInformation']) {
                    if (key === 'purpose' && !C.needsPurpose(s)) continue;
                    if (['replaceabilityText', 'commercial', 'otherInformation'].includes(key) && !s.nonfree[key].trim()) continue;
                    entry(plainLabel('nonfree.' + key), s.nonfree[key]);
                }
                const reduction = C.nonfreeReduction(s);
                if (reduction) entry(t('review-size-handling'), reduction === 'reduce' ? t('review-reduce') : t('review-keep-size', s.nonfree.noReduceReason.trim()));
            } else if (C.copyrightKind(s) === 'pd') {
                const j = s.jurisdiction;
                entry(t('review-origin'), j.origin === 'OTHER' ? optionText('jurisdiction.otherOrigin', j.otherOrigin) : optionText('jurisdiction.origin', j.origin));
                if (j.origin !== 'US') entry(plainLabel('jurisdiction.originCopyrightStatus'), optionText('jurisdiction.originCopyrightStatus', j.originCopyrightStatus));
                entry(plainLabel('jurisdiction.usCopyrightStatus'), optionText('jurisdiction.usCopyrightStatus', j.usCopyrightStatus));
                entry(plainLabel('jurisdiction.legalBasis'), j.legalBasis);
                entry(t('license'), s.route === 'local-pd-usonly' ? 'PD-USonly' : s.license.pdTemplate);
            } else {
                entry(t('license'), s.license.type === 'custom' ? s.license.customDescription : LICENSE_LABELS[s.license.type]);
                if (!C.usesOwnSource(s)) entry(plainLabel('license.evidence'), s.license.evidence);
            }
            output.value = C.buildWikitext(s);
            // Draft warnings were shown at the file step; this box is for those raised on publishing.
            warningBox.replaceChildren();
        }
        function renderWarnings(warnings, confirmation, target = warningBox) {
            target.replaceChildren();
            const descriptions = {
                duplicate: t('warning-duplicate'),
                'duplicate-archive': t('warning-duplicate-archive'),
                'was-deleted': t('warning-was-deleted'),
                exists: messages.exists, 'exists-normalized': messages.exists,
                badfilename: t('warning-badfilename')
            };
            for (const [code, value] of Object.entries(warnings)) {
                const box = add(target, 'div', '', { class: 'fuw-note fuw-note-warning' });
                add(box, 'p', descriptions[code] || t('warning-unknown') + code);
                if (code === 'duplicate' && Array.isArray(value)) {
                    const ul = add(box, 'ul');
                    value.forEach(name => link(add(ul, 'li'), String(name), mw.util.getUrl('File:' + name)));
                } else if (typeof value === 'string') add(box, 'p', value, { class: 'fuw-mono' });
                if (confirmation && ['duplicate', 'duplicate-archive', 'was-deleted'].includes(code)) {
                    const row = add(box, 'div', '', { class: 'fuw-check' });
                    add(row, 'input', '', { type: 'checkbox', 'data-warning': code, id: 'fuw-warning-' + code });
                    add(row, 'label', t('warning-acknowledge'), { for: 'fuw-warning-' + code });
                }
            }
        }
        async function copyText(textarea) {
            try { await navigator.clipboard.writeText(textarea.value); status.textContent = t('copied'); }
            catch (_) { textarea.focus(); textarea.select(); status.textContent = t('copy-manually'); }
        }
        async function publishFile() {
            if (DEV_MODE || !ALLOW_REAL_UPLOAD) throw fail('prototype');
            if (!confirmInput.checked) { confirmInput.focus(); throw fail('review'); }
            await checkFilename();
            const checked = [...warningBox.querySelectorAll('input[data-warning]:checked')].map(input => input.dataset.warning);
            const upload = await session.publish(s, checked);
            if (upload.result === 'Warning') {
                renderWarnings(upload.warnings, true);
                status.textContent = messages.warnings;
                return;
            }
            clearDraft();
            renderSuccess(upload.filename || s.file.name);
        }
        function renderSuccess(filename) {
            const section = sections.review;
            [...section.children].forEach(child => { if (child.tagName !== 'H3') child.hidden = true; });
            const done = add(section, 'div', '', { class: 'fuw-result fuw-result-commons' });
            add(done, 'h4', t('done-heading'));
            link(add(done, 'p'), 'File:' + filename, mw.util.getUrl('File:' + filename), false);
            add(done, 'p', t('done-usage'));
            add(done, 'label', t('done-usage-label'), { for: 'fuw-usage-code', class: 'fuw-sr' });
            const code = add(done, 'input', '', { type: 'text', readonly: '', id: 'fuw-usage-code', class: 'fuw-output' });
            code.value = '[[File:' + filename + '|thumb|' + t('done-caption') + ']]';
            const actions = add(done, 'div', '', { class: 'fuw-actions' });
            button(actions, t('done-usage-copy'), () => copyText(code));
            const target = s.nonfree.validatedArticle || s.usage.page;
            if (target) link(actions, t('done-article-link') + target, mw.util.getUrl(target), false).className = 'fuw-button';
            nav.hidden = true; resetBox.hidden = true;
            done.querySelector('h4').setAttribute('tabindex', '-1');
            done.querySelector('h4').focus();
        }
        // Drafts survive reloads now; only warn while bytes are still being sent.
        window.addEventListener('beforeunload', e => { if (session.busy) { e.preventDefault(); e.returnValue = ''; } });

        /* ---------- Start ---------- */
        C.setRoute(s);
        const ids = stepIds();
        const resume = draft && ids.includes(draft.step) ? (ids.indexOf(draft.step) > ids.indexOf('file') ? 'file' : draft.step) : 'copyright';
        showStep(resume, false);
        if (draft && (s.answers.sourceKind || resume !== 'copyright')) status.textContent = t('draft-restored');
        loadCapabilities().catch(() => { /* Shown in the file step; the rest of the wizard works without it. */ });
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
    else start();
}());
// </nowiki>