User:Difool/AddReviewScores.js
Appearance
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
//
const PID_DETERMINATION_METHOD = "P459";
const PID_INSTANCE_OF = "P31";
const PID_NR_REVIEWS = "P7887";
const PID_POINT_IN_TIME = "P585";
const PID_REASON_FOR_PREFERRED_RANK = "P7452";
const PID_RETRIEVED = "P813";
const PID_REVIEW_SCORE = "P444";
const PID_REVIEW_SCORE_BY = "P447";
const PID_STATED_IN = "P248";
const QID_FILM = "Q11424";
const PID_PUBLICATION_DATE = "P577";
const PID_IMDB = "P345";
const QID_IMDB = "Q37312";
const PID_ROTTEN_TOMATOES = "P1258";
const QID_ROTTEN_TOMATOES = "Q105584";
const QID_TOMATOMETER_SCORE = "Q108403393";
const PID_METACRITIC = "P1712";
const QID_METACRITIC = "Q150248";
const QID_METASCORE = "Q106515043";
const PID_LETTERBOXD = "P6127";
const QID_LETTERBOXD = "Q18709181";
const QID_CRITIC_REVIEW = "Q80698083";
const QID_GLOBAL_USER_RATING = "Q137041451";
const QID_WEIGHTED_AVERAGE = "Q107218751";
const FRESHNESS_MS = 1000 * 60 * 60 * 24 * 365; // 1 year
// Default settings
const defaultSettings = {
enabledProviders: {
imdb: true,
rotten: true,
metacritic: true,
letterboxd: true,
},
};
// Registry of rating providers
const ratingProviders = {
imdb: {
key: "imdb",
idProp: PID_IMDB,
label: "Internet Movie Database",
wikidataQ: QID_IMDB, // review score by, stated in
determinationQ: QID_WEIGHTED_AVERAGE,
numberUnitQ: QID_GLOBAL_USER_RATING,
placeholder: "e.g. 5.9",
buildUrl: (id) => `https://www.imdb.com/title/${id}/ratings/`,
min: 0,
max: 10,
min_precision: 1,
max_precision: 1, // one decimal place
type: "number",
},
rotten: {
key: "rotten",
idProp: PID_ROTTEN_TOMATOES,
label: "Rotten Tomatoes",
score: "Tomatometer",
wikidataQ: QID_ROTTEN_TOMATOES,
determinationQ: QID_TOMATOMETER_SCORE,
numberUnitQ: QID_CRITIC_REVIEW,
placeholder: "e.g. 20%",
min: 0,
max: 100,
max_precision: 0, // integer only
type: "percentage",
buildUrl: (id) =>
`https://www.rottentomatoes.com/m/${id.replace(/^m\//, "")}`,
},
metacritic: {
key: "metacritic",
idProp: PID_METACRITIC,
label: "Metacritic",
score: "Metascore",
wikidataQ: QID_METACRITIC,
determinationQ: QID_METASCORE,
numberUnitQ: QID_CRITIC_REVIEW,
buildUrl: (id) => `https://www.metacritic.com/${id}`,
placeholder: "e.g. 67",
min: 0,
max: 100,
max_precision: 0,
type: "number",
},
letterboxd: {
key: "letterboxd",
idProp: PID_LETTERBOXD,
label: "Letterboxd",
wikidataQ: QID_LETTERBOXD,
determinationQ: QID_WEIGHTED_AVERAGE,
numberUnitQ: QID_GLOBAL_USER_RATING,
buildUrl: (id) => `https://letterboxd.com/film/${id}/`,
placeholder: "e.g. 4.2",
min: 0,
max: 5,
min_precision: 1,
max_precision: 2, // allow two decimals
type: "number",
},
};
// ==== 01 UTILITY ========================================================
function deduceHint(provider) {
if (provider.type === "percentage") {
return "%";
}
return "/" + provider.max;
}
function getQualifierValue(stmt, pid) {
if (!stmt.qualifiers || !stmt.qualifiers[pid]) return null;
// take the first snak value
const snak = stmt.qualifiers[pid][0];
if (!snak || !snak.datavalue) return null;
return (
snak.datavalue.value.time || snak.datavalue.value.id || snak.datavalue.value
);
}
function getQualifierValueFromQualifiers(qualifiers, pid) {
if (!qualifiers || !qualifiers[pid]) return null;
// take the first snak value
const snak = qualifiers[pid][0];
if (!snak || !snak.datavalue) return null;
return (
snak.datavalue.value.time || snak.datavalue.value.id || snak.datavalue.value
);
}
// Parse Wikibase time like "+2021-04-01T00:00:00Z"
function parseWikibaseTime(timeStr) {
if (!timeStr) return null;
let t = timeStr.replace(/^\+/, "");
t = t.replace(/-00/g, "-01");
const d = new Date(t);
return isNaN(d.getTime()) ? null : d;
}
// Build Wikibase day-only time string
function wikibaseDayTimeString(year, month, day) {
// month: 1–12, day: 1–31
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
return `+${year}-${mm}-${dd}T00:00:00Z`;
}
// Load settings from localStorage
function cache_loadSettings() {
try {
return {
...defaultSettings,
...JSON.parse(localStorage.getItem("AddReviewScores_settings")),
};
} catch {
return { ...defaultSettings };
}
}
// Save settings to localStorage
function cache_saveSettings(settings) {
localStorage.setItem("AddReviewScores_settings", JSON.stringify(settings));
}
// ==== 02 VALIDATION ========================================================
function validateAndFormatScore(rawVal, provider) {
// allow empty input
if (!rawVal || rawVal.trim() === "") {
return { valid: true, normalized: "", formatted: "" };
}
let clean = rawVal.trim();
// detect "num / max" style input (with optional spaces)
const fractionMatch = clean.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+)$/);
if (fractionMatch) {
const num = parseFloat(fractionMatch[1]);
const denom = parseInt(fractionMatch[2], 10);
// only normalize if denominator matches provider.max
if (denom === provider.max) {
clean = fractionMatch[1]; // just keep the numerator
}
}
// normalize: strip spaces and non-numeric except dot
clean = clean.replace(/\s+/g, "").replace(/[^\d.]/g, "");
let num = parseFloat(clean);
if (isNaN(num)) {
return { valid: false, normalized: "", formatted: "" };
}
// clamp to range
if (num < provider.min || num > provider.max) {
return { valid: false, normalized: "", formatted: "" };
}
// round to precision
const factor = Math.pow(10, provider.max_precision);
num = Math.round(num * factor) / factor;
if (provider.min_precision !== undefined) {
precision = provider.min_precision;
} else if (provider.max_precision !== undefined) {
precision = provider.max_precision;
} else {
precision = 0;
}
const displayNum = num.toFixed(precision);
// deduce postfix
let postfix = provider.type === "percentage" ? "%" : "/" + provider.max;
return {
valid: true,
normalized: num.toString(), // "95"
formatted: `${displayNum}${postfix}`, // "95.0/100"
};
}
function validateAndFormatCount(rawVal, provider) {
// allow empty input
if (!rawVal || rawVal.trim() === "") {
return { valid: true, normalized: "", formatted: "" };
}
// normalize: strip spaces
let clean = rawVal.trim().toUpperCase();
// detect suffix multipliers
let multiplier = 1;
if (clean.endsWith("M")) {
multiplier = 1000000;
clean = clean.slice(0, -1);
} else if (clean.endsWith("K")) {
multiplier = 1000;
clean = clean.slice(0, -1);
}
// strip non-numeric except dot
clean = clean.replace(/[^\d.]/g, "");
let num = parseFloat(clean);
if (isNaN(num)) {
return { valid: false, normalized: "", formatted: "" };
}
num = num * multiplier;
// round to integer (counts are usually whole numbers)
num = Math.round(num);
return {
valid: true,
normalized: num.toString(), // "1800000"
formatted: num.toString(), // "1800000"
};
}
// ==== 03 ENTITY ========================================================
// Read current entity JSON from page
async function getEntityFromPage() {
try {
const api = new mw.Api();
const res = await api.get({
action: "wbgetentities",
format: "json",
ids: mw.config.get("wgPageName"), // use page name (Q-id)
props: "claims|labels|descriptions|aliases",
});
const id = mw.config.get("wgPageName");
if (res && res.entities && res.entities[id]) {
return res.entities[id];
}
} catch (e) {
console.error("Failed to fetch entity via wbgetentities", e);
}
return null;
}
// Check if entity is a film/TV (screen production) or has an IMDb 'tt' ID
function isScreenProductionEntity(entity) {
if (!entity || !entity.claims) return false;
// First, check instance of claims for film (or similar types)
const inst = entity.claims[PID_INSTANCE_OF] || [];
const isFilmLike = inst.some((c) => {
if (c.rank === "deprecated") return false;
const v = c.mainsnak?.datavalue?.value;
if (!v) return false;
// value may be an object with 'id' or an array of ids
return v.id === QID_FILM || (Array.isArray(v) && v.includes(QID_FILM));
});
if (isFilmLike) return true;
// Also treat entities with an IMDb ID starting with 'tt' as screen productions
const imdbStmts = entity.claims[PID_IMDB] || [];
for (const stmt of imdbStmts) {
if (stmt.rank === "deprecated") continue;
const val = stmt.mainsnak?.datavalue?.value;
if (!val) continue;
// P345 values are typically plain strings like 'tt1234567'
if (typeof val === "string" && val.startsWith("tt")) return true;
// or sometimes wrapped in an object
if (val.id && typeof val.id === "string" && val.id.startsWith("tt")) return true;
}
return false;
}
// Check if the film is published in the past (or no publication date)
function isPublishedInPast(entity) {
const pubDates = entity.claims?.[PID_PUBLICATION_DATE] || [];
if (pubDates.length === 0) return true; // no date, okay
// Find the earliest publication date
let earliest = null;
for (const stmt of pubDates) {
if (stmt.rank === "deprecated") continue;
const dateStr = stmt.mainsnak?.datavalue?.value?.time;
if (!dateStr) continue;
const date = parseWikibaseTime(dateStr);
if (!date) continue;
if (!earliest || date < earliest) earliest = date;
}
if (!earliest) return true; // no valid date, okay
const now = new Date();
return earliest < now;
}
// ==== 04 PROVIDER_SCORE ========================================================
// Create a providerMethodKey for (review score by + determination method)
function getProviderMethodKeyFromStatement(statement) {
const by = getQualifierValue(statement, PID_REVIEW_SCORE_BY);
let method = getQualifierValue(statement, PID_DETERMINATION_METHOD);
// Normalize IMDB statements with missing determination method
// Treat missing/null method as QID_WEIGHTED_AVERAGE for IMDB (Q37312)
if (by === QID_IMDB && !method) {
method = QID_WEIGHTED_AVERAGE;
}
return `${by}|${method}`;
}
// Get the providerMethodKey in the same format as getProviderMethodKeyFromStatement
function getProviderMethodKeyFromProvider(provider) {
return `${provider.wikidataQ}|${provider.determinationQ}`;
}
// Group existing review score statements by providerMethodKey
function groupScoresByProviderMethodKey(entity) {
const groups = {};
(entity.claims[PID_REVIEW_SCORE] || []).forEach((stmt) => {
// Skip deprecated statements
if (stmt.rank === "deprecated") return;
const key = getProviderMethodKeyFromStatement(stmt);
if (!groups[key]) groups[key] = [];
groups[key].push(stmt);
});
return groups;
}
// Find the statement with the latest point in time qualifier
function findLatestScore(group) {
return group.reduce((latest, stmt) => {
if (!latest) return stmt;
const d = getPointInTime(stmt);
if (!d) return latest;
const dl = getPointInTime(latest);
if (!dl) return stmt;
return d > dl ? stmt : latest;
}, null);
}
// Check if existing scores have an acceptable rank configuration
function isAcceptableForm(entity) {
const groups = groupScoresByProviderMethodKey(entity);
for (const providerMethodKey in groups) {
const group = groups[providerMethodKey];
const preferred = group.filter((s) => s.rank === "preferred");
if (preferred.length > 1) return false;
if (preferred.length === 1) {
const latest = findLatestScore(group);
if (preferred[0] !== latest) return false;
}
}
return true;
}
// Check if each review score has exactly one PID_REVIEW_SCORE_BY and optionally one PID_DETERMINATION_METHOD
function hasValidQualifiers(entity) {
const p444 = entity.claims?.[PID_REVIEW_SCORE] || [];
for (const stmt of p444) {
if (!stmt.qualifiers) return false;
const by = stmt.qualifiers[PID_REVIEW_SCORE_BY];
if (!by || by.length !== 1) return false;
const method = stmt.qualifiers[PID_DETERMINATION_METHOD];
if (method && method.length !== 1) return false;
}
return true;
}
// Check if any existing preferred rank has a reason for preferred rank qualifier
function hasReasonForPreferredRank(entity) {
return (entity.claims[PID_REVIEW_SCORE] || []).some((stmt) => {
return (
stmt.rank === "preferred" &&
stmt.qualifiers &&
stmt.qualifiers[PID_REASON_FOR_PREFERRED_RANK]
);
});
}
// Extract the point in time from a score statement as a Date object or null
function getPointInTime(statement) {
// First, try the point in time qualifier
let timeStr = getQualifierValue(statement, PID_POINT_IN_TIME);
if (timeStr) return parseWikibaseTime(timeStr);
// If not present, try the retrieved time from references
if (statement.references) {
for (const ref of statement.references) {
if (ref.snaks && ref.snaks[PID_RETRIEVED]) {
const snak = ref.snaks[PID_RETRIEVED][0];
if (snak && snak.datavalue) {
timeStr = snak.datavalue.value.time;
if (timeStr) return parseWikibaseTime(timeStr);
}
}
}
}
return null;
}
// Check if a rating statement is recent (within FRESHNESS_MS)
function isRecentRatingStatement(statement) {
const parsed = getPointInTime(statement);
if (!parsed) return false; // no point in time -> assume old
const now = Date.now();
return now - parsed.getTime() < FRESHNESS_MS;
}
// Check if provider already has a recent rating on the item
function providerHasRecentRating(entity, provider) {
// scan P444 statements with qualifier review score by = provider.wikidataQ
const p444 = entity.claims?.[PID_REVIEW_SCORE] || [];
for (const s of p444) {
const key = getProviderMethodKeyFromStatement(s);
if (key != getProviderMethodKeyFromProvider(provider)) continue;
if (isRecentRatingStatement(s)) return true;
}
return false;
}
// Check if provider has an exact existing rating (after normalization)
function providerHasExactRating(
entity,
provider,
formattedScore,
formattedCount,
) {
const groups = groupScoresByProviderMethodKey(entity);
const pkey = getProviderMethodKeyFromProvider(provider);
const groupForProvider = groups[pkey] || [];
const latestStmt = findLatestScore(groupForProvider);
if (!latestStmt) return false;
const reviewCountQualifiers = latestStmt.qualifiers?.[PID_NR_REVIEWS] || [];
const existingScoreValue = latestStmt.mainsnak?.datavalue?.value;
const existingScoreFormatted = validateAndFormatScore(
existingScoreValue,
provider,
).formatted;
const existingCountValue = reviewCountQualifiers[0]?.datavalue?.value?.amount;
const existingCountFormatted = validateAndFormatCount(
existingCountValue,
provider,
).formatted;
return (
existingScoreFormatted === formattedScore &&
existingCountFormatted === formattedCount
);
}
// ==== 05 CLAIM ========================================================
// Deep clone a claim statement
function cloneClaim(stmt) {
// Deep clone the statement object so we don't mutate entity.claims directly
const clone = JSON.parse(JSON.stringify(stmt));
// Remove any helper fields that shouldn't go to the API
delete clone._providerKey;
return clone;
}
// Build a short summary listing provider labels used
function buildEditSummaryFromProviders(claims) {
const used = new Set(
claims.filter((c) => c && c._providerKey).map((c) => c._providerKey),
);
if (used.size === 0)
return "Add ratings ([[User:Difool/AddReviewScores.js]])";
const labels = Array.from(used, (key) => {
const provider = ratingProviders[key];
return provider?.wikidataQ ? `[[${provider.wikidataQ}]]` : key;
});
const noun = labels.length > 1 ? "ratings" : "rating";
return `Add ${noun} from ${labels.join(
", ",
)} ([[User:Difool/AddReviewScores.js]])`;
}
// Build a P444 claim object for the given provider, score, and count
function buildScoreClaim(provider, scoreFormatted, countNormalized) {
if (!provider) return null;
// Build claim object for P444 (string)
const claim = {
mainsnak: {
snaktype: "value",
property: PID_REVIEW_SCORE,
datavalue: {
value: scoreFormatted,
type: "string",
},
},
type: "statement",
rank: "normal",
qualifiers: {},
references: [],
};
// qualifiers
if (provider.wikidataQ) {
claim.qualifiers[PID_REVIEW_SCORE_BY] = [
{
snaktype: "value",
property: PID_REVIEW_SCORE_BY,
datavalue: {
value: { "entity-type": "item", id: provider.wikidataQ },
type: "wikibase-entityid",
},
},
];
}
// Number of reviews (P7887)
if (
provider.numberUnitQ &&
countNormalized !== null &&
countNormalized !== "" &&
!isNaN(Number(countNormalized))
) {
claim.qualifiers = claim.qualifiers || {};
claim.qualifiers[PID_NR_REVIEWS] = [
{
snaktype: "value",
property: PID_NR_REVIEWS,
datavalue: {
value: {
amount: `+${Number(countNormalized)}`, // leading +
unit: `http://www.wikidata.org/entity/${provider.numberUnitQ}`,
},
type: "quantity",
},
datatype: "quantity",
},
];
}
// Point in time (P585) — current day only
const today = new Date();
const year = today.getUTCFullYear();
const month = String(today.getUTCMonth() + 1).padStart(2, "0");
const day = String(today.getUTCDate()).padStart(2, "0");
// Build Wikibase day-only time string
const dayOnlyIso = `+${year}-${month}-${day}T00:00:00Z`;
claim.qualifiers = claim.qualifiers || {};
claim.qualifiers[PID_POINT_IN_TIME] = [
{
snaktype: "value",
property: PID_POINT_IN_TIME,
datavalue: {
value: {
time: dayOnlyIso,
timezone: 0,
before: 0,
after: 0,
precision: 11, // day precision
calendarmodel: "http://www.wikidata.org/entity/Q1985727", // Gregorian
},
type: "time",
},
datatype: "time",
},
];
// determination method if available
if (provider.determinationQ) {
claim.qualifiers[PID_DETERMINATION_METHOD] = [
{
snaktype: "value",
property: PID_DETERMINATION_METHOD,
datavalue: {
value: { "entity-type": "item", id: provider.determinationQ },
type: "wikibase-entityid",
},
},
];
}
// reference: stated in + provider id + retrieved
const ref = {
snaks: {},
};
if (provider.wikidataQ) {
ref.snaks[PID_STATED_IN] = [
{
snaktype: "value",
property: PID_STATED_IN,
datavalue: {
value: { "entity-type": "item", id: provider.wikidataQ },
type: "wikibase-entityid",
},
},
];
}
// ref.snaks[PID_RETRIEVED] = [
// {
// snaktype: "value",
// property: PID_RETRIEVED,
// datavalue: {
// value: {
// time: `+${new Date().toISOString()}`,
// precision: 11,
// timezone: 0,
// },
// type: "time",
// },
// },
// ];
claim.references.push(ref);
// Tag this claim as new so rank assignment logic knows not to clone it
claim._isNew = true;
claim._providerKey = provider.key;
return claim;
}
// Submit all claims in one atomic edit
async function submitAllClaims(claims) {
if (!claims || claims.length === 0) {
mw.notify("No ratings to submit.", { type: "info" });
return;
}
// Prepare payload: remove helper fields before sending
const claimsForApi = claims.map((c) => {
const copy = JSON.parse(JSON.stringify(c)); // deep clone
delete copy._providerKey;
return copy;
});
const updates = { claims: claimsForApi };
const api = new mw.Api();
const pageId = mw.config.get("wgPageName");
const summary = buildEditSummaryFromProviders(claims);
// runBtn.setDisabled(True);
mw.notify("Submitting ratings...", {
autoHide: false,
tag: "ratings-progress",
type: "info",
});
try {
const resp = await api.postWithEditToken({
action: "wbeditentity",
id: pageId,
data: JSON.stringify(updates),
summary: summary,
baserevid: mw.config.get("wgRevisionId"),
format: "json",
});
if (resp && resp.entity) {
mw.notify("Ratings added successfully.", {
type: "success",
tag: "ratings-progress",
});
location.reload();
} else {
mw.notify("Edit did not apply (no entity in response).", {
tag: "ratings-progress",
type: "warn",
});
console.error("Unexpected wbeditentity response:", resp);
}
} catch (err) {
console.error("submitAllClaims error:", err);
mw.notify(
"Failed to add ratings: " +
(err && err.message ? err.message : "unknown error"),
{ type: "error", tag: "ratings-progress" },
);
} finally {
// runBtn.setDisabled(false);
}
}
// ==== 06 UI ========================================================
function showWarning(message) {
mw.notify(message, { type: "warn" });
}
function showError(message) {
mw.notify(message, { type: "error" });
}
function getStartupErrorMsg(entity) {
if (hasReasonForPreferredRank(entity)) {
return "Cannot add scores: existing preferred rank has a 'reason for preferred rank' qualifier.";
}
if (!isAcceptableForm(entity)) {
return "Existing scores have unsupported rank configuration. Please fix ranks manually before adding new scores.";
}
if (!hasValidQualifiers(entity)) {
return "Existing scores have invalid qualifier structure. Each score must have exactly one 'review score by' and one 'determination method' qualifier.";
}
return null;
}
// Determine warning/info message based on validation
function getRatingMessage(entity, provider, scoreValidation, countValidation) {
if (!scoreValidation) return null;
if (!scoreValidation.valid) {
return {
type: "warning",
message: `⚠️ Score must be between ${provider.min} and ${provider.max}.`,
};
}
if (!countValidation.valid) {
return {
type: "warning",
// reviews or ratings based on provider
message: `⚠️ Enter a valid number of reviews.`,
};
}
// Check for duplicate critic rating
const hasExistingRating = providerHasExactRating(
entity,
provider,
scoreValidation.formatted,
countValidation.formatted,
);
if (hasExistingRating) {
return {
type: "info",
// not necessary to repeat the label as the row already shows it
message: `This is the current rating + count. It won't be added.`,
};
}
return null;
}
// Show warning/info message below the row based on validation
function handleRatingWarning(entity, provider, row, scoreInput, countInput) {
score = scoreInput.getValue();
count = countInput.getValue();
// Normalize values
const scoreValidation = validateAndFormatScore(score, provider);
const countValidation = validateAndFormatCount(count, provider);
if (scoreValidation.valid) {
scoreInput.setValue(scoreValidation.normalized);
}
if (countValidation.valid) {
countInput.setValue(countValidation.normalized);
}
const msg = getRatingMessage(
entity,
provider,
scoreValidation,
countValidation,
);
// Remove any existing warning row for this provider
if (row.next().hasClass("warning-row")) {
row.next().remove();
}
if (msg) {
row.addClass("has-warning");
const warnTr = $("")
.addClass("warning-row")
.attr("data-provider", provider);
// empty first column
warnTr.append($(""));
const warnTd = $(" ")
.attr("colspan", 4) // span across all columns in your table
.addClass(msg.type) // "warning" or "info"
.text(msg.message);
warnTr.append(warnTd);
row.after(warnTr);
} else {
row.removeClass("has-warning");
}
}
function renderLink(claimId, text) {
return $("").attr("href", `#${claimId}`).text(text);
}
// Build a single rating row
function buildSingleRatingRow(entity, rowData, table, onRowRemoved) {
const { key, provider: p, idVal, latestStmt, hasRecentRating } = rowData;
// Create the OOUI input
const scoreInput = new OO.ui.TextInputWidget({
placeholder: "Enter score",
classes: ["wikidataRatings-score-input"],
});
// Inline hint span
const hintSpan = $("")
.addClass("wikidataRatings-hint")
.text(deduceHint(p));
// Count input also as OOUI widget
const countInput = new OO.ui.TextInputWidget({
placeholder: "Enter count",
classes: ["wikidataRatings-count-input"],
});
const row = $("")
.addClass("rating-row")
.attr("data-provider-key", key)
.data("scoreWidget", scoreInput)
.data("countWidget", countInput);
scoreInput.$input.on("blur", () => {
handleRatingWarning(entity, p, row, scoreInput, countInput);
});
countInput.$input.on("blur", () => {
handleRatingWarning(entity, p, row, scoreInput, countInput);
});
const linkButton = new OO.ui.ButtonWidget({
label: "Open",
href: p.buildUrl(idVal),
target: "_blank",
flags: ["progressive"],
});
const removeButton = new OO.ui.ButtonWidget({
label: "Remove",
flags: ["destructive"],
});
// Hook up remove action
removeButton.on("click", () => {
row.addClass("wikidataRatings-removed").hide();
if (onRowRemoved) onRowRemoved();
});
// Add class for rows with recent ratings and hide them initially
if (hasRecentRating) {
row.addClass("wikidataRatings-recent").hide();
}
const labelText = p.score ? `${p.label} - ${p.score}` : p.label;
// Provider name cell
let nameCell;
if (latestStmt) {
nameCell = $("")
.addClass("wikidataRatings-col-name")
.append(renderLink(latestStmt.id, labelText));
} else {
nameCell = $(" ").addClass("wikidataRatings-col-name").text(labelText);
}
row.append(nameCell);
// Score cell with wrapper
const $scoreWrapper = $("")
.addClass("score-wrapper")
.append(scoreInput.$element)
.append(hintSpan);
row.append(
$("").addClass("wikidataRatings-col-score").append($scoreWrapper),
);
// Count cell with wrapper
const $countWrapper = $("")
.addClass("count-wrapper")
.append(countInput.$element);
row.append(
$("").addClass("wikidataRatings-col-count").append($countWrapper),
);
// Link cell
row.append(
$(" ").addClass("wikidataRatings-col-link").append(linkButton.$element),
);
// Remove cell
row.append(
$(" ")
.addClass("wikidataRatings-col-remove")
.append(removeButton.$element),
);
table.find("#WikidataRatings_body").append(row);
}
function collectAllScoreInputs(entity) {
const inputs = [];
const skipped = [];
let hasError = false;
$(".rating-row").each(function () {
const $row = $(this);
if ($row.hasClass("wikidataRatings-removed")) return; // skip removed rows
if ($row.hasClass("wikidataRatings-recent") && !$row.is(":visible")) return; // skip hidden recent rows
const providerKey = $row.attr("data-provider-key");
const provider = ratingProviders[providerKey];
if (!provider) return; // skip if provider not found
const scoreInput = $row.data("scoreWidget");
const countInput = $row.data("countWidget");
const score = scoreInput.getValue();
const count = countInput.getValue();
// Abort if empty
if (!score || !count) {
hasError = true;
return false; // break out of loop
}
// Validate
const scoreValidation = validateAndFormatScore(score, provider);
const countValidation = validateAndFormatCount(count, provider);
if (!scoreValidation.valid || !countValidation.valid) {
hasError = true;
return false;
}
if (
providerHasExactRating(
entity,
provider,
scoreValidation.formatted,
countValidation.formatted,
)
) {
skipped.push(provider);
return; // skip
}
inputs.push({ provider, scoreValidation, countValidation });
});
return hasError ? null : { inputs, skipped };
}
// Show settings dialog with provider checkboxes
function showSettingsDialog(currentSettings, saveSettings, onClose) {
// Create provider checkboxes
const providerFields = [];
for (const [key, provider] of Object.entries(ratingProviders)) {
const enabled = currentSettings.enabledProviders?.[key] !== false;
const cb = new OO.ui.CheckboxInputWidget({ selected: enabled });
providerFields.push(
new OO.ui.FieldLayout(cb, {
label: provider.label,
align: "inline",
}),
);
cb.providerKey = key;
}
const providerFieldset = new OO.ui.FieldsetLayout({
label: "Rating Providers",
items: providerFields,
});
// Dialog definition
function SettingsDialog(config) {
SettingsDialog.super.call(this, config);
}
OO.inheritClass(SettingsDialog, OO.ui.ProcessDialog);
SettingsDialog.static.name = "settingsDialogRatings";
SettingsDialog.static.title = "Rating Providers Settings";
SettingsDialog.static.actions = [
{ action: "close", label: "Close", flags: ["safe", "primary"] },
];
SettingsDialog.static.size = "medium";
SettingsDialog.prototype.initialize = function () {
SettingsDialog.super.prototype.initialize.apply(this, arguments);
this.content = new OO.ui.PanelLayout({
padded: true,
expanded: false,
});
this.content.$element.append(providerFieldset.$element);
this.$body.append(this.content.$element);
};
SettingsDialog.prototype.getActionProcess = function (action) {
if (action === "close") {
return new OO.ui.Process(() => {
const newSettings = {
enabledProviders: {},
};
providerFields.forEach((field) => {
const cb = field.fieldWidget;
newSettings.enabledProviders[cb.providerKey] = cb.isSelected();
});
saveSettings(newSettings);
if (onClose) onClose();
this.close({ action: "close" });
});
}
return SettingsDialog.super.prototype.getActionProcess.call(this, action);
};
// Launch dialog
const windowManager = new OO.ui.WindowManager();
$("body").append(windowManager.$element);
const dialog = new SettingsDialog();
windowManager.addWindows([dialog]);
windowManager.openWindow(dialog);
}
// Build the UI panel
function buildUIPanel(entity, settings) {
const box = $(`
`);
// Add h3 with link to P444
const h3 = $(""
).append(renderLink("P444", "Movie Ratings"));
box.append(h3);
const errorMsg = getStartupErrorMsg(entity);
if (errorMsg) {
box.append($(`${errorMsg}`));
} else {
const result = getProviderRowsData(entity, settings);
if (result.reason === "no_identifiers") {
box.append($(""
).text("No rating provider identifiers found."));
} else {
const isAllUpToDate = result.reason === "all_up_to_date";
if (isAllUpToDate) {
box.append($(""
).text("All ratings are up to date."));
}
const rowsData = result.rowsData;
const table = $(`
`);
box.append(table);
// Build header row
const header = $("")
.append($("").addClass("wikidataRatings-col-name").text("Source"))
.append(
$(" ")
.addClass("wikidataRatings-col-score")
.text("Score / Percentage"),
)
.append(
$(" ")
.addClass("wikidataRatings-col-count")
.text("Reviews / Ratings"),
)
.append($(" ").addClass("wikidataRatings-col-link").text("Link"))
.append(
$(" ").addClass("wikidataRatings-col-remove").text("Remove"),
);
table.find("#WikidataRatings_head").append(header);
// Function to update readdBtn visibility (defined in outer scope so all functions have access)
// Note: readdBtn is defined later but will be in scope when this function is called
const updateReaddBtnVisibility = () => {
const hasRemovedRows =
$("#WikidataRatings_body .wikidataRatings-removed").length > 0;
if (hasRemovedRows) {
readdBtn.$element.show();
} else {
readdBtn.$element.hide();
}
};
// Build buttons
const buttonContainer = $(
'',
);
const leftButtonsDiv = $("");
// Create runBtn and readdBtn regardless of isAllUpToDate
const runBtn = new OO.ui.ButtonWidget({
label: "Add all ratings",
flags: ["progressive", "primary"],
});
runBtn.on("click", () => {
const payload = processRatingsSubmission(entity);
if (!payload) return; // aborted due to validation/safety
submitAllClaims(payload.claims);
});
const readdBtn = new OO.ui.ButtonWidget({
label: "Readd all removed",
flags: ["progressive"],
});
readdBtn.on("click", () => {
$("#WikidataRatings_body .wikidataRatings-removed")
.removeClass("wikidataRatings-removed")
.show();
updateReaddBtnVisibility();
});
// Initially hide the readdBtn since no rows are removed yet
updateReaddBtnVisibility();
if (!isAllUpToDate) {
// Show buttons if not all up to date
leftButtonsDiv.append(runBtn.$element);
leftButtonsDiv.append($(''));
leftButtonsDiv.append(readdBtn.$element);
leftButtonsDiv.append($(''));
}
// Only show "Show recent ratings" button if there are actual recent ratings
const hasAnyRecentRatings = rowsData.some((r) => r.hasRecentRating);
if (hasAnyRecentRatings) {
const showRecentBtn = new OO.ui.ButtonWidget({
label: "Show recent ratings",
flags: ["progressive"],
});
showRecentBtn.on("click", () => {
const isHidden =
showRecentBtn.$element.attr("data-hidden") === "true";
if (isHidden) {
// Show recent rows
$("#WikidataRatings_body .wikidataRatings-recent").show();
showRecentBtn.setLabel("Hide recent ratings");
showRecentBtn.$element.attr("data-hidden", "false");
// If we're in all_up_to_date mode, show the action buttons when showing recent rows
if (isAllUpToDate) {
leftButtonsDiv.prepend(readdBtn.$element);
leftButtonsDiv.prepend($(''));
leftButtonsDiv.prepend(runBtn.$element);
leftButtonsDiv.prepend($(''));
}
} else {
// Hide recent rows
$("#WikidataRatings_body .wikidataRatings-recent").hide();
showRecentBtn.setLabel("Show recent ratings");
showRecentBtn.$element.attr("data-hidden", "true");
// If we're in all_up_to_date mode, hide the action buttons when hiding recent rows
if (isAllUpToDate) {
runBtn.$element.detach();
readdBtn.$element.detach();
leftButtonsDiv
.find('span[style*="margin: 0 5px"]')
.slice(0, 2)
.detach();
}
}
});
showRecentBtn.$element.attr("data-hidden", "true");
leftButtonsDiv.append(showRecentBtn.$element);
}
// Settings button (always shown in same row, aligned right)
const settingsBtn = new OO.ui.ButtonWidget({
label: "⚙ Settings",
flags: [],
});
settingsBtn.on("click", () => {
showSettingsDialog(settings, cache_saveSettings, () => {
// Reload the UI
$("#WikidataRatings_box").remove();
const updatedSettings = cache_loadSettings();
buildUIPanel(entity, updatedSettings);
});
});
buttonContainer.append(leftButtonsDiv);
buttonContainer.append(settingsBtn.$element);
box.append(buttonContainer);
// Build rows
for (const rowData of rowsData) {
buildSingleRatingRow(
entity,
rowData,
table,
updateReaddBtnVisibility,
);
}
}
}
$(".wikibase-entitytermsview-entitytermsforlanguagelistview").append(box);
}
// Get data for rows to be added in the UI
function getProviderRowsData(entity, settings) {
const groups = groupScoresByProviderMethodKey(entity);
const rowsData = [];
let hasAnyIdentifiers = false;
for (const key of Object.keys(ratingProviders)) {
const provider = ratingProviders[key];
// Check if provider is enabled in settings
if (
settings &&
settings.enabledProviders &&
!settings.enabledProviders[key]
) {
continue;
}
const idClaim = (entity.claims || {})[provider.idProp] || [];
if (idClaim.length === 0) continue;
// Find first non-deprecated statement with a valid value
let idVal = null;
for (const stmt of idClaim) {
if (stmt.rank === "deprecated") continue;
idVal = stmt.mainsnak?.datavalue?.value;
if (idVal) break;
}
if (!idVal) continue;
hasAnyIdentifiers = true;
const hasRecent = providerHasRecentRating(entity, provider);
const pkey = getProviderMethodKeyFromProvider(provider);
const groupForProvider = groups[pkey] || [];
const latestStmt = findLatestScore(groupForProvider);
rowsData.push({
key,
provider,
idVal,
latestStmt,
hasRecentRating: hasRecent,
});
}
if (!hasAnyIdentifiers) {
return { rowsData: [], reason: "no_identifiers" };
} else if (rowsData.every((r) => r.hasRecentRating)) {
return { rowsData, reason: "all_up_to_date" };
} else {
return { rowsData, reason: "has_rows" };
}
}
// ==== 07 PROCESSING ========================================================
// Helper: adjust rank for new vs existing claims
function ensureRank(stmt, desiredRank, updatedClaims) {
const isNew = stmt._isNew === true;
if (isNew) {
stmt.rank = desiredRank;
} else if (stmt.rank !== desiredRank) {
const claim = cloneClaim(stmt);
claim.rank = desiredRank;
updatedClaims.push(claim);
}
}
// Process the ratings submission
function processRatingsSubmission(entity) {
// 1. Collect and validate inputs
const visibleRows = $("#WikidataRatings_body tr")
.not(".wikidataRatings-removed")
.not(".warning-row")
.filter(function () {
// Exclude rows with recent ratings that are currently hidden
return (
!$(this).hasClass("wikidataRatings-recent") || $(this).is(":visible")
);
});
const result = collectAllScoreInputs(entity);
if (!result) {
showError("Invalid or missing inputs...");
return null;
}
const { inputs, skipped } = result;
const expected = visibleRows.length - skipped.length;
if (inputs.length !== expected) {
showWarning(
"All inputs must be filled before adding ratings. Remove rows you don't want to fill.",
);
return null;
}
// 2. Safety checks
if (hasReasonForPreferredRank(entity)) {
showWarning(
"Cannot proceed: existing preferred rank has a 'reason for preferred rank' qualifier.",
);
return null;
}
if (!isAcceptableForm(entity)) {
showWarning(
"Cannot proceed: existing ranks are inconsistent. Please fix manually.",
);
return null;
}
// 3. Build new claims
const inputClaims = inputs.map((input) =>
buildScoreClaim(
input.provider,
input.scoreValidation.formatted,
input.countValidation.formatted,
),
);
inputClaims.forEach((c) => (c.rank = "normal")); // initial rank
// 4. Group existing + new claims by providerMethodKey
const scoreGroups = groupScoresByProviderMethodKey(entity);
inputClaims.forEach((claim) => {
const key = getProviderMethodKeyFromStatement(claim);
if (!scoreGroups[key]) scoreGroups[key] = [];
scoreGroups[key].push(claim);
});
// 5. Rank assignment per group
const updatedClaims = [];
let hasMultipleScoresInAnyGroup = Object.values(scoreGroups).some(
(g) => g.length > 1,
);
for (const providerMethodKey in scoreGroups) {
const scoreGroup = scoreGroups[providerMethodKey];
if (scoreGroup.length === 1) {
if (hasMultipleScoresInAnyGroup) {
// global consistency: if one group has preferred, all groups must have preferred
const scoreStatement = scoreGroup[0];
ensureRank(scoreStatement, "preferred", updatedClaims);
} else {
// only one score overall -> keep normal
const scoreStatement = scoreGroup[0];
ensureRank(scoreStatement, "normal", updatedClaims);
}
} else {
// multiple scores in this group -> latest preferred, others normal
const latestStatement = findLatestScore(scoreGroup);
scoreGroup.forEach((scoreStatement) => {
if (scoreStatement === latestStatement) {
ensureRank(scoreStatement, "preferred", updatedClaims);
} else {
ensureRank(scoreStatement, "normal", updatedClaims);
}
});
}
}
// 6. Add all new claims themselves
updatedClaims.push(...inputClaims);
// 7. Build payload
return { claims: updatedClaims };
}
// === 08 INIT ========================================================
(function (mw) {
"use strict";
// Only run on item pages in view mode
if (
mw.config.get("wgNamespaceNumber") !== 0 ||
mw.config.get("wgAction") !== "view" ||
mw.config.get("wgArticleId") === 0
) {
return;
}
// Load required modules, then run init
mw.loader
.using(["mediawiki.util", "oojs-ui-core", "oojs-ui-widgets"])
.then(initRatingsInjector);
async function initRatingsInjector() {
mw.util.addCSS(`/* Base table styling */
.wikidataRatings-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed; /* enforce column widths strictly */
}
.wikidataRatings-table th,
.wikidataRatings-table td {
padding: 4px 8px;
border-bottom: 1px solid #ddd;
vertical-align: middle;
}
/* Column widths and alignment */
.wikidataRatings-col-name {
white-space: nowrap; /* prevent wrapping */
}
.wikidataRatings-col-score {
display: flex; /* inline input + hint */
align-items: center; /* vertical alignment */
gap: 4px; /* controlled spacing */
}
.wikidataRatings-col-link,
.wikidataRatings-col-remove {
width: 6em;
text-align: center;
}
/* Input widths */
.wikidataRatings-score {
width: 70px;
display: inline-block;
}
.wikidataRatings-count {
width: 140px;
}
/* Hint styling */
.wikidataRatings-hint {
margin-left: 6px;
font-size: 90%;
color: #666;
white-space: nowrap; /* keep /10 or % on one line */
width: 3em; /* optional fixed width */
}
/* OOUI overrides inside table cells */
.wikidataRatings-col-score .oo-ui-fieldLayout {
margin: 0;
padding: 0;
}
.wikidataRatings-col-score .oo-ui-fieldLayout-header {
display: none; /* remove extra header above input */
}
.wikidataRatings-col-score .oo-ui-fieldLayout-messages {
font-size: 85%; /* shrink error text */
white-space: nowrap; /* prevent wrapping */
overflow: hidden;
text-overflow: ellipsis; /* show "…" if too long */
max-width: 100px; /* cap width so it doesn't blow up the column */
}
.wikidataRatings-col-score .oo-ui-textInputWidget,
.wikidataRatings-col-count .oo-ui-textInputWidget {
margin: 0;
padding: 0;
display: inline-flex; /* keep input compact */
align-items: center;
}
.ratings tr.has-warning td {
border-bottom: none;
}
.warning-row td {
/*background-color: #fff3cd;*/
/*color: #856404;*/
/*font-style: italic;*/
border-top: none;
}
/* General rule */
.ratings td {
border-bottom: 1px solid #ccc;
}
/* Override when row has warning */
.ratings tr.has-warning td {
border-bottom: none !important;
}
/* Override the default border for rows with warnings */
.wikidataRatings-table tr.has-warning td {
border-bottom: none !important;
}
.inline-warning {
display: block;
white-space: nowrap; /* avoid wrapping */
font-size: 90%;
color: #666;
text-align: center;
margin-top: 4px; /* spacing below inputs */
}
/* Style for inline warnings under score+count */
.wikidataRatings-warning {
display: block;
white-space: nowrap; /* prevent wrapping */
font-size: 90%;
color: #666;
text-align: center;
margin-top: 4px; /* spacing below inputs */
}
/* Make warnings in scoreCell and countCell align horizontally */
td.scoreCell, td.countCell {
position: relative; /* allow absolute positioning inside */
}
td.scoreCell .wikidataRatings-warning {
position: absolute;
left: 0;
right: 0;
text-align: center;
grid-column: span 2; /* if you use CSS grid */
}
td.scoreCell {
position: relative;
}
td.scoreCell .wikidataRatings-warning {
position: absolute;
left: 0;
right: 200%; /* stretch into the next cell */
text-align: center;
white-space: nowrap;
}
/* Keep as table-cell, flex only inside wrapper */
.score-wrapper,
.count-wrapper {
display: flex;
align-items: center;
gap: 8px; /* spacing between input + hint */
}
.wikidataRatings-col-score,
.wikidataRatings-col-count {
vertical-align: middle; /* ensure baseline alignment */
}
.wikidataRatings-table td {
padding: 4px; /* consistent padding */
/*border: 1px solid #ccc;*/
vertical-align: middle; /* consistent baseline */
}
.wikidataRatings-col-score,
.wikidataRatings-col-count {
display: table-cell; /* keep them as table cells */
}
`);
const entity = await getEntityFromPage();
if (!entity) return;
if (!isScreenProductionEntity(entity)) return; // not a film/TV or IMDb 'tt' id, exit
if (!isPublishedInPast(entity)) return; // not yet published, exit
const settings = cache_loadSettings();
buildUIPanel(entity, settings);
}
})(mediaWiki);
//