Jump to content

User:Difool/AddReviewScores.js

From Wikidata

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 = $(`
style="margin-top:10px; border:1px solid #ccc; padding:10px;">
`); // 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); //