跳转到内容

User:Sekidoki/Scripts/FileUploadWizard-core.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// <nowiki>
/* FileUploadWizard prototype: policy and transport, independent of the DOM. */
(function (host, factory) {
    'use strict';
    const core = factory();
    if (typeof module === 'object' && module.exports) {
        module.exports = core;
    } else {
        host.FileUploadWizardCore = core;
    }
}(typeof globalThis === 'object' ? globalThis : this, function () {
    'use strict';
    const MAIN_REGIONS = Object.freeze(['TW', 'CN', 'HK', 'MO', 'SG', 'MY']);
    // Origins offered under "other": ISO 3166-1 codes, chosen from a list so that the United States,
    // its territories and the main regions cannot be entered here to skip their own checks.
    const OTHER_REGIONS = Object.freeze('AD AE AF AG AI AL AM AO AQ AR AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GW GY HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MQ MR MS MT MU MV MW MX MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TZ UA UG UY UZ VA VC VE VG VN VU WF WS XK YE YT ZA ZM ZW'.split(' '));
    function regionName(code, locale = 'zh-Hant') {
        try { return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || code; }
        catch (_) { return code; }
    }
    // Where the file came from. Only 'own' can skip author/source/evidence fields.
    const SOURCE_KINDS = Object.freeze(['own', 'own-published', 'online', 'scan', 'government', 'other', 'unknown']);
    // What licence evidence exists. 'own' is offered only for the uploader's own work.
    const EVIDENCE_KINDS = Object.freeze(['own', 'free-license', 'pd', 'restricted', 'copyrighted', 'unknown']);
    // Template names checked on zh.wikipedia on 2026-11-08 (lower-case Cc-by-* names redirect here).
    const FREE_LICENSES = Object.freeze(['CC-BY-SA-4.0', 'CC-BY-4.0', 'Cc-zero', 'CC-BY-SA-3.0', 'CC-BY-3.0', 'CC-BY-SA-2.5', 'CC-BY-2.5', 'CC-BY-SA-2.0', 'CC-BY-2.0']);
    const COMMONS_TRANSFER_TEMPLATE = 'Copy to Wikimedia Commons';
    // Template:SVG-Res is the low-resolution statement for a non-free logo uploaded as SVG.
    const SVG_RESOLUTION_TEMPLATE = 'SVG-Res';
    // An oversized non-free image is queued for the resizing bot unless the uploader explains why not.
    const REDUCE_TEMPLATE = 'Non-free reduce';
    const NO_REDUCE_TEMPLATE = 'Non-free no reduce';
    // Section headings use the site's own interface messages, so they follow the reader's language.
    const DESCRIPTION_HEADING = '== {{int:filedesc}} ==\n';
    const LICENSE_HEADING = '\n\n== {{int:license-header}} ==\n';
    const UPLOAD_COMMENT = '使用[[Template:Upload Wizard Page|檔案上傳精靈]]上傳';
    // Wikipedia:非自由內容 suggests about 0.1 megapixels; over 1,000 px on a side draws closer review.
    const NONFREE_PIXEL_GUIDE = 100000;
    // The guide says "about" 0.1 megapixels, so a file a few percent over is not sent for resizing.
    const NONFREE_PIXEL_TOLERANCE = 1.05;
    const NONFREE_SIDE_LIMIT = 1000;
    const NONFREE_LICENSES = Object.freeze({
        logo: ['Non-free logo'],
        cover: ['Non-free book cover', 'Non-free album cover', 'Non-free game cover', 'Non-free video cover'],
        excerpt: ['Non-free film screenshot', 'Non-free television screenshot', 'Non-free game screenshot', 'Non-free audio sample', 'Non-free video sample'],
        portrait: ['Non-free biog-pic'],
        art: ['Non-free 2D art', 'Non-free 3D art'],
        historic: ['Non-free historic image'],
        other: ['Non-free fair use in']
    });
    // Verified against live zh.wikipedia template source on 2026-10-02; unchanged on 2026-11-11.
    // The generic template gained Author, Publication, Date, Replaceability_text, Minimality and
    // Commercial on 2026-11-10. The typed templates below do not forward those parameters.
    const RATIONALE_PROFILES = Object.freeze({
        generic: {"id": "generic", "template": "Non-free use rationale", "uses": [], "fields": []},
        logo: {"id": "logo", "template": "Non-free use rationale logo", "lowResolution": "Low resolution", "uses": ["Infobox", "Org", "Brand", "Product", "Public facility", "Public authority", "Other"], "fields": [{"key": "Used_for", "parameter": "Used for", "label": "標誌代表的名稱"}, {"key": "Owner", "parameter": "Owner", "label": "著作權持有者"}, {"key": "Type", "parameter": "Type", "label": "作品或媒體類型"}, {"key": "History", "parameter": "History", "label": "標誌的歷史用途"}]},
        book: {"id": "book", "template": "Non-free use rationale book cover", "uses": ["Infobox", "Header", "Section", "Author", "Other"], "fields": [{"key": "Title", "parameter": "Title", "label": "作品標題"}, {"key": "Volume", "parameter": "Volume", "label": "卷數"}, {"key": "Author", "parameter": "Author", "label": "書籍作者"}, {"key": "Illustrator", "parameter": "Illustrator", "label": "插畫者"}, {"key": "Publisher", "parameter": "Publisher", "label": "發表/發行者"}, {"key": "Cover_artist", "parameter": "Cover_artist", "label": "封面設計者"}, {"key": "Owner", "parameter": "Owner", "label": "著作權持有者"}, {"key": "Type", "parameter": "Type", "label": "作品或媒體類型"}]},
        album: {"id": "album", "template": "Non-free use rationale album cover", "uses": ["Infobox", "Header", "Section", "Other"], "fields": [{"key": "Name", "parameter": "Name", "label": "作品名稱"}, {"key": "Artist", "parameter": "Artist", "label": "演出者/創作者"}, {"key": "Label", "parameter": "Label", "label": "唱片公司"}, {"key": "Graphic_Artist", "parameter": "Graphic Artist", "label": "美術/封面設計者"}, {"key": "Item", "parameter": "Item", "label": "封面或封底"}, {"key": "Type", "parameter": "Type", "label": "作品或媒體類型"}, {"key": "Owner", "parameter": "Owner", "label": "著作權持有者"}]},
        videoCover: {"id": "videoCover", "template": "Non-free use rationale video cover", "uses": ["Infobox", "Header", "Section", "Other"], "fields": [{"key": "Title", "parameter": "Title", "label": "作品標題"}, {"key": "Volume", "parameter": "Volume", "label": "卷數"}, {"key": "Season", "parameter": "Season", "label": "季數"}, {"key": "Distributor", "parameter": "Distributor", "label": "經銷商"}, {"key": "Publisher", "parameter": "Publisher", "label": "發表/發行者"}, {"key": "Studio", "parameter": "Studio", "label": "製作公司"}, {"key": "Type", "parameter": "Type", "label": "作品或媒體類型"}, {"key": "Owner", "parameter": "Owner", "label": "著作權持有者"}]},
        gameCover: {"id": "gameCover", "template": "Non-free use rationale video game cover", "uses": ["Infobox", "Header", "Section", "Artist", "Other"], "fields": [{"key": "Name", "parameter": "Name", "label": "作品名稱"}, {"key": "Developer", "parameter": "Developer", "label": "遊戲開發商"}, {"key": "Publisher", "parameter": "Publisher", "label": "發表/發行者"}, {"key": "Distributor", "parameter": "Distributor", "label": "經銷商"}, {"key": "Graphic_Artist", "parameter": "Graphic Artist", "label": "美術/封面設計者"}, {"key": "Type", "parameter": "Type", "label": "作品或媒體類型"}, {"key": "Owner", "parameter": "Owner", "label": "著作權持有者"}]},
        gameScreenshot: {"id": "gameScreenshot", "template": "Non-free use rationale video game screenshot", "uses": ["Infobox", "Header", "Section", "Artist", "Other"], "fields": [{"key": "Name", "parameter": "Name", "label": "作品名稱"}, {"key": "Developer", "parameter": "Developer", "label": "遊戲開發商"}, {"key": "Publisher", "parameter": "Publisher", "label": "發表/發行者"}, {"key": "Distributor", "parameter": "Distributor", "label": "經銷商"}, {"key": "Graphic_Artist", "parameter": "Graphic Artist", "label": "美術/封面設計者"}, {"key": "Type", "parameter": "Type", "label": "作品或媒體類型"}, {"key": "Owner", "parameter": "Owner", "label": "著作權持有者"}]},
        audio: {"id": "audio", "template": "Non-free use rationale audio sample", "uses": ["Infobox", "Header", "Section", "Artist", "Technique", "Other"], "fields": [{"key": "Name", "parameter": "Name", "label": "作品名稱"}, {"key": "Artist", "parameter": "Artist", "label": "演出者/創作者"}, {"key": "Label", "parameter": "Label", "label": "唱片公司"}, {"key": "Musicians", "parameter": "Musicians", "label": "作曲者/演奏者"}, {"key": "Type", "parameter": "Type", "label": "作品或媒體類型"}, {"key": "Owner", "parameter": "Owner", "label": "著作權持有者"}]},
        biog: {"id": "biog", "template": "Non-free use rationale biog", "lowResolution": "Low resolution", "uses": ["Infobox", "Header", "Section", "Other"], "fields": [{"key": "name", "parameter": "name", "label": "人物全名"}, {"key": "birth", "parameter": "birth", "date": true, "label": "出生日期"}, {"key": "death", "parameter": "death", "date": true, "label": "逝世日期"}]},
        videoScreenshot: {"id": "videoScreenshot", "template": "Non-free use rationale video screenshot", "uses": ["Infobox", "Org", "Work"], "fields": [{"key": "Used_for", "parameter": "Used_for", "label": "截圖所屬作品或組織"}]},
    });
    const RATIONALE_BY_LICENSE = Object.freeze({
        'Non-free logo': 'logo', 'Non-free book cover': 'book', 'Non-free album cover': 'album',
        'Non-free video cover': 'videoCover', 'Non-free game cover': 'gameCover',
        'Non-free game screenshot': 'gameScreenshot', 'Non-free audio sample': 'audio',
        'Non-free biog-pic': 'biog', 'Non-free film screenshot': 'videoScreenshot',
        'Non-free television screenshot': 'videoScreenshot'
    });
    // Labels written into the file page; LanguageConverter handles Simplified readers.
    // Interface wording for both scripts is in FileUploadWizard-i18n.js.
    const REGION_LABELS = Object.freeze({ US: '美國', TW: '臺灣', CN: '中國大陸', HK: '香港', MO: '澳門', SG: '新加坡', MY: '馬來西亞' });
    const STATUS_LABELS = Object.freeze({ 'public-domain': '公有領域', copyrighted: '仍受著作權保護', unknown: '無法確定' });
    // zh.wikipedia convention: an SVG logo always carries Template:SVG-Res, and one over the size
    // guide is tagged Non-free reduce as well. The two are meant to appear together.
    function usesSvgResolution(s) {
        return rationaleProfile(s).id === 'logo' && isSvg(s);
    }
    // The server's verdict once the file is stashed; before that, the name is all there is.
    function isSvg(s) {
        const mime = s.file.info && s.file.info.mime;
        return mime ? mime === 'image/svg+xml' : /\.svg$/i.test(s.file.name.trim());
    }
    function rationaleProfile(s) {
        return RATIONALE_PROFILES[RATIONALE_BY_LICENSE[s.nonfree.license] || 'generic'];
    }
    // The generic template has one Purpose row, answered by one question. The typed templates
    // word the purpose from Use, so only Use=Other needs it written out as well.
    function needsPurpose(s) {
        const profile = rationaleProfile(s);
        return profile.uses.length > 0 && s.nonfree.rationale[profile.id].use === 'Other';
    }
    // Accepts a year, a year and month, or a full date; old works are often only known by year.
    function validDate(value) {
        const match = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(value);
        if (!match) return false;
        const year = Number(match[1]), month = match[2] ? Number(match[2]) : 1, day = match[3] ? Number(match[3]) : 1;
        const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
        const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
        return year > 0 && month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1];
    }
    const LOCAL_ROUTES = ['free-local-access', 'free-userspace', 'free-esoteric', 'local-pd-usonly', 'nonfree'];
    function createState() {
        return {
            route: null,
            uploader: '',
            answers: { sourceKind: '', evidenceKind: '', ownConfirmed: false, purpose: 'general', commonsUnavailable: false, localOnly: false },
            file: { object: null, filekey: null, name: '', size: 0, type: '', info: null },
            metadata: { description: '', author: '', source: '', publication: '', publicationDate: '' },
            license: { type: '', evidence: '', pdTemplate: '', customDescription: '' },
            jurisdiction: { origin: '', otherOrigin: '', originCopyrightStatus: '', usCopyrightStatus: '', legalBasis: '' },
            localFree: { reason: null },
            nonfree: { article: '', validatedArticle: '', type: '', license: '', purpose: '', significance: '', replaceability: '', replaceabilityText: '', minimality: '', noReduce: false, noReduceReason: '', commercial: '', otherInformation: '', rationale: Object.fromEntries(Object.values(RATIONALE_PROFILES).map(profile => [profile.id, Object.fromEntries([['use', ''], ...profile.fields.map(field => [field.key, ''])])])), published: false, policy: false },
            usage: { page: '', purpose: '', necessity: '' },
            warnings: [], errors: []
        };
    }
    // Every path answers both questions: where the file came from, and what licence evidence exists.
    function copyrightKind(s) {
        const a = s.answers;
        if (!a.sourceKind || a.sourceKind === 'unknown' || !a.evidenceKind || a.evidenceKind === 'unknown') return '';
        if (a.evidenceKind === 'own') return a.sourceKind === 'own' && a.ownConfirmed ? 'license' : '';
        if (a.evidenceKind === 'free-license') return 'license';
        if (a.evidenceKind === 'pd') return 'pd';
        if (['restricted', 'copyrighted'].includes(a.evidenceKind)) return 'nonfree';
        return '';
    }
    function freeRoute(s) {
        if (s.answers.purpose === 'userspace' && s.answers.localOnly) return 'free-userspace';
        if (s.answers.purpose === 'esoteric' && s.answers.localOnly) return 'free-esoteric';
        return s.answers.commonsUnavailable ? 'free-local-access' : 'free-commons';
    }
    // These are the project's proposed rules, not an automatic legal opinion.
    function decideRoute(s) {
        const kind = copyrightKind(s), j = s.jurisdiction;
        if (kind === 'nonfree') return 'nonfree';
        if (kind === 'license') return freeRoute(s);
        if (kind !== 'pd') return 'copyright-check';
        if (!['US', 'OTHER', ...MAIN_REGIONS].includes(j.origin) || (j.origin === 'OTHER' && !OTHER_REGIONS.includes(j.otherOrigin))) return 'copyright-check';
        if (j.origin === 'US') {
            if (j.usCopyrightStatus === 'copyrighted') return 'nonfree';
            return j.usCopyrightStatus === 'public-domain' ? freeRoute(s) : 'copyright-check';
        }
        if (MAIN_REGIONS.includes(j.origin) && j.originCopyrightStatus === 'copyrighted') return 'nonfree';
        if (j.usCopyrightStatus === 'copyrighted') return 'nonfree';
        if (j.usCopyrightStatus !== 'public-domain') return 'copyright-check';
        if (j.originCopyrightStatus === 'public-domain') return freeRoute(s);
        if (j.origin === 'OTHER' && j.originCopyrightStatus === 'copyrighted') return 'local-pd-usonly';
        return 'copyright-check';
    }
    function setRoute(s) {
        s.route = decideRoute(s);
        s.localFree.reason = ({ 'free-local-access': 'commons-unavailable', 'free-userspace': 'userspace', 'free-esoteric': 'esoteric', 'local-pd-usonly': 'pd-us-only' })[s.route] || null;
        return s.route;
    }
    // Why the route is still 'copyright-check'. An empty list means a question is unanswered.
    function missingForDecision(s) {
        const a = s.answers, j = s.jurisdiction, missing = [];
        if (decideRoute(s) !== 'copyright-check') return missing;
        if (a.sourceKind === 'unknown') missing.push('source-unknown');
        if (a.evidenceKind === 'unknown') missing.push('evidence-unknown');
        if (a.evidenceKind === 'own' && a.sourceKind === 'own' && !a.ownConfirmed) missing.push('own-confirm');
        if (a.evidenceKind === 'own' && a.sourceKind !== 'own') missing.push('own-published');
        if (copyrightKind(s) === 'pd') {
            if (!j.origin) missing.push('origin');
            else if (j.origin === 'unknown') missing.push('origin-unknown');
            else if (j.origin === 'OTHER' && !OTHER_REGIONS.includes(j.otherOrigin)) missing.push('otherOrigin');
            if (j.origin && j.origin !== 'US' && j.origin !== 'unknown' && j.originCopyrightStatus !== 'public-domain' && j.originCopyrightStatus !== 'copyrighted') missing.push(j.originCopyrightStatus === 'unknown' ? 'originStatus-unknown' : 'originStatus');
            if (j.usCopyrightStatus !== 'public-domain' && j.usCopyrightStatus !== 'copyrighted') missing.push(j.usCopyrightStatus === 'unknown' ? 'usStatus-unknown' : 'usStatus');
        }
        return missing;
    }
    function validTemplate(value) {
        return /^[\p{L}\p{N}][\p{L}\p{N} _./()-]*$/u.test(value) && /^PD-/i.test(value);
    }
    function usesOwnSource(s) {
        const a = s.answers;
        return a.sourceKind === 'own' && a.evidenceKind === 'own' && a.ownConfirmed === true && copyrightKind(s) === 'license' && !['nonfree', 'copyright-check'].includes(decideRoute(s));
    }
    // People releasing their own work pick a current licence; others match what the source states.
    const OWN_WORK_LICENSES = Object.freeze(['CC-BY-SA-4.0', 'CC-BY-4.0', 'Cc-zero']);
    function licenseChoices(s) {
        return usesOwnSource(s) ? [...OWN_WORK_LICENSES] : [...FREE_LICENSES, 'custom'];
    }
    function detailErrors(s) {
        const errors = [], route = decideRoute(s);
        if (route === 'copyright-check') return ['copyright'];
        if (s.metadata.publicationDate && !validDate(s.metadata.publicationDate)) errors.push('metadata.publicationDate');
        if (usesOwnSource(s) && !s.uploader.trim()) errors.push('own-uploader');
        for (const key of ['description', 'author', 'source']) {
            if (['author', 'source'].includes(key) && usesOwnSource(s)) continue;
            // The rationale template asks for the author only where known.
            if (key === 'author' && route === 'nonfree') continue;
            if (!s.metadata[key].trim()) errors.push('metadata.' + key);
        }
        if (route === 'nonfree') {
            if (s.nonfree.validatedArticle !== s.nonfree.article.trim() || !s.nonfree.validatedArticle) errors.push('article-check');
            // Required here means required by Template:Non-free use rationale; its optional rows stay optional.
            for (const key of ['type', 'license', 'purpose', 'significance', 'replaceability', 'minimality']) {
                // The tag list depends on the type, so only the type is reported until one is chosen.
                if (key === 'license' && !s.nonfree.type) continue;
                if (key === 'purpose' && !needsPurpose(s)) continue;
                if (!s.nonfree[key].trim()) errors.push('nonfree.' + key);
            }
            const profile = rationaleProfile(s);
            for (const field of profile.fields.filter(field => field.date)) {
                const value = s.nonfree.rationale[profile.id][field.key];
                if (value && !validDate(value)) errors.push('nonfree.rationale.' + profile.id + '.' + field.key);
            }
            if (profile.uses.length && !profile.uses.includes(s.nonfree.rationale[profile.id].use)) errors.push('nonfree.rationale.' + profile.id + '.use');
            if (s.nonfree.license && !(NONFREE_LICENSES[s.nonfree.type] || []).includes(s.nonfree.license)) errors.push('nonfree.license');
            if (nonfreeResolutionWarning(s.file.info) && s.nonfree.noReduce && !s.nonfree.noReduceReason.trim()) errors.push('nonfree.noReduceReason');
            if (!s.nonfree.published) errors.push('nonfree.published');
            if (!s.nonfree.policy) errors.push('nonfree.policy');
        } else if (copyrightKind(s) === 'license') {
            if (!licenseChoices(s).includes(s.license.type)) errors.push('license.type');
            if (s.license.type === 'custom' && !s.license.customDescription.trim()) errors.push('license.customDescription');
            if (!usesOwnSource(s) && !s.license.evidence.trim()) errors.push('license.evidence');
        } else {
            if (!s.jurisdiction.legalBasis.trim()) errors.push('jurisdiction.legalBasis');
            if (route !== 'local-pd-usonly' && !validTemplate(s.license.pdTemplate)) errors.push('license.pdTemplate');
            if (route !== 'local-pd-usonly' && /^PD-US-?only$/i.test(s.license.pdTemplate)) errors.push('license.pdTemplate');
        }
        return [...new Set(errors)];
    }
    function fileErrors(file, name, limits) {
        const errors = [];
        if (!file || !file.size) return ['file'];
        const extension = name.split('.').pop().toLowerCase();
        if (!name.trim() || /[\x00-\x1f\x7f#<>\[\]|:{}\/\\]/.test(name) || /~~~/.test(name) || name !== name.trim() || name.startsWith('.')) errors.push('filename');
        if (!limits || !limits.extensions.length || !Number.isFinite(limits.maxSize) || limits.maxSize <= 0) return [...errors, 'siteinfo'];
        if (!name.includes('.') || !limits.extensions.includes(extension)) errors.push('extension');
        const original = file.name.split('.').pop().toLowerCase();
        const normalized = ext => ({ jpeg: 'jpg', tif: 'tiff', oga: 'ogg' })[ext] || ext;
        if (normalized(extension) !== normalized(original)) errors.push('extension-change');
        if (file.size > limits.maxSize) errors.push('size');
        return errors;
    }
    // Camera and screenshot defaults say nothing about the content.
    function genericFilename(name) {
        const base = String(name || '').replace(/\.[^.]+$/, '');
        return /^(?:IMG|DSC[FN]?|DCIM|PXL|MVIMG|VID|P\d{6,}|Screenshot|Screen Shot|螢幕擷取畫面|屏幕截图|截圖|截图|image|photo|untitled)[\s_-]*[\d\s_.()-]*$/i.test(base) || /^[\d\s_.()-]+$/.test(base);
    }
    function nonfreeResolutionWarning(info) {
        if (!info || !info.width || !info.height) return '';
        // The pixel guide is about images; clips are judged by length. An SVG counts by its nominal size.
        if (info.mime && !info.mime.startsWith('image/')) return '';
        if (Math.max(info.width, info.height) > NONFREE_SIDE_LIMIT) return 'side';
        if (info.width * info.height > NONFREE_PIXEL_GUIDE * NONFREE_PIXEL_TOLERANCE) return 'pixels';
        return '';
    }
    // '' when the size is fine; otherwise 'reduce', or 'no-reduce' when the uploader opts out.
    // Opting out needs a reason, which detailErrors enforces.
    function nonfreeReduction(s) {
        if (decideRoute(s) !== 'nonfree' || !nonfreeResolutionWarning(s.file.info)) return '';
        return s.nonfree.noReduce ? 'no-reduce' : 'reduce';
    }
    function articleFromResponse(response) {
        const q = response.query || {}, pages = q.pages || [];
        if (q.interwiki || pages.length !== 1) throw new Error('article-check');
        const p = pages[0];
        if (p.missing || p.invalid || p.ns !== 0 || p.redirect || (p.pageprops && Object.hasOwn(p.pageprops, 'disambiguation'))) throw new Error('article-check');
        return p.title;
    }
    // The date the camera recorded, from stashimageinfo's commonmetadata, as YYYY-MM-DD; '' if none.
    // Only a suggestion for the uploader's own work: on a scan or a download it dates the copy.
    function dateFromMetadata(info) {
        const entries = (info && Array.isArray(info.commonmetadata) && info.commonmetadata) || [];
        for (const name of ['DateTimeOriginal', 'DateTimeDigitized', 'DateTime']) {
            const entry = entries.find(item => item && item.name === name && typeof item.value === 'string');
            const match = entry && /^(\d{4})[:-](\d{2})[:-](\d{2})/.exec(entry.value.trim());
            if (match && validDate(match.slice(1).join('-'))) return match.slice(1).join('-');
        }
        return '';
    }
    // Values a link may fill in ahead of time: ?fuwarticle=, ?fuwtype= and ?fuwdescription=.
    // They are starting text only. The article still has to be confirmed, and nothing here
    // answers a copyright question. Returns a signature of what was applied, '' if nothing.
    function applyPrefill(s, params) {
        const text = (key, limit) => String(params[key] || '').replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, limit);
        const article = text('fuwarticle', 255).replace(/_/g, ' '), type = text('fuwtype', 20), description = text('fuwdescription', 2000);
        const applied = {};
        if (article) { s.nonfree.article = article; s.nonfree.validatedArticle = ''; applied.article = article; }
        if (Object.hasOwn(NONFREE_LICENSES, type)) { s.nonfree.type = type; s.nonfree.license = ''; applied.type = type; }
        if (description) { s.metadata.description = description; applied.description = description; }
        return Object.keys(applied).length ? JSON.stringify(applied) : '';
    }
    // The server's own explanation of a refusal (abuse filter, captcha, rate limit), in either
    // error format of the action API. '' when the failure carried none.
    function serverMessage(response) {
        const error = response && (response.error || (Array.isArray(response.errors) && response.errors[0]));
        return error ? String(error.text || error.info || '').trim() : '';
    }
    // Why a file name cannot be used, from a prop=info|imageinfo query with intestactions=upload
    // (full detail) and an action=titleblacklist check. '' when the name is free to use.
    // Asked at the file step so a blocked name is not discovered only on publishing.
    function filenameProblem(response, blacklist) {
        const pages = response && response.query && response.query.pages;
        if (!Array.isArray(pages) || pages.length !== 1 || pages[0].invalid || pages[0].ns !== 6) return 'filename';
        const page = pages[0];
        if (!page.missing || page.imageinfo || page.imagerepository === 'shared') return 'exists';
        const errors = page.actions && page.actions.upload;
        const codes = Array.isArray(errors) ? errors.map(error => String((error && error.code) || '')) : errors === false ? ['denied'] : [];
        if ((blacklist && blacklist.titleblacklist && blacklist.titleblacklist.result === 'blacklisted') || codes.some(code => /titleblacklist/i.test(code))) return 'title-blacklisted';
        // Account-level refusals (no upload right, blocks) are reported by the capability check instead.
        if (codes.some(code => /protect/i.test(code))) return 'title-protected';
        return '';
    }
    // Fields are plain text. Encode syntax characters so input cannot break out of
    // a template parameter, add templates/categories, set behaviour switches such
    // as __NOINDEX__, or create active HTML. Markup that only works at the start of a line
    // (headings, lists, indented blocks, rules) is encoded there, on every line of the value.
    function wikiText(value) {
        return String(value || '').replace(/&/g, '&amp;').replace(/[<>{}\[\]|]/g, c => '&#' + c.charCodeAt(0) + ';').replace(/~/g, '&#126;').replace(/_(?=_)/g, '&#95;')
            .replace(/^[=*#:; \t]|^-(?=---)/gm, c => '&#' + c.charCodeAt(0) + ';');
    }
    // Upload warnings the uploader may acknowledge and publish past; any other needs a new name or file.
    const CONFIRMABLE_WARNINGS = Object.freeze(['duplicate', 'duplicate-archive', 'was-deleted']);
    function template(name, fields, ownUploader = '') {
        // Generate only the fixed own template and user link; encode their user data.
        // A list value is written one item per line; a bare newline would run the items together.
        // { template } is a fixed template call made by the wizard, never user text.
        const encode = value => Array.isArray(value) ? value.map(wikiText).join('<br />\n') : value && value.template ? '{{' + value.template + '}}' : wikiText(value);
        return '{{' + name + '\n' + Object.entries(fields).map(([key, value]) => '|' + key + '=' +
            (ownUploader && name === 'Information' && key === 'Source' ? '{{own}}' :
                ownUploader && name === 'Information' && key === 'Author' ? '[[User:' + wikiText(ownUploader) + '|' + wikiText(ownUploader) + ']]' : encode(value))).join('\n') + '\n}}';
    }
    function licenseTag(s) {
        const route = decideRoute(s);
        if (route === 'nonfree') return s.nonfree.license;
        if (route === 'local-pd-usonly') return 'PD-USonly';
        if (copyrightKind(s) === 'pd') return s.license.pdTemplate;
        return s.license.type === 'custom' ? '' : s.license.type;
    }
    // Every template buildWikitext emits, so the UI can confirm they exist before upload.
    function requiredTemplates(s) {
        const route = decideRoute(s), names = [];
        if (route === 'nonfree') names.push(rationaleProfile(s).template);
        else names.push('Information');
        if (route === 'nonfree' && usesSvgResolution(s)) names.push(SVG_RESOLUTION_TEMPLATE);
        const reduction = nonfreeReduction(s);
        if (reduction) names.push(reduction === 'reduce' ? REDUCE_TEMPLATE : NO_REDUCE_TEMPLATE);
        if (licenseTag(s)) names.push(licenseTag(s));
        if (route === 'free-userspace') names.push('Userspace file');
        if (route === 'free-esoteric') names.push('Esoteric file');
        if (route === 'free-local-access') names.push(COMMONS_TRANSFER_TEMPLATE);
        return names;
    }
    function buildWikitext(s) {
        if (detailErrors(s).length) throw new Error('details');
        const route = decideRoute(s), m = s.metadata, n = s.nonfree;
        if (route === 'nonfree') {
            const profile = rationaleProfile(s), extra = n.rationale[profile.id];
            const lines = values => values.map(value => value.trim()).filter(Boolean).join('\n');
            const purpose = lines([needsPurpose(s) ? n.purpose : '', n.significance]);
            const tag = ['Non-free biog-pic', 'Non-free fair use in'].includes(n.license) ? '{{' + n.license + '|1=' + wikiText(n.validatedArticle) + '}}' : '{{' + n.license + '}}';
            const reduction = nonfreeReduction(s);
            // Maintenance tags sit above the description, where the resizing bot and reviewers look.
            const sizeTag = reduction === 'no-reduce' ? '{{' + NO_REDUCE_TEMPLATE + '}}\n不縮小的原因:' + wikiText(n.noReduceReason.trim()) + '\n\n'
                : reduction === 'reduce' ? '{{' + REDUCE_TEMPLATE + (isSvg(s) ? '|type=svg' : '') + '}}\n\n' : '';
            const page = fields => sizeTag + DESCRIPTION_HEADING + template(profile.template, Object.fromEntries(Object.entries(fields).filter(([, value]) => String(value).trim()))) + LICENSE_HEADING + tag + '\n';
            // Each answer fills one row of Template:Non-free use rationale; rows left empty are not shown.
            if (profile.id === 'generic') {
                return page({
                    Description: m.description, Author: m.author, Source: m.source, Publication: m.publication, Date: m.publicationDate,
                    Article: n.validatedArticle, Purpose: purpose, Replaceability: n.replaceability, Replaceability_text: n.replaceabilityText,
                    Minimality: n.minimality, Commercial: n.commercial, 'Other information': n.otherInformation
                });
            }
            // The typed templates supply their own wording for 使用範圍, so the answer about amount
            // and size fills the low-resolution row. Rows they cannot show are listed under 其他資訊.
            const ownRows = profile.id === 'videoScreenshot';
            const notes = [['作者或著作權持有者', ownRows ? '' : m.author], ['發行人', m.publication], ['日期', m.publicationDate], ['市場影響', ownRows ? '' : n.commercial]]
                .filter(([, value]) => value.trim()).map(([label, value]) => label + ':' + value.trim());
            if (n.otherInformation.trim()) notes.push(n.otherInformation.trim());
            // Most typed templates spell this row Low_resolution; a profile names its own spelling otherwise.
            const lowResolution = profile.lowResolution || 'Low_resolution';
            const fields = {
                Description: m.description, Source: m.source, Article: n.validatedArticle, [lowResolution]: n.minimality,
                Use: extra.use, Purpose: purpose, Replaceability: lines([n.replaceability, n.replaceabilityText])
            };
            for (const field of profile.fields) {
                if (extra[field.key].trim()) fields[field.parameter] = extra[field.key].trim();
            }
            // Owner suppresses these credits in the live templates' default descriptions.
            const hiddenCredits = { book: ['Publisher', 'Cover_artist'], album: ['Label', 'Graphic_Artist'],
                videoCover: ['Distributor', 'Publisher', 'Studio'], gameScreenshot: ['Distributor', 'Publisher', 'Developer'],
                audio: ['Label', 'Musicians'] };
            if (extra.Owner && extra.Owner.trim()) {
                for (const field of profile.fields.filter(field => (hiddenCredits[profile.id] || []).includes(field.key))) {
                    if (extra[field.key].trim()) notes.push(field.label + ':' + extra[field.key].trim());
                }
            }
            // The album template silently omits Type values outside its built-in list.
            if (profile.id === 'album' && extra.Type.trim() && !['studio', 'album', 'studio album', 'demo', 'ep', 'EP', 'live', '現場', 'greatest', '精選', 'remix', '混音', 'box', '套裝', 'compilation', 'soundtrack', 'film', '電影', 'video', 'single'].includes(extra.Type.trim())) notes.push('作品類型:' + extra.Type.trim());
            // These templates otherwise hide supplied detail behind built-in wording.
            if (profile.id === 'logo' && extra.use !== 'Other') fields.Commentary = purpose;
            if (profile.id === 'gameCover' || profile.id === 'biog') {
                fields.Description += profile.fields.filter(field => extra[field.key].trim()).map(field => '\n' + field.label + ':' + extra[field.key].trim()).join('');
            }
            // A vector logo has no pixel size to state, so the low-resolution row carries Template:SVG-Res
            // and the answer about amount moves to the row for the portion used.
            if (usesSvgResolution(s)) {
                fields[lowResolution] = { template: SVG_RESOLUTION_TEMPLATE };
                fields.Portion = n.minimality;
            }
            // The game cover template adds its own full stop after this row.
            if (profile.id === 'gameCover') fields[lowResolution] = n.minimality.trim().replace(/。$/, '');
            let notesKey = 'other_information';
            if (profile.id === 'biog') notesKey = 'Other_information';
            if (profile.id === 'videoScreenshot') {
                fields.Author = m.author;
                fields.Commercial = n.commercial;
                // Its Purpose row is worded from Use alone.
                notes.push('使用目的與重要性:' + purpose); delete fields.Purpose;
                notesKey = 'Other information';
            }
            fields[notesKey] = notes;
            return page(fields);
        }
        const isPD = copyrightKind(s) === 'pd', j = s.jurisdiction;
        const fields = { Description: m.description, Source: m.source, Date: m.publicationDate, Author: m.author, Permission: isPD ? j.legalBasis : usesOwnSource(s) ? '' : s.license.evidence };
        const tag = licenseTag(s);
        const licenseText = tag ? '{{' + tag + '}}' : wikiText(s.license.customDescription.trim());
        let text = DESCRIPTION_HEADING + template('Information', fields, usesOwnSource(s) ? s.uploader : '') + LICENSE_HEADING + licenseText + '\n';
        if (isPD) {
            const origin = j.origin === 'OTHER' ? regionName(j.otherOrigin) : REGION_LABELS[j.origin] || j.origin;
            const originStatus = STATUS_LABELS[j.origin === 'US' ? j.usCopyrightStatus : j.originCopyrightStatus] || '';
            text += '\n' + wikiText('作品來源地:' + origin + '\n來源地著作權狀態:' + originStatus + '\n美國著作權狀態:' + (STATUS_LABELS[j.usCopyrightStatus] || '')) + '\n';
        }
        if (route === 'free-userspace') text += '{{Userspace file}}\n';
        if (route === 'free-esoteric') text += '{{Esoteric file}}\n';
        if (route === 'free-local-access') text += '\n{{' + COMMONS_TRANSFER_TEMPLATE + '}}\n本地上傳理由:上傳者目前無法使用維基共享資源。\n';
        if (route === 'local-pd-usonly') {
            text += '\n作品在來源地仍受著作權保護,不得移至維基共享資源。強烈建議實際使用參照非自由內容原則;此標記不代表 NFCC 法律狀態。\n';
            for (const [key, label] of [['page', '預計使用頁面'], ['purpose', '使用目的'], ['necessity', '必要性']]) {
                if (s.usage[key].trim()) text += wikiText(label + ':' + s.usage[key]) + '\n';
            }
        }
        return text;
    }
    // Preserve MediaWiki's second rejection argument, including upload warnings.
    function request(promise) {
        return new Promise((resolve, reject) => {
            promise.then(resolve, (code, result) => {
                if (result && result.upload && result.upload.warnings) resolve(result);
                else reject(Object.assign(new Error(typeof code === 'string' ? code : (code && code.message) || 'api'), { response: result }));
            });
        });
    }
    // Error codes meaning the stashed copy is gone and must be uploaded again.
    const STASH_GONE = ['stashfailed', 'stashnosuchfilekey', 'stashedfilenotfound', 'stashwrongowner', 'stashnotloggedin', 'filekey-not-found'];
    const CHUNK_SIZE = 5 * 1024 * 1024;
    const CHUNK_THRESHOLD = 50 * 1024 * 1024;
    class UploadSession {
        constructor(api, options = {}) {
            this.api = api;
            this.allowPublish = options.allowPublish === true;
            this.filekey = null;
            this.file = null;
            this.name = '';
            this.review = null;
            this.pending = null;
            this.busy = false;
            this.completed = false;
        }
        invalidate() { this.review = null; this.pending = null; }
        hasStash(file, name) { return !!this.filekey && this.file === file && this.name === name; }
        dropStash() { this.filekey = null; this.invalidate(); }
        // Large files go up in chunks; onProgress receives a fraction from 0 to 1.
        async stash(file, name, onProgress) {
            if (this.busy) throw new Error('busy');
            this.busy = true;
            this.invalidate();
            this.filekey = null;
            this.file = file;
            this.name = name;
            try {
                const chunked = file.size > CHUNK_THRESHOLD && typeof this.api.chunkedUpload === 'function';
                const data = { stash: true, filename: name };
                const pending = chunked ? this.api.chunkedUpload(file, data, CHUNK_SIZE)
                    : typeof this.api.upload === 'function' ? this.api.upload(file, data)
                        : this.api.postWithToken('csrf', { action: 'upload', formatversion: 2, assert: 'user', stash: true, filename: name, file }, { contentType: 'multipart/form-data' });
                if (onProgress && typeof pending.progress === 'function') {
                    let chunk = 0, last = 0;
                    pending.progress(fraction => {
                        if (chunked && fraction < last) chunk++;
                        last = fraction;
                        const sent = chunked ? chunk * CHUNK_SIZE + fraction * Math.min(CHUNK_SIZE, file.size - chunk * CHUNK_SIZE) : fraction * file.size;
                        onProgress(Math.min(1, sent / file.size));
                    });
                }
                const result = await request(pending);
                const upload = result.upload;
                if (!upload || !upload.filekey || !['Success', 'Warning'].includes(upload.result)) throw new Error('stash');
                this.filekey = upload.filekey;
                return upload;
            } finally { this.busy = false; }
        }
        setReview(s) {
            if (!this.filekey || this.file !== s.file.object || this.name !== s.file.name || detailErrors(s).length || !LOCAL_ROUTES.includes(decideRoute(s))) throw new Error('review');
            const review = { filename: s.file.name, text: buildWikitext(s), route: decideRoute(s), filekey: this.filekey };
            this.review = review;
            this.pending = null;
            return review;
        }
        async publish(s, acknowledgements = []) {
            if (!this.allowPublish) throw new Error('prototype');
            if (this.busy || this.completed) throw new Error('busy');
            if (!this.review || this.file !== s.file.object || this.review.filename !== s.file.name || this.review.text !== buildWikitext(s) || this.review.route !== decideRoute(s)) throw new Error('review');
            const warnings = this.pending ? Object.keys(this.pending) : [];
            // Overwriting and unknown warnings require returning to the file step.
            if (warnings.some(key => !CONFIRMABLE_WARNINGS.includes(key) || !acknowledgements.includes(key))) throw new Error('warnings');
            this.busy = true;
            try {
                const data = { action: 'upload', formatversion: 2, assert: 'user', filekey: this.filekey, filename: this.review.filename, text: this.review.text, comment: UPLOAD_COMMENT, watchlist: 'preferences',
                    // A refusal is explained in the reader's language, so it can be shown as it is.
                    errorformat: 'plaintext', errorlang: 'uselang', errorsuselocal: true };
                if (warnings.length) data.ignorewarnings = true;
                let result;
                try { result = await request(this.api.postWithToken('csrf', data)); }
                catch (error) {
                    if (STASH_GONE.includes(error.message)) { this.dropStash(); throw new Error('stash-expired'); }
                    throw error;
                }
                const upload = result.upload;
                if (!upload) throw new Error('upload');
                if (upload.result === 'Warning' && upload.warnings && Object.keys(upload.warnings).length) {
                    this.pending = upload.warnings;
                    if (upload.filekey) this.filekey = upload.filekey;
                    return upload;
                }
                if (upload.result !== 'Success') throw new Error('upload');
                this.completed = true;
                this.pending = null;
                return upload;
            } finally { this.busy = false; }
        }
    }
    return Object.freeze({ MAIN_REGIONS, OTHER_REGIONS, regionName,SOURCE_KINDS, EVIDENCE_KINDS, FREE_LICENSES, NONFREE_LICENSES, RATIONALE_PROFILES, REGION_LABELS, STATUS_LABELS, COMMONS_TRANSFER_TEMPLATE, NONFREE_PIXEL_GUIDE, NONFREE_SIDE_LIMIT, nonfreeReduction, rationaleProfile, needsPurpose, validDate, LOCAL_ROUTES, createState, copyrightKind, decideRoute, setRoute, missingForDecision, usesOwnSource, licenseChoices, detailErrors, fileErrors, genericFilename, nonfreeResolutionWarning, articleFromResponse, filenameProblem, dateFromMetadata, applyPrefill, serverMessage, buildWikitext, licenseTag, requiredTemplates, wikiText, CONFIRMABLE_WARNINGS, validTemplate, request, UploadSession });
}));
// </nowiki>