cejs
Version:
A JavaScript module framework that is simple to use.
1,581 lines (1,420 loc) • 63.9 kB
JavaScript
/**
* @name CeL function for MediaWiki (Wikipedia / 維基百科): edit
*
* @fileoverview 本檔案包含了 MediaWiki 自動化作業用程式庫的子程式庫。
*
* TODO:<code>
</code>
*
* @since 2019/10/11 拆分自 CeL.application.net.wiki
*/
// More examples: see /_test suite/test.js
// Wikipedia bots demo: https://github.com/kanasimi/wikibot
'use strict';
// 'use asm';
// --------------------------------------------------------------------------------------------
// 不採用 if 陳述式,可以避免 Eclipse JSDoc 與 format 多縮排一層。
typeof CeL === 'function' && CeL.run({
// module name
name : 'application.net.wiki.edit',
require : 'application.net.wiki.'
// load MediaWiki module basic functions
+ '|application.net.wiki.namespace.'
// for BLANK_TOKEN
+ '|application.net.wiki.task.'
//
+ '|application.net.wiki.page.',
// 設定不匯出的子函式。
no_extend : 'this,*',
// 為了方便格式化程式碼,因此將 module 函式主體另外抽出。
code : module_code
});
function module_code(library_namespace) {
// requiring
var wiki_API = library_namespace.application.net.wiki, KEY_SESSION = wiki_API.KEY_SESSION;
// @inner
var PATTERN_category_prefix = wiki_API.PATTERN_category_prefix, BLANK_TOKEN = wiki_API.BLANK_TOKEN;
var gettext = library_namespace.cache_gettext(function(_) {
gettext = _;
});
// ------------------------------------------------------------------------
wiki_API.get_task_id = function get_task_id(options) {
if (options.check_section) {
return options.check_section;
}
var session = wiki_API.session_of_options(options);
var check_task_id = session.latest_task_configuration;
if (check_task_id
&& (check_task_id = check_task_id.configuration_page_title)
// e.g., 'User:Cewbot/log/20200122/configuration'
&& (check_task_id = check_task_id.match(/\/(\d{8})\//))) {
check_task_id = check_task_id[1];
} else if (/* !check_task_id && */session.check_options) {
check_task_id = session.check_options.check_section;
}
return check_task_id;
};
var KEY_any_task = '*';
/**
* check if need to stop / 檢查是否需要緊急停止作業 (Emergency shutoff-compliant).
*
* 此功能之工作機制/原理:<br />
* 在 .edit() 編輯(機器人執行作業)之前,先檢查是否有人在緊急停止頁面留言要求停止作業。<br />
* 只要在緊急停止頁面有指定的章節標題、或任何章節,就當作有人留言要停止作業,並放棄編輯。
*
* TODO:<br />
* https://www.mediawiki.org/w/api.php?action=query&meta=userinfo&uiprop=hasmsg
*
* @param {Function}callback
* 回調函數。 callback({Boolean}need stop)
* @param {Object}[options]
* 附加參數/設定選擇性/特殊功能與選項
*
* @see https://www.mediawiki.org/wiki/Manual:Parameters_to_index.php#Edit_and_submit
* https://www.mediawiki.org/wiki/Help:Magic_words#URL_encoded_page_names
* https://www.mediawiki.org/wiki/Help:Links
* https://zh.wikipedia.org/wiki/User:Cewbot/Stop
*/
wiki_API.check_stop = function check_stop(callback, options) {
// 前置處理。
if (!library_namespace.is_Object(options)) {
if (typeof options === 'string') {
options = {
title : options
};
} else {
options = Object.create(null);
}
}
var session = wiki_API.session_of_options(options) || this;
/**
* 緊急停止作業將檢測之頁面標題。 check title:<br />
* 只檢查此緊急停止頁面。
*
* @type {String}
*/
var title = options.title;
if (typeof title === 'function') {
title = title(options.token || session.token);
}
var check_task_id = wiki_API.get_task_id(options)
|| wiki_API.check_stop.KEY_any_task;
if (!title
&& !(title = wiki_API.check_stop.title(options.token
|| session.token))) {
session.task_control_status[check_task_id] = {
latest_checked : Date.now(),
no_stop_page : true,
stopped : false
};
// task_status
callback(session.task_control_status[check_task_id]);
return;
}
library_namespace.debug({
// gettext_config:{"id":"check-the-emergency-stop-page-$1"}
T : [ '檢查緊急停止頁面 %1', wiki_API.title_link_of(title) ]
}, 1, 'wiki_API.check_stop');
// console.trace([ session.API_URL, title ]);
wiki_API.page([ session.API_URL, title ], function(page_data, error) {
if (error)
callback(page_data, error);
var content = wiki_API.content_of(page_data),
// default: NOT stopped
stopped = false, PATTERN;
if (!content) {
library_namespace.info([ 'wiki_API.check_stop: ', {
// page_data maybe undefined when the network is down.
T : [ !page_data || 'missing' in page_data
// gettext_config:{"id":"the-emergency-stop-page-was-not-found-($1)"}
? 'The emergency stop page was not found (%1).'
// gettext_config:{"id":"the-emergency-stop-page-is-empty-($1)"}
: 'The emergency stop page is empty (%1).',
//
wiki_API.title_link_of(title) ]
}, {
// gettext_config:{"id":"the-operation-will-proceed-as-usual"}
T : 'The operation will proceed as usual.'
} ]);
} else if (typeof options.checker === 'function') {
// 以 options.checker 的回傳來設定是否 stopped。
stopped = options.checker(content);
if (stopped) {
library_namespace.warn([ 'wiki_API.check_stop: ', {
// gettext_config:{"id":"emergency-stop-edit-has-been-set"}
T : '已設定緊急停止編輯作業!'
} ]);
}
content = null;
} else {
// 指定 pattern
PATTERN = options.pattern
// options.check_section: 指定的緊急停止章節標題, section title to check.
/** {String}緊急停止作業將檢測之章節標題。 */
|| options.check_section
/**
* for == 停止作業: 20150503 機器人作業 == <code>
* (new RegExp('\n==(.*?)' + '20150503' + '\\s*==\n')).test('\n== 停止作業:20150503 ==\n') === true
* </code>
*/
&& new RegExp('(?:^|\n)==(.*?)'
//
+ options.check_section + '(.*?)==\n');
}
if (content) {
if (!library_namespace.is_RegExp(PATTERN)) {
// use default pattern
PATTERN = wiki_API.check_stop.pattern;
}
library_namespace.debug(
//
'wiki_API.check_stop: 採用 pattern: ' + PATTERN);
stopped = PATTERN.test(content, page_data);
if (stopped) {
library_namespace.warn([ 'wiki_API.check_stop: ', {
// gettext_config:{"id":"there-is-a-messages-on-the-emergency-stop-page-$1-to-stop-the-editing-operation"}
T : [ '緊急停止頁面 %1 有留言要停止編輯作業!',
//
wiki_API.title_link_of(title) ]
} ]);
}
}
session.task_control_status[check_task_id] = {
latest_checked : Date.now(),
// stop editing
// editing stopped
stopped : stopped
};
// task_status
callback(session.task_control_status[check_task_id]);
}, options);
};
wiki_API.check_stop.KEY_any_task = KEY_any_task;
/**
* default page title to check:<br />
* [[{{TALKSPACE}}:{{ROOTPAGENAME}}/Stop]]
*
* @param {Object}token
* login 資訊,包含“csrf”令牌/密鑰。
*
* @returns {String}
*/
wiki_API.check_stop.title = function(token) {
return token.login_user_name ? 'User talk:' + token.login_user_name
+ '/Stop' : '';
};
/**
* default check pattern: 任何章節/段落 section<br />
* default: 只要在緊急停止頁面有任何章節,就當作有人留言要求 stop。
*
* @type {RegExp}
*/
wiki_API.check_stop.pattern = /\n=(.+?)=\n/;
// ------------------------------------------------------------------------
// [[Help:Edit summary]] actual limit is 500 [[Unicode codepoint]]s.
function add_section_to_summary(summary, section_title) {
if (!section_title)
return summary || '';
// 所有"/*錨點*/"註解都會 .trim() 後轉成網頁錨點連結。且 "/*...*/" 之前亦可加入文字。
return '/* ' + section_title + ' */ ' + (summary || '');
}
/**
* 編輯頁面。一次處理一個標題。<br />
* 警告:除非 text 輸入 {Function},否則此函數不會檢查頁面是否允許機器人帳戶訪問!此時需要另外含入檢查機制!
*
* 2016/7/17 18:55:24<br />
* 當採用 section=new 時,minor=1 似乎無效?
*
* @example <code>
// 2021/10/7 13:29:12
// Create new page with template.
const variable_Map = new CeL.wiki.Variable_Map({ FC_list: '* 1\n* 2' });
variable_Map.template = function (page_data) {
// Will run at the page created.
// assert: !wiki_API.content_of(page_data) === true;
return 'FC_list:\n' + this.format('FC_list');
};
await wiki.edit_page(new_page_title, variable_Map, { summary: 'test' });
// Update page only (must setup manually first)
const variable_Map = new CeL.wiki.Variable_Map({ FC_list: '* 1\n* 2' });
// setup manually
await wiki.edit_page('Wikipedia:沙盒', p => p.wikitext + '\nFC_list:\n' + variable_Map.format('FC_list'), { summary: 'test' });
variable_Map.set('FC_list', '*2\n*3');
await wiki.edit_page('Wikipedia:沙盒', variable_Map, { summary: 'test' });
</code>
*
* @param {String|Array}title
* page title 頁面標題。 {String}title or [ {String}API_URL,
* {String}title or {Object}page_data ]
* @param {String|Function}text
* page contents 頁面內容。 {String}text or {Function}text(page_data)
* @param {Object}token
* login 資訊,包含“csrf”令牌/密鑰。
* @param {Object}[options]
* 附加參數/設定選擇性/特殊功能與選項
* @param {Function}callback
* 回調函數。 callback(page_data, {String|any}error, result)
* @param {String}timestamp
* 頁面時間戳記。 e.g., '2015-01-02T02:52:29Z'
*
* @see https://www.mediawiki.org/w/api.php?action=help&modules=edit
*/
function wiki_API_edit(title, text, token, options, callback, timestamp) {
var action = {
action : 'edit'
};
if (wiki_API.need_get_API_parameters(action, options,
wiki_API[action.action], arguments)) {
// console.trace('Waiting for get API parameters');
return;
}
// console.trace(title);
// console.log(text);
if (library_namespace.is_thenable(text)) {
if (library_namespace.is_debug(3))
console.trace(text);
text = text.then(function(text) {
// console.trace(text);
// console.trace('' + callback);
wiki_API_edit(title, text, token, options, callback,
//
timestamp);
}, function(error) {
callback(title, error);
});
var session = wiki_API.session_of_options(options);
if (session && session.running) {
if (false) {
console.trace(session.actions);
console.trace(session.actions[0]);
console.trace('wiki_API_edit: '
+ 'Calling wiki_API.prototype.next() '
+ [ session.running, session.actions.length ]);
text.then(function(text) {
console.trace(text);
});
}
session.next(/* promise */text);
}
return;
}
var is_undo = options && options.undo;
if (is_undo) {
// 一般 undo_count 超過1也不一定能成功?因此設定輸入 {undo:1} 時改 {undo:true} 亦可。
if (is_undo === true) {
options.undo = is_undo = 1;
} else if (!(is_undo >= 1)) {
delete options.undo;
}
}
var undo_count = options
&& (options.undo_count || is_undo
&& (is_undo < wiki_API_edit.undo_count_limit && is_undo));
if (wiki_API.Variable_Map.is_Variable_Map(text)) {
// 對於新創或空白頁面,應已設定 {String}text.template。
text = text.to_page_text_updater();
}
if (undo_count || typeof text === 'function') {
library_namespace.debug('先取得內容再 edit / undo '
+ wiki_API.title_link_of(title) + '。', 1, 'wiki_API_edit');
// console.log(title);
var _options;
if (undo_count) {
_options = Object.clone(options);
_options.get_page_before_undo = true;
if (!_options.rvlimit) {
_options.rvlimit = undo_count;
}
if (!_options.rvprop) {
_options.rvprop =
// user: 提供 user name 給 text() 用。
typeof text === 'function' ? 'ids|timestamp|user'
// 無須 content,盡量減少索求的資料量。
: 'ids|timestamp';
}
} else {
_options = Object.clone(options);
delete _options.rollback_action;
}
wiki_API.page(title, function(page_data, error) {
if (options && (!options.ignore_denial
// TODO: 每經過固定時間,或者編輯特定次數之後,就再檢查一次。
&& wiki_API_edit.denied(page_data, options.bot_id,
// 若您不想接受機器人的通知、提醒或警告,請使用{{bots|optout=notification_name}}模板。
// Please using {{bots|optout=notification_name}},
// the bot will skip this page.
options.notification_name))) {
library_namespace.warn([ 'wiki_API_edit: ', {
// gettext_config:{"id":"editing-of-$1-has-been-rejected-$2"}
T : [ 'Editing of %1 has been rejected: %2',
//
wiki_API.title_link_of(page_data),
//
options.notification_name ]
} ]);
callback(page_data, 'denied');
} else {
// @see wiki_API.prototype.next()
if (false) {
console.trace('Set .page_to_edit: '
+ wiki_API.title_link_of(page_data) + ' ('
+ title + ')' + ' ('
+ wiki_API.title_link_of(options.page_to_edit)
+ ')');
console.trace(options);
}
if (options) {
if (!page_data && !error) {
console.trace('No page_data or error set!');
throw new Error('wiki_API_edit: '
//
+ 'No page_data or error set!');
}
options.page_to_edit = page_data;
options.last_page_error = error;
if (undo_count) {
delete options.undo_count;
// page_data =
// {pageid:0,ns:0,title:'',revisions:[{revid:0,parentid:0,user:'',timestamp:''},...]}
var revision = wiki_API.content_of
.revision(page_data);
if (revision) {
timestamp = revision.timestamp;
// 指定 rev_id 版本編號。
options.undo = revision.revid;
}
options.undoafter = page_data.revisions
// get the oldest revision
.at(-1).parentid;
}
}
// 這裡不直接指定 text,是為了使(回傳要編輯資料的)設定值函數能即時依page_data變更 options。
if (undo_count) {
// text = '';
}
if (typeof text === 'function') {
// or: text(wiki_API.content_of(page_data),
// page_data.title, page_data)
// .call(options,): 使(回傳要編輯資料的)設定值函數能以this即時變更 options。
// 注意: 更改此介面需同時修改 wiki_API.prototype.work 中 'edit' 之介面。
text = text.call(options, page_data);
}
// 需要同時改變 wiki_API.prototype.next!
wiki_API_edit(title, text, token, options, callback,
timestamp);
}
}, _options);
return;
}
var not_passed = !is_undo
&& wiki_API_edit.check_data(text, title, options,
'wiki_API_edit');
if (options.discard_changes) {
// console.trace('手動放棄修改。');
if (!not_passed) {
text = [ wiki_API_edit.cancel, text || options.discard_changes ];
not_passed = true;
}
}
if (not_passed) {
library_namespace.debug('直接執行 callback。', 2, 'wiki_API_edit');
// console.trace([not_passed, text]);
callback(title, options.error_with_symbol ? text : not_passed);
return;
}
// 處理 [ {String}API_URL, {String}title or {Object}page_data ]
if (Array.isArray(title)) {
action = [ title[0], action ];
title = title[1];
}
if (options && options.write_to) {
// 設定寫入目標。一般為 debug、test 測試期間用。
// e.g., write_to:'Wikipedia:沙盒',
title = options.write_to;
library_namespace.debug('依 options.write_to 寫入至 '
+ wiki_API.title_link_of(title), 1, 'wiki_API_edit');
}
// 造出可 modify 的 options。
if (options) {
library_namespace.debug('#1: ' + Object.keys(options).join(','), 4,
'wiki_API_edit');
}
var change_to_contentmodel;
if (typeof text === 'object'
&& /\.json$/i.test(wiki_API.title_of(title))) {
text = JSON.stringify(text);
change_to_contentmodel = wiki_API.content_of.revision(title);
change_to_contentmodel = change_to_contentmodel
&& change_to_contentmodel.contentmodel;
change_to_contentmodel = change_to_contentmodel === 'json' ? null
: 'json';
// console.trace(change_to_contentmodel);
}
// assert: typeof text === 'string'
if (options && options.skip_nochange && options.page_to_edit) {
var original_content = wiki_API.content_of(options.page_to_edit);
if (original_content && /\.json$/i.test(wiki_API.title_of(title))) {
try {
// text = JSON.stringify(JSON.parse(text));
original_content = JSON.stringify(JSON
.parse(original_content));
} catch (e) {
// TODO: handle exception
}
}
if (text === original_content) {
// free
original_content = null;
library_namespace.debug('Skip '
//
+ wiki_API.title_link_of(options.page_to_edit)
// 'nochange', no change
+ ': The same content.', 1, 'wiki_API_edit');
callback(title, 'nochange');
return;
}
// free
original_content = null;
}
// 前置處理。
if (is_undo) {
options = library_namespace.setup_options(options);
} else {
options = Object.assign({
text : text
}, options);
}
if (wiki_API.is_page_data(title)) {
// 將 {Object}page_data 最新版本的 timestamp 標記註記到 options 去。
wiki_API_edit.set_stamp(options, title);
if (title.pageid)
options.pageid = title.pageid;
else
options.title = title.title;
} else {
options.title = title;
}
if (timestamp || options.page_to_edit) {
// 若是 timestamp 並非最新版,則會放棄編輯。
wiki_API_edit.set_stamp(options, timestamp);
}
if (options.sectiontitle && options.section !== 'new') {
options.summary = add_section_to_summary(options.summary,
options.sectiontitle);
delete options.sectiontitle;
}
// the token should be sent as the last parameter.
library_namespace.debug('options.token = ' + JSON.stringify(token), 6,
'wiki_API_edit');
options.token = (library_namespace.is_Object(token)
//
? token.csrftoken : token) || BLANK_TOKEN;
library_namespace.debug('#2: ' + Object.keys(options).join(','), 4,
'wiki_API_edit');
var post_data = wiki_API.extract_parameters(options, action);
wiki_API.query(action, function(data, error) {
// console.log(data);
if (error) {
} else if (data.error) {
// 檢查 MediaWiki 伺服器是否回應錯誤資訊。
error = data.error;
error.toString = wiki_API.query.error_toString;
} else if (data.edit && data.edit.result !== 'Success') {
error = {
code : data.edit.result,
info : data.edit.info
/**
* 新用戶要輸入過多或特定內容如 URL,可能遇到:<br />
* [Failure] 必需輸入驗證碼
*/
|| (data.edit.captcha ? '必需輸入驗證碼'
/**
* 垃圾連結 [[MediaWiki:Abusefilter-warning-link-spam]] e.g.,
* youtu.be, bit.ly
*
* @see 20170708.import_VOA.js
*/
: data.edit.spamblacklist
//
? 'Contains spam link 包含被列入黑名單的連結: '
//
+ data.edit.spamblacklist
//
: JSON.stringify(data.edit)),
toString : wiki_API.query.error_toString
};
}
if (error || !data) {
/**
* <code>
wiki_API_edit: Error to edit [User talk:Flow]: [no-direct-editing] Direct editing via API is not supported for content model flow-board used by User_talk:Flow
wiki_API_edit: Error to edit [[Wikiversity:互助客栈/topic list]]: [tags-apply-not-allowed-one] The tag "Bot" is not allowed to be manually applied.
[[Wikipedia:首页/明天]]是連鎖保護
wiki_API_edit: Error to edit [[Wikipedia:典範條目/2019年1月9日]]: [cascadeprotected] This page has been protected from editing because it is transcluded in the following page, which is protected with the "cascading" option turned on: * [[:Wikipedia:首页/明天]]
* </code>
*
* @see https://doc.wikimedia.org/mediawiki-core/master/php/ApiEditPage_8php_source.html
*/
if (!data || !data.error) {
} else if (data.error.code === 'no-direct-editing'
// .section: 章節編號。 0 代表最上層章節,new 代表新章節。
&& options.section === 'new') {
library_namespace.debug({
// gettext_config:{"id":"unable-to-edit-in-the-normal-way-so-try-it-as-a-flow-discussion-page"}
T : '無法以正常方式編輯,嘗試當作 Flow 討論頁面。'
}, 1, 'wiki_API_edit');
// console.log(options);
// edit_topic()
wiki_API.Flow.edit(title,
// 新章節/新話題的標題文字。輸入空字串""的話,會用 summary 當章節標題。
options.sectiontitle,
// [[mw:Flow]] 會自動簽名,因此去掉簽名部分。
text.replace(/[\s\n\-]*~~~~[\s\n\-]*$/, ''), options.token,
options, callback);
return;
} else if (data.error.code === 'missingtitle') {
// "The page you specified doesn't exist."
// console.log(options);
}
/**
* <del>遇到過長/超過限度的頁面 (e.g., 過多 transclusion。),可能產生錯誤:<br />
* [editconflict] Edit conflict detected</del>
*
* when edit:<br />
* [contenttoobig] The content you supplied exceeds the article
* size limit of 2048 kilobytes
*
* 頁面大小系統上限 2,048 KB = 2 MB。
*
* 須注意是否有其他競相編輯的 bots。
*/
library_namespace.warn([ 'wiki_API_edit: ', {
// gettext_config:{"id":"failed-to-edit-the-page-$1-$2"}
T : [ 'Failed to edit the page %1: %2',
//
wiki_API.title_link_of(title), String(error) ]
} ]);
} else if (data.edit && ('nochange' in data.edit)) {
// 在極少的情況下,data.edit === undefined。
library_namespace.info([ 'wiki_API_edit: ', {
// gettext_config:{"id":"no-changes-to-page-content-$1"}
T : [ 'No changes to page content: %1',
//
wiki_API.title_link_of(title) ]
} ]);
}
if (!error && data && data.edit && change_to_contentmodel) {
library_namespace.info('wiki_API_edit: 自動變更頁面的內容模型: '
+ wiki_API.title_link_of(title) + '→'
+ change_to_contentmodel);
// console.trace(session, title, change_to_contentmodel);
wiki_API.changecontentmodel(title,
//
change_to_contentmodel, function(_data, _error) {
// console.trace(_error);
if (_error && _error.code === 'nochanges')
_error = null;
// Copy the result of .changecontentmodel()
data.changecontentmodel_data = _data;
if (typeof callback === 'function') {
callback(title, _error, data);
}
}, options);
return;
}
if (typeof callback === 'function') {
// assert: wiki_API.is_page_data(title)
// BUT title IS NOT latest page data!
// It contains only basic page information,
// e.g., .pageid, .ns, .title
// title.title === wiki_API.title_of(title)
callback(title, error, data);
// console.trace(title);
}
}, post_data, options);
}
/**
* 放棄編輯頁面用。 CeL.wiki.edit.cancel<br />
* assert: true === !!wiki_API_edit.cancel
*
* @type any
*/
wiki_API_edit.cancel = typeof Symbol === 'function' ? Symbol('CANCEL_EDIT')
//
: {
cancel : '放棄編輯頁面用'
};
/** {Natural}小於此數則代表當作 undo 幾個版本。 */
wiki_API_edit.undo_count_limit = 100;
/**
* 對要編輯的資料作基本檢測。
*
* @param data
* 要編輯的資料。
* @param title
* title or id.
* @param {String}caller
* caller to show.
*
* @returns error: 非undefined表示((data))為有問題的資料。
*/
wiki_API_edit.check_data = function check_data(data, title, options, caller) {
var action;
// return CeL.wiki.edit.cancel as a symbol to skip this edit,
// do not generate warning message.
// 可以利用 ((return [ CeL.wiki.edit.cancel, 'reason' ];)) 來回傳 reason。
// ((return [ CeL.wiki.edit.cancel, 'skip' ];)) 來跳過 (skip)
// 本次編輯動作,不特別顯示或處理。
// 被 skip/pass 的話,連警告都不顯現,當作正常狀況。
if (data === wiki_API_edit.cancel) {
// 統一規範放棄編輯頁面訊息。
data = [ wiki_API_edit.cancel ];
}
// data.trim()
if (!data) {
if (options && options.allow_blanking) {
library_namespace.debug('Blanking page '
// 清空頁面 [[w:en:Wikipedia:Page blanking]]
+ wiki_API.title_link_of(title), 1, caller
|| 'wiki_API_edit.check_data');
} else {
action = [ 'Blanking page', gettext(typeof data === 'string'
// 內容被清空。白紙化。
// gettext_config:{"id":"content-is-empty"}
? 'Content is empty'
// gettext_config:{"id":"content-is-not-settled"}
: 'Content is not settled') ];
// console.trace(action);
}
} else if (Array.isArray(data) && data[0] === wiki_API_edit.cancel) {
action = data.slice(1);
if (action.length === 1) {
// error messages
// gettext_config:{"id":"abandon-change"}
action[1] = action[0] || gettext('Abandon change');
}
if (!action[0]) {
// error code
action[0] = 'cancel';
}
library_namespace.debug('採用個別特殊訊息: ' + action, 2, caller
|| 'wiki_API_edit.check_data');
}
if (action) {
if (action[1] !== 'skip') {
// 被 skip/pass 的話,連警告都不顯現,當作正常狀況。
library_namespace.warn((caller || 'wiki_API_edit.check_data')
+ ': ' + wiki_API.title_link_of(title) + ': '
// gettext_config:{"id":"no-reason-provided"}
+ (action[1] || gettext('No reason provided')));
} else {
library_namespace.debug(
'Skip ' + wiki_API.title_link_of(title), 2, caller
|| 'wiki_API_edit.check_data');
}
return action[0];
}
};
/**
* 處理編輯衝突用。 to detect edit conflicts.
*
* 注意: 會改變 options! Warning: will modify options!
*
* 此功能之工作機制/原理:<br />
* 在 .page() 會取得每個頁面之 page_data.revisions[0].timestamp(各頁面不同)。於 .edit()
* 時將會以從 page_data 取得之 timestamp 作為時間戳記傳入呼叫,當 MediaWiki 系統 (API)
* 發現有新的時間戳記,會回傳編輯衝突,並放棄編輯此頁面。<br />
* 詳見 [https://github.com/kanasimi/CeJS/blob/master/application/net/wiki.js
* wiki_API_edit.set_stamp]。
*
* @param {Object}options
* 附加參數/設定選擇性/特殊功能與選項
* @param {String}timestamp
* 頁面時間戳記。 e.g., '2015-01-02T02:52:29Z'
*
* @returns {Object}options
*
* @see https://www.mediawiki.org/wiki/API:Edit
*/
wiki_API_edit.set_stamp = function(options, timestamp) {
if (false && options.page_to_edit) {
console.trace(options.page_to_edit);
if (wiki_API.is_page_data(timestamp)) {
console.trace(options.page_to_edit === timestamp);
}
// options.baserevid =
}
if (wiki_API.is_page_data(timestamp)
// 在 .page() 會取得 page_data.revisions[0].timestamp
&& (timestamp = wiki_API.content_of.revision(timestamp))) {
// console.trace(timestamp);
if (timestamp.revid) {
// 添加編輯之基準版本號以偵測/避免編輯衝突。
options.baserevid = timestamp.revid;
}
// 自 page_data 取得 timestamp.
timestamp = timestamp.timestamp;
}
// timestamp = '2000-01-01T00:00:00Z';
if (timestamp) {
library_namespace.debug(timestamp, 3, 'wiki_API_edit.set_stamp');
options.basetimestamp = options.starttimestamp = timestamp;
}
return options;
};
/**
* Get the contents of [[Template:Bots]].
*
* @param {String}content
* page contents 頁面內容。
*
* @returns {Array}contents of [[Template:Bots]].
*
* @see https://zh.wikipedia.org/wiki/Template:Bots
*/
wiki_API_edit.get_bot = function(content) {
// TODO: use parse_template(content, 'bots')
var bots = [], matched, PATTERN = /{{[\s\n]*bots[\s\n]*(\S[\s\S]*?)}}/ig;
while (matched = PATTERN.exec(content)) {
library_namespace.debug(matched.join('<br />'), 1,
'wiki_API_edit.get_bot');
if (matched = matched[1].trim().replace(/(^\|\s*|\s*\|$)/g, '')
// .split('|')
)
bots.push(matched);
}
if (0 < bots.length) {
library_namespace.debug(bots.join('<br />'), 1,
'wiki_API_edit.get_bot');
return bots;
}
};
/**
* 測試頁面是否允許機器人帳戶訪問,遵守[[Template:Bots]]。機器人另須考慮{{Personal announcement}}的情況。
*
* [[Special:Log/massmessage]] Delivery of "message" to [[User talk:user]]<br />
* was skipped because the target has opted-out of message delivery<br />
* failed with an error code of protectedpage / contenttoobig
*
* @param {String}content
* page contents 頁面內容。
* @param {String}bot_id
* 機器人帳戶名稱。
* @param {String}notification_name
* message notifications of action. 按通知種類而過濾(optout)。
* ignore_opted_out allows /[a-z\d\-\_]+/ that will not affects
* RegExp. ignore_opted_out will splits with /[,|]/.
*
* @returns {Boolean|String}封鎖機器人帳戶訪問。
*/
wiki_API_edit.denied = function(content, bot_id, notification_name) {
if (!content)
return;
var page_data;
if (wiki_API.is_page_data(content)) {
page_data = content;
content = wiki_API.content_of(content);
}
// assert: !content || typeof content === 'string'
if (typeof content === 'string') {
// 去掉絕對不會影響 deny code 的內容。
content = content.replace(/<\!--[\s\S]*?-->/g, '').replace(
/<nowiki\s*>[\s\S]*<\/nowiki>/g, '');
}
if (!content)
return;
library_namespace.debug('contents to test: [' + content + ']', 3,
'wiki_API_edit.denied');
var bots = wiki_API_edit.get_bot(content),
/** {String}denied messages */
denied, allow_bot;
if (bots) {
library_namespace.debug('test ' + bot_id + '/' + notification_name,
3, 'wiki_API_edit.denied');
// botlist 以半形逗號作間隔。
bot_id = (bot_id = bot_id && bot_id.toLowerCase()) ? new RegExp(
'(?:^|[\\s,])(?:all|' + bot_id + ')(?:$|[\\s,])', 'i')
: wiki_API_edit.denied.all;
if (notification_name) {
if (typeof notification_name === 'string'
// 以 "|" 或半形逗號 "," 隔開 optout。
&& notification_name.includes(',')) {
notification_name = notification_name.split(',');
}
if (Array.isArray(notification_name)) {
notification_name = notification_name.map(function(name) {
return name.trim();
}).join('|');
}
if (typeof notification_name === 'string') {
// 預設必須包含 optout=all
notification_name = new RegExp('(?:^|[\\s,])(?:all|'
+ notification_name.trim() + ')(?:$|[\\s,])');
} else if (!library_namespace.is_RegExp(notification_name)) {
library_namespace.warn(
//
'wiki_API_edit.denied: Invalid notification_name: ['
+ notification_name + ']');
notification_name = null;
}
// 警告: 自訂 {RegExp}notification_name 可能頗危險。
}
bots.some(function(data) {
// data = data.toLowerCase();
library_namespace.debug('test [' + data + ']', 1,
'wiki_API_edit.denied');
var matched,
/** {RegExp}封鎖機器人訪問之 pattern。 */
PATTERN;
// 過濾機器人所發出的通知/提醒
// 頁面/用戶以bots模板封鎖通知
if (notification_name) {
PATTERN =
//
/(?:^|\|)[\s\n]*optout[\s\n]*=[\s\n]*([^{}|]+)/ig;
while (matched = PATTERN.exec(data)) {
if (notification_name.test(matched[1])) {
// 一被拒絕即跳出。
return denied = 'Opt out of ' + matched[1];
}
}
}
// 檢查被拒絕之機器人帳戶名稱列表(以半形逗號作間隔)
PATTERN = /(?:^|\|)[\s\n]*deny[\s\n]*=[\s\n]*([^|]+)/ig;
while (matched = PATTERN.exec(data)) {
if (bot_id.test(matched[1])) {
// 一被拒絕即跳出。
return denied = 'Banned: ' + matched[1];
}
}
// 檢查被允許之機器人帳戶名稱列表(以半形逗號作間隔)
PATTERN = /(?:^|\|)[\s\n]*allow[\s\n]*=[\s\n]*([^|]+)/ig;
while (matched = PATTERN.exec(data)) {
if (!bot_id.test(matched[1])) {
// 一被拒絕即跳出。
return denied = 'Not in allowed bots list: ['
+ matched[1] + ']';
}
if (page_data)
allow_bot = matched[1];
}
});
}
// {{Nobots}}判定
if (!denied && /{{[\s\n]*nobots[\s\n]*}}/i.test(content))
denied = 'Ban all compliant bots.';
if (denied) {
// console.trace(content);
library_namespace.warn('wiki_API_edit.denied: '
//
+ (page_data ? wiki_API.title_link_of(page_data) + ' ' : '')
+ denied);
return denied;
}
if (allow_bot) {
// 特別標記本 bot 為被允許之 bot。
page_data.allow_bot = allow_bot;
}
};
/**
* pattern that will be denied.<br />
* i.e. "deny=all", !("allow=all")
*
* @type {RegExp}
*/
wiki_API_edit.denied.all = /(?:^|[\s,])all(?:$|[\s,])/;
// ------------------------------------------------------------------------
// 不用 copy_to 的原因是 copy_to(wiki) 得遠端操作 wiki,不能保證同步性。
// this_wiki.copy_from(wiki) 則呼叫時多半已經設定好 wiki,直接在本this_wiki中操作比較不會有同步性問題。
// 因為直接採wiki_API.prototype.copy_from()會造成.page().copy_from()時.page()尚未執行完,
// 這會使執行.copy_from()時尚未取得.last_page,因此只好另開function。
// @see [[Template:Copied]], [[Special:Log/import]]
// TODO: 添加 wikidata sitelinks 語言連結。處理分類。處理模板。
function wiki_API_prototype_copy_from(title, options, callback) {
if (typeof options === 'function') {
// shift arguments
callback = options;
options = undefined;
}
options = wiki_API.add_session_to_options(this, options);
var _this = this, copy_from_wiki;
function edit() {
// assert: wiki_API.is_page_data(title)
var content_to_copy = wiki_API.content_of(title);
if (typeof options.processor === 'function') {
// options.processor(from content_to_copy, to content)
content_to_copy = options.processor(title, wiki_API
.content_of(_this.last_page));
}
if (!content_to_copy) {
library_namespace
.warn('wiki_API_prototype_copy_from: Nothing to copy!');
_this.next();
}
var content;
if (options.append && (content
//
= wiki_API.content_of(_this.last_page).trimEnd())) {
content_to_copy = content + '\n' + content_to_copy;
options.summary = 'Append from '
+ wiki_API.title_link_of(title, copy_from_wiki) + '.';
}
if (!options.summary) {
options.summary = 'Copy from '
// TODO: 複製到非維基項目外的私人維基,例如moegirl時,可能需要用到[[zhwiki:]]這樣的prefix。
+ wiki_API.title_link_of(title, copy_from_wiki) + '.';
}
_this.actions.unshift(
// wiki.edit(page, options, callback)
[ 'edit', content_to_copy, options, callback ]);
_this.next();
}
if (wiki_API.is_wiki_API(title)) {
// from page 為另一 wiki_API
copy_from_wiki = title;
// wiki.page('title').copy_from(wiki)
title = copy_from_wiki.last_page;
if (!title) {
// wiki.page('title').copy_from(wiki);
library_namespace.debug('先擷取同名title: '
+ wiki_API
.title_link_of(this.last_page, copy_from_wiki));
// TODO: create interwiki link
copy_from_wiki.page(wiki_API.title_of(this.last_page),
//
function(page_data) {
library_namespace.debug('Continue coping page');
// console.log(copy_from_wiki.last_page);
wiki_API_prototype_copy_from.call(_this, copy_from_wiki,
options, callback);
});
return;
}
}
if (wiki_API.is_page_data(title)) {
// wiki.page().copy_from(page_data)
edit();
} else {
// treat title as {String}page title in this wiki
// wiki.page().copy_from(title)
var to_page_data = this.last_page;
// 即時性,不用 async。
// wiki_API.page(title, callback, options)
wiki_API.page(title, function(from_page_data) {
// recover this.last_page
_this.last_page = to_page_data;
title = from_page_data;
edit();
}, options);
}
return this;
}
wiki_API_edit.copy_from = wiki_API_prototype_copy_from;
// ================================================================================================================
// https://www.mediawiki.org/w/api.php?action=help&modules=changecontentmodel
function changecontentmodel(title, model, callback, options) {
if (wiki_API.need_get_API_parameters('changecontentmodel', options,
changecontentmodel, arguments)) {
return;
}
var action = wiki_API.normalize_title_parameter(title, Object.assign({
multi : false
}, options));
// console.trace(title, action);
var session = wiki_API.session_of_options(options);
Object.assign(action[1], {
action : 'changecontentmodel',
model : model
});
// console.trace(action, options);
// console.trace(action, options, arguments);
var post_data = Object.assign({
token : session.token.csrftoken
}, wiki_API.extract_parameters(options, action));
// console.trace(action, options);
wiki_API.query(action, function(data, error) {
// console.trace(data, error);
if (!error && data && data.error
// && data.error.code !== 'nochanges'
) {
error = data.error;
error.toString = wiki_API.query.error_toString;
}
callback && callback(data, error);
}, post_data, options);
}
wiki_API.changecontentmodel = changecontentmodel;
// ================================================================================================================
/**
* 上傳檔案/媒體。
*
* arguments: Similar to wiki_API_edit<br />
* wiki_API.upload(file_path, token, options, callback);
*
* TODO: https://commons.wikimedia.org/wiki/Commons:Structured_data<br />
* 檔案資訊 添加/編輯 說明 (Must be plain text. Cannot use wikitext!)
* https://commons.wikimedia.org/w/api.php?action=help&modules=wbsetlabel
* wikitext_to_plain_text(wikitext)
*
* @param {String}file_path
* file path/url
* @param {Object}token
* login 資訊,包含“csrf”令牌/密鑰。
* @param {Object}[options]
* 附加參數/設定選擇性/特殊功能與選項
* @param {Function}[callback]
* 回調函數。 callback(page_data, error, result)
*
* @see https://commons.wikimedia.org/w/api.php?action=help&modules=upload
* https://www.mediawiki.org/wiki/API:Upload
*/
wiki_API.upload = function upload(file_path, token, options, callback) {
var action = {
action : 'upload'
};
if (wiki_API.need_get_API_parameters(action, options,
wiki_API[action.action], arguments)) {
return;
}
// must set options.ignorewarnings to reupload
// 前置處理。
options = library_namespace.new_options(options);
// When set .variable_Map, after successful update, the content of file
// page will be auto-updated too.
if (!('page_text_updater' in options) && options.variable_Map) {
// auto set options.page_text_updater
options.page_text_updater = options.variable_Map;
}
if (options.page_text_updater) {
// https://www.mediawiki.org/w/api.php?action=help&modules=upload
// A "csrf" token retrieved from action=query&meta=tokens
options.token = token;
}
// 備註 comment won't accept templates and external links
if (!options.comment && options.summary) {
library_namespace.warn(
// 錯置?
'wiki_API.upload: Please use .comment instead of .summary!');
options.comment = options.summary;
}
var structured_data = Object.assign(Object.create(null),
options.structured_data);
// upload_text: media description
if (!options.text) {
// 從 options / file_data / media_data 自動抽取出文件資訊。
options.text = {
description : options.description,
date : library_namespace.is_Date(options.date) ? options.date
.toISOString().replace(/\.\d+Z$/, 'Z') : options.date,
source : options.source_url || options.media_url
|| options.file_url,
author : /^Q\d+/.test(options.author) ? '{{label|'
+ options.author + '}}' : options.author,
permission : options.permission,
other_versions : options.other_versions
|| options['other versions'] || '',
other_fields : options.other_fields || options['other fields']
|| ''
};
// inception (P571) 成立或建立時間
if (!structured_data.date)
structured_data.date = options.text.date;
} else {
[ "description", "date", "source", "author", "permission",
"other_versions", "other_fields" ]
.forEach(function(parameter) {
if (parameter in options) {
library_namespace
.error('wiki_API.upload: Cannot assign both options.text and options.'
+ parameter
+ '! Maybe you want to change options.text to options.additional_text?');
}
});
}
// options.location: [latitude, longitude, altitude / height / -depth ]
if (options.location) {
if (isNaN(options.location[0]) || isNaN(options.location[1])) {
delete options.location;
} else if (!structured_data.location) {
structured_data.location = options.location;
}
}
if (options.location && options.variable_Map) {
if (options.location[0] && !options.variable_Map.has('latitude'))
options.variable_Map.set('latitude', options.location[0]);
if (options.location[1] && !options.variable_Map.has('longitude'))
options.variable_Map.set('longitude', options.location[1]);
if (options.location[2] && !options.variable_Map.has('altitude'))
options.variable_Map.set('altitude', options.location[2]);
}
if (library_namespace.is_Object(options.text)) {
var variable_Map = options.variable_Map;
if (variable_Map) {
for ( var property in options.text) {
var value = options.text[property];
if (!variable_Map.has(property)
//
&& wiki_API.is_valid_parameters_value(value)
//
&& !Variable_Map__PATTERN_mark.test(value)
// 自動將每次更新可能會改變的值轉成可更新標記。
&& [ 'date', 'source' ].includes(property)) {
variable_Map.set(property, value);
}
if (variable_Map.has(property)) {
options.text[property] = variable_Map.format(property);
}
}
}
options.text = [ '== {{int:filedesc}} ==',
// 將 .text 當作文件資訊。
wiki_API.template_text(options.text, {
name : 'Information',
separator : '\n| '
}) ];
// https://commons.wikimedia.org/wiki/Commons:Geocoding#Adding_a_location_template
// If the image page has an {{Information}} template, or similar,
// the {{Location}} template should come immediately after it.
if (options.location) {
options.text.push(wiki_API.template_text([
options.location_template_name || 'Location',
variable_Map ? variable_Map.format('latitude')
: options.location[0],
variable_Map ? variable_Map.format('longitude')
: options.location[1] ]));
}
options.text = options.text.join('\n');
}
if (options.license) {
options.text += '\n== {{int:license-header}} ==\n'
+ wiki_API.template_text.join_array(options.license);
}
// Additional wikitext to place before categories.
if (options.additional_text) {
options.text += '\n' + options.additional_text.trim();
}
// append categories
if (options.categories) {
options.text += '\n'
//
+ wiki_API.template_text.join_array(options.categories
//
.map(function(category_name) {
if (category_name && !category_name.includes('[[')) {
if (!PATTERN_category_prefix.test(category_name))
category_name = 'Category:' + category_name;
// NG: CeL.wiki.title_link_of()
category_name = '[[' + category_name + ']]';
}
return category_name;
}));
}
// assert: typeof options.text === 'string'
// TODO: check {{Information|permission=license}}
var post_data = wiki_API.extract_parameters(options, action);
// One of the parameters "filekey", "file" and "url" is required.
if (false && file_path.includes('://')) {
post_data.url = file_path;
// The "filename" parameter must be set.
if (!post_data.filename) {
post_data.filename = file_path.match(/[\\\/]*$/)[0];
}
// Uploads by URL are not allowed from this domain.
} else {
// 自動先下載 fetch 再上傳。
// file: 必須使用 multipart/form-data 以檔案上傳的方式傳送。
if (!options.form_data) {
// options.form_data 會被當作傳入 to_form_data() 之 options。
options.form_data = true;
}
post_data.file = file_path.includes('://') ? {
// to_form_data() will get file using get_URL()
url : file_path
} : {
file : file_path
};
}
if (!post_data.filename) {
// file path → file name
post_data.filename = file_path.match(/[^\\\/]*$/)[0]
// {result:'Warning',warnings:{badfilename:''}}
.replace(/#/g, '-');
// https://www.mediawiki.org/wiki/Manual:$wgFileExtensions
}
post_data.token = token;
if (!structured_data['media type']) {
var matched;
if (library_namespace.MIME_of) {
matched = library_namespace.MIME_of(post_data.filename);
} else if (matched = post_data.filename
.match(/\.(png|jpeg|gif|webp|bmp)$/i)) {
matched = 'image/' + matched[1].toLowerCase();
}
if (matched) {
structured_data['media type'] = matched;
}
}
var session = wiki_API.session_of_options(options);
// console.trace(session);
if (options.show_message && post_data.file.url) {
library_namespace.log(file_path + '\nUpload to → '
+ wiki_API.title_link_of(session.to_namespace(
// 'File:' +
post_data.filename, 'File')));
}
if (session) {
// console.trace(session.token, post_data);
}
if (session && session.API_URL && options.check_media) {
// TODO: Skip exists file
// @see 20181016.import_earthquake_shakemap.js
}
// no really update
if (options.test_only) {
if (options.test_only !== 'no message') {
delete options[KEY_SESSION];
delete options.text;
action = post_data.text;
delete post_data.text;
console.log('-'.repeat(80));
console.log(options);
console.log(post_data);
library_namespace.info('wiki_API.upload: test edit text:\n'
+ action);
}
callback(null, 'Test edit');
return;
}
// console.trace(post_data);
wiki_API.query(action, upload_callback.bind(null,
//
function check_structured_data(data, error) {
// console.trace([ data, error ]);
error = wiki_API.query.handle_error(data, error);
// 檢查伺服器回應是否有錯誤資訊。
if (error) {
library_namespace.error('wiki_API.upload: ' + error);
callback(data, error);
return;
}
if (!structured_data.date) {
// inception (P571) 成立或建立時間
structured_data.date = options.date;
}
if (structured_data.location
// assert: Array.isArray(structured_data.location)
&& !structured_data.location.precision) {
structured_data.location.precision = .1;
}
// normalize structured_data
Object.keys(structured_data).forEach(function(name) {
if (structured_data[name] === undefined) {
delete structured_data[name];
return;
}
var property_id = structured_data_mapping[name];
if (property_id && !(property_id in structured_data)) {
structured_data[property_id] = structured_data[name];
delete structured_data[name];
}
});
// console.trace([ post_data.filename, structured_data ]);
if (library_namespace.is_empty_object(structured_data)) {
callback(data, error);
return;
}
// --------------------------------------------
// 確保不會直接執行 session.edit_structured_data(),而是將之推入 session.actions。
session.running = true;
session.edit_structured_data(session.to_namespace(
// 'File:' + data.filename
post_data.filename, 'File'),
//
function fill_structured_data(entity) {
var summary_list = [], data = Object.create(null);
for ( var property_id in structured_data) {
if (entity.claims
//
&& wiki_API.data.value_of(entity.claims[property_id])) {
if (false) {
console.log([ property_id, wiki_API.data.value_of(
//
entity.claims[property_id]) ]);
}
continue;
}
var value = structured_data[property_id];
data[property_id] = value;
var config = structured_data_config[property_id];
var summary_name = config && config[KEY_summary_name]
|| property_id;
summary_name = wiki_API.title_link_of('d:Property:'
+ property_id, summary_name);
summary_list.push(summary_name + '=' + value);
}
// console.log(entity.claims);
// console.trace([ summary_list, data ]);
if (summary_list.length === 0)
return [ wiki_API.edit.cancel, 'skip' ];
// gettext_config:{"id":"Comma-separator"}
this.summary += summary_list.join(gettext('Comma-separator'));
return data;
}, {
// 標記此編輯為機器人編輯。
bot : options.bot,
summary : 'Modify structured data: '
}, function structured_data_callback(_data, _error) {
// console.trace([ _data, error, _error ]);
if (error) {
if (data && data.error
&& data.error.code === 'fileexists-no-change')
;
}
callback(data, error || _error);
});
// 本執行序擁有執行權,因此必須手動執行 session.next(),否則會中途跳出。
session.next();
}, options), post_data, options);
};
var KEY_summary_name = 'summary_name',
// {inner} alias
structured_data_mapping = {
// [[Commons:Structured data/Modeling/Location]]
// coordinates of depicted place (P9149) 描述地坐標
location : 'P9149',
// [[Commons:Structured data/Modeling/Depiction]]
// depicts (P180) 描繪內容
depicts : 'P180',
// [[Commons:Structured data/Modeling/Properties table]]
// TODO: file format (P2701) 文件格式
'file format' : 'P2701',
// TODO: data size (P3575) 資料大小
'data size' : 'P3575',
'media type' : 'P1163',
// [[Commons:Structured data/Modeling/Date]]
// inception (P571) 成立或建立時間
'created datetime' : 'P571',
date : 'P571',
inception : 'P571'
}, structured_data_config = Object.create(null);
Object.keys(structured_data_mapping).forEach(function(name) {
var property_id = structured_data_mapping[name];
var property_config = structured_data_config[property_id];
if (!property_config) {
structured_data_config[property_id]
//
= property_config = Object.create(null);
}
if (!property_config[KEY_summary_name])
property_config[KEY_summary_name] = name;
});
function upload_callback(callback, options, data, error) {
if (error || !data || (error = data.error)
/**
* <code>
{upload:{result:'Warning',warnings:{exists:'file_name',nochange:{}},filekey:'',sessionkey:''}}
{upload:{result:'Warning',warnings:{"duplicate":["file_name"]}}
{upload:{result:'Warning',warnings:{"was-deleted":"file_name","duplicate-archive":"file_name"}}
{upload:{result:'Success',filename:'',imageinfo:{}}}
{upload:{result:'Success',filename:'',warnings:{duplicate:['.jpg','.jpg']},imageinfo:{}}}
{"error":{"code":"fileexists-no-change","info":"The upload is an exact duplicate of the current version of [[:File:name.jpg]].","stasherrors":[{"message":"uploadstash-exception","params":["UploadStashBadPathException","Path doesn't exist."],"code":"uploadstash-exception","type":"error"}],"*":"See https://test.wikipedia.org/w/api.php for API usage. Subscribe to the mediawiki-api-announce m