MediaWiki:Gadget-templating.js: Difference between revisions
Appearance
No edit summary |
No edit summary |
||
| (47 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/* global mw, $ */ | /* global mw, $ */ | ||
mw.loader.using(['mediawiki.api']).done(function () { | mw.loader.using(['mediawiki.api']).done(function () { | ||
// ---- timing knobs (ms) ---- | |||
var WB_TIMING = { | |||
pollInterval: 50, // how often we poll for readiness | |||
valueViewTimeout: 1200, // wait for value view to exist | |||
widgetTimeout: 1800, // wait for input widget/$input | |||
renderTimeout: 1200, // wait for statement list to reflect the save (+1) | |||
settleDelay: 0, // optional pause between claims (0 = off) | |||
commitDelay: 120 // short pause to let the widget commit before next step | |||
}; | |||
// Special property remapping | |||
var WB_SPECIAL = { | |||
PRIMARY_P: 'P1', // the “real” property to write | |||
PRIMARY_FROM: 'P10816', // the template surrogate to read from | |||
PENDING_ITEM: 'Q59' // the placeholder for items | |||
}; | |||
// Only entities with P1 = this Q are valid templates | |||
var TEMPLATE_CLASS_Q = 'Q196450'; | |||
// QID -> template key mapping for auto-select later | |||
var WbTemplatesById = {}; | |||
// read ?template=Q123 from the URL | |||
function getUrlTemplateId() { | |||
try { | |||
var p = new URLSearchParams(window.location.search); | |||
var t = p.get('template'); | |||
if (t && /^Q\d+$/i.test(t)) return t.toUpperCase(); | |||
} catch (e) {} | |||
return null; | |||
} | |||
function getTargetClassId(entity) { | |||
var s = (entity.claims && entity.claims[WB_SPECIAL.PRIMARY_FROM] || [])[0]; | |||
return s && s.mainsnak && s.mainsnak.datavalue && s.mainsnak.datavalue.value && s.mainsnak.datavalue.value.id || null; | |||
} | |||
// gate: accept entities that have P1 = TEMPLATE_CLASS_Q | |||
function isTemplateEntity(entity) { | |||
try { | |||
var arr = (entity.claims && entity.claims.P1) || []; | |||
for (var i = 0; i < arr.length; i++) { | |||
var ms = arr[i].mainsnak; | |||
if ( | |||
ms && ms.snaktype === 'value' && | |||
ms.datavalue && ms.datavalue.type === 'wikibase-entityid' && | |||
ms.datavalue.value && ms.datavalue.value.id === TEMPLATE_CLASS_Q | |||
) { | |||
return true; | |||
} | |||
} | |||
} catch (_) {} | |||
return false; | |||
} | |||
function removeAutoTemplateParams() { | |||
try { | |||
if (!history.replaceState) return; | |||
var url = new URL(window.location.href); | |||
url.searchParams.delete('template'); // required | |||
url.searchParams.delete('details'); // optional, if you used it | |||
history.replaceState(null, '', url.toString()); | |||
} catch (e) { | |||
// very old browsers fallback: do nothing (avoid nuking other params) | |||
} | |||
} | |||
function getQualifierSnaks(sv) { | |||
var out = []; | |||
try { | |||
var items = sv._qualifiers && sv._qualifiers.items ? sv._qualifiers.items() : []; | |||
if (!items || !items.length) return out; | |||
var qslv = $(items[items.length - 1]).data().snaklistview; | |||
var list = qslv && qslv._listview ? qslv._listview.items() : []; | |||
list.each(function () { | |||
var qsv = $(this).data().snakview; | |||
if (qsv) out.push(qsv); | |||
}); | |||
} catch (e) {} | |||
return out; | |||
} | |||
function ensureQualifiersCommitted(sv, perTimeout) { | |||
var qs = getQualifierSnaks(sv); | |||
var p = Promise.resolve(); | |||
for (var i = 0; i < qs.length; i++) { | |||
(function (qsnak) { | |||
p = p.then(function () { | |||
// try to observe a non-null datavalue; if it times out, continue anyway | |||
return waitForViewValue(qsnak, perTimeout || 500).catch(function(){}); | |||
}); | |||
})(qs[i]); | |||
} | |||
return p; | |||
} | |||
function blurAllQualifierInputs(sv) { | |||
try { | |||
var items = sv._qualifiers && sv._qualifiers.items ? sv._qualifiers.items() : []; | |||
if (!items || !items.length) return; | |||
var qslv = $(items[items.length - 1]).data().snaklistview; | |||
var list = qslv && qslv._listview ? qslv._listview.items() : []; | |||
list.each(function () { | |||
try { | |||
var qsnak = $(this).data().snakview; | |||
var vv = qsnak && qsnak._variation && qsnak._variation._valueView; | |||
var ex = vv && vv._expert; | |||
var $inp = ex && ex.$input; | |||
if ($inp && $inp.length) { $inp.trigger('input').trigger('change').blur(); } | |||
emitValueViewChange(qsnak); | |||
} catch (e) {} | |||
}); | |||
} catch (e) {} | |||
} | |||
function qualifierHasValue(q) { | |||
if (!q || !q.property) return false; | |||
var dt = q.property.datatype || q.datatype || 'string'; | |||
var mv = q.value && q.value.mainVal; | |||
if (!mv) return false; | |||
if (dt === 'wikibase-item') return !!mv.id; | |||
if (dt === 'monolingualtext') return !!(mv.text && mv.text !== '_'); | |||
if (dt === 'string' || dt === 'external-id' || dt === 'url' || dt === 'edtf') | |||
return String(mv).length > 0 && mv !== '_'; | |||
if (dt === 'globe-coordinate' || dt === 'globecoordinate') return typeof mv.latitude === 'number' && typeof mv.longitude === 'number'; | |||
if (dt === 'time') return !!mv.time; | |||
if (dt === 'quantity') return !!(mv.amount || mv.value); | |||
return false; | |||
} | |||
function toInputText(dt, mv) { | |||
if (!mv) return ''; | |||
if (typeof mv === 'string') return mv; | |||
if (dt === 'string' || dt === 'external-id' || dt === 'url' || dt === 'edtf') return (mv.text || mv.value || '') + ''; | |||
if (dt === 'monolingualtext') return (mv.text || '') + ''; | |||
if (dt === 'globe-coordinate' || dt === 'globecoordinate') { | |||
return (mv.latitude != null && mv.longitude != null) ? (mv.latitude + ',' + mv.longitude) : ''; | |||
} | |||
if (dt === 'time') return (mv.time || '') + ''; | |||
if (dt === 'quantity') return (mv.amount || mv.value || '') + ''; | |||
return (mv.text || mv.value || '') + ''; | |||
} | |||
/* Commit simple datatypes so valueView.value() becomes non-null */ | |||
function hardCommitSimpleValue(sv, snak, dt, mainVal) { | |||
return waitForValueWidget(snak, dt, 800).then(function(vw){ | |||
var vv = snak && snak._variation && snak._variation._valueView; | |||
var ex = vv && vv._expert; | |||
var $inp = vw && vw.$input; | |||
var txt = toInputText(dt, mainVal); | |||
try { | |||
if ($inp && $inp.length) { | |||
// type target | |||
$inp.val(txt).trigger('input').trigger('change'); | |||
// legacy eachchange hooks | |||
if (vw.jqe && vw.jqe.handlers) { | |||
for (var i = 0; i < vw.jqe.handlers.length; i++) { try { vw.jqe.handlers[i].call(); } catch(e) {} } | |||
} | |||
// some builds need an explicit apply on the expert widget | |||
if (ex && typeof ex._apply === 'function') ex._apply(); | |||
else if (ex && typeof ex.applyValue === 'function') ex.applyValue(); | |||
$inp.blur(); | |||
} | |||
} catch(e1) {} | |||
// notify both views | |||
emitValueViewChange(snak); | |||
try { var se = sv.widgetEventPrefix ? (sv.widgetEventPrefix + 'change') : 'change'; sv.element && sv.element.trigger(se); } catch(e2){} | |||
// if still null, toggle placeholder then back (forces conversion) | |||
return waitForViewValue(snak, 600).catch(function(){ | |||
try { | |||
if ($inp && $inp.length) { | |||
$inp.val('_').trigger('input').trigger('change'); | |||
if (vw.jqe && vw.jqe.handlers) { | |||
for (var j = 0; j < vw.jqe.handlers.length; j++) { try { vw.jqe.handlers[j].call(); } catch(e3) {} } | |||
} | |||
if (ex && typeof ex._apply === 'function') ex._apply(); | |||
else if (ex && typeof ex.applyValue === 'function') ex.applyValue(); | |||
$inp.val(txt).trigger('input').trigger('change').blur(); | |||
} | |||
} catch(e4) {} | |||
emitValueViewChange(snak); | |||
return waitForViewValue(snak, 600); | |||
}); | |||
}); | |||
} | |||
// set vv.value(...) AND (if widget exists) type into the input + fire eachchange | |||
function setAndCommitValue(snak, dt, mainVal) { | |||
// 1) try datamodel set | |||
var dvSet = false; | |||
try { dvSet = setValueOnValueView(snak, dt, mainVal); } catch (e) { dvSet = false; } | |||
// 2) if non-item, also drive the input widget when present | |||
if (dt === 'wikibase-item') { | |||
// items are handled elsewhere (pickEntity), nothing to do here | |||
emitValueViewChange(snak); | |||
return Promise.resolve(dvSet); | |||
} | |||
return waitForValueWidget(snak, dt, 600).then(function (vw) { | |||
try { | |||
var txt = toInputText(dt, mainVal); | |||
if (vw.$input && vw.$input.length) { | |||
vw.$input.val(txt).trigger('input').trigger('change').blur(); | |||
} | |||
// legacy commit hooks | |||
if (vw.jqe && vw.jqe.handlers) { | |||
for (var i = 0; i < vw.jqe.handlers.length; i++) { | |||
try { vw.jqe.handlers[i].call(); } catch (e2) {} | |||
} | |||
} | |||
} catch (e3) {} | |||
emitValueViewChange(snak); | |||
return true; | |||
}, function () { | |||
// no widget surfaced; at least emit change for vv | |||
emitValueViewChange(snak); | |||
return dvSet; | |||
}); | |||
} | |||
function qidToNumeric(id) { | |||
return parseInt(String(id).replace(/^[A-Za-z]/, ''), 10); | |||
} | |||
function emitValueViewChange(snak) { | |||
try { | |||
var vv = snak && snak._variation && snak._variation._valueView; | |||
if (!vv || !vv.element) return; | |||
var ev = vv.widgetEventPrefix ? (vv.widgetEventPrefix + 'change') : 'change'; | |||
vv.element.trigger(ev); | |||
} catch (e) {} | |||
} | |||
// Wait until the valueView has a non-null value() (i.e., savable) | |||
function waitForViewValue(snak, timeoutMs) { | |||
var start = Date.now(); | |||
var timeout = (typeof timeoutMs === 'number') ? timeoutMs : WB_TIMING.valueViewTimeout; | |||
return new Promise(function (resolve, reject) { | |||
(function tick() { | |||
try { | |||
var vv = snak && snak._variation && snak._variation._valueView; | |||
var val = vv && typeof vv.value === 'function' ? vv.value() : null; | |||
if (val !== null) return resolve(val); | |||
} catch (e) {} | |||
if (Date.now() - start > timeout) return reject(new Error('view value still null')); | |||
setTimeout(tick, WB_TIMING.pollInterval); | |||
})(); | |||
}); | |||
} | |||
function buildMainValFromMainsnak(ms, dt) { | |||
if (!ms || ms.snaktype !== 'value' || !ms.datavalue) return null; | |||
var dv = ms.datavalue.value; | |||
switch (dt) { | |||
case 'wikibase-item': | |||
case 'wikibase-property': | |||
return dv && dv.id ? { id: dv.id, datatype: dt, _pending: (dv.id === WB_SPECIAL.PENDING_ITEM) } : null; | |||
case 'string': | |||
case 'external-id': | |||
case 'url': | |||
case 'edtf': // <- treat as plain text | |||
if (typeof dv === 'string') { | |||
if (dv === '_') return null; | |||
if (dv === '-999999999') return { text: '', _pending: true }; // ← pending EDTF | |||
return dv; | |||
} else if (dv && typeof dv.value === 'string') { | |||
if (dv.value === '_') return null; | |||
if (dv.value === '-999999999') return { text: '', _pending: true }; // ← pending EDTF | |||
return dv.value; | |||
} | |||
return null; | |||
case 'monolingualtext': | |||
if (!dv || !dv.text || dv.text === '_') return null; | |||
return { text: dv.text, language: dv.language || (typeof userLang === 'string' ? userLang : 'en') }; | |||
case 'time': | |||
return dv && dv.time ? { time: dv.time, precision: dv.precision } : null; | |||
case 'globe-coordinate': | |||
case 'globecoordinate': | |||
return (dv && typeof dv.latitude === 'number' && typeof dv.longitude === 'number') | |||
? { latitude: dv.latitude, longitude: dv.longitude, precision: dv.precision, globe: dv.globe } | |||
: null; | |||
case 'quantity': | |||
return dv && dv.amount | |||
? { amount: dv.amount, unit: dv.unit || null, upperBound: dv.upperBound, lowerBound: dv.lowerBound } | |||
: null; | |||
default: | |||
return dv && (dv.id || dv.text || dv.time || dv.latitude != null || dv.amount) | |||
? dv | |||
: null; | |||
} | |||
} | |||
function buildDataValueFor(dt, mainVal) { | |||
if (!mainVal) return null; | |||
if (dt === 'wikibase-item' || dt === 'wikibase-property') { | |||
var id = mainVal.id; | |||
return { | |||
type: 'wikibase-entityid', | |||
value: { | |||
id: id, | |||
'entity-type': (/^P/i.test(id) ? 'property' : 'item'), | |||
'numeric-id': qidToNumeric(id) | |||
} | |||
}; | |||
} | |||
if (dt === 'string' || dt === 'external-id' || dt === 'url' || dt === 'edtf') { | |||
return { type: 'string', value: mainVal.text || mainVal.value || String(mainVal) }; | |||
} | |||
if (dt === 'monolingualtext') { | |||
return { type: 'monolingualtext', value: { text: mainVal.text || '', language: mainVal.language || 'en' } }; | |||
} | |||
if (dt === 'time') { | |||
return { type: 'time', value: { | |||
time: mainVal.time, | |||
precision: mainVal.precision || 11, | |||
timezone: 0, before: 0, after: 0, | |||
calendarmodel: 'http://www.wikidata.org/entity/Q1985727' | |||
}}; | |||
} | |||
if (dt === 'globe-coordinate' || dt === 'globecoordinate') { | |||
return { type: 'globecoordinate', value: { | |||
latitude: mainVal.latitude, longitude: mainVal.longitude, | |||
precision: mainVal.precision || 1e-6, | |||
globe: mainVal.globe || 'http://www.wikidata.org/entity/Q2' | |||
}}; | |||
} | |||
return null; | |||
} | |||
/** Set the value directly on the valueView and emit a change */ | |||
function setValueOnValueView(snak, dt, mainVal) { | |||
try { | |||
var vv = snak._variation && snak._variation._valueView; | |||
var dv = buildDataValueFor(dt, mainVal); | |||
if (!vv || !dv) return false; | |||
if (typeof vv.value === 'function') { | |||
vv.value(dv); // set datamodel value | |||
// notify listeners (statementview watches these) | |||
var ev = vv.widgetEventPrefix ? (vv.widgetEventPrefix + 'change') : 'change'; | |||
vv.element && vv.element.trigger(ev); | |||
return true; | |||
} | |||
} catch (e) {} | |||
return false; | |||
} | |||
function doSave(toolbar, pid, prevCount) { | |||
// Try the controller API first | |||
try { | |||
if (toolbar && toolbar._controller && typeof toolbar._controller.stopEditing === 'function') { | |||
toolbar._controller.stopEditing(false); | |||
} else { | |||
// Fallback: click the save/publish button in the toolbar DOM | |||
var $root = toolbar && toolbar.$element ? toolbar.$element : $(document); | |||
var $btn = $root.find('.wikibase-toolbarbutton-save, .wikibase-toolbarbutton-publish'); | |||
if ($btn.length) { $btn.click(); } | |||
} | |||
} catch (e) { | |||
console.warn('doSave: toolbar save failed, trying button fallback', e); | |||
try { | |||
var $root2 = toolbar && toolbar.$element ? toolbar.$element : $(document); | |||
var $btn2 = $root2.find('.wikibase-toolbarbutton-save, .wikibase-toolbarbutton-publish'); | |||
if ($btn2.length) { $btn2.click(); } | |||
} catch (e2) {} | |||
} | |||
// Wait until the statement list shows one more row for this property | |||
return waitForStatementRendered(pid, prevCount, WB_TIMING.renderTimeout || 1200); | |||
} | |||
function sleep(ms){ return new Promise(function(res){ setTimeout(res, ms); }); } | |||
// ===== 0) CONFIG ===== | // ===== 0) CONFIG ===== | ||
| Line 27: | Line 407: | ||
return out; | return out; | ||
} | } | ||
// Robustly select a wikibase entity in an EntitySelector (plugin optional) | |||
function pickEntity(vw, qid, commitDelay) { | |||
return new Promise(function (resolve) { | |||
var committed = false; | |||
try { | |||
// Preferred: official OOUI widget API (if exposed) | |||
if (vw.plugin && typeof vw.plugin.setValue === 'function') { | |||
vw.plugin.setValue(qid); | |||
committed = true; | |||
} | |||
// Common fallback on many builds | |||
else if (vw.plugin && typeof vw.plugin._select === 'function') { | |||
vw.plugin._select({ id: qid, label: qid }); | |||
committed = true; | |||
} | |||
} catch (e) {} | |||
// Last resort: simulate typing + Enter + blur on the raw input | |||
if (!committed) { | |||
try { | |||
vw.$input | |||
.val(qid) | |||
.trigger('input') | |||
.trigger('change') | |||
.trigger({ type: 'keydown', which: 13, keyCode: 13 }) | |||
.trigger({ type: 'keyup', which: 13, keyCode: 13 }) | |||
.blur(); | |||
committed = true; | |||
} catch (e2) {} | |||
} | |||
// Let the widget finish internal commits | |||
sleep(typeof commitDelay === 'number' ? commitDelay : 120).then(resolve); | |||
}); | |||
} | |||
function buildQualifierFromSnak(qpid, snak, propMeta) { | |||
if (!snak || snak.snaktype !== 'value') { | |||
return { | |||
property: { id: qpid, label: (propMeta[qpid] && propMeta[qpid].label) || qpid, datatype: (propMeta[qpid] && propMeta[qpid].datatype) || (snak && snak.datatype) || 'string' }, | |||
datatype: (propMeta[qpid] && propMeta[qpid].datatype) || (snak && snak.datatype) || 'string' | |||
}; | |||
} | |||
var meta = propMeta[qpid] || { id: qpid, label: qpid, datatype: snak.datatype || 'string' }; | |||
var dt = meta.datatype || snak.datatype || 'string'; | |||
var dv = snak.datavalue && snak.datavalue.value; | |||
var q = { property: { id: meta.id, label: meta.label, datatype: dt }, datatype: dt }; | |||
function isUnderscore(v) { return v === '_'; } | |||
if (dt === 'wikibase-item' && dv && dv.id) { | |||
if (dv.id === WB_SPECIAL.PENDING_ITEM) { | |||
// Sentinel -> add the qualifier slot but no value | |||
q._scaffold = true; | |||
} else { | |||
q.value = { mainVal: { id: dv.id, datatype: 'wikibase-item' } }; | |||
} | |||
} else if ((dt === 'string' || dt === 'external-id' || dt === 'url' || dt === 'edtf') && | |||
typeof dv === 'string' && !isUnderscore(dv)) { | |||
q.value = { mainVal: dv }; | |||
}else if (dt === 'edtf') { | |||
var ed = valOf(dv); | |||
if (ed === '-999999999') { | |||
q._scaffold = true; // pending EDTF qualifier | |||
} else if (ed && !isUnderscore(ed)) { | |||
q.value = { mainVal: ed }; | |||
} | |||
} else if (dt === 'monolingualtext' && dv && dv.text && !isUnderscore(dv.text)) { | |||
q.value = { mainVal: { text: dv.text, language: dv.language || userLang } }; | |||
} else if ((dt === 'globe-coordinate' || dt === 'globecoordinate') && dv && typeof dv.latitude === 'number' && typeof dv.longitude === 'number') { | |||
q.value = { mainVal: { latitude: dv.latitude, longitude: dv.longitude, precision: dv.precision, globe: dv.globe } }; | |||
} else if (dt === 'time' && dv && dv.time) { | |||
q.value = { mainVal: { time: dv.time, precision: dv.precision } }; | |||
} | |||
return q; | |||
} | |||
function extractQualifiersFromStatement(stmt, propMeta) { | |||
var out = []; | |||
if (!stmt || !stmt.qualifiers) return out; | |||
// Respect qualifiers-order if present, otherwise iterate keys | |||
var order = stmt['qualifiers-order']; | |||
if (order && order.length) { | |||
for (var i = 0; i < order.length; i++) { | |||
var qpid = order[i]; | |||
var arr = stmt.qualifiers[qpid] || []; | |||
for (var j = 0; j < arr.length; j++) { | |||
out.push(buildQualifierFromSnak(qpid, arr[j], propMeta)); | |||
} | |||
} | |||
} else { | |||
for (var qlpid in stmt.qualifiers) { | |||
var arr2 = stmt.qualifiers[qlpid] || []; | |||
for (var k = 0; k < arr2.length; k++) { | |||
out.push(buildQualifierFromSnak(qlpid, arr2[k], propMeta)); | |||
} | |||
} | |||
} | |||
return out; | |||
} | |||
function getBestLabel(labels) { | function getBestLabel(labels) { | ||
if (!labels) return undefined; | if (!labels) return undefined; | ||
| Line 94: | Line 578: | ||
} | } | ||
function fetchPropertyMeta(entityMap) { | |||
var pids = {}; | |||
for (var qid in entityMap) { | |||
var claims = entityMap[qid].claims || {}; | |||
for (var pid in claims) { | |||
pids[pid] = true; // mainsnak pid | |||
// If the template uses P10816 as surrogate, we also need meta for P1 | |||
if (pid === WB_SPECIAL.PRIMARY_FROM) { | |||
pids[WB_SPECIAL.PRIMARY_P] = true; | |||
} | |||
var stmts = claims[pid] || []; | |||
for (var i = 0; i < stmts.length; i++) { | |||
var qobj = (stmts[i] && stmts[i].qualifiers) || {}; | |||
for (var qpid in qobj) { pids[qpid] = true; } // qualifier pids | |||
} | |||
} | } | ||
} | } | ||
var ids = Object.keys(pids); | |||
var out = {}; | |||
var batches = chunk(ids, 50); | |||
var d = $.Deferred(); | |||
(function next(i) { | |||
if (i >= batches.length) { d.resolve(out); return; } | |||
api.get({ | |||
action: 'wbgetentities', | |||
ids: batches[i].join('|'), | |||
props: 'labels|datatype', | |||
languages: userLang | |||
}).then(function (res) { | |||
$.extend(out, res.entities || {}); | |||
next(i + 1); | |||
}).fail(d.reject); | |||
})(0); | |||
return d.promise().then(function () { | |||
var meta = {}; | |||
for (var pid in out) { | |||
var ent = out[pid] || {}; | |||
meta[pid] = { | |||
id: pid, | |||
label: getBestLabel(ent.labels) || pid, | |||
datatype: ent.datatype | |||
}; | |||
} | |||
return meta; | |||
}); | |||
} | |||
// ===== 3) Transform to your WbTemplates shape ===== | // ===== 3) Transform to your WbTemplates shape ===== | ||
function transformToTemplate(entity, propMeta) { | |||
var rawLabel = getBestLabel(entity.labels) || entity.id; | |||
var label = rawLabel; // cleanTemplateLabel(rawLabel); | |||
var targetClassId = getTargetClassId(entity); | |||
var prefilledClaims = []; | |||
var userDefinedClaims = []; | |||
var claims = entity.claims || {}; | |||
for (var pid in claims) { | |||
if (pid === WB_SPECIAL.PRIMARY_P) { continue; } | |||
// If we see P10816, we will write it *as* P1 | |||
var targetPid = (pid === WB_SPECIAL.PRIMARY_FROM) ? WB_SPECIAL.PRIMARY_P : pid; | |||
var statements = claims[pid]; | |||
if (!statements || !statements.length) continue; | |||
var prop = propMeta[targetPid] || { id: targetPid, label: targetPid, datatype: 'string' }; | |||
// Track if we found at least one concrete (non-placeholder) value across statements | |||
var hadConcrete = false; | |||
for (var i = 0; i < statements.length; i++) { | |||
var stmt = statements[i]; | |||
var msk = stmt && stmt.mainsnak; | |||
if (!msk) continue; | |||
var dt = prop.datatype || msk.datatype || 'string'; | |||
var mainVal = buildMainValFromMainsnak(msk, dt); | |||
if (mainVal !== null) { | |||
var qualifiersRaw = extractQualifiersFromStatement(stmt, propMeta); | |||
// Is this statement “pending” because of the sentinel? | |||
var hasPending = | |||
(dt === 'wikibase-item' && mainVal._pending === true) || | |||
(dt === 'edtf' && mainVal && mainVal._pending === true) || | |||
qualifiersRaw.some(function (q) { return q && q._scaffold === true; }); | |||
if (hasPending) { | |||
// Leave row open; scaffold qualifier properties without values | |||
var mixedQs = qualifiersRaw.map(function(q){ | |||
if (q && q._scaffold === true) { | |||
return { property: q.property, datatype: q.datatype, _scaffold: true }; | |||
} | |||
return qualifierHasValue(q) | |||
? q | |||
: { property: q.property, datatype: q.datatype, _scaffold: true }; | |||
}); | |||
userDefinedClaims.push({ | |||
property: { id: prop.id, label: prop.label }, | |||
datatype: prop.datatype, | |||
qualifiers: mixedQs | |||
}); | |||
hadConcrete = true; // prevent extra fallback scaffold later | |||
continue; // do NOT prefill this statement | |||
} | |||
hadConcrete = true; | |||
var qualifiers = qualifiersRaw.filter(qualifierHasValue); | |||
prefilledClaims.push({ | |||
property: { id: prop.id, label: prop.label, datatype: dt }, | |||
value: { mainVal: mainVal }, | |||
qualifiers: qualifiers | |||
}); | |||
} | |||
} | |||
// If no concrete values at all, scaffold a single user-defined claim with qualifiers from the first stmt (if any) | |||
if (!hadConcrete) { | |||
var sourceStmt = statements[0]; | |||
var qScaffold = extractQualifiersFromStatement(sourceStmt, propMeta); | |||
userDefinedClaims.push({ | |||
property: { id: prop.id, label: prop.label }, | |||
datatype: prop.datatype, | |||
qualifiers: qScaffold | |||
}); | |||
} | |||
} | |||
var template = { key: slugify(label), label: label, prefilledClaims: prefilledClaims, userDefinedClaims: userDefinedClaims, targetClassId: targetClassId, // ← stays | |||
}; | |||
console.log(template); | |||
return template; | |||
} | |||
function buildWbTemplatesFromQuery(query) { | |||
return searchTemplates(query).then(function (qids) { | |||
if (!qids.length) return {}; | |||
// | return fetchEntities(qids).then(function (entities) { | ||
var | // Keep only entities that are actually templates (P1 = TEMPLATE_CLASS_Q) | ||
for (var | var gated = {}; | ||
for (var id in entities) { | |||
if ( | if (isTemplateEntity(entities[id])) gated[id] = entities[id]; | ||
} | } | ||
if (!Object.keys(gated).length) return {}; | |||
return fetchPropertyMeta(gated).then(function (propMeta) { | |||
var map = {}; | |||
WbTemplatesById = {}; // reset mapping for this run | |||
for (var eid in gated) { | |||
var tpl = transformToTemplate(gated[eid], propMeta); | |||
map[tpl.key] = { | |||
label: tpl.label, | |||
prefilledClaims: tpl.prefilledClaims, | |||
userDefinedClaims: tpl.userDefinedClaims, | |||
targetClassId: tpl.targetClassId | |||
}; | |||
// remember which template key corresponds to which QID | |||
WbTemplatesById[gated[eid].id] = tpl.key; | |||
} | |||
// collect target class Q-ids | |||
var classIds = []; | |||
for (var k in map) { | |||
if (map[k].targetClassId) classIds.push(map[k].targetClassId); | |||
} | |||
// de-dup | |||
classIds = Array.from(new Set(classIds)); | |||
if (!classIds.length) { | |||
return map; | |||
} | |||
// fetch class labels and attach to each template | |||
return fetchEntities(classIds).then(function(ents){ | |||
for (var k in map) { | |||
var cid = map[k].targetClassId; | |||
if (cid && ents[cid]) { | |||
map[k].targetClass = { id: cid, label: getBestLabel(ents[cid].labels) || cid }; | |||
} else if (cid) { | |||
map[k].targetClass = { id: cid, label: cid }; | |||
map[ | } else { | ||
map[k].targetClass = null; | |||
} | |||
} | |||
} | } | ||
return map; | return map; | ||
}); | }); | ||
}); | }); | ||
}); | }); | ||
} | }); | ||
} | |||
// ===== 4) YOUR existing helpers (unchanged) ===== | // ===== 4) YOUR existing helpers (unchanged) ===== | ||
| Line 227: | Line 804: | ||
}); | }); | ||
return count; | return count; | ||
} | |||
function refreshSVContext(item) { | |||
try { | |||
var svItems = item.statementgroupview.statementlistview._listview.items(); | |||
var last = svItems.last().data(); | |||
var sv = last.statementview; | |||
var snak = sv.$mainSnak.data().snakview; | |||
var toolbar = svItems.last().data().edittoolbar; | |||
return { sv: sv, snak: snak, toolbar: toolbar }; | |||
} catch (e) { | |||
return { sv: null, snak: null, toolbar: null }; | |||
} | |||
} | } | ||
| Line 236: | Line 826: | ||
if (now > prevCount) return resolve(); | if (now > prevCount) return resolve(); | ||
if (Date.now() - start > timeout) return resolve(); // fail-safe | if (Date.now() - start > timeout) return resolve(); // fail-safe | ||
setTimeout(tick, | setTimeout(tick, WB_TIMING.pollInterval); | ||
})(); | |||
}); | |||
} | |||
function waitForValueView(snak, timeoutMs) { | |||
var start = Date.now(); | |||
var timeout = (typeof timeoutMs === 'number') ? timeoutMs : WB_TIMING.valueViewTimeout; | |||
return new Promise(function (resolve, reject) { | |||
(function tick() { | |||
try { | |||
var vv = snak && snak._variation && snak._variation._valueView; | |||
if (vv && vv.element && vv.element.length) return resolve(vv); | |||
} catch (e) {} | |||
if (Date.now() - start > timeout) return reject(new Error('value view not ready')); | |||
setTimeout(tick, WB_TIMING.pollInterval); | |||
})(); | |||
}); | |||
} | |||
// Count qualifier snaks on a statementview | |||
function getQualifierCount(sv) { | |||
try { | |||
var items = sv._qualifiers && sv._qualifiers.items ? sv._qualifiers.items() : []; | |||
if (!items || !items.length) return 0; | |||
var qslv = $(items[items.length - 1]).data().snaklistview; | |||
return qslv && qslv._listview ? qslv._listview.items().length : 0; | |||
} catch (e) { return 0; } | |||
} | |||
function waitForQualifierRendered(sv, prevCount, timeoutMs) { | |||
var start = Date.now(); | |||
var timeout = (typeof timeoutMs === 'number') ? timeoutMs : WB_TIMING.valueViewTimeout; | |||
return new Promise(function (resolve) { | |||
(function tick() { | |||
if (getQualifierCount(sv) > prevCount) return resolve(); | |||
if (Date.now() - start > timeout) return resolve(); // fail-safe | |||
setTimeout(tick, WB_TIMING.pollInterval); | |||
})(); | })(); | ||
}); | }); | ||
| Line 264: | Line 889: | ||
if ($input && $input.length) return resolve({ $input: $input, plugin: plugin, jqe: jqe }); | if ($input && $input.length) return resolve({ $input: $input, plugin: plugin, jqe: jqe }); | ||
if (Date.now() - start > timeout) return reject(new Error('value widget not ready')); | if (Date.now() - start > timeout) return reject(new Error('value widget not ready')); | ||
setTimeout(tick, | setTimeout(tick, WB_TIMING.pollInterval); | ||
})(); | |||
}); | |||
} | |||
function emitStatementChange(sv) { | |||
try { | |||
var ev = sv.widgetEventPrefix ? (sv.widgetEventPrefix + 'change') : 'change'; | |||
sv.element && sv.element.trigger(ev); | |||
} catch (e) {} | |||
} | |||
// Wait until the row is savable (valueView.value() or statementview.value() becomes non-null). | |||
// Always resolves: true = savable, false = timed out (we'll try to save anyway). | |||
function waitForSavableStatement(sv, snak, timeoutMs) { | |||
var start = Date.now(); | |||
var timeout = (typeof timeoutMs === 'number') ? timeoutMs : WB_TIMING.valueViewTimeout; | |||
function valueViewHasValue() { | |||
try { | |||
var vv = snak && snak._variation && snak._variation._valueView; | |||
if (vv && typeof vv.value === 'function') { return vv.value() !== null; } | |||
} catch (e) {} | |||
return false; | |||
} | |||
function statementViewHasValue() { | |||
try { | |||
if (sv && typeof sv.value === 'function') { return sv.value() !== null; } | |||
} catch (e) {} | |||
return false; | |||
} | |||
return new Promise(function (resolve) { | |||
(function tick() { | |||
if (valueViewHasValue() || statementViewHasValue()) return resolve(true); | |||
if (Date.now() - start > timeout) return resolve(false); // ← never reject | |||
setTimeout(tick, WB_TIMING.pollInterval); | |||
})(); | })(); | ||
}); | }); | ||
} | } | ||
function prefillStatement(claim, onlyProperty) { | function prefillStatement(claim, onlyProperty) { | ||
return new Promise(function (resolve | return new Promise(function (resolve) { | ||
var pid = claim.property.id; | var pid = claim.property.id; | ||
var prevCount = getStatementCount(pid); | var prevCount = getStatementCount(pid); | ||
var statementListView = $(".wikibase-statementgrouplistview").first().data().wikibaseStatementgrouplistview; | var statementListView = $(".wikibase-statementgrouplistview").first().data().wikibaseStatementgrouplistview; | ||
statementListView.enterNewItem(); | statementListView.enterNewItem(); | ||
var items = statementListView.listview.items(); | var items = statementListView.listview.items(); | ||
var item = items.last().data(); | var item = items.last().data(); | ||
var | var svItems = item.statementgroupview.statementlistview._listview.items(); | ||
var snak = sv.$mainSnak.data().snakview; | var sv = svItems.last().data().statementview; | ||
var snak = sv.$mainSnak.data().snakview; | |||
var toolbar = svItems.last().data().edittoolbar; | |||
// Select main property | |||
var es = getPropertyEntity(claim.property); | var es = getPropertyEntity(claim.property); | ||
var selector = snak._getPropertySelector(); | var selector = snak._getPropertySelector(); | ||
selector.element.val(es.label); | selector.element.val(es.label); | ||
selector.element.attr("title", es.label); | selector.element.attr("title", es.label); | ||
selector._trigger('change'); | selector._trigger('change'); | ||
selector._select(es); | selector._select(es); | ||
// Wait for the value view + widget, set main value (no save yet) | |||
// Wait for the value view, then commit main value (datamodel + widget for simple types) | |||
var mainVal = claim.value && claim.value.mainVal; | |||
var dt = claim.property.datatype || (mainVal && mainVal.datatype) || claim.datatype || 'string'; | |||
waitForValueView(snak, WB_TIMING.valueViewTimeout) | |||
.then(function () { | |||
if (dt === 'wikibase-item' && mainVal && mainVal.id) { | |||
return waitForValueWidget(snak, dt, WB_TIMING.widgetTimeout) | |||
.then(function (vw) { return pickEntity(vw, mainVal.id, WB_TIMING.commitDelay).then(function(){ return { dt: dt, mainVal: mainVal }; }); }, | |||
function () { return { dt: dt, mainVal: mainVal }; }); | |||
} | |||
// 👉 simple types: force the widget to commit so valueView.value() is non-null | |||
return hardCommitSimpleValue(sv, snak, dt, mainVal).then(function(){ return { dt: dt, mainVal: mainVal }; }); | |||
}) | |||
// 2) For items we do a quick savable wait; for non-items skip it (we’ll verify after qualifiers) | |||
.then(function (ctx) { | |||
if (ctx.dt === 'wikibase-item') { | |||
emitStatementChange(sv); | |||
return waitForSavableStatement(sv, snak, WB_TIMING.valueViewTimeout).then(function () { return ctx; }); | |||
} | |||
return ctx; // non-item: proceed immediately | |||
}) | |||
// 3) Add ALL qualifiers | |||
.then(function (ctx) { | |||
return addQualifiersSequentially(sv, claim.qualifiers || []).then(function () { return ctx; }); | |||
}) | |||
// 4) Row may have re-rendered: re-acquire sv/snak/toolbar | |||
.then(function () { | |||
// re-acquire (qualifiers may re-render the row) | |||
var ctx = refreshSVContext(item); | |||
sv = ctx.sv; | |||
snak = ctx.snak; | |||
toolbar = ctx.toolbar; | |||
var mainVal = claim.value && claim.value.mainVal; | |||
var dt = claim.property.datatype || (mainVal && mainVal.datatype) || claim.datatype || 'string'; | |||
// re-commit mainsnak on the *current* row | |||
if (dt === 'wikibase-item' && mainVal && mainVal.id) { | |||
return waitForValueWidget(snak, dt, WB_TIMING.widgetTimeout) | |||
.then(function (vw) { return pickEntity(vw, mainVal.id, WB_TIMING.commitDelay); }) | |||
.then(function () { emitStatementChange(sv); return waitForSavableStatement(sv, snak, WB_TIMING.valueViewTimeout); }); | |||
} else { | |||
return hardCommitSimpleValue(sv, snak, dt, mainVal) | |||
.then(function () { emitStatementChange(sv); return waitForSavableStatement(sv, snak, WB_TIMING.valueViewTimeout); }); | |||
} | |||
}) | |||
.then(function () { | |||
// already re-acquired sv/snak/toolbar and re-committed mainsnak above… | |||
// make sure qualifiers aren’t holding focus and their datavalues exist | |||
blurAllQualifierInputs(sv); | |||
return ensureQualifiersCommitted(sv, 500); | |||
}) | |||
.then(function () { | |||
// make sure qualifiers aren’t holding focus | |||
return doSave(toolbar, pid, prevCount); | |||
}) | |||
.then(resolve) | |||
.catch(function () { return doSave(toolbar, pid, prevCount).then(resolve); }); | |||
}); | |||
} | |||
function pendingStatement(claim, opts) { | |||
opts = opts || {}; | |||
var shouldSave = (opts.save === true); // default: leave open (no save) | |||
var prop = claim.property; | var prop = claim.property; | ||
var pid = prop.id; | var pid = prop.id; | ||
| Line 345: | Line 1,032: | ||
var item = items.last().data(); | var item = items.last().data(); | ||
var svItems = item.statementgroupview.statementlistview._listview.items(); | var svItems = item.statementgroupview.statementlistview._listview.items(); | ||
var sv = | var sv = svItems.last().data().statementview; // <- always last row we just created | ||
var snak = sv.$mainSnak.data().snakview; | var snak = sv.$mainSnak.data().snakview; | ||
var toolbar = svItems.last().data().edittoolbar; | var toolbar = svItems.last().data().edittoolbar; | ||
| Line 353: | Line 1,040: | ||
var selector = snak._getPropertySelector(); | var selector = snak._getPropertySelector(); | ||
// | // Resolve when property has actually been applied | ||
var propertyReady = new Promise(function (res) { | var propertyReady = new Promise(function (res) { | ||
var done = false; | var done = false; function ok(){ if(!done){ done=true; res(); } } | ||
selector.element.one('entityselectorselected', ok); | selector.element.one('entityselectorselected', ok); | ||
$(snak._variation).one('afterdraw', ok); | $(snak._variation).one('afterdraw', ok); | ||
}); | }); | ||
// Select the property ( | // Select the property (redraw value view) | ||
selector.element.val(es.label); | selector.element.val(es.label); | ||
selector.element.attr('title', es.label); | selector.element.attr('title', es.label); | ||
| Line 369: | Line 1,053: | ||
selector._select(es); | selector._select(es); | ||
propertyReady.then(function () { | propertyReady.then(function () { | ||
var dt = claim.datatype || prop.datatype || 'string'; | var dt = claim.datatype || prop.datatype || 'string'; | ||
// | // Wait until the value widget exists | ||
waitForValueWidget(snak, dt, | return waitForValueWidget(snak, dt, WB_TIMING.widgetTimeout).then(function (vw) { | ||
try { | try { | ||
// Poke the widget so the input is visibly open, then clear to blank | |||
if (dt === 'wikibase-item') { | if (dt === 'wikibase-item') { | ||
if (vw.plugin && typeof vw.plugin._select === 'function') { | if (vw.plugin && typeof vw.plugin._select === 'function') { | ||
vw.plugin._select({ id: 'Q80', datatype: 'wikibase-item' }); | vw.plugin._select({ id: 'Q80', datatype: 'wikibase-item' }); | ||
| Line 391: | Line 1,074: | ||
vw.$input.val('').trigger('input').trigger('change'); | vw.$input.val('').trigger('input').trigger('change'); | ||
} | } | ||
} catch (e) { | // keep focus on main value field for user convenience | ||
try { vw.$input.focus(); } catch (e) {} | |||
} catch (e) { /* ignore */ } | |||
// If the template carries qualifier scaffolds, add them (still no save) | |||
if (claim.qualifiers && claim.qualifiers.length) { | |||
return addQualifiersSequentially(sv, claim.qualifiers); | |||
} | |||
}).then(function () { | |||
// If caller asked to save (rare for details), do it; otherwise resolve. | |||
if (shouldSave) { | |||
return doSave(toolbar, pid, prevCount).then(resolve); | |||
} | } | ||
// Leave the row open in edit mode: | |||
// | // nudge the statement so toolbar enables Save button in case user types | ||
emitStatementChange(sv); | |||
resolve(); | |||
}).catch(function () { | }).catch(function () { | ||
// Even if widget | // Even if something failed (e.g., widget too slow), just resolve to keep UI responsive | ||
resolve(); | |||
}); | }); | ||
}); | }); | ||
}); | }); | ||
} | } | ||
function addOneQualifierToStatement(sv, q) { | function addOneQualifierToStatement(sv, q) { | ||
// If no concrete value AND not marked for scaffolding, skip | |||
if (!qualifierHasValue(q) && !q._scaffold) return Promise.resolve(); | |||
return new Promise(function (resolve) { | return new Promise(function (resolve) { | ||
try { | try { | ||
var qlistview = sv._qualifiers; | var qlistview = sv._qualifiers; | ||
qlistview.enterNewItem(); | var prev = getQualifierCount(sv); | ||
qlistview.enterNewItem(); | |||
var $qlItems = sv._qualifiers.items(); | var $qlItems = sv._qualifiers.items(); | ||
var qslv = $qlItems && $qlItems.length ? $($qlItems[$qlItems.length - 1]).data().snaklistview : null; | var qslv = $qlItems && $qlItems.length ? $($qlItems[$qlItems.length - 1]).data().snaklistview : null; | ||
if (!qslv || !qslv.enterNewItem) { resolve(); return; } | if (!qslv || !qslv.enterNewItem) { resolve(); return; } | ||
qslv.enterNewItem( | qslv.enterNewItem(); | ||
waitForQualifierRendered(sv, prev).then(function () { | |||
var qsnak = qslv._listview.items().last().data().snakview; | |||
var qprop = getPropertyEntity(q.property); | |||
var qsel = qsnak._getPropertySelector(); | |||
qsel.element.val(qprop.label); | |||
qsel._trigger('change'); | |||
qsel._select(qprop); | |||
// If this is a scaffold-only qualifier, leave its value blank | |||
if (q._scaffold === true) { resolve(); return; } | |||
var dt = q.property.datatype || q.datatype || 'string'; | |||
waitForValueWidget(qsnak, dt, WB_TIMING.widgetTimeout).then(function (vw) { | |||
try { | |||
if (dt === 'wikibase-item' && q.value && q.value.mainVal && q.value.mainVal.id) { | |||
return pickEntity(vw, q.value.mainVal.id, WB_TIMING.commitDelay).then(function () { | |||
try { // If the selector didn't show a label (empty visual), hint the ID in the input. | |||
// We do NOT change the selection; we just help the user see something. | |||
if (vw.$input && (!vw.$input.val() || !$.trim(vw.$input.val()))) { | |||
vw.$input.attr('placeholder', q.value.mainVal.id); // visible hint | |||
vw.$input.attr('title', q.value.mainVal.id); // tooltip on hover | |||
vw.$input.val(q.value.mainVal.id); | |||
} | |||
// Blur to finalize the qualifier UI state | |||
vw.$input && vw.$input.blur(); | |||
} catch(e){} | |||
emitValueViewChange(qsnak); // commit qualifier valueView | |||
resolve(); | |||
}); | |||
} else { | } else { | ||
// >>> NEW: robust commit for simple types (string/url/ext-id/monolingual/time/coord/quantity) | |||
var vv = qsnak && qsnak._variation && qsnak._variation._valueView; | |||
var ex = vv && vv._expert; | |||
var $inp = vw && vw.$input; | |||
var base = toInputText( dt, q.value && q.value.mainVal ); | |||
var nudged = base + (base ? ' ' : ' '); | |||
function fireEachchange() { | |||
}).catch(function () { resolve(); }); | try { | ||
if (vw.jqe && vw.jqe.handlers) { | |||
for (var i = 0; i < vw.jqe.handlers.length; i++) { try { vw.jqe.handlers[i].call(); } catch(_){} } | |||
} catch (e) { | } | ||
} catch(_) {} | |||
} | |||
function applyExpert() { | |||
try { | |||
if (ex && typeof ex._apply === 'function') ex._apply(); | |||
else if (ex && typeof ex.applyValue === 'function') ex.applyValue(); | |||
} catch(_) {} | |||
} | |||
if ($inp && $inp.length) { | |||
$inp.val(base).trigger('input').trigger('change'); fireEachchange(); applyExpert(); | |||
$inp.val(nudged).trigger('input').trigger('change'); fireEachchange(); applyExpert(); | |||
$inp.val(base).trigger('input').trigger('change'); fireEachchange(); applyExpert(); | |||
$inp.blur(); | |||
} | |||
emitValueViewChange(qsnak); | |||
// ensure the qualifier has a non-null datavalue before we proceed | |||
return waitForViewValue(qsnak, 600).then(function(){ resolve(); }, function(){ resolve(); }); | |||
} | |||
} catch (e) { resolve(); } | |||
}).catch(function () { resolve(); }); | |||
}); | |||
} catch (e) { resolve(); } | |||
}); | }); | ||
} | } | ||
| Line 546: | Line 1,257: | ||
$('.wikibase-entitytermsview').append(html); | $('.wikibase-entitytermsview').append(html); | ||
var key | // group templates by target class label | ||
for ( | var groups = {}; // groupLabel -> [{key, label}] | ||
for (var k in WbTemplates) { | |||
var grp = (WbTemplates[k].targetClass && WbTemplates[k].targetClass.label) || 'Other'; | |||
if (!groups[grp]) groups[grp] = []; | |||
groups[grp].push({ key: k, label: WbTemplates[k].label }); | |||
} | } | ||
var $sel = $('#wbtemplate').empty(); | |||
Object.keys(groups).sort().forEach(function(glab){ | |||
var $og = $('<optgroup>').attr('label', glab); | |||
groups[glab].forEach(function(opt){ | |||
$og.append($('<option>', { value: opt.key, text: opt.label })); | |||
}); | |||
$sel.append($og); | |||
}); | |||
| Line 565: | Line 1,285: | ||
} | } | ||
// ===== 6) Build templates, then render UI ===== | |||
var _requestedQid = getUrlTemplateId(); | |||
buildWbTemplatesFromQuery(SEARCH_QUERY) | |||
.then(function (map) { | |||
WbTemplates = map; | |||
window.WbTemplates = WbTemplates; | |||
if (!WbTemplates || !Object.keys(WbTemplates).length) { | |||
console.warn('No templates found for query:', SEARCH_QUERY); | |||
return; | |||
} | |||
. | |||
initTemplatingUI(); | |||
// Auto-select ONLY if it’s in the loaded (gated) templates | |||
if (_requestedQid && WbTemplatesById[_requestedQid]) { | |||
var key = WbTemplatesById[_requestedQid]; | |||
$('#wbtemplate').val(key); | |||
var $btn = $('#applyTemplateBtn').prop('disabled', true); | |||
applyTemplate(key).then(function () { | |||
$btn.prop('disabled', false); | |||
// optional: also add details | |||
addDetails(key, { skipExisting: true }).then(function(){ | |||
removeAutoTemplateParams(); | |||
}); | |||
}); | |||
} | |||
}) | |||
.fail(function (err) { | |||
console.error('Template build failed:', err); | |||
}); | |||
}); | }); | ||
Latest revision as of 18:11, 25 September 2025
/* global mw, $ */
mw.loader.using(['mediawiki.api']).done(function () {
// ---- timing knobs (ms) ----
var WB_TIMING = {
pollInterval: 50, // how often we poll for readiness
valueViewTimeout: 1200, // wait for value view to exist
widgetTimeout: 1800, // wait for input widget/$input
renderTimeout: 1200, // wait for statement list to reflect the save (+1)
settleDelay: 0, // optional pause between claims (0 = off)
commitDelay: 120 // short pause to let the widget commit before next step
};
// Special property remapping
var WB_SPECIAL = {
PRIMARY_P: 'P1', // the “real” property to write
PRIMARY_FROM: 'P10816', // the template surrogate to read from
PENDING_ITEM: 'Q59' // the placeholder for items
};
// Only entities with P1 = this Q are valid templates
var TEMPLATE_CLASS_Q = 'Q196450';
// QID -> template key mapping for auto-select later
var WbTemplatesById = {};
// read ?template=Q123 from the URL
function getUrlTemplateId() {
try {
var p = new URLSearchParams(window.location.search);
var t = p.get('template');
if (t && /^Q\d+$/i.test(t)) return t.toUpperCase();
} catch (e) {}
return null;
}
function getTargetClassId(entity) {
var s = (entity.claims && entity.claims[WB_SPECIAL.PRIMARY_FROM] || [])[0];
return s && s.mainsnak && s.mainsnak.datavalue && s.mainsnak.datavalue.value && s.mainsnak.datavalue.value.id || null;
}
// gate: accept entities that have P1 = TEMPLATE_CLASS_Q
function isTemplateEntity(entity) {
try {
var arr = (entity.claims && entity.claims.P1) || [];
for (var i = 0; i < arr.length; i++) {
var ms = arr[i].mainsnak;
if (
ms && ms.snaktype === 'value' &&
ms.datavalue && ms.datavalue.type === 'wikibase-entityid' &&
ms.datavalue.value && ms.datavalue.value.id === TEMPLATE_CLASS_Q
) {
return true;
}
}
} catch (_) {}
return false;
}
function removeAutoTemplateParams() {
try {
if (!history.replaceState) return;
var url = new URL(window.location.href);
url.searchParams.delete('template'); // required
url.searchParams.delete('details'); // optional, if you used it
history.replaceState(null, '', url.toString());
} catch (e) {
// very old browsers fallback: do nothing (avoid nuking other params)
}
}
function getQualifierSnaks(sv) {
var out = [];
try {
var items = sv._qualifiers && sv._qualifiers.items ? sv._qualifiers.items() : [];
if (!items || !items.length) return out;
var qslv = $(items[items.length - 1]).data().snaklistview;
var list = qslv && qslv._listview ? qslv._listview.items() : [];
list.each(function () {
var qsv = $(this).data().snakview;
if (qsv) out.push(qsv);
});
} catch (e) {}
return out;
}
function ensureQualifiersCommitted(sv, perTimeout) {
var qs = getQualifierSnaks(sv);
var p = Promise.resolve();
for (var i = 0; i < qs.length; i++) {
(function (qsnak) {
p = p.then(function () {
// try to observe a non-null datavalue; if it times out, continue anyway
return waitForViewValue(qsnak, perTimeout || 500).catch(function(){});
});
})(qs[i]);
}
return p;
}
function blurAllQualifierInputs(sv) {
try {
var items = sv._qualifiers && sv._qualifiers.items ? sv._qualifiers.items() : [];
if (!items || !items.length) return;
var qslv = $(items[items.length - 1]).data().snaklistview;
var list = qslv && qslv._listview ? qslv._listview.items() : [];
list.each(function () {
try {
var qsnak = $(this).data().snakview;
var vv = qsnak && qsnak._variation && qsnak._variation._valueView;
var ex = vv && vv._expert;
var $inp = ex && ex.$input;
if ($inp && $inp.length) { $inp.trigger('input').trigger('change').blur(); }
emitValueViewChange(qsnak);
} catch (e) {}
});
} catch (e) {}
}
function qualifierHasValue(q) {
if (!q || !q.property) return false;
var dt = q.property.datatype || q.datatype || 'string';
var mv = q.value && q.value.mainVal;
if (!mv) return false;
if (dt === 'wikibase-item') return !!mv.id;
if (dt === 'monolingualtext') return !!(mv.text && mv.text !== '_');
if (dt === 'string' || dt === 'external-id' || dt === 'url' || dt === 'edtf')
return String(mv).length > 0 && mv !== '_';
if (dt === 'globe-coordinate' || dt === 'globecoordinate') return typeof mv.latitude === 'number' && typeof mv.longitude === 'number';
if (dt === 'time') return !!mv.time;
if (dt === 'quantity') return !!(mv.amount || mv.value);
return false;
}
function toInputText(dt, mv) {
if (!mv) return '';
if (typeof mv === 'string') return mv;
if (dt === 'string' || dt === 'external-id' || dt === 'url' || dt === 'edtf') return (mv.text || mv.value || '') + '';
if (dt === 'monolingualtext') return (mv.text || '') + '';
if (dt === 'globe-coordinate' || dt === 'globecoordinate') {
return (mv.latitude != null && mv.longitude != null) ? (mv.latitude + ',' + mv.longitude) : '';
}
if (dt === 'time') return (mv.time || '') + '';
if (dt === 'quantity') return (mv.amount || mv.value || '') + '';
return (mv.text || mv.value || '') + '';
}
/* Commit simple datatypes so valueView.value() becomes non-null */
function hardCommitSimpleValue(sv, snak, dt, mainVal) {
return waitForValueWidget(snak, dt, 800).then(function(vw){
var vv = snak && snak._variation && snak._variation._valueView;
var ex = vv && vv._expert;
var $inp = vw && vw.$input;
var txt = toInputText(dt, mainVal);
try {
if ($inp && $inp.length) {
// type target
$inp.val(txt).trigger('input').trigger('change');
// legacy eachchange hooks
if (vw.jqe && vw.jqe.handlers) {
for (var i = 0; i < vw.jqe.handlers.length; i++) { try { vw.jqe.handlers[i].call(); } catch(e) {} }
}
// some builds need an explicit apply on the expert widget
if (ex && typeof ex._apply === 'function') ex._apply();
else if (ex && typeof ex.applyValue === 'function') ex.applyValue();
$inp.blur();
}
} catch(e1) {}
// notify both views
emitValueViewChange(snak);
try { var se = sv.widgetEventPrefix ? (sv.widgetEventPrefix + 'change') : 'change'; sv.element && sv.element.trigger(se); } catch(e2){}
// if still null, toggle placeholder then back (forces conversion)
return waitForViewValue(snak, 600).catch(function(){
try {
if ($inp && $inp.length) {
$inp.val('_').trigger('input').trigger('change');
if (vw.jqe && vw.jqe.handlers) {
for (var j = 0; j < vw.jqe.handlers.length; j++) { try { vw.jqe.handlers[j].call(); } catch(e3) {} }
}
if (ex && typeof ex._apply === 'function') ex._apply();
else if (ex && typeof ex.applyValue === 'function') ex.applyValue();
$inp.val(txt).trigger('input').trigger('change').blur();
}
} catch(e4) {}
emitValueViewChange(snak);
return waitForViewValue(snak, 600);
});
});
}
// set vv.value(...) AND (if widget exists) type into the input + fire eachchange
function setAndCommitValue(snak, dt, mainVal) {
// 1) try datamodel set
var dvSet = false;
try { dvSet = setValueOnValueView(snak, dt, mainVal); } catch (e) { dvSet = false; }
// 2) if non-item, also drive the input widget when present
if (dt === 'wikibase-item') {
// items are handled elsewhere (pickEntity), nothing to do here
emitValueViewChange(snak);
return Promise.resolve(dvSet);
}
return waitForValueWidget(snak, dt, 600).then(function (vw) {
try {
var txt = toInputText(dt, mainVal);
if (vw.$input && vw.$input.length) {
vw.$input.val(txt).trigger('input').trigger('change').blur();
}
// legacy commit hooks
if (vw.jqe && vw.jqe.handlers) {
for (var i = 0; i < vw.jqe.handlers.length; i++) {
try { vw.jqe.handlers[i].call(); } catch (e2) {}
}
}
} catch (e3) {}
emitValueViewChange(snak);
return true;
}, function () {
// no widget surfaced; at least emit change for vv
emitValueViewChange(snak);
return dvSet;
});
}
function qidToNumeric(id) {
return parseInt(String(id).replace(/^[A-Za-z]/, ''), 10);
}
function emitValueViewChange(snak) {
try {
var vv = snak && snak._variation && snak._variation._valueView;
if (!vv || !vv.element) return;
var ev = vv.widgetEventPrefix ? (vv.widgetEventPrefix + 'change') : 'change';
vv.element.trigger(ev);
} catch (e) {}
}
// Wait until the valueView has a non-null value() (i.e., savable)
function waitForViewValue(snak, timeoutMs) {
var start = Date.now();
var timeout = (typeof timeoutMs === 'number') ? timeoutMs : WB_TIMING.valueViewTimeout;
return new Promise(function (resolve, reject) {
(function tick() {
try {
var vv = snak && snak._variation && snak._variation._valueView;
var val = vv && typeof vv.value === 'function' ? vv.value() : null;
if (val !== null) return resolve(val);
} catch (e) {}
if (Date.now() - start > timeout) return reject(new Error('view value still null'));
setTimeout(tick, WB_TIMING.pollInterval);
})();
});
}
function buildMainValFromMainsnak(ms, dt) {
if (!ms || ms.snaktype !== 'value' || !ms.datavalue) return null;
var dv = ms.datavalue.value;
switch (dt) {
case 'wikibase-item':
case 'wikibase-property':
return dv && dv.id ? { id: dv.id, datatype: dt, _pending: (dv.id === WB_SPECIAL.PENDING_ITEM) } : null;
case 'string':
case 'external-id':
case 'url':
case 'edtf': // <- treat as plain text
if (typeof dv === 'string') {
if (dv === '_') return null;
if (dv === '-999999999') return { text: '', _pending: true }; // ← pending EDTF
return dv;
} else if (dv && typeof dv.value === 'string') {
if (dv.value === '_') return null;
if (dv.value === '-999999999') return { text: '', _pending: true }; // ← pending EDTF
return dv.value;
}
return null;
case 'monolingualtext':
if (!dv || !dv.text || dv.text === '_') return null;
return { text: dv.text, language: dv.language || (typeof userLang === 'string' ? userLang : 'en') };
case 'time':
return dv && dv.time ? { time: dv.time, precision: dv.precision } : null;
case 'globe-coordinate':
case 'globecoordinate':
return (dv && typeof dv.latitude === 'number' && typeof dv.longitude === 'number')
? { latitude: dv.latitude, longitude: dv.longitude, precision: dv.precision, globe: dv.globe }
: null;
case 'quantity':
return dv && dv.amount
? { amount: dv.amount, unit: dv.unit || null, upperBound: dv.upperBound, lowerBound: dv.lowerBound }
: null;
default:
return dv && (dv.id || dv.text || dv.time || dv.latitude != null || dv.amount)
? dv
: null;
}
}
function buildDataValueFor(dt, mainVal) {
if (!mainVal) return null;
if (dt === 'wikibase-item' || dt === 'wikibase-property') {
var id = mainVal.id;
return {
type: 'wikibase-entityid',
value: {
id: id,
'entity-type': (/^P/i.test(id) ? 'property' : 'item'),
'numeric-id': qidToNumeric(id)
}
};
}
if (dt === 'string' || dt === 'external-id' || dt === 'url' || dt === 'edtf') {
return { type: 'string', value: mainVal.text || mainVal.value || String(mainVal) };
}
if (dt === 'monolingualtext') {
return { type: 'monolingualtext', value: { text: mainVal.text || '', language: mainVal.language || 'en' } };
}
if (dt === 'time') {
return { type: 'time', value: {
time: mainVal.time,
precision: mainVal.precision || 11,
timezone: 0, before: 0, after: 0,
calendarmodel: 'http://www.wikidata.org/entity/Q1985727'
}};
}
if (dt === 'globe-coordinate' || dt === 'globecoordinate') {
return { type: 'globecoordinate', value: {
latitude: mainVal.latitude, longitude: mainVal.longitude,
precision: mainVal.precision || 1e-6,
globe: mainVal.globe || 'http://www.wikidata.org/entity/Q2'
}};
}
return null;
}
/** Set the value directly on the valueView and emit a change */
function setValueOnValueView(snak, dt, mainVal) {
try {
var vv = snak._variation && snak._variation._valueView;
var dv = buildDataValueFor(dt, mainVal);
if (!vv || !dv) return false;
if (typeof vv.value === 'function') {
vv.value(dv); // set datamodel value
// notify listeners (statementview watches these)
var ev = vv.widgetEventPrefix ? (vv.widgetEventPrefix + 'change') : 'change';
vv.element && vv.element.trigger(ev);
return true;
}
} catch (e) {}
return false;
}
function doSave(toolbar, pid, prevCount) {
// Try the controller API first
try {
if (toolbar && toolbar._controller && typeof toolbar._controller.stopEditing === 'function') {
toolbar._controller.stopEditing(false);
} else {
// Fallback: click the save/publish button in the toolbar DOM
var $root = toolbar && toolbar.$element ? toolbar.$element : $(document);
var $btn = $root.find('.wikibase-toolbarbutton-save, .wikibase-toolbarbutton-publish');
if ($btn.length) { $btn.click(); }
}
} catch (e) {
console.warn('doSave: toolbar save failed, trying button fallback', e);
try {
var $root2 = toolbar && toolbar.$element ? toolbar.$element : $(document);
var $btn2 = $root2.find('.wikibase-toolbarbutton-save, .wikibase-toolbarbutton-publish');
if ($btn2.length) { $btn2.click(); }
} catch (e2) {}
}
// Wait until the statement list shows one more row for this property
return waitForStatementRendered(pid, prevCount, WB_TIMING.renderTimeout || 1200);
}
function sleep(ms){ return new Promise(function(res){ setTimeout(res, ms); }); }
// ===== 0) CONFIG =====
var SEARCH_QUERY = 'haswbstatement:P1=Q196450'; // exactly what works in Special:Search
var WbTemplates = {};
var api = new mw.Api();
var userLang = mw.config.get('wgUserLanguage') || 'en';
// Serialize all UI writes so nothing overlaps
var WriteQueue = (function () {
var chain = Promise.resolve();
function push(taskFn) {
chain = chain.then(function () {
return taskFn();
}).catch(function (e) {
console.error('Write failed:', e);
// swallow so the queue continues
});
return chain;
}
return { push: push };
})();
// ===== 1) Small helpers (ES5-safe) =====
function chunk(arr, n) {
var out = [], i;
for (i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
return out;
}
// Robustly select a wikibase entity in an EntitySelector (plugin optional)
function pickEntity(vw, qid, commitDelay) {
return new Promise(function (resolve) {
var committed = false;
try {
// Preferred: official OOUI widget API (if exposed)
if (vw.plugin && typeof vw.plugin.setValue === 'function') {
vw.plugin.setValue(qid);
committed = true;
}
// Common fallback on many builds
else if (vw.plugin && typeof vw.plugin._select === 'function') {
vw.plugin._select({ id: qid, label: qid });
committed = true;
}
} catch (e) {}
// Last resort: simulate typing + Enter + blur on the raw input
if (!committed) {
try {
vw.$input
.val(qid)
.trigger('input')
.trigger('change')
.trigger({ type: 'keydown', which: 13, keyCode: 13 })
.trigger({ type: 'keyup', which: 13, keyCode: 13 })
.blur();
committed = true;
} catch (e2) {}
}
// Let the widget finish internal commits
sleep(typeof commitDelay === 'number' ? commitDelay : 120).then(resolve);
});
}
function buildQualifierFromSnak(qpid, snak, propMeta) {
if (!snak || snak.snaktype !== 'value') {
return {
property: { id: qpid, label: (propMeta[qpid] && propMeta[qpid].label) || qpid, datatype: (propMeta[qpid] && propMeta[qpid].datatype) || (snak && snak.datatype) || 'string' },
datatype: (propMeta[qpid] && propMeta[qpid].datatype) || (snak && snak.datatype) || 'string'
};
}
var meta = propMeta[qpid] || { id: qpid, label: qpid, datatype: snak.datatype || 'string' };
var dt = meta.datatype || snak.datatype || 'string';
var dv = snak.datavalue && snak.datavalue.value;
var q = { property: { id: meta.id, label: meta.label, datatype: dt }, datatype: dt };
function isUnderscore(v) { return v === '_'; }
if (dt === 'wikibase-item' && dv && dv.id) {
if (dv.id === WB_SPECIAL.PENDING_ITEM) {
// Sentinel -> add the qualifier slot but no value
q._scaffold = true;
} else {
q.value = { mainVal: { id: dv.id, datatype: 'wikibase-item' } };
}
} else if ((dt === 'string' || dt === 'external-id' || dt === 'url' || dt === 'edtf') &&
typeof dv === 'string' && !isUnderscore(dv)) {
q.value = { mainVal: dv };
}else if (dt === 'edtf') {
var ed = valOf(dv);
if (ed === '-999999999') {
q._scaffold = true; // pending EDTF qualifier
} else if (ed && !isUnderscore(ed)) {
q.value = { mainVal: ed };
}
} else if (dt === 'monolingualtext' && dv && dv.text && !isUnderscore(dv.text)) {
q.value = { mainVal: { text: dv.text, language: dv.language || userLang } };
} else if ((dt === 'globe-coordinate' || dt === 'globecoordinate') && dv && typeof dv.latitude === 'number' && typeof dv.longitude === 'number') {
q.value = { mainVal: { latitude: dv.latitude, longitude: dv.longitude, precision: dv.precision, globe: dv.globe } };
} else if (dt === 'time' && dv && dv.time) {
q.value = { mainVal: { time: dv.time, precision: dv.precision } };
}
return q;
}
function extractQualifiersFromStatement(stmt, propMeta) {
var out = [];
if (!stmt || !stmt.qualifiers) return out;
// Respect qualifiers-order if present, otherwise iterate keys
var order = stmt['qualifiers-order'];
if (order && order.length) {
for (var i = 0; i < order.length; i++) {
var qpid = order[i];
var arr = stmt.qualifiers[qpid] || [];
for (var j = 0; j < arr.length; j++) {
out.push(buildQualifierFromSnak(qpid, arr[j], propMeta));
}
}
} else {
for (var qlpid in stmt.qualifiers) {
var arr2 = stmt.qualifiers[qlpid] || [];
for (var k = 0; k < arr2.length; k++) {
out.push(buildQualifierFromSnak(qlpid, arr2[k], propMeta));
}
}
}
return out;
}
function getBestLabel(labels) {
if (!labels) return undefined;
if (labels[userLang] && labels[userLang].value) return labels[userLang].value;
for (var k in labels) {
if (labels[k] && labels[k].value) return labels[k].value;
}
return undefined;
}
function isPlaceholderSnak(snak) {
if (!snak || snak.snaktype !== 'value' || !snak.datavalue) return false;
var dt = snak.datatype;
var v = snak.datavalue.value;
if (dt === 'string' || dt === 'external-id' || dt === 'url') return v === '_';
if (dt === 'monolingualtext') return v && v.text === '_';
return false;
}
function cleanTemplateLabel(raw) {
if (!raw) return '';
return raw.replace(/^RDA\s*/i, '').replace(/\s*Template$/i, '').trim();
}
function slugify(s) {
if (!s) return '';
// simple slug (ASCII only)
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
// ===== 2) API: search → entities → property meta =====
function searchTemplates(query) {
return api.get({
action: 'query',
list: 'search',
srsearch: query,
srbackend: 'CirrusSearch',
srqiprofile: 'wikibase',
srnamespace: 120,
srlimit: 50,
formatversion: 2
}).then(function (res) {
var hits = (res.query && res.query.search) || [];
var ids = [];
for (var i = 0; i < hits.length; i++) {
var m = /Q\d+$/.exec(hits[i].title);
if (m) ids.push(m[0]);
}
return ids;
});
}
function fetchEntities(ids) {
var out = {};
var batches = chunk(ids, 50);
var d = $.Deferred();
(function next(i) {
if (i >= batches.length) { d.resolve(out); return; }
api.get({
action: 'wbgetentities',
ids: batches[i].join('|'),
props: 'labels|claims',
languages: userLang
}).then(function (res) {
$.extend(out, res.entities || {});
next(i + 1);
}).fail(d.reject);
})(0);
return d.promise();
}
function fetchPropertyMeta(entityMap) {
var pids = {};
for (var qid in entityMap) {
var claims = entityMap[qid].claims || {};
for (var pid in claims) {
pids[pid] = true; // mainsnak pid
// If the template uses P10816 as surrogate, we also need meta for P1
if (pid === WB_SPECIAL.PRIMARY_FROM) {
pids[WB_SPECIAL.PRIMARY_P] = true;
}
var stmts = claims[pid] || [];
for (var i = 0; i < stmts.length; i++) {
var qobj = (stmts[i] && stmts[i].qualifiers) || {};
for (var qpid in qobj) { pids[qpid] = true; } // qualifier pids
}
}
}
var ids = Object.keys(pids);
var out = {};
var batches = chunk(ids, 50);
var d = $.Deferred();
(function next(i) {
if (i >= batches.length) { d.resolve(out); return; }
api.get({
action: 'wbgetentities',
ids: batches[i].join('|'),
props: 'labels|datatype',
languages: userLang
}).then(function (res) {
$.extend(out, res.entities || {});
next(i + 1);
}).fail(d.reject);
})(0);
return d.promise().then(function () {
var meta = {};
for (var pid in out) {
var ent = out[pid] || {};
meta[pid] = {
id: pid,
label: getBestLabel(ent.labels) || pid,
datatype: ent.datatype
};
}
return meta;
});
}
// ===== 3) Transform to your WbTemplates shape =====
function transformToTemplate(entity, propMeta) {
var rawLabel = getBestLabel(entity.labels) || entity.id;
var label = rawLabel; // cleanTemplateLabel(rawLabel);
var targetClassId = getTargetClassId(entity);
var prefilledClaims = [];
var userDefinedClaims = [];
var claims = entity.claims || {};
for (var pid in claims) {
if (pid === WB_SPECIAL.PRIMARY_P) { continue; }
// If we see P10816, we will write it *as* P1
var targetPid = (pid === WB_SPECIAL.PRIMARY_FROM) ? WB_SPECIAL.PRIMARY_P : pid;
var statements = claims[pid];
if (!statements || !statements.length) continue;
var prop = propMeta[targetPid] || { id: targetPid, label: targetPid, datatype: 'string' };
// Track if we found at least one concrete (non-placeholder) value across statements
var hadConcrete = false;
for (var i = 0; i < statements.length; i++) {
var stmt = statements[i];
var msk = stmt && stmt.mainsnak;
if (!msk) continue;
var dt = prop.datatype || msk.datatype || 'string';
var mainVal = buildMainValFromMainsnak(msk, dt);
if (mainVal !== null) {
var qualifiersRaw = extractQualifiersFromStatement(stmt, propMeta);
// Is this statement “pending” because of the sentinel?
var hasPending =
(dt === 'wikibase-item' && mainVal._pending === true) ||
(dt === 'edtf' && mainVal && mainVal._pending === true) ||
qualifiersRaw.some(function (q) { return q && q._scaffold === true; });
if (hasPending) {
// Leave row open; scaffold qualifier properties without values
var mixedQs = qualifiersRaw.map(function(q){
if (q && q._scaffold === true) {
return { property: q.property, datatype: q.datatype, _scaffold: true };
}
return qualifierHasValue(q)
? q
: { property: q.property, datatype: q.datatype, _scaffold: true };
});
userDefinedClaims.push({
property: { id: prop.id, label: prop.label },
datatype: prop.datatype,
qualifiers: mixedQs
});
hadConcrete = true; // prevent extra fallback scaffold later
continue; // do NOT prefill this statement
}
hadConcrete = true;
var qualifiers = qualifiersRaw.filter(qualifierHasValue);
prefilledClaims.push({
property: { id: prop.id, label: prop.label, datatype: dt },
value: { mainVal: mainVal },
qualifiers: qualifiers
});
}
}
// If no concrete values at all, scaffold a single user-defined claim with qualifiers from the first stmt (if any)
if (!hadConcrete) {
var sourceStmt = statements[0];
var qScaffold = extractQualifiersFromStatement(sourceStmt, propMeta);
userDefinedClaims.push({
property: { id: prop.id, label: prop.label },
datatype: prop.datatype,
qualifiers: qScaffold
});
}
}
var template = { key: slugify(label), label: label, prefilledClaims: prefilledClaims, userDefinedClaims: userDefinedClaims, targetClassId: targetClassId, // ← stays
};
console.log(template);
return template;
}
function buildWbTemplatesFromQuery(query) {
return searchTemplates(query).then(function (qids) {
if (!qids.length) return {};
return fetchEntities(qids).then(function (entities) {
// Keep only entities that are actually templates (P1 = TEMPLATE_CLASS_Q)
var gated = {};
for (var id in entities) {
if (isTemplateEntity(entities[id])) gated[id] = entities[id];
}
if (!Object.keys(gated).length) return {};
return fetchPropertyMeta(gated).then(function (propMeta) {
var map = {};
WbTemplatesById = {}; // reset mapping for this run
for (var eid in gated) {
var tpl = transformToTemplate(gated[eid], propMeta);
map[tpl.key] = {
label: tpl.label,
prefilledClaims: tpl.prefilledClaims,
userDefinedClaims: tpl.userDefinedClaims,
targetClassId: tpl.targetClassId
};
// remember which template key corresponds to which QID
WbTemplatesById[gated[eid].id] = tpl.key;
}
// collect target class Q-ids
var classIds = [];
for (var k in map) {
if (map[k].targetClassId) classIds.push(map[k].targetClassId);
}
// de-dup
classIds = Array.from(new Set(classIds));
if (!classIds.length) {
return map;
}
// fetch class labels and attach to each template
return fetchEntities(classIds).then(function(ents){
for (var k in map) {
var cid = map[k].targetClassId;
if (cid && ents[cid]) {
map[k].targetClass = { id: cid, label: getBestLabel(ents[cid].labels) || cid };
} else if (cid) {
map[k].targetClass = { id: cid, label: cid };
} else {
map[k].targetClass = null;
}
}
return map;
});
});
});
});
}
// ===== 4) YOUR existing helpers (unchanged) =====
function statementExists(property, value) {
items.toArray().some(function (item) {
return !!$(item).data().propertyId &&
$(item).data().statementgroupview.options.value.getKey() === property &&
$(item).data().statementgroupview.options.value.getItemContainer().toArray().some(function (claimItem) {
return claimItem.getClaim().getMainSnak().getValue().getSerialization() === value;
});
});
}
function getPropertyEntity(property) {
return {
id: String(property.id),
title: 'Property:' + property.id,
datatype: property.datatype,
label: property.label
};
}
function getStatementCount(pid) {
var count = 0;
$('.wikibase-statementgroupview').each(function () {
var d = $(this).data();
if (d && d.propertyId === pid) {
try { count = d.statementgroupview.statementlistview._listview.items().length; } catch (e) {}
}
});
return count;
}
function refreshSVContext(item) {
try {
var svItems = item.statementgroupview.statementlistview._listview.items();
var last = svItems.last().data();
var sv = last.statementview;
var snak = sv.$mainSnak.data().snakview;
var toolbar = svItems.last().data().edittoolbar;
return { sv: sv, snak: snak, toolbar: toolbar };
} catch (e) {
return { sv: null, snak: null, toolbar: null };
}
}
function waitForStatementRendered(pid, prevCount, timeoutMs) {
var start = Date.now(), timeout = timeoutMs || 1000;
return new Promise(function (resolve) {
(function tick() {
var now = getStatementCount(pid);
if (now > prevCount) return resolve();
if (Date.now() - start > timeout) return resolve(); // fail-safe
setTimeout(tick, WB_TIMING.pollInterval);
})();
});
}
function waitForValueView(snak, timeoutMs) {
var start = Date.now();
var timeout = (typeof timeoutMs === 'number') ? timeoutMs : WB_TIMING.valueViewTimeout;
return new Promise(function (resolve, reject) {
(function tick() {
try {
var vv = snak && snak._variation && snak._variation._valueView;
if (vv && vv.element && vv.element.length) return resolve(vv);
} catch (e) {}
if (Date.now() - start > timeout) return reject(new Error('value view not ready'));
setTimeout(tick, WB_TIMING.pollInterval);
})();
});
}
// Count qualifier snaks on a statementview
function getQualifierCount(sv) {
try {
var items = sv._qualifiers && sv._qualifiers.items ? sv._qualifiers.items() : [];
if (!items || !items.length) return 0;
var qslv = $(items[items.length - 1]).data().snaklistview;
return qslv && qslv._listview ? qslv._listview.items().length : 0;
} catch (e) { return 0; }
}
function waitForQualifierRendered(sv, prevCount, timeoutMs) {
var start = Date.now();
var timeout = (typeof timeoutMs === 'number') ? timeoutMs : WB_TIMING.valueViewTimeout;
return new Promise(function (resolve) {
(function tick() {
if (getQualifierCount(sv) > prevCount) return resolve();
if (Date.now() - start > timeout) return resolve(); // fail-safe
setTimeout(tick, WB_TIMING.pollInterval);
})();
});
}
function waitForValueWidget(snak, datatype, timeoutMs) {
var start = Date.now(), timeout = timeoutMs || 10000;
return new Promise(function (resolve, reject) {
(function tick() {
var vv = snak && snak._variation && snak._variation._valueView;
var ex = vv && vv._expert;
var $input = ex && ex.$input;
var plugin = null, jqe = null;
try {
if ($input && $input.length) {
var data = $input.data();
if (datatype === 'wikibase-item') {
plugin = data.entityselector || data['mw.widgets.EntitySelector'] || data.ooWidget || null;
} else {
plugin = data.inputautoexpand || null;
}
jqe = data.jqueryEventSpecialEachchange || null;
}
} catch (e) {}
if ($input && $input.length) return resolve({ $input: $input, plugin: plugin, jqe: jqe });
if (Date.now() - start > timeout) return reject(new Error('value widget not ready'));
setTimeout(tick, WB_TIMING.pollInterval);
})();
});
}
function emitStatementChange(sv) {
try {
var ev = sv.widgetEventPrefix ? (sv.widgetEventPrefix + 'change') : 'change';
sv.element && sv.element.trigger(ev);
} catch (e) {}
}
// Wait until the row is savable (valueView.value() or statementview.value() becomes non-null).
// Always resolves: true = savable, false = timed out (we'll try to save anyway).
function waitForSavableStatement(sv, snak, timeoutMs) {
var start = Date.now();
var timeout = (typeof timeoutMs === 'number') ? timeoutMs : WB_TIMING.valueViewTimeout;
function valueViewHasValue() {
try {
var vv = snak && snak._variation && snak._variation._valueView;
if (vv && typeof vv.value === 'function') { return vv.value() !== null; }
} catch (e) {}
return false;
}
function statementViewHasValue() {
try {
if (sv && typeof sv.value === 'function') { return sv.value() !== null; }
} catch (e) {}
return false;
}
return new Promise(function (resolve) {
(function tick() {
if (valueViewHasValue() || statementViewHasValue()) return resolve(true);
if (Date.now() - start > timeout) return resolve(false); // ← never reject
setTimeout(tick, WB_TIMING.pollInterval);
})();
});
}
function prefillStatement(claim, onlyProperty) {
return new Promise(function (resolve) {
var pid = claim.property.id;
var prevCount = getStatementCount(pid);
var statementListView = $(".wikibase-statementgrouplistview").first().data().wikibaseStatementgrouplistview;
statementListView.enterNewItem();
var items = statementListView.listview.items();
var item = items.last().data();
var svItems = item.statementgroupview.statementlistview._listview.items();
var sv = svItems.last().data().statementview;
var snak = sv.$mainSnak.data().snakview;
var toolbar = svItems.last().data().edittoolbar;
// Select main property
var es = getPropertyEntity(claim.property);
var selector = snak._getPropertySelector();
selector.element.val(es.label);
selector.element.attr("title", es.label);
selector._trigger('change');
selector._select(es);
// Wait for the value view + widget, set main value (no save yet)
// Wait for the value view, then commit main value (datamodel + widget for simple types)
var mainVal = claim.value && claim.value.mainVal;
var dt = claim.property.datatype || (mainVal && mainVal.datatype) || claim.datatype || 'string';
waitForValueView(snak, WB_TIMING.valueViewTimeout)
.then(function () {
if (dt === 'wikibase-item' && mainVal && mainVal.id) {
return waitForValueWidget(snak, dt, WB_TIMING.widgetTimeout)
.then(function (vw) { return pickEntity(vw, mainVal.id, WB_TIMING.commitDelay).then(function(){ return { dt: dt, mainVal: mainVal }; }); },
function () { return { dt: dt, mainVal: mainVal }; });
}
// 👉 simple types: force the widget to commit so valueView.value() is non-null
return hardCommitSimpleValue(sv, snak, dt, mainVal).then(function(){ return { dt: dt, mainVal: mainVal }; });
})
// 2) For items we do a quick savable wait; for non-items skip it (we’ll verify after qualifiers)
.then(function (ctx) {
if (ctx.dt === 'wikibase-item') {
emitStatementChange(sv);
return waitForSavableStatement(sv, snak, WB_TIMING.valueViewTimeout).then(function () { return ctx; });
}
return ctx; // non-item: proceed immediately
})
// 3) Add ALL qualifiers
.then(function (ctx) {
return addQualifiersSequentially(sv, claim.qualifiers || []).then(function () { return ctx; });
})
// 4) Row may have re-rendered: re-acquire sv/snak/toolbar
.then(function () {
// re-acquire (qualifiers may re-render the row)
var ctx = refreshSVContext(item);
sv = ctx.sv;
snak = ctx.snak;
toolbar = ctx.toolbar;
var mainVal = claim.value && claim.value.mainVal;
var dt = claim.property.datatype || (mainVal && mainVal.datatype) || claim.datatype || 'string';
// re-commit mainsnak on the *current* row
if (dt === 'wikibase-item' && mainVal && mainVal.id) {
return waitForValueWidget(snak, dt, WB_TIMING.widgetTimeout)
.then(function (vw) { return pickEntity(vw, mainVal.id, WB_TIMING.commitDelay); })
.then(function () { emitStatementChange(sv); return waitForSavableStatement(sv, snak, WB_TIMING.valueViewTimeout); });
} else {
return hardCommitSimpleValue(sv, snak, dt, mainVal)
.then(function () { emitStatementChange(sv); return waitForSavableStatement(sv, snak, WB_TIMING.valueViewTimeout); });
}
})
.then(function () {
// already re-acquired sv/snak/toolbar and re-committed mainsnak above…
// make sure qualifiers aren’t holding focus and their datavalues exist
blurAllQualifierInputs(sv);
return ensureQualifiersCommitted(sv, 500);
})
.then(function () {
// make sure qualifiers aren’t holding focus
return doSave(toolbar, pid, prevCount);
})
.then(resolve)
.catch(function () { return doSave(toolbar, pid, prevCount).then(resolve); });
});
}
function pendingStatement(claim, opts) {
opts = opts || {};
var shouldSave = (opts.save === true); // default: leave open (no save)
var prop = claim.property;
var pid = prop.id;
var prevCount = getStatementCount(pid);
return new Promise(function (resolve) {
var statementListView = $(".wikibase-statementgrouplistview").first().data().wikibaseStatementgrouplistview;
statementListView.enterNewItem();
var items = statementListView.listview.items();
var item = items.last().data();
var svItems = item.statementgroupview.statementlistview._listview.items();
var sv = svItems.last().data().statementview; // <- always last row we just created
var snak = sv.$mainSnak.data().snakview;
var toolbar = svItems.last().data().edittoolbar;
// Build the full property entity object the selector expects
var es = getPropertyEntity(prop);
var selector = snak._getPropertySelector();
// Resolve when property has actually been applied
var propertyReady = new Promise(function (res) {
var done = false; function ok(){ if(!done){ done=true; res(); } }
selector.element.one('entityselectorselected', ok);
$(snak._variation).one('afterdraw', ok);
});
// Select the property (redraw value view)
selector.element.val(es.label);
selector.element.attr('title', es.label);
selector._trigger('change');
selector._select(es);
propertyReady.then(function () {
var dt = claim.datatype || prop.datatype || 'string';
// Wait until the value widget exists
return waitForValueWidget(snak, dt, WB_TIMING.widgetTimeout).then(function (vw) {
try {
// Poke the widget so the input is visibly open, then clear to blank
if (dt === 'wikibase-item') {
if (vw.plugin && typeof vw.plugin._select === 'function') {
vw.plugin._select({ id: 'Q80', datatype: 'wikibase-item' });
} else {
vw.$input.val('Q80').trigger('input').trigger('change');
}
vw.$input.val('').trigger('input').trigger('change');
} else if (dt === 'globe-coordinate' || dt === 'globecoordinate') {
vw.$input.val('0,0').trigger('input').trigger('change');
vw.$input.val('').trigger('input').trigger('change');
} else {
vw.$input.val('__').trigger('input').trigger('change');
vw.$input.val('').trigger('input').trigger('change');
}
// keep focus on main value field for user convenience
try { vw.$input.focus(); } catch (e) {}
} catch (e) { /* ignore */ }
// If the template carries qualifier scaffolds, add them (still no save)
if (claim.qualifiers && claim.qualifiers.length) {
return addQualifiersSequentially(sv, claim.qualifiers);
}
}).then(function () {
// If caller asked to save (rare for details), do it; otherwise resolve.
if (shouldSave) {
return doSave(toolbar, pid, prevCount).then(resolve);
}
// Leave the row open in edit mode:
// nudge the statement so toolbar enables Save button in case user types
emitStatementChange(sv);
resolve();
}).catch(function () {
// Even if something failed (e.g., widget too slow), just resolve to keep UI responsive
resolve();
});
});
});
}
function addOneQualifierToStatement(sv, q) {
// If no concrete value AND not marked for scaffolding, skip
if (!qualifierHasValue(q) && !q._scaffold) return Promise.resolve();
return new Promise(function (resolve) {
try {
var qlistview = sv._qualifiers;
var prev = getQualifierCount(sv);
qlistview.enterNewItem();
var $qlItems = sv._qualifiers.items();
var qslv = $qlItems && $qlItems.length ? $($qlItems[$qlItems.length - 1]).data().snaklistview : null;
if (!qslv || !qslv.enterNewItem) { resolve(); return; }
qslv.enterNewItem();
waitForQualifierRendered(sv, prev).then(function () {
var qsnak = qslv._listview.items().last().data().snakview;
var qprop = getPropertyEntity(q.property);
var qsel = qsnak._getPropertySelector();
qsel.element.val(qprop.label);
qsel._trigger('change');
qsel._select(qprop);
// If this is a scaffold-only qualifier, leave its value blank
if (q._scaffold === true) { resolve(); return; }
var dt = q.property.datatype || q.datatype || 'string';
waitForValueWidget(qsnak, dt, WB_TIMING.widgetTimeout).then(function (vw) {
try {
if (dt === 'wikibase-item' && q.value && q.value.mainVal && q.value.mainVal.id) {
return pickEntity(vw, q.value.mainVal.id, WB_TIMING.commitDelay).then(function () {
try { // If the selector didn't show a label (empty visual), hint the ID in the input.
// We do NOT change the selection; we just help the user see something.
if (vw.$input && (!vw.$input.val() || !$.trim(vw.$input.val()))) {
vw.$input.attr('placeholder', q.value.mainVal.id); // visible hint
vw.$input.attr('title', q.value.mainVal.id); // tooltip on hover
vw.$input.val(q.value.mainVal.id);
}
// Blur to finalize the qualifier UI state
vw.$input && vw.$input.blur();
} catch(e){}
emitValueViewChange(qsnak); // commit qualifier valueView
resolve();
});
} else {
// >>> NEW: robust commit for simple types (string/url/ext-id/monolingual/time/coord/quantity)
var vv = qsnak && qsnak._variation && qsnak._variation._valueView;
var ex = vv && vv._expert;
var $inp = vw && vw.$input;
var base = toInputText( dt, q.value && q.value.mainVal );
var nudged = base + (base ? ' ' : ' ');
function fireEachchange() {
try {
if (vw.jqe && vw.jqe.handlers) {
for (var i = 0; i < vw.jqe.handlers.length; i++) { try { vw.jqe.handlers[i].call(); } catch(_){} }
}
} catch(_) {}
}
function applyExpert() {
try {
if (ex && typeof ex._apply === 'function') ex._apply();
else if (ex && typeof ex.applyValue === 'function') ex.applyValue();
} catch(_) {}
}
if ($inp && $inp.length) {
$inp.val(base).trigger('input').trigger('change'); fireEachchange(); applyExpert();
$inp.val(nudged).trigger('input').trigger('change'); fireEachchange(); applyExpert();
$inp.val(base).trigger('input').trigger('change'); fireEachchange(); applyExpert();
$inp.blur();
}
emitValueViewChange(qsnak);
// ensure the qualifier has a non-null datavalue before we proceed
return waitForViewValue(qsnak, 600).then(function(){ resolve(); }, function(){ resolve(); });
}
} catch (e) { resolve(); }
}).catch(function () { resolve(); });
});
} catch (e) { resolve(); }
});
}
function addQualifiersSequentially(sv, qualifiers) {
qualifiers = qualifiers || [];
var chain = Promise.resolve();
for (var i = 0; i < qualifiers.length; i++) {
(function (q) {
chain = chain.then(function () { return addOneQualifierToStatement(sv, q); });
})(qualifiers[i]);
}
return chain;
}
function getExistingPropertyIds() {
var map = {};
$('.wikibase-statementgroupview').each(function () {
var d = $(this).data();
if (d && d.propertyId) map[d.propertyId] = true;
});
return map;
}
function addDetails(templateName, opts) {
opts = opts || {};
var skipExisting = (typeof opts.skipExisting === 'boolean') ? opts.skipExisting : true;
var tpl = WbTemplates[templateName] || { userDefinedClaims: [] };
var claims = (tpl.userDefinedClaims || []).slice();
var existing = getExistingPropertyIds();
var seq = Promise.resolve();
claims.forEach(function (claim) {
if (skipExisting && existing[claim.property.id]) { return; }
seq = seq.then(function () {
return WriteQueue.push(function () {
return pendingStatement(claim).then(function () {
existing[claim.property.id] = true;
});
});
});
});
return seq;
}
function applyTemplate(templateName) {
var claims = (WbTemplates[templateName].prefilledClaims || []).slice();
var seq = Promise.resolve();
claims.forEach(function (claim) {
seq = seq.then(function () {
return WriteQueue.push(function () {
return prefillStatement(claim, false); // returns a Promise
});
});
});
return seq;
}
// ===== 5) UI (render AFTER templates are loaded) =====
function initTemplatingUI() {
var html = ''
+ '<div class="wikibase-templating-section">'
+ ' <div class="wikibase-templating-section-title"><span class="magic-wand-icon"></span> RDA Entity template</div>'
+ ' <div class="wikibase-templating-help"><span class="settings-text">Select an RDA entity class to apply predefined template entries and additional entity details.</span></div>'
+ ' <div class="wikibase-templating-form oo-ui-labelElement oo-ui-fieldLayout">'
+ ' <div class=""><label for="wbtemplate">Entity class</label></div>'
+ ' <div><select id="wbtemplate" class="cdx-select"></select></div>'
+ ' </div>'
+ ' <div class="mw-htmlform-submit-buttons">'
+ ' <button id="applyTemplateBtn" class="cdx-button">Apply template</button>'
+ ' <button id="addDetailsBtn" class="cdx-button">Add details</button>'
+ ' </div>'
+ '</div>';
$('.wikibase-entitytermsview').append(html);
// group templates by target class label
var groups = {}; // groupLabel -> [{key, label}]
for (var k in WbTemplates) {
var grp = (WbTemplates[k].targetClass && WbTemplates[k].targetClass.label) || 'Other';
if (!groups[grp]) groups[grp] = [];
groups[grp].push({ key: k, label: WbTemplates[k].label });
}
var $sel = $('#wbtemplate').empty();
Object.keys(groups).sort().forEach(function(glab){
var $og = $('<optgroup>').attr('label', glab);
groups[glab].forEach(function(opt){
$og.append($('<option>', { value: opt.key, text: opt.label }));
});
$sel.append($og);
});
$('#applyTemplateBtn').off('click').on('click', function () {
var $b=$(this).prop('disabled', true);
applyTemplate($('#wbtemplate').val()).then(function(){ $b.prop('disabled', false); });
});
$('#addDetailsBtn').off('click').on('click', function () {
var $b=$(this).prop('disabled', true);
addDetails($('#wbtemplate').val(), { skipExisting: true }).then(function(){ $b.prop('disabled', false); });
});
}
// ===== 6) Build templates, then render UI =====
var _requestedQid = getUrlTemplateId();
buildWbTemplatesFromQuery(SEARCH_QUERY)
.then(function (map) {
WbTemplates = map;
window.WbTemplates = WbTemplates;
if (!WbTemplates || !Object.keys(WbTemplates).length) {
console.warn('No templates found for query:', SEARCH_QUERY);
return;
}
initTemplatingUI();
// Auto-select ONLY if it’s in the loaded (gated) templates
if (_requestedQid && WbTemplatesById[_requestedQid]) {
var key = WbTemplatesById[_requestedQid];
$('#wbtemplate').val(key);
var $btn = $('#applyTemplateBtn').prop('disabled', true);
applyTemplate(key).then(function () {
$btn.prop('disabled', false);
// optional: also add details
addDetails(key, { skipExisting: true }).then(function(){
removeAutoTemplateParams();
});
});
}
})
.fail(function (err) {
console.error('Template build failed:', err);
});
});