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, '&').replace(/[<>{}\[\]|]/g, c => '&#' + c.charCodeAt(0) + ';').replace(/~/g, '~').replace(/_(?=_)/g, '_')
.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>