Module:DPLA
CodeDiscussionEditHistoryLinksLink count Subpages:DocumentationTestsResultsSandboxLive code All modules
This module provides helper functions for the {{DPLA metadata}} template family, which displays Digital Public Library of America file metadata directly from Structured Data on Commons (SDC). It is part of the broader DPLA partner-uploads pipeline; the same metadata that an automated bot (User:DPLA bot) writes to a file's MediaInfo via the sdc-sync step is what this module reads back out for display.
Each function filters the file's SDC statements on the determination-method qualifier (determination method or standard (P459) = determined by GLAM institution and stated at its website (Q61848113), "heuristic"). Statements without that qualifier are ignored — this is the marker that distinguishes statements added by the DPLA workflow from statements added by other tools or editors, and ensures we only surface metadata we own.
The module is meant to be invoked from wikitext via {{#invoke:DPLA|function}}; it does not currently expose a module-to-module API (use {{DPLA metadata}} or the individual functions from your template instead).
ExampleExample
A file page rendered via {{DPLA metadata}} will populate the standard {{Information}} block from SDC alone. To inspect a single field directly:
Creator: {{#invoke:DPLA|creator}}
On File:Baist's real estate atlas of surveys of Denver, Col. (Plate 29) - DPLA - e676ae8aea3e4a855bce073ba419bb4e.jpg the result would look like this:
- Creator: Baist, G. Wm. (George William), 1859-1927
For testing or reuse on non-file pages, every function accepts a |page= argument pointing at a specific Commons file:
{{#invoke:DPLA|creator|page=File:Foo.jpg}}
FunctionsFunctions
All functions take optional named arguments. |page= overrides the entity to read from (default: the current page's MediaInfo). |sep= (where supported) overrides the separator used to join multiple values (default: , ).
Display / prose helpersDisplay / prose helpers
hubhub
Returns the regional DPLA hub's display name, derived from the Commons media contributed by (P9126) (Wikimedia partnership) chain. Each DPLA-determined file has two publisher entries by design — DPLA itself (Digital Public Library of America (Q2944483)) and the regional hub that aggregates the contributing institution's records (e.g. Plains to Peaks Collective (Q83878503) for the Plains to Peaks Collective). This function returns the label of the non-DPLA publisher.
Returns an empty string if no qualifying hub entry is present.
Wikitext usage:
{{#invoke:DPLA|hub}}
subjectssubjects
Returns the union of entity subjects (main subject (P921)) and string subjects (DPLA subject term (P4272)), deduplicated by exact match. Entity subjects are rendered as their Wikidata labels (no link); string subjects are rendered verbatim. Order: P921 first (in entity statement order), then any P4272 strings whose verbatim form isn't already covered by a P921 label.
This addresses a gap in the previous {{DPLA metadata}}, which read only P921 — many DPLA items have only the string form because the source data's subject headings (often Library of Congress Subject Headings) don't reconcile to Wikidata QIDs.
Wikitext usage:
{{#invoke:DPLA|subjects}}
{{#invoke:DPLA|subjects|sep= — }}
creatorcreator
Returns the creator(s). Each creator (P170) statement may be:
- an entity (, rendered as the Wikidata label),
- a string (rendered verbatim — used when ingestion3 had a creator name string but no entity match), or
- a "somevalue" snak with a author name string (P2093) stated-as qualifier (used when the specific creator identity is unknown but a name string is given by the contributing institution).
Multiple statements are joined with |sep= (default , ). Returns an empty string if no creator statement is present.
Wikitext usage:
{{#invoke:DPLA|creator}}
datedate
Returns the date. Tries each DPLA-determined inception (P571) (point in time) statement in turn:
- If the mainsnak is a real time value, formats it with the precision recorded in the snak: day →
YYYY-MM-DD, month →YYYY-MM, year →YYYY. - If the mainsnak is a "somevalue" with a object named as (P1932) stated-as qualifier, returns the qualifier text. This is how DPLA encodes circa dates, date ranges, and other non-machine-readable date expressions.
Returns the first usable rendering; empty string if none.
Wikitext usage:
{{#invoke:DPLA|date}}
local_idlocal_id
Returns a wikitext bullet item with the local identifier prefixed by the appropriate label:
- For NARA items (hub = National Archives and Records Administration (Q518155)), uses U.S. National Archives Identifier (P1225) (NARA archival identifier) with the label
National Archives Identifier. - For all others, uses inventory number (P217) (collection-or-list identifier), prefixed with the institution name from collection (P195).
Returns an empty string if no identifier is present.
Wikitext usage:
{{#invoke:DPLA|local_id}}
Identity / partnership helpersIdentity / partnership helpers
hub_qidhub_qid
Same lookup as {{#invoke:DPLA|hub}}, but returns the bare Q-ID string (e.g. Q83878503) rather than its label. Suitable as input to {{Label}}, {{Institution}}, or further Wikidata data lookups (e.g. fetching the hub's logo or category property). Empty string if no qualifying publisher entry is present.
institution_qidinstitution_qid
Returns the Q-ID of the file's institution (from collection (P195), the collection property). Used to address the institution in the partnership prose and to look up the institution's category. By DPLA convention the collection (P195) institution and the Commons media contributed by (P9126) entry with role = repository (Q108296843) (aggregator) are the same Q-ID; this helper uses P195 because it matches the existing {{DPLA}} template's visible-prose convention.
dpla_iddpla_id
Returns the file's DPLA item ID (the 32-character hex string from DPLA ID (P760)). Used to construct the https://dp.la/item/... link in the partnership info-box and to gate the SDC-presence check in {{DPLA metadata}}. Empty string if absent.
source_catalog_urlsource_catalog_url
Returns the URL from the file's source of file (P7482) (source of file) statement's described at URL (P973) (described at URL) qualifier — i.e. the contributing institution's own page for this item. Used as the target of the "Record in source catalog" link. Empty string if absent.
partner_logopartner_logo
Returns the |File:=-prefixed page name for the partner logo to show in the partnership info-box. Looks up the hub's Wikidata item for logo image (P154) (logo image) first, then image (P18) (image) as a fallback. If neither is set (or no hub is present), returns File:DPLA square logo.svg.
categoriescategories
Emits the full set of DPLA partner / institution categories for this file as wikitext. Replaces the {{DPLA/hub cat}} and {{DPLA/inst cat}} subpages used by the live {{DPLA}} template, and also emits the main parent category. The returned wikitext is one or more links, newline-separated:
- Main category — always added:
Media contributed by the Digital Public Library of America. - Hub category — look up content partnership category (P8464) (Commons category) on the hub Q-ID (publisher role). Fallback
...with unknown partnerif the hub's Wikidata item has no Commons category property set. - Institution category — look up content partnership category (P8464) on the institution Q-ID (aggregator role). Fallback
...with unknown institution. - Contributing-institution categories — one per Commons media contributed by (P9126) statement with role = custodial unit (Q108296919) that has a Commons category property set. No fallback — these are optional.
Wikitext usage (typically placed at the bottom of {{DPLA metadata}}'s body, outside the divbox):
{{#invoke:DPLA|categories}}
Property referenceProperty reference
The module reads the following SDC properties. All are filtered by the determination method or standard (P459) = determined by GLAM institution and stated at its website (Q61848113) qualifier unless noted.
| Property | Description | Used by |
|---|---|---|
| creator (P170) | creator | creator
|
| collection (P195) | collection (institution) | institution_qid, local_id
|
| inventory number (P217) | collection-or-list identifier | local_id
|
| inception (P571) | point in time (date) | date
|
| DPLA ID (P760) | DPLA ID | dpla_id
|
| main subject (P921) | main subject (Wikidata entity) | subjects
|
| U.S. National Archives Identifier (P1225) | NARA archival identifier | local_id
|
| object named as (P1932) | stated as (qualifier on P571) | date
|
| author name string (P2093) | author name string (qualifier on P170) | creator
|
| object of statement has role (P3831) | object has role (qualifier on P9126) | hub, hub_qid, partner_logo, categories
|
| DPLA subject term (P4272) | subject heading | subjects
|
| source of file (P7482) | source of file | source_catalog_url
|
| described at URL (P973) | described at URL (qualifier on P7482) | source_catalog_url
|
| Commons media contributed by (P9126) | Wikimedia partnership | hub, hub_qid, partner_logo, categories
|
In addition, the module performs Wikidata lookups (not SDC) on the hub's, institution's, and contributing-institutions' Q-IDs to fetch:
- content partnership category (P8464) — Commons category, used by
categories - logo image (P154) — logo image, used by
partner_logo - image (P18) — image, used as a fallback by
partner_logo
TestingTesting
Template:DPLA metadata/testcases runs each function against a sample of real file pages and shows the outputs of the live module alongside the {{Module:DPLA/sandbox}} version side by side. When iterating on the module, edit the sandbox and reload the testcases page to compare.
(The wikitext-style side-by-side page lives in template namespace rather than {{Module:DPLA/testcases}} because Commons defaults pages under {{Module:}} to the Lua content model; if we add programmatic unit tests later, those would live at {{Module:DPLA/testcases}} using {{Module:UnitTests}}.)
See alsoSee also
- {{DPLA metadata}} — the user-facing template that invokes this module
- {{DPLA}} — partnership infobox (10M+ uses; not currently a caller of this module, but the patterns inside it overlap; this module is intended to replace its functionality within {{DPLA metadata}} so the latter no longer has to depend on the former)
- {{License from structured data}} → Module:License — the licensing equivalent: SDC-driven, used by {{DPLA metadata}}'s
|permission=field - Module:Wd — generic Wikidata accessor used elsewhere by {{DPLA metadata}}
- The DPLA partner-uploads pipeline source:
ingest_wikimedia/sdc.py(the Python that writes the SDC this module reads)
Code
--[==[
Module:DPLA — helpers for the {{DPLA metadata}} template family.
This module reads Structured Data on Commons (SDC) statements that the
Digital Public Library of America partner-uploads workflow (`sdc-sync`)
writes to file pages, and renders them as the fields shown by
{{DPLA metadata}}.
All helpers filter to DPLA-authored SDC by the reference the writer
stamps on every statement it makes: a P123 (publisher) = Q2944483
(Digital Public Library of America) snak. Statements lacking that
reference — including ones that carry only the legacy P459 = Q61848113
("heuristic") determination qualifier without DPLA provenance — are
treated as non-DPLA and rendered as user-contributed.
Chunked claims: long string and monolingualtext values that exceed
Wikibase's 1500-character limit are written as multiple statements on
the same property, each carrying a P1545 (series ordinal) qualifier in
the form "<letter><ordinal>" (e.g. "A1", "A2", "B1"). The helpers
below transparently reassemble these: chunks with the same series
letter on the same property are sorted by ordinal and concatenated
into one logical value. Statements without a P1545 qualifier are
returned as their own singleton values, preserving the order each
series or singleton first appears in.
Documentation: [[Module:DPLA/doc]]
Testcases: [[Template:DPLA metadata/testcases]]
Sandbox: [[Module:DPLA/sandbox]]
Repository: https://github.com/dpla/ingest-wikimedia
]==]
local p = {}
-- ============================================================
-- Constants
-- ============================================================
-- SDC properties (mirrors what sdc-sync writes in
-- ingest_wikimedia/sdc.py — keep these in sync).
local P = {
DETERMINATION_METHOD = 'P459',
PUBLISHER = 'P123',
REFERENCE_URL = 'P854', -- reference snak: dp.la item URL (owning item)
RETRIEVED = 'P813', -- reference snak: retrieved-on date
INSTITUTION = 'P195',
CREATOR = 'P170',
DATE = 'P571',
STATED_AS = 'P1932',
SOURCING_CIRCUMSTANCES = 'P1480',
SUBJECT_STRING = 'P4272',
SUBJECT_ENTITY = 'P921',
PARTNERSHIP = 'P9126',
ROLE = 'P3831',
SOURCE_OF_FILE = 'P7482',
DESCRIBED_AT_URL = 'P973',
LOCAL_IDENTIFIER = 'P217',
NARA_IDENTIFIER = 'P1225',
AUTHOR_NAME_STRING = 'P2093',
DPLA_ID = 'P760',
PAGE = 'P304',
SERIES_ORDINAL = 'P1545',
DESCRIPTION = 'P10358',
COMMONS_CATEGORY = 'P8464',
LOGO_IMAGE = 'P154',
IMAGE = 'P18',
TITLE = 'P1476',
OTHER_VERSIONS = 'P6802',
}
local Q = {
HEURISTIC = 'Q61848113', -- determination method = "heuristic"
DPLA = 'Q2944483', -- Digital Public Library of America
-- P3831 (object has role) values, named for their true Wikidata roles.
-- A service hub's hub is an aggregator (like DPLA); its institution is
-- a repository. A content hub (NARA, Smithsonian) is itself a
-- repository; its contributing department is a custodial unit.
ROLE_AGGREGATOR = 'Q393351', -- object-has-role = "aggregator"
ROLE_REPOSITORY = 'Q108296843',-- object-has-role = "repository"
ROLE_CONTRIBUTING = 'Q108296919',-- object-has-role = "custodial unit"
NARA = 'Q518155', -- National Archives and Records Administration
CIRCA = 'Q5727902', -- sourcing circumstances = "circa"
}
-- The hub-label string the existing {{DPLA}} template branches on for
-- the NARA-identifier line. Must match the en label of Q518155.
local NARA_HUB_LABEL = 'National Archives and Records Administration'
local DEFAULT_PARTNER_LOGO = 'File:DPLA square logo.svg'
-- Maintenance categories for unresolvable partners / institutions —
-- preserve the exact names the existing {{DPLA/hub cat}} and
-- {{DPLA/inst cat}} templates use, so maintenance queries continue to
-- find files via these category names.
local UNKNOWN_PARTNER_CAT = 'Media contributed by the Digital Public Library of America with unknown partner'
local UNKNOWN_INSTITUTION_CAT = 'Media contributed by the Digital Public Library of America with unknown institution'
local MAIN_CAT = 'Media contributed by the Digital Public Library of America'
-- Tracking category for files where the rendered metadata table
-- includes at least one user-contributed (non-DPLA-attributed)
-- value — i.e. anything that surfaces in the yellow box. Emitted
-- under the same condition as the yellow box itself so the
-- category membership tracks "files with non-DPLA metadata
-- enhancements" exactly.
local ENHANCED_CAT = 'Media from the Digital Public Library of America with metadata enhancements'
-- Tracking category for a file that the SHA1-uniqueness redesign
-- centralized: it carries metadata from MORE THAN ONE DPLA item (a
-- cross-item duplicate → an extra blue box renders) OR occupies MORE
-- THAN ONE page position within a single item (a within-item duplicate →
-- a P760 with several P304 page qualifiers → the multi-line "Other pages"
-- navigation). Emitted whenever either condition holds so these merged
-- files can be found and audited.
local MULTI_CAT = 'DPLA multi-item or multi-page files'
-- Required-SDC properties tracked by the "DPLA files missing
-- required SDC statements" maintenance category. Mirrors the
-- ``{{#invoke:SDC_tracking|SDC_statement_exist}}`` calls in the
-- legacy ``{{DPLA}}`` template: any file whose MediaInfo entity
-- lacks ANY of these properties lands in the catch-all maintenance
-- category so operators can backfill from it. ``P170`` (creator)
-- is broken out into its own ``files missing creator`` category
-- by ``render_metadata_table`` because its backlog is triaged
-- separately from the other missing properties.
local REQUIRED_SDC_PROPS = {
'P760', -- DPLA ID
'P1476', -- title
'P6216', -- copyright status
'P195', -- collection
'P9126', -- partnership (DPLA + hub + institution P3831-qualifier)
'P7482', -- source of file (catalog URL etc.)
}
-- Commons-compatible DPLA-attributed rights values for P275 (copyright
-- license) and P6426 (copyright status as a creator). Derived from
-- ``rights.json`` by URI-substring classification: free Creative Commons
-- (``/licenses/by/*`` and ``/licenses/by-sa/*``) + ``/publicdomain/zero/*``
-- + ``/publicdomain/mark/*`` are eligible; RightsStatements.org NoC-* and
-- NKC-* are eligible; everything else (CC NC and/or ND restrictions,
-- ``InC*``/``UND``/``CNE`` rights statements) is treated as ineligible.
-- Anything not in this set — including Q-IDs we don't recognise — is
-- treated as potentially incompatible and the file is surfaced via the
-- ``files with potential copyright issues`` tracking category for human
-- review. A 151-element allowlist is smaller and stabler than
-- the equivalent denylist and fails safe on unknown values.
local ELIGIBLE_RIGHTS = {
['Q6938433'] = true,
['Q7257361'] = true,
['Q14946043'] = true,
['Q14947546'] = true,
['Q15914252'] = true,
['Q18195572'] = true,
['Q18199165'] = true,
['Q18199175'] = true,
['Q18810143'] = true,
['Q18810333'] = true,
['Q18810341'] = true,
['Q19068220'] = true,
['Q19113751'] = true,
['Q19125117'] = true,
['Q20007257'] = true,
['Q24331618'] = true,
['Q26116436'] = true,
['Q26259495'] = true,
['Q27940776'] = true,
['Q30942811'] = true,
['Q42716613'] = true,
['Q44282633'] = true,
['Q44282641'] = true,
['Q47001652'] = true,
['Q47530911'] = true,
['Q47530955'] = true,
['Q52555753'] = true,
['Q53859967'] = true,
['Q56292840'] = true,
['Q62619894'] = true,
['Q63241773'] = true,
['Q63340742'] = true,
['Q67918154'] = true,
['Q75209430'] = true,
['Q75434631'] = true,
['Q75443434'] = true,
['Q75445499'] = true,
['Q75446609'] = true,
['Q75446635'] = true,
['Q75450165'] = true,
['Q75452310'] = true,
['Q75457467'] = true,
['Q75457506'] = true,
['Q75460106'] = true,
['Q75460149'] = true,
['Q75466259'] = true,
['Q75470365'] = true,
['Q75470422'] = true,
['Q75474094'] = true,
['Q75475677'] = true,
['Q75476747'] = true,
['Q75477775'] = true,
['Q75486069'] = true,
['Q75487055'] = true,
['Q75488238'] = true,
['Q75491630'] = true,
['Q75494411'] = true,
['Q75500112'] = true,
['Q75501683'] = true,
['Q75504835'] = true,
['Q75506669'] = true,
['Q75663969'] = true,
['Q75665696'] = true,
['Q75705948'] = true,
['Q75706881'] = true,
['Q75759387'] = true,
['Q75759731'] = true,
['Q75760479'] = true,
['Q75761383'] = true,
['Q75761779'] = true,
['Q75762418'] = true,
['Q75762784'] = true,
['Q75763101'] = true,
['Q75764151'] = true,
['Q75764470'] = true,
['Q75764895'] = true,
['Q75765287'] = true,
['Q75766316'] = true,
['Q75767185'] = true,
['Q75767606'] = true,
['Q75768706'] = true,
['Q75770766'] = true,
['Q75771320'] = true,
['Q75771874'] = true,
['Q75775133'] = true,
['Q75775714'] = true,
['Q75776014'] = true,
['Q75776487'] = true,
['Q75777688'] = true,
['Q75778801'] = true,
['Q75779562'] = true,
['Q75779905'] = true,
['Q75789929'] = true,
['Q75850366'] = true,
['Q75850813'] = true,
['Q75850832'] = true,
['Q75851799'] = true,
['Q75852313'] = true,
['Q75852938'] = true,
['Q75853187'] = true,
['Q75853514'] = true,
['Q75853549'] = true,
['Q75854323'] = true,
['Q75856699'] = true,
['Q75857518'] = true,
['Q75858169'] = true,
['Q75859019'] = true,
['Q75859751'] = true,
['Q75866892'] = true,
['Q75882470'] = true,
['Q75889409'] = true,
['Q75894644'] = true,
['Q75894680'] = true,
['Q76631753'] = true,
['Q76767348'] = true,
['Q76769447'] = true,
['Q77014037'] = true,
['Q77021108'] = true,
['Q77131257'] = true,
['Q77132386'] = true,
['Q77133402'] = true,
['Q77135172'] = true,
['Q77136299'] = true,
['Q77143083'] = true,
['Q77352646'] = true,
['Q77355872'] = true,
['Q77361415'] = true,
['Q77362254'] = true,
['Q77363039'] = true,
['Q77363856'] = true,
['Q77364488'] = true,
['Q77364872'] = true,
['Q77365183'] = true,
['Q77365530'] = true,
['Q77366066'] = true,
['Q77366576'] = true,
['Q77367349'] = true,
['Q80837139'] = true,
['Q80837607'] = true,
['Q86239208'] = true,
['Q86239559'] = true,
['Q86239991'] = true,
['Q86240326'] = true,
['Q86240624'] = true,
['Q86241082'] = true,
['Q98755364'] = true,
['Q98960995'] = true,
['Q99438747'] = true,
}
-- ============================================================
-- Localization (label + banner text)
-- ============================================================
--
-- Hybrid strategy:
--
-- 1. Field labels that semantically match existing Commons file-info
-- messages reuse them via ``mw.message`` — those already carry
-- ~150-language coverage via translatewiki.net.
-- 2. DPLA-specific labels (Creator with archival semantics, Institution,
-- Subject, DPLA ID, Other pages, Partnership) plus the two banner
-- strings and a few inline phrases (Catalog record, File URL, IIIF
-- manifest, circa, "Page N") come from [[Module:DPLA/i18n]] — a Lua
-- data table editable by any Commons editor without admin rights.
--
-- Translators: see the "Localization" section of [[Template:DPLA metadata/doc]]
-- for step-by-step instructions on adding your language to Module:DPLA/i18n.
--
-- Per-call cache for the viewer's language. ``mw.getCurrentFrame():preprocess
-- ('{{int:lang}}')`` is moderately expensive and the same value applies to
-- every label rendered on one file page; cache it module-wide for the
-- duration of one #invoke.
local _viewerLang
local function getViewerLang()
if _viewerLang then return _viewerLang end
local ok, lang = pcall(function()
return mw.getCurrentFrame():preprocess('{{int:lang}}')
end)
_viewerLang = (ok and lang and lang ~= '') and lang or 'en'
return _viewerLang
end
-- Map our internal label names to existing MediaWiki messages that already
-- carry good translation coverage and semantically match. Creator
-- deliberately is NOT mapped to ``wm-license-information-author`` —
-- archival "creator" (per the Society of American Archivists definition)
-- can be the entity responsible for the records' aggregation, not the
-- individual author of any item in them. We register our own translation.
local REUSED_MESSAGES = {
title = { key = 'wm-license-artwork-title', en = 'Title' },
description = { key = 'wm-license-information-description',en = 'Description' },
date = { key = 'wm-license-information-date', en = 'Date' },
source = { key = 'wm-license-information-source', en = 'Source' },
permission = { key = 'wm-license-information-permission', en = 'Permission' },
}
-- Wikidata items whose labels can serve as a fallback when neither the
-- viewer's language nor its MediaWiki fallback chain is present in
-- Module:DPLA/i18n. Wikidata's community-maintained labels often cover
-- languages the i18n module hasn't reached yet, especially for common
-- single-noun concepts (circa, creator, subject).
--
-- Listed only for keys where the Wikidata item is a precise semantic
-- match in most languages. Skipped where the Wikidata label drifts
-- (Q178706 "institution" → "social institution" in fr/nl; Q728646
-- "partnership" → "Personengesellschaft" legal-business sense in de;
-- Q1069725 "page" → just "page" with no slot for the page number).
-- Those keys are translator-supplied via Module:DPLA/i18n only.
--
-- ``mw.wikibase.getLabelByLang`` accepts P-IDs and Q-IDs interchangeably,
-- so a property label (P760) works as a fallback the same way an item
-- label does.
local FALLBACK_WIKIDATA = {
creator = 'Q59275219', -- archival creator (matches SAA definition)
subject = 'Q12310021',
catalog_record = 'Q59211006',
circa = 'Q5727902',
iiif_manifest = 'Q22682088', -- International Image Interoperability Framework
dpla_id = 'P760', -- Wikidata property: "DPLA ID"
}
-- English baselines for i18n keys this module introduces that the on-wiki
-- ``Module:DPLA/i18n`` subpage does not yet carry. ``localizedLabel``
-- consults this AFTER the subpage's own ``en`` row (step 4 of its fallback
-- chain), so a freshly-deployed module renders sensible English on the
-- sandbox before translators mirror the keys onto the subpage. Values use
-- the same ``$1`` positional-placeholder convention as the subpage entries
-- and are expanded by ``substituteParams`` at the call site (like
-- ``page_n``). Keep in sync with [[Module:DPLA/i18n]].
local I18N_EN_BASELINE = {
-- Blue-box banner for one contributing DPLA item on a cross-item
-- duplicate (a Commons file carrying statements from several DPLA
-- items). Mirrors the ``banner_dpla`` voice verbatim and appends a
-- per-item scope clause so each box names the item its metadata came
-- from. ``$1`` is the ``dpla:`` interwiki link to the item.
-- Double-quoted to carry the ``institution's`` apostrophe, matching the
-- on-wiki ``banner_dpla`` string.
banner_dpla_item = "This file was uploaded by the [[w:Digital Public Library of America|Digital Public Library of America]], and the following item metadata was created by the contributing institution's staff for DPLA item $1.",
-- Per-line prefix for the multi-line "Other pages" navigation of an
-- in-item multi-page file. ``$1`` is the page number this line
-- navigates from.
page_context = 'As page $1:',
}
-- Lazy-load the Lua i18n data. mw.loadData caches across #invoke calls
-- within a parse and is the right tool for translation tables that are
-- read every render.
local function getI18nData()
local ok, data = pcall(mw.loadData, 'Module:DPLA/i18n')
if ok and type(data) == 'table' then return data end
return { en = {} }
end
-- Resolve a localized string for the given internal name. Order of
-- precedence (each step skipped silently when nothing matches):
--
-- 1. Reused MediaWiki message in the viewer's language. Covers the field
-- labels that already have community translations on translatewiki
-- via the ``wm-license-*`` message family (title, description, date,
-- source, permission).
-- 2. ``Module:DPLA/i18n`` entry for the viewer's language plus any non-
-- English entries in the MediaWiki fallback chain. Lets translators
-- override default wording without admin help.
-- 3. Wikidata label for the QID listed in ``FALLBACK_WIKIDATA``, walking
-- the same non-English fallback chain. Community-maintained labels
-- catch languages that haven't been added to Module:DPLA/i18n yet.
-- 4. English baselines (Lua i18n.en → REUSED_MESSAGES[name].en → key
-- name verbatim). Always fires as the last resort.
--
-- English is deliberately held to the end so the Wikidata fallback can
-- intervene for non-English viewers whose language is in Wikidata but not
-- yet in Module:DPLA/i18n.
local function localizedLabel(name)
local lang = getViewerLang()
-- 1. Reused MediaWiki message.
local reused = REUSED_MESSAGES[name]
if reused then
local ok, msg = pcall(function()
return mw.message.new(reused.key):inLanguage(lang):plain()
end)
if ok and msg and msg ~= '' then
-- ⧼key⧽ is MediaWiki's "message undefined" sentinel (UTF-8 bytes
-- ``\226\167\188`` and ``\226\167\189`` for the open / close
-- angle-brackets). Treat as fall-through.
local opens = msg:sub(1, 3) == '\226\167\188'
local closes = msg:sub(-3) == '\226\167\189'
if not (opens and closes) then
return msg
end
end
end
-- Build the non-English fallback chain. Holding English back lets the
-- Wikidata step (3) intervene for non-English viewers before we land
-- on the English baseline (4).
local nonEnChain = {lang}
for _, c in ipairs(mw.language.getFallbacksFor(lang) or {}) do
if c ~= 'en' then table.insert(nonEnChain, c) end
end
-- 2. Module:DPLA/i18n in the non-English chain.
local data = getI18nData()
for _, c in ipairs(nonEnChain) do
local row = data[c]
if row and row[name] then return row[name] end
end
-- 3. Wikidata label fallback (single-noun concepts only).
local qid = FALLBACK_WIKIDATA[name]
if qid then
for _, c in ipairs(nonEnChain) do
local ok, label = pcall(mw.wikibase.getLabelByLang, qid, c)
if ok and label and label ~= '' then
return label
end
end
end
-- 4. English baselines.
local en = data.en
if en and en[name] then return en[name] end
if I18N_EN_BASELINE[name] then return I18N_EN_BASELINE[name] end
if reused then return reused.en end
return name
end
-- Substitute $1, $2, ... in a translation template with positional args.
-- Replacement is non-recursive (a value containing "$1" doesn't trigger
-- further substitution) and treats the values as plain text — wikitext in
-- the arguments is left alone for the parser to handle downstream.
local function substituteParams(template, ...)
local args = {...}
return (template:gsub('%$(%d+)', function(n)
return args[tonumber(n)] or ('$' .. n)
end))
end
-- ============================================================
-- Entity loading
-- ============================================================
-- Resolve which entity to read from. By default this is the MediaInfo of
-- the current page (the file we're rendering on); pass page=File:Foo.jpg
-- to override — primarily used by /testcases.
local function getEntity(args)
if args.page and args.page ~= '' then
local title = mw.title.new(args.page)
if not title or not title.id or title.id == 0 then return nil end
return mw.wikibase.getEntity('M' .. tostring(title.id))
end
return mw.wikibase.getEntity()
end
-- ============================================================
-- Statement filtering and snak rendering
-- ============================================================
-- True iff `stmt` carries a DPLA-authored reference: a reference with a
-- P123 (publisher) = Q2944483 (DPLA) snak. This is the authoritative
-- provenance marker — sdc-sync stamps it on every statement it writes,
-- and it is the same marker the Python writer's `_is_dpla_reference`
-- keys on. We deliberately do NOT accept the P459 determination-method
-- qualifier on its own: a statement can carry that qualifier without
-- DPLA provenance (stale writes from an older sdc-sync that did not add
-- references, or foreign edits), and treating those as authoritative
-- would leak non-DPLA data into the DPLA-rendered fields. Anything left
-- out by this test surfaces as user-contributed — the intended signal
-- that the file is unsynced or carries non-DPLA SDC and wants a re-sync.
local function isDplaDetermined(stmt)
for _, ref in ipairs(stmt.references or {}) do
for _, snak in ipairs((ref.snaks or {})[P.PUBLISHER] or {}) do
if snak.snaktype == 'value'
and snak.datavalue
and snak.datavalue.value
and snak.datavalue.value.id == Q.DPLA then
return true
end
end
end
return false
end
-- All DPLA-determined statements for one property, in entity-order.
local function dplaStatements(entity, propertyId)
local out = {}
if not entity or not entity.statements then return out end
for _, stmt in ipairs(entity.statements[propertyId] or {}) do
if isDplaDetermined(stmt) then
table.insert(out, stmt)
end
end
return out
end
-- All non-DPLA-determined statements for one property — used to render
-- values that any other editor has added to the file's MediaInfo entity
-- (treated as user-contributed metadata, displayed in the yellow box).
local function nonDplaStatements(entity, propertyId)
local out = {}
if not entity or not entity.statements then return out end
for _, stmt in ipairs(entity.statements[propertyId] or {}) do
if not isDplaDetermined(stmt) then
table.insert(out, stmt)
end
end
return out
end
-- Filter predicate inverse of isDplaDetermined, for passing as the
-- ``filterFn`` argument to reassembleChunkedValues when extracting the
-- non-DPLA (user-contributed) values.
local function isNonDplaDetermined(stmt)
return not isDplaDetermined(stmt)
end
-- The DPLA item id a DPLA-determined statement belongs to. Every DPLA
-- statement carries a reference {P123=DPLA, P854=https://dp.la/item/<id>,
-- P813=retrieved}; the owning id is parsed out of that P854 URL. One Commons
-- file can carry statements from several DPLA items (a cross-item duplicate:
-- distinct source items with byte-identical media merged onto one file), and
-- P854 is the ONLY per-statement link to the owning item — the P760 mainsnak
-- gives the id for the P760 statement alone. Returns the id string, or nil for
-- a legacy pre-redesign reference that carries P123 but no P854.
local function owningDplaId(stmt)
for _, ref in ipairs(stmt.references or {}) do
local isDpla = false
for _, snak in ipairs((ref.snaks or {})[P.PUBLISHER] or {}) do
if snak.snaktype == 'value'
and snak.datavalue
and snak.datavalue.value
and snak.datavalue.value.id == Q.DPLA then
isDpla = true
break
end
end
if isDpla then
for _, snak in ipairs((ref.snaks or {})[P.REFERENCE_URL] or {}) do
if snak.snaktype == 'value'
and snak.datavalue
and type(snak.datavalue.value) == 'string' then
local id = snak.datavalue.value:match('^https://dp%.la/item/(.+)$')
if id then return id end
end
end
return nil -- DPLA ref present but no parseable P854 (legacy)
end
end
return nil
end
-- Distinct DPLA item ids on a file, in first-appearance order, read from the
-- DPLA-determined P760 (DPLA ID) statements — one per contributing item. More
-- than one => a cross-item duplicate (render a separate blue box per id).
-- Falls back to the P760 mainsnak value when the reference has no P854
-- (legacy), so old single-item files still yield exactly one id.
local function dplaItemIds(entity)
local ids, seen = {}, {}
if not entity or not entity.statements then return ids end
for _, stmt in ipairs(entity.statements[P.DPLA_ID] or {}) do
if isDplaDetermined(stmt) then
local id = owningDplaId(stmt)
if not id and stmt.mainsnak and stmt.mainsnak.snaktype == 'value'
and stmt.mainsnak.datavalue then
id = stmt.mainsnak.datavalue.value
end
if id and not seen[id] then
seen[id] = true
table.insert(ids, id)
end
end
end
return ids
end
-- Predicate factory: DPLA-determined AND owned by ``itemId``. Drops into the
-- ``filterFn`` slot of reassembleChunkedValues (chunked title/description/
-- subject) so each per-item box reassembles only its own item's chunks — this
-- also prevents P1545 series-letter collisions between items (two items both
-- using series letter 'A' would otherwise be concatenated into one value).
local function isDplaDeterminedForItem(itemId)
return function(stmt)
return isDplaDetermined(stmt) and owningDplaId(stmt) == itemId
end
end
-- Statement-selector factory scoped to one item. Drops into the
-- ``statementsFn`` slot of creatorText/dateText/institutionQidOf/subjectsText.
local function dplaStatementsForItem(itemId)
return function(entity, propertyId)
local out = {}
if not entity or not entity.statements then return out end
for _, stmt in ipairs(entity.statements[propertyId] or {}) do
if isDplaDetermined(stmt) and owningDplaId(stmt) == itemId then
table.insert(out, stmt)
end
end
return out
end
end
-- Generic statement-selector factory from a per-statement predicate.
-- ``buildDplaRows`` receives one ``filterFn`` (the chunk-path predicate)
-- and derives the ``statementsFn`` the list-path helpers want from it, so a
-- single scoping decision drives both paths. ``statementsMatching
-- (isDplaDetermined)`` is equivalent to ``dplaStatements`` and
-- ``statementsMatching(isDplaDeterminedForItem(id))`` is equivalent to
-- ``dplaStatementsForItem(id)`` — same statements, in entity order.
local function statementsMatching(filterFn)
return function(entity, propertyId)
local out = {}
if not entity or not entity.statements then return out end
for _, stmt in ipairs(entity.statements[propertyId] or {}) do
if filterFn(stmt) then
table.insert(out, stmt)
end
end
return out
end
end
-- All values for a qualifier on a statement; skips somevalue/novalue.
local function qualifierValues(stmt, qualifierId)
local out = {}
if not stmt.qualifiers or not stmt.qualifiers[qualifierId] then return out end
for _, q in ipairs(stmt.qualifiers[qualifierId]) do
if q.snaktype == 'value' and q.datavalue then
table.insert(out, q.datavalue.value)
end
end
return out
end
local function firstQualifierValue(stmt, qualifierId)
local vals = qualifierValues(stmt, qualifierId)
return vals[1]
end
-- Render a string-typed snak. Returns nil if the snak is not a string
-- value (e.g. somevalue, novalue, or a different datatype).
local function renderStringSnak(stmt)
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'string' then
return ms.datavalue.value
end
return nil
end
-- Render an {{Unknown|<role>}} credit for a creator (P170) somevalue
-- statement that carries a P3831 (object of statement has role) qualifier
-- but no P2093 (author name string) — e.g. a community "unknown photographer"
-- migrated as P170 somevalue + P3831 = Q33231 (photographer). The English
-- role label is passed to {{Unknown}}, which lower-cases and localises it
-- ("Unknown photographer"). Returns nil when there is no role qualifier (the
-- caller then renders nothing, preserving prior behaviour); a nil frame
-- (internal callers without template access) yields a plain string.
local function unknownRoleText(stmt, frame)
local role = firstQualifierValue(stmt, P.ROLE)
if type(role) ~= 'table' or not role.id then return nil end
local label = mw.wikibase.getLabelByLang(role.id, 'en')
if not label or label == '' then return nil end
if frame then
return frame:expandTemplate{ title = 'Unknown', args = {label} }
end
return 'Unknown ' .. label
end
-- Render an entity-typed snak to its plain label (no link). Returns
-- nil for non-entity values; falls back to the bare Q-ID if the label
-- is missing in the current content language.
local function renderEntitySnak(stmt)
local ms = stmt.mainsnak
if ms.snaktype ~= 'value' or not ms.datavalue then return nil end
if ms.datavalue.type ~= 'wikibase-entityid' then return nil end
local id = ms.datavalue.value.id
return mw.wikibase.getLabel(id) or id, id
end
-- ============================================================
-- Chunked-claim reassembly
-- ============================================================
-- Parse a P1545 series-ordinal value (e.g. "A1", "B12", "AA3") into
-- (letter, ordinal_number). Returns nil for malformed or non-string
-- values, in which case the caller treats the statement as a singleton.
local function parseSeriesOrdinal(value)
if type(value) ~= 'string' then return nil end
local letter, ord = value:match('^([A-Z]+)(%d+)$')
if letter and ord then
return letter, tonumber(ord)
end
return nil
end
-- Reassemble chunked claims for one property into a list of logical
-- values. ``getText`` extracts the text content of one statement's
-- mainsnak (e.g. `function(stmt) return stmt.mainsnak.datavalue.value.text end`
-- for monolingualtext, or `function(stmt) return stmt.mainsnak.datavalue.value end`
-- for string). Returns nil text → statement is skipped.
--
-- Statements with a P1545 qualifier are grouped by series letter and
-- concatenated in ordinal order — that's one logical value per series.
-- Statements without P1545 are kept as singleton values. Output order
-- follows entity statement order: each series or singleton appears at
-- the position of its first statement in the entity.
--
-- Concatenation is direct: sdc-sync chunks values at non-whitespace
-- boundaries (so neither side of a chunk join carries leading or
-- trailing whitespace), and pre-normalizes the source value to match
-- Wikibase's server-side normalization before chunking. Reassembled
-- output is bytewise identical to the canonical form sdc-sync stored.
local function reassembleChunkedValues(entity, propertyId, getText, filterFn)
filterFn = filterFn or isDplaDetermined
if not entity or not entity.statements then return {} end
local statements = entity.statements[propertyId] or {}
local seriesData = {} -- letter -> { ordinal_number -> text }
local seenSeries = {} -- letter -> true (for first-seen ordering)
local order = {} -- list of {kind='singleton'|'series', key=text|letter}
for _, stmt in ipairs(statements) do
if filterFn(stmt) then
local text = getText(stmt)
if text then
local letter, ordinal = parseSeriesOrdinal(
firstQualifierValue(stmt, P.SERIES_ORDINAL)
)
if letter then
seriesData[letter] = seriesData[letter] or {}
seriesData[letter][ordinal] = text
if not seenSeries[letter] then
seenSeries[letter] = true
table.insert(order, {kind = 'series', key = letter})
end
else
table.insert(order, {kind = 'singleton', key = text})
end
end
end
end
local out = {}
for _, entry in ipairs(order) do
if entry.kind == 'singleton' then
table.insert(out, entry.key)
else
local data = seriesData[entry.key]
local ordinals = {}
for ord in pairs(data) do table.insert(ordinals, ord) end
table.sort(ordinals)
local parts = {}
for _, ord in ipairs(ordinals) do
table.insert(parts, data[ord])
end
table.insert(out, table.concat(parts))
end
end
return out
end
-- Text extractors for the two chunkable datatypes:
local function monolingualtextOf(stmt)
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'monolingualtext' then
return ms.datavalue.value.text
end
return nil
end
local function stringOf(stmt)
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'string' then
return ms.datavalue.value
end
return nil
end
-- ============================================================
-- Partnership helpers (P9126 role lookups)
-- ============================================================
-- Find the (first) non-DPLA partner Q-ID with a particular P3831 role.
-- Returns the Q-ID string, or nil if no qualifying statement is present.
local function partnerByRole(entity, roleQid)
if not entity then return nil end
for _, stmt in ipairs(dplaStatements(entity, P.PARTNERSHIP)) do
local matchesRole = false
for _, r in ipairs(qualifierValues(stmt, P.ROLE)) do
if r.id == roleQid then
matchesRole = true
break
end
end
if matchesRole then
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid'
and ms.datavalue.value.id ~= Q.DPLA then
return ms.datavalue.value.id
end
end
end
return nil
end
-- Catalog-record link as a NARA interwiki when applicable, else nil.
-- Commons has a ``nara:`` interwiki that resolves to
-- https://catalog.archives.gov/id/<NAID>; using it produces an
-- internal-styled link instead of the external-URL chevron, but only
-- when the file's contributing institution is NARA — other partners'
-- catalog URLs route to partner-specific systems and stay as plain
-- external URLs. The "hub partnership" Q-ID is now always DPLA itself
-- (it's filtered out by partnerByRole), so we gate on P195 instead.
local function naraCatalogInterwiki(entity, statementsFn, filterFn)
statementsFn = statementsFn or dplaStatements
filterFn = filterFn or isDplaDetermined
if not entity or not entity.statements then return nil end
local instQid
for _, stmt in ipairs(statementsFn(entity, P.INSTITUTION)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid' then
instQid = ms.datavalue.value.id
break
end
end
if instQid ~= Q.NARA then return nil end
local naids = reassembleChunkedValues(entity, P.NARA_IDENTIFIER, stringOf, filterFn)
local naid = naids[1]
if not naid or naid == '' then return nil end
return '[[nara:' .. naid .. '|' .. localizedLabel('catalog_record') .. ']]'
end
-- All non-DPLA partner Q-IDs with a particular P3831 role (some roles
-- can repeat across multiple statements, e.g. several contributing
-- institutions for a joint donation).
local function allPartnersByRole(entity, roleQid)
local out = {}
if not entity then return out end
for _, stmt in ipairs(dplaStatements(entity, P.PARTNERSHIP)) do
local matchesRole = false
for _, r in ipairs(qualifierValues(stmt, P.ROLE)) do
if r.id == roleQid then
matchesRole = true
break
end
end
if matchesRole then
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid'
and ms.datavalue.value.id ~= Q.DPLA then
table.insert(out, ms.datavalue.value.id)
end
end
end
return out
end
-- Look up a partner Q-ID's Commons category property (P8464). Returns
-- the category name (without "Category:" prefix), or nil if absent.
--
-- P8464's datatype is wikibase-item: the mainsnak's value is a Q-ID
-- pointing to a Wikidata item that *represents* the Commons category.
-- That item's commonswiki sitelink holds the actual category page
-- title (e.g. "Category:Media contributed by Denver Public Library").
-- We strip the "Category:" prefix so the caller can rebuild the link
-- consistently with the rest of the categories block.
--
-- A small number of older Wikidata items still have P8464 typed as a
-- bare string. We tolerate that legacy shape too.
local function commonsCategoryFor(qid)
if not qid or qid == '' then return nil end
local partnerEntity = mw.wikibase.getEntity(qid)
if not partnerEntity then return nil end
local statements = partnerEntity:getBestStatements(P.COMMONS_CATEGORY)
if not statements then return nil end
for _, stmt in ipairs(statements) do
local ms = stmt.mainsnak
if ms.snaktype == 'value' and ms.datavalue then
if ms.datavalue.type == 'wikibase-entityid' then
local catItemQid = ms.datavalue.value.id
local sitelink = mw.wikibase.getSitelink(catItemQid, 'commonswiki')
if sitelink then
-- Parenthesize to drop gsub's count return value.
return (sitelink:gsub('^Category:', ''))
end
elseif ms.datavalue.type == 'string' then
return ms.datavalue.value
end
end
end
return nil
end
-- Look up an image-typed property (P154 / P18) on a partner Q-ID and
-- return its File: page name (with the "File:" prefix), or nil.
local function imageFileFor(qid, propertyId)
if not qid or qid == '' then return nil end
local partnerEntity = mw.wikibase.getEntity(qid)
if not partnerEntity then return nil end
local statements = partnerEntity:getBestStatements(propertyId)
if not statements then return nil end
for _, stmt in ipairs(statements) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'string' then
return 'File:' .. ms.datavalue.value
end
end
return nil
end
-- ============================================================
-- Public functions
-- ============================================================
--- p.hub — derive the regional DPLA hub's display name.
function p.hub(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
local qid = partnerByRole(entity, Q.ROLE_AGGREGATOR)
if not qid then return '' end
return mw.wikibase.getLabel(qid) or qid
end
--- p.hub_qid — the bare Q-ID of the hub (no label resolution).
function p.hub_qid(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
return partnerByRole(entity, Q.ROLE_AGGREGATOR) or ''
end
--- p.institution_qid — the Q-ID of the file's institution (P195).
function p.institution_qid(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if not entity then return '' end
for _, stmt in ipairs(dplaStatements(entity, P.INSTITUTION)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid' then
return ms.datavalue.value.id
end
end
return ''
end
--- p.dpla_id — the file's DPLA item ID (P760 string).
-- The DPLA ID is 32 chars so it's never chunked in practice, but we
-- route through the chunking-aware reassembler for uniformity.
function p.dpla_id(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
local values = reassembleChunkedValues(entity, P.DPLA_ID, stringOf)
return values[1] or ''
end
--- p.source_catalog_url — URL to the item record on the source catalog.
function p.source_catalog_url(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if not entity then return '' end
for _, stmt in ipairs(dplaStatements(entity, P.SOURCE_OF_FILE)) do
local url = firstQualifierValue(stmt, P.DESCRIBED_AT_URL)
if type(url) == 'string' and url ~= '' then
return url
end
end
return ''
end
-- The DPLA-authored P7482 statement carries three URL qualifiers that
-- together describe where this file's source bits live. ``sourceUrls``
-- pulls the first DPLA-authored P7482 and returns each URL (any may be
-- nil):
-- * fileUrl — P2699 (URL) qualifier, per-ordinal direct download
-- URL materialized by sdc-sync from file-list.txt
-- * iiifUrl — P6108 (IIIF manifest URL) qualifier, present only
-- when the source DPLA item ships an iiifManifest
-- * catalogUrl — P973 (described at URL) qualifier, the partner
-- catalog page for this DPLA item
local function sourceUrls(entity, statementsFn)
statementsFn = statementsFn or dplaStatements
if not entity then return nil, nil, nil end
for _, stmt in ipairs(statementsFn(entity, P.SOURCE_OF_FILE)) do
local function strQual(qid)
local v = firstQualifierValue(stmt, qid)
if type(v) == 'string' and v ~= '' then return v end
return nil
end
return strQual('P2699'), strQual('P6108'), strQual(P.DESCRIBED_AT_URL)
end
return nil, nil, nil
end
--- p.source_field — render the Information template's Source row as
-- icon-prefixed links to the file URL, IIIF manifest, and catalog
-- record. Items missing from the SDC are skipped silently; the row
-- shows however many of the three are present, joined with " | ".
-- Returns empty when none are present so the Information template's
-- Source row is hidden entirely.
--
-- Icon images (rendered at 24px wide inline):
-- * File URL : Codex icon download color-progressive.svg
-- * IIIF : International Image Interoperability Framework logo.png
-- * Catalog rec.: Breezeicons-actions-22-database-index.svg
function p.source_field(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
local fileUrl, iiifUrl, catalogUrl = sourceUrls(entity)
local cells = {}
if fileUrl then
table.insert(cells,
'[[File:Codex icon download color-progressive.svg|24px|alt=|class=skin-invert|link=]] [' ..
fileUrl .. ' ' .. localizedLabel('file_url') .. ']'
)
end
if iiifUrl then
table.insert(cells,
'[[File:International Image Interoperability Framework logo.png|24px|alt=|link=]] [' ..
iiifUrl .. ' ' .. localizedLabel('iiif_manifest') .. ']'
)
end
if catalogUrl then
local naraLink = naraCatalogInterwiki(entity)
if naraLink then
table.insert(cells,
'[[File:Breezeicons-actions-22-database-index.svg|24px|alt=|class=skin-invert|link=]] ' ..
naraLink
)
else
table.insert(cells,
'[[File:Breezeicons-actions-22-database-index.svg|24px|alt=|class=skin-invert|link=]] [' ..
catalogUrl .. ' ' .. localizedLabel('catalog_record') .. ']'
)
end
end
local parts = {}
for i, cell in ipairs(cells) do
local style = 'display:inline-block; padding: 4px 16px; vertical-align: middle;'
if i > 1 then
style = style .. ' border-left: 2px solid #a2a9b1;'
end
table.insert(parts, '<span style="' .. style .. '">' .. cell .. '</span>')
end
return table.concat(parts)
end
--- p.partner_logo — File: page name for the partner logo.
function p.partner_logo(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if not entity then return DEFAULT_PARTNER_LOGO end
local instQid
for _, stmt in ipairs(dplaStatements(entity, P.INSTITUTION)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid' then
instQid = ms.datavalue.value.id
break
end
end
if not instQid then return DEFAULT_PARTNER_LOGO end
return imageFileFor(instQid, P.LOGO_IMAGE)
or imageFileFor(instQid, P.IMAGE)
or DEFAULT_PARTNER_LOGO
end
--- p.values — generic getter for all DPLA-determined values of a property.
-- Handles the data types DPLA SDC uses for displayable fields. For
-- string and monolingualtext, chunked values are transparently
-- reassembled via the P1545 series-ordinal convention.
--
-- Optional named args:
-- raw=1 — for entity values, return the bare Q-ID instead of the label
-- sep=… — separator between values (default ", ")
function p.values(frame)
local args = (frame and frame.args) or {}
local propId = args[1]
if not propId or propId == '' then return '' end
local entity = getEntity(args)
if not entity then return '' end
local raw = args.raw == '1' or args.raw == 'true'
local sep = args.sep or ', '
local out = {}
-- Sniff the first DPLA-determined statement's datatype to pick the
-- right rendering path.
local stmts = dplaStatements(entity, propId)
if #stmts == 0 then return '' end
local firstMS = stmts[1].mainsnak
local datatype = firstMS.datavalue and firstMS.datavalue.type or nil
if datatype == 'string' then
return table.concat(reassembleChunkedValues(entity, propId, stringOf), sep)
elseif datatype == 'monolingualtext' then
return table.concat(reassembleChunkedValues(entity, propId, monolingualtextOf), sep)
end
-- Non-chunkable datatypes — render per-statement.
for _, stmt in ipairs(stmts) do
local ms = stmt.mainsnak
if ms.snaktype == 'value' and ms.datavalue then
local t = ms.datavalue.type
if t == 'wikibase-entityid' then
local id = ms.datavalue.value.id
if raw then
table.insert(out, id)
else
table.insert(out, mw.wikibase.getLabel(id) or id)
end
elseif t == 'time' then
local v = ms.datavalue.value
local timeStr = (v.time or ''):gsub('^%+', '')
local precision = v.precision or 11
if precision >= 11 then
table.insert(out, timeStr:sub(1, 10))
elseif precision == 10 then
table.insert(out, timeStr:sub(1, 7))
else
table.insert(out, timeStr:sub(1, 4))
end
end
end
end
return table.concat(out, sep)
end
--- p.first_qid — bare Q-ID of the first DPLA-determined entity-valued
-- statement for the given property.
function p.first_qid(frame)
local args = (frame and frame.args) or {}
local propId = args[1]
if not propId or propId == '' then return '' end
local entity = getEntity(args)
if not entity then return '' end
for _, stmt in ipairs(dplaStatements(entity, propId)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid' then
return ms.datavalue.value.id
end
end
return ''
end
--- p.titles — all DPLA-determined title (P1476) values.
-- Reassembles chunked titles (P1545 series-ordinal) into one logical
-- value per series; singletons preserved as their own values.
function p.titles(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
return table.concat(
reassembleChunkedValues(entity, P.TITLE, monolingualtextOf),
args.sep or ', '
)
end
--- p.subjects — union of entity subjects (P921) and string subjects (P4272).
-- P4272 string subjects are chunking-aware; P921 entity subjects render
-- as Wikidata labels. Deduplicated by exact text match.
function p.subjects(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if not entity then return '' end
local seen = {}
local out = {}
for _, stmt in ipairs(dplaStatements(entity, P.SUBJECT_ENTITY)) do
local label = renderEntitySnak(stmt)
-- Case-insensitive dedup key: a P921 entity label and a P4272
-- string term that differ only in case (e.g. "assassination" vs
-- "Assassination") are the same subject to a reader.
local key = label and mw.ustring.lower(label)
if label and not seen[key] then
seen[key] = true
table.insert(out, label)
end
end
for _, s in ipairs(reassembleChunkedValues(entity, P.SUBJECT_STRING, stringOf)) do
local key = mw.ustring.lower(s)
if not seen[key] then
seen[key] = true
table.insert(out, s)
end
end
return table.concat(out, args.sep or ', ')
end
--- p.creator — render the creator(s).
function p.creator(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if not entity then return '' end
local out = {}
for _, stmt in ipairs(dplaStatements(entity, P.CREATOR)) do
local ms = stmt.mainsnak
if ms.snaktype == 'somevalue' then
local name = firstQualifierValue(stmt, P.AUTHOR_NAME_STRING)
if type(name) == 'string' then
table.insert(out, name)
else
local u = unknownRoleText(stmt, frame)
if u then table.insert(out, u) end
end
elseif ms.snaktype == 'value' and ms.datavalue then
if ms.datavalue.type == 'string' then
table.insert(out, ms.datavalue.value)
elseif ms.datavalue.type == 'wikibase-entityid' then
local id = ms.datavalue.value.id
-- QID → ``{{creator|Wikidata=Q…}}`` Commons Creator
-- infobox, matching hand-authored ``{{Creator:Foo}}``
-- wikitext. See ``creatorText`` for the row-render
-- variant used by buildDplaRows / buildUserRows.
table.insert(out, frame:expandTemplate{
title = 'creator',
args = {Wikidata = id},
})
end
end
end
return table.concat(out, args.sep or ', ')
end
--- p.date — render the date(s).
-- For each DPLA-determined P571 statement:
-- * value-typed time: format with the snak's recorded precision
-- (day → YYYY-MM-DD, month → YYYY-MM, year → YYYY).
-- * somevalue + P1932 (stated as): use the qualifier text — the
-- legacy shape for unparseable date strings.
-- "Approximate" parsed dates carry a P1480 (sourcing circumstances)
-- qualifier with value Q5727902 (circa); we prefix those with "circa ".
function p.date(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if not entity then return '' end
local out = {}
for _, stmt in ipairs(dplaStatements(entity, P.DATE)) do
local ms = stmt.mainsnak
local isCirca = false
for _, sc in ipairs(qualifierValues(stmt, P.SOURCING_CIRCUMSTANCES)) do
if type(sc) == 'table' and sc.id == Q.CIRCA then
isCirca = true
break
end
end
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'time' then
local v = ms.datavalue.value
local timeStr = (v.time or ''):gsub('^%+', '')
local precision = v.precision or 11
local rendered
if precision >= 11 then
rendered = timeStr:sub(1, 10)
elseif precision == 10 then
rendered = timeStr:sub(1, 7)
elseif precision == 9 then
rendered = timeStr:sub(1, 4)
elseif precision == 8 then
-- decade
rendered = timeStr:sub(1, 3) .. '0s'
else
rendered = timeStr:sub(1, 4)
end
if isCirca then
rendered = localizedLabel('circa') .. ' ' .. rendered
end
table.insert(out, rendered)
elseif ms.snaktype == 'somevalue' then
local stated = firstQualifierValue(stmt, P.STATED_AS)
if type(stated) == 'string' then
table.insert(out, stated)
elseif type(stated) == 'table' and stated.text then
table.insert(out, stated.text)
end
end
end
return table.concat(out, args.sep or ', ')
end
--- p.local_id — render the local identifier with the appropriate label.
-- Reassembles chunked P217 / P1225 values (long source identifiers)
-- before rendering.
function p.local_id(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if not entity then return '' end
if p.hub(frame) == NARA_HUB_LABEL then
local naids = reassembleChunkedValues(entity, P.NARA_IDENTIFIER, stringOf)
if #naids > 0 then
return '* National Archives Identifier: ' .. table.concat(naids, ', ')
end
return ''
end
local locals = reassembleChunkedValues(entity, P.LOCAL_IDENTIFIER, stringOf)
if #locals > 0 then
local instLabel
for _, inst in ipairs(dplaStatements(entity, P.INSTITUTION)) do
instLabel = renderEntitySnak(inst)
if instLabel then break end
end
local joined = table.concat(locals, ', ')
if instLabel and instLabel ~= '' then
return '* ' .. instLabel .. ' identifier: ' .. joined
end
return '* Identifier: ' .. joined
end
return ''
end
-- ============================================================
-- Multipage navigation (P304 on P760)
-- ============================================================
-- All P304 page numbers (integers, deduped, sorted ascending) carried as
-- qualifiers on ONE P760 statement. A single DPLA-item statement can now
-- carry several P304 qualifiers — an in-item duplicate, where one Commons
-- file occupies multiple page positions in the same DPLA item. Returns {}
-- when the statement has no integer P304 qualifier. Per-statement so a
-- per-item box could navigate only its own item's pages.
local function pageNumbersOfStatement(stmt)
local seen, out = {}, {}
for _, v in ipairs(qualifierValues(stmt, P.PAGE)) do
if type(v) == 'string' then
local n = tonumber(v)
if n and not seen[n] then
seen[n] = true
table.insert(out, n)
end
end
end
table.sort(out)
return out
end
-- Every P304 page number this file occupies within ONE DPLA item — the
-- first DPLA-determined P760 statement (matching ``filterFn``, default
-- ``isDplaDetermined``) that carries at least one page — deduped and
-- sorted. Returns {} when no P304 qualifier is present, i.e. the file is a
-- singleton in its extension series and the "Other pages" field should be
-- omitted; this reproduces the old ``currentPageNumber`` selection ("first
-- P304 of the first DPLA P760 with a page").
--
-- Deliberately reads a SINGLE statement rather than aggregating across a
-- cross-item file's several P760 statements: the current file's title
-- encodes one item's page position, and title-based prev/next navigation
-- only makes sense within that one item's series — mixing another item's
-- page numbers in would emit sibling links in the wrong series. A per-item
-- box can pass ``isDplaDeterminedForItem(id)`` to navigate exactly that
-- item's pages; an in-item duplicate carries all its pages on one P760
-- statement, so a single statement is the complete set for that item.
local function allPageNumbers(entity, filterFn)
filterFn = filterFn or isDplaDetermined
if not entity or not entity.statements then return {} end
for _, stmt in ipairs(entity.statements[P.DPLA_ID] or {}) do
if filterFn(stmt) then
local nums = pageNumbersOfStatement(stmt)
if #nums > 0 then return nums end
end
end
return {}
end
-- Build a sibling file's title by swapping the "(page N)" suffix in the
-- current file's title. Returns the candidate title string (without
-- the "File:" prefix), or nil if the current title doesn't carry a
-- "(page N)" suffix at all (in which case we can't find siblings by
-- title-pattern enumeration).
local function siblingTitleFor(currentTitle, newPageNumber)
if not currentTitle then return nil end
-- Strip File: prefix if present.
local base = currentTitle:gsub('^File:', '')
-- Match "...(page N).ext" — preserve the bits before/after.
local prefix, _, ext = base:match('^(.-)%(page (%d+)%)(%..+)$')
if not prefix then return nil end
return prefix .. '(page ' .. tostring(newPageNumber) .. ')' .. ext
end
-- Resolve a sibling File: title to an mw.title object when the page exists
-- (redirects count as existing), or nil otherwise.
local function siblingExists(siblingBaseTitle)
if not siblingBaseTitle then return nil end
local title = mw.title.makeTitle('File', siblingBaseTitle)
if title and title.exists then
return title
end
return nil
end
-- True when a sibling ordinal is really THIS page wearing another page
-- number -- either the identical title, or a redirect that resolves back to
-- the current file. Such an ordinal carries no new content (it is an in-item
-- duplicate merged into this page under the SHA1-uniqueness rules); rendering
-- it would show the current file's own thumbnail and loop the reader back to
-- the canonical, so it must be skipped.
local function resolvesToSelf(siblingTitleObj, currentTitle)
if not siblingTitleObj then return false end
if siblingTitleObj.fullText == currentTitle then return true end
if siblingTitleObj.isRedirect then
local target = siblingTitleObj.redirectTarget
if target and target.fullText == currentTitle then return true end
end
return false
end
-- Walk outward from the current ordinal in `step` direction (+1 for next,
-- -1 for prev), skipping any ordinal that resolves back to this same file,
-- until a genuinely different page is found. Returns (siblingTitleObj,
-- ordinal) for the first such page, or nil when the run of pages ends (a
-- missing ordinal) before one is found. A sibling that redirects to some
-- OTHER page of the item is a valid target: its thumbnail and link resolve
-- through the redirect and we label it by its own ordinal -- merged pages
-- behave like ordinary pages in every respect but the shared description.
local function findNavSibling(currentTitle, currentOrdinal, step)
local i = currentOrdinal + step
while i >= 1 do
local sib = siblingExists(siblingTitleFor(currentTitle, i))
if not sib then return nil end -- ran off the end of the item
if not resolvesToSelf(sib, currentTitle) then
return sib, i
end
i = i + step -- self-duplicate ordinal: skip
end
return nil
end
-- Render one prev/next thumbnail cell for a resolved sibling. `arrow` is
-- 'prev' or 'next' (controls the arrow side); `ordinal` labels the cell.
local function navCell(sib, ordinal, arrow)
local label = substituteParams(localizedLabel('page_n'), tostring(ordinal))
local thumb = '[[File:' .. sib.text .. '|100px|alt=' .. label ..
'|link=' .. sib.fullText .. ']]'
local link = '[[:' .. sib.fullText .. '|' .. label .. ']]'
local caption
if arrow == 'prev' then
caption = '<span style="font-size:1.2em;" aria-hidden="true">←</span> ' .. link
else
caption = link .. ' <span style="font-size:1.2em;" aria-hidden="true">→</span>'
end
return '<div style="display:inline-block;text-align:center;margin:0 8px;">' ..
thumb .. '<br/>' .. caption .. '</div>'
end
--- p.other_pages -- render the "Other pages" navigation cell.
-- For a page of a multi-page DPLA item, show thumbnails of the previous and
-- next pages, each linking to that page. Sibling discovery uses the DPLA
-- uploader's title convention ``... - DPLA - <dpla_id> (page N).<ext>``: the
-- current file's own ordinal is read from its "(page N)" title and the
-- neighbouring ordinals are probed by title.
--
-- In-item duplicates are merged under the SHA1-uniqueness rules: the
-- duplicate page becomes a redirect to the page it duplicates. Navigation
-- treats those redirect ordinals as ordinary pages EXCEPT that an ordinal
-- resolving back to the current file (a duplicate of THIS page) is skipped --
-- otherwise the cell would display this page's own thumbnail and loop the
-- reader back to the canonical. ``findNavSibling`` skips such ordinals and
-- continues outward to the next genuinely different page, so the reader can
-- still click up and down the item. When every other ordinal resolves to
-- this page (e.g. an item whose remaining pages are all duplicates of page
-- 1), neither direction yields a sibling and the field is suppressed.
--
-- Returns '' when the file is not part of a paged item, when its title has
-- no "(page N)" ordinal, or when no navigable sibling exists.
function p.other_pages(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if #allPageNumbers(entity) == 0 then return '' end
local titleObj
if args.page and args.page ~= '' then
titleObj = mw.title.new(args.page)
else
titleObj = mw.title.getCurrentTitle()
end
if not titleObj then return '' end
local currentTitle = titleObj.fullText
-- Current file's own ordinal, from its "(page N)" title suffix.
local ordStr = currentTitle:gsub('^File:', ''):match('%(page (%d+)%)%..+$')
if not ordStr then return '' end
local currentOrdinal = tonumber(ordStr)
local cells = {}
local prevSib, prevOrd = findNavSibling(currentTitle, currentOrdinal, -1)
if prevSib then
table.insert(cells, navCell(prevSib, prevOrd, 'prev'))
end
local nextSib, nextOrd = findNavSibling(currentTitle, currentOrdinal, 1)
if nextSib then
table.insert(cells, navCell(nextSib, nextOrd, 'next'))
end
if #cells == 0 then return '' end
return table.concat(cells, ' ')
end
--- p.categories — emit the full set of DPLA partner / institution
-- categories for this file. Three categories, matching the legacy
-- ``{{DPLA}}`` template's tracking output:
--
-- * MAIN_CAT — always
-- * Hub category — the regional aggregator (e.g. "Plains to Peaks
-- Collective"). Identified by a non-DPLA P9126 statement carrying
-- ``P3831 = Q393351`` (ROLE_AGGREGATOR); ``partnerByRole`` filters
-- out DPLA itself so the remaining publisher is the regional
-- hub. For partners where the contributing institution is also
-- its own hub (NARA, Smithsonian), the bot omits the redundant
-- hub-ROLE_AGGREGATOR entry, so we fall back to the institution
-- Q-ID — same category on Commons, no spurious "unknown partner"
-- classification.
-- * Institution category — the contributing institution, read from
-- ``P195`` (collection). Falls back to UNKNOWN_INSTITUTION_CAT
-- when the P195 entity has no Commons sitelink.
-- Render the three "Media contributed by..." category links from a
-- pre-resolved (hubQid, instQid) pair. Either argument may be nil,
-- in which case its slot falls back to the UNKNOWN_* maintenance
-- category — same shape the legacy ``{{DPLA/hub cat}}`` /
-- ``{{DPLA/inst cat}}`` templates emitted, preserved here so existing
-- maintenance queries keep finding their files.
local function buildCategoryList(hubQid, instQid)
local parts = {'[[Category:' .. MAIN_CAT .. ']]'}
local hubCat = commonsCategoryFor(hubQid)
if hubCat then
table.insert(parts, '[[Category:' .. hubCat .. ']]')
else
table.insert(parts, '[[Category:' .. UNKNOWN_PARTNER_CAT .. ']]')
end
local instCat = commonsCategoryFor(instQid)
if instCat then
table.insert(parts, '[[Category:' .. instCat .. ']]')
else
table.insert(parts, '[[Category:' .. UNKNOWN_INSTITUTION_CAT .. ']]')
end
return table.concat(parts, '\n')
end
-- Resolve (hubQid, instQid) for category emission from an SDC entity.
-- The DPLA SDC writer encodes the partnership graph across two
-- properties:
-- * ``P195`` (collection) — usually the hub-level Q-ID, kept for
-- legacy ``{{DPLA/hub cat}}`` compatibility.
-- * ``P9126`` (object of statement has role) — three entries per
-- file: DPLA itself (role = ROLE_AGGREGATOR), the hub (role =
-- ROLE_REPOSITORY), and the granular contributing institution
-- (role = ROLE_CONTRIBUTING). The contributing institution can
-- be more specific than P195 — e.g. ``National Archives at
-- College Park - Cartographic`` versus the parent ``National
-- Archives and Records Administration`` in P195.
-- Prefer the role-tagged P9126 values so the category emission lines
-- up with the granular institution where one exists. Fall back to
-- P195 when the role-tagged entries are absent (older bot writes,
-- partner-equals-institution cases like NARA-direct uploads).
local function resolveCategoryQidsFromEntity(entity)
if not entity then return nil, nil end
-- P195 (collection) always carries the contributing institution
-- Q-ID — every code path that writes SDC writes one, and where
-- no separate hub exists (direct uploads) the institution is also
-- the hub.
local p195Qid
for _, stmt in ipairs(dplaStatements(entity, P.INSTITUTION)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid' then
p195Qid = ms.datavalue.value.id
break
end
end
-- The hub is the non-DPLA, non-institution Q-ID in the P9126
-- partnership chain. Role qualifiers (P3831) are deliberately
-- ignored here: the two hub models tag roles differently — a
-- service hub's hub is an aggregator, a content hub's hub is a
-- repository — so no single role test finds the hub across both.
-- Set-based identification gets the correct hub Q-ID regardless
-- of model.
local hubQid
for _, stmt in ipairs(dplaStatements(entity, P.PARTNERSHIP)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid' then
local qid = ms.datavalue.value.id
if qid ~= Q.DPLA and qid ~= p195Qid then
hubQid = qid
break
end
end
end
-- A content hub (NARA, Smithsonian) records its granular contributing
-- institution on a P9126 partner carrying the custodial-unit role.
-- When present, P195 holds the *hub* (the content hub itself, e.g.
-- NARA) and this partner is the institution — e.g. P195 = NARA,
-- contributing = "John F. Kennedy Presidential Library & Museum". A
-- service hub never sets this role, so its institution stays on P195
-- (handled below).
local contributingQid = partnerByRole(entity, Q.ROLE_CONTRIBUTING)
if contributingQid then
return p195Qid, contributingQid
end
-- Fall back to institution-is-its-own-hub when no separate hub
-- Q-ID is present (BPL-direct, NARA-direct files where DPLA
-- flattened the partnership). buildCategoryList renders a single
-- category in that case via MediaWiki's natural categorylinks
-- dedup.
return hubQid or p195Qid, p195Qid
end
function p.categories(frame)
local args = (frame and frame.args) or {}
local entity = getEntity(args)
if not entity then return '' end
local hubQid, instQid = resolveCategoryQidsFromEntity(entity)
return buildCategoryList(hubQid, instQid)
end
-- ============================================================
-- Custom metadata-table rendering (replaces {{Information}})
-- ============================================================
-- Edit-pencil affordances are intentionally NOT emitted. DPLA SDC is
-- authoritative and synced from the source catalog by the bot; we don't
-- want to invite ad-hoc edits to the rendered fields. The propertyId
-- argument to ``tableRow`` is preserved for forward compatibility but
-- currently unused.
-- HTML id="..." attributes that Commons' machine-readable scanners
-- (including AntiCompositeBot's NoLicense tagger and external
-- file-metadata consumers) look for to extract structured per-file
-- fields. Each canonical row in the rendered table carries the
-- matching id so a file rendered through Module:DPLA stays out of
-- the ``Files with no machine-readable <field>`` maintenance
-- categories. Keys here mirror the label keys passed to ``add()``
-- in ``buildDplaRows`` / ``buildUserRows``. See
-- https://commons.wikimedia.org/wiki/Commons:Machine-readable_data
-- for the full list of recognised ids and the bot scanners that
-- key on them.
local MACHINE_READABLE_ID = {
description = 'fileinfotpl_desc',
creator = 'fileinfotpl_aut',
date = 'fileinfotpl_date',
source = 'fileinfotpl_src',
permission = 'fileinfotpl_perm',
}
local function tableRow(label, value, _propertyId, fieldIdAttr)
if value == nil or value == '' then return nil end
-- The fileinfotpl_* machine-readable id must sit on the LABEL cell, not
-- the value cell. Commons' CommonsMetadata extension (which populates the
-- ``Files with no machine-readable <field>`` categories and the
-- ``extmetadata`` API) locates the element by id and then reads its NEXT
-- SIBLING cell as the field value — mirroring {{Information}}, whose id
-- sits on the "Author"/"Source"/… label cell. With the id on the value
-- cell its sibling is empty, so every field extracted as nothing and the
-- file landed in the maintenance categories regardless of content.
local idAttr = fieldIdAttr and (' id="' .. fieldIdAttr .. '"') or ''
return '<tr style="vertical-align: top">'
.. '<td class="fileinfo-paramfield"' .. idAttr .. '>' .. label .. '</td>'
.. '<td>' .. value .. '</td>'
.. '</tr>'
end
-- Top banner row mirroring {{Artwork}}'s title header: the file's title
-- spans the full table width, centered, in larger and bolder type.
-- Returns nil when no title can be derived so the dispatcher can omit
-- the banner row.
local function titleBanner(title)
if not title or title == '' then return nil end
return '<tr><th colspan="2" style="text-align:center; font-size:1.25em; padding:0.6em; '
.. 'background-color:var(--background-color-interactive, #e0e0ee); color:inherit;">'
.. title .. '</th></tr>'
end
-- Each field-text helper accepts an optional ``statementsFn`` that
-- selects which statements to render (``dplaStatements`` by default,
-- ``nonDplaStatements`` for the yellow user-contributed box). The
-- ``filterFn`` parallel argument is used inside chunked-value
-- reassembly, which iterates the raw statement list itself.
local function titlesText(entity, statementsFn, filterFn)
filterFn = filterFn or isDplaDetermined
return table.concat(reassembleChunkedValues(entity, P.TITLE, monolingualtextOf, filterFn), ', ')
end
local function descriptionText(entity, statementsFn, filterFn)
filterFn = filterFn or isDplaDetermined
-- Multi-value descriptions get a blank-line break between values,
-- not commas — descriptions are often paragraph-length, and a
-- comma between two paragraphs reads as one continuous wall of
-- text. ``reassembleChunkedValues`` already concatenates chunked
-- single-values (statements sharing a P1545 ordinal letter
-- prefix, used to bypass the per-statement string-length limit)
-- into one entry, so the separator only fires between truly
-- distinct descriptions.
return table.concat(
reassembleChunkedValues(entity, P.DESCRIPTION, monolingualtextOf, filterFn),
'<br /><br />'
)
end
-- ``frame`` is threaded so a P170 QID-valued statement can expand
-- ``{{creator|Wikidata=Q…}}`` — the Commons Creator infobox —
-- matching what a hand-authored ``{{Creator:Foo}}`` wikitext link
-- would render on the file page. Mirrors the ``Institution`` row's
-- expandTemplate pattern already used elsewhere in this module.
-- Without ``frame`` (test harnesses that call the helper directly),
-- falls back to ``mw.wikibase.getLabel`` — bare label instead of
-- the full infobox.
local function creatorText(entity, statementsFn, frame)
statementsFn = statementsFn or dplaStatements
if not entity then return '' end
local out = {}
for _, stmt in ipairs(statementsFn(entity, P.CREATOR)) do
local ms = stmt.mainsnak
if ms.snaktype == 'somevalue' then
local name = firstQualifierValue(stmt, P.AUTHOR_NAME_STRING)
if type(name) == 'string' then
table.insert(out, name)
else
local u = unknownRoleText(stmt, frame)
if u then table.insert(out, u) end
end
elseif ms.snaktype == 'value' and ms.datavalue then
if ms.datavalue.type == 'string' then
table.insert(out, ms.datavalue.value)
elseif ms.datavalue.type == 'wikibase-entityid' then
local id = ms.datavalue.value.id
if frame then
table.insert(out, frame:expandTemplate{
title = 'creator',
args = {Wikidata = id},
})
else
table.insert(out, mw.wikibase.getLabel(id) or id)
end
end
end
end
return table.concat(out, ', ')
end
-- Format an ISO-prefix date string ("YYYY-MM-DD" or "YYYY-MM") to a
-- human-readable form via the content language's date formatter (e.g.
-- "1967-03-28" → "28 March 1967", matching the {{Artwork}} renderer's
-- output). Falls back to the raw ISO string on any formatter error so a
-- BC date or other unsupported input doesn't blow up the whole row.
local function formatWikibaseTime(timeStr, precision)
if not timeStr or timeStr == '' then return '' end
local lang = mw.language.getContentLanguage()
local fmt, input
if precision >= 11 then
fmt = 'j F Y'
input = timeStr:sub(1, 10)
elseif precision == 10 then
-- formatDate parses "YYYY-MM-01" reliably; fabricating the day
-- doesn't affect the "F Y" output (month + year only).
fmt = 'F Y'
input = timeStr:sub(1, 7) .. '-01'
elseif precision == 9 then
return timeStr:sub(1, 4)
elseif precision == 8 then
return timeStr:sub(1, 3) .. '0s'
else
return timeStr:sub(1, 4)
end
-- Reject BC / year-zero values; ``formatDate`` accepts them but the
-- output for negative years is platform-dependent. Show raw instead.
if input:sub(1, 1) == '-' or input:sub(1, 4) == '0000' then
return timeStr:sub(1, #input)
end
local ok, result = pcall(function() return lang:formatDate(fmt, input) end)
if ok and result and result ~= '' then return result end
return timeStr:sub(1, #input)
end
-- Parse a year-range string into (start_year, end_year) with
-- start <= end, or return nil for non-range / unparseable inputs.
-- Mirror of ingest_wikimedia.sdc.parse_date_range; the Python side
-- uses this same canonicalisation when deciding whether a legacy
-- ``{{Artwork}}`` date claim is a duplicate of DPLA's own range
-- claim, and the SDC reconciler uses it to remove inferred-from-
-- Wikitext claims that are now equivalent to a DPLA-sourced one.
-- Module:DPLA mirrors the canonicalisation at render time so the
-- yellow box stays clean between sync runs.
--
-- Recognised shapes (post wikitext-expansion or raw):
-- * YYYY [- / – —] YYYY
-- * between YYYY and YYYY (case-insensitive)
-- * {{other date|between|YYYY|YYYY}} (raw wikitext fallback)
--
-- Lua patterns have no alternation or {n,m} quantifier — captures
-- pull "one or more digits" and a helper validates the year is
-- plausible (100..9999) so e.g. "5-100" can't pretend to be a year
-- range. Year 0 is rejected for the same reason parse_dpla_date
-- rejects it: proleptic Gregorian has no year 0.
local function _yearRange(a, b)
local ya, yb = tonumber(a), tonumber(b)
if not ya or not yb then return nil end
if ya < 100 or ya > 9999 then return nil end
if yb < 100 or yb > 9999 then return nil end
if ya > yb then ya, yb = yb, ya end
return ya, yb
end
local function parseDateRange(s)
if type(s) ~= 'string' or s == '' then return nil end
local trimmed = s:gsub('^%s+', ''):gsub('%s+$', '')
if trimmed == '' then return nil end
local lower = trimmed:lower()
-- "between X and Y" (lowercased input → ASCII pattern)
local a, b = lower:match('^between%s+(%d+)%s+and%s+(%d+)$')
if a then
local ya, yb = _yearRange(a, b)
if ya then return ya, yb end
end
-- "{{other date|between|X|Y}}" — tolerates ``other_date`` alias
-- and optional whitespace around the pipes.
a, b = lower:match('^{{%s*other[ _]date%s*|%s*between%s*|%s*(%d+)%s*|%s*(%d+)%s*}}$')
if a then
local ya, yb = _yearRange(a, b)
if ya then return ya, yb end
end
-- "YYYY [sep] YYYY" — Lua patterns can't alternate over
-- separators, so try each one. ``-`` and ``/`` are ASCII; ``–``
-- (en-dash) and ``—`` (em-dash) are multi-byte UTF-8 sequences
-- that Lua's byte-oriented patterns still match literally.
for _, sep in ipairs({'%-', '/', '–', '—'}) do
a, b = trimmed:match('^(%d+)%s*' .. sep .. '%s*(%d+)$')
if a then
local ya, yb = _yearRange(a, b)
if ya then return ya, yb end
end
end
return nil
end
-- Produce a comparable key for a P571 statement that compares equal
-- across equivalent encodings (value-typed time vs. somevalue+P1932
-- single date, range-shaped P1932 across format variants, raw P1932
-- prose strings). Mirror of ``_statement_comparable_value`` in
-- tools/sdc_sync.py; used to suppress non-DPLA P571 statements whose
-- value duplicates a DPLA-attributed one in the yellow box.
--
-- Returns nil for shapes we can't compare — caller treats nil as
-- "render normally", not as a match.
local function dateStatementComparable(stmt)
local ms = stmt and stmt.mainsnak
if not ms then return nil end
if ms.snaktype == 'value' and ms.datavalue
and ms.datavalue.type == 'time' then
local v = ms.datavalue.value or {}
local circa = ''
for _, q in ipairs((stmt.qualifiers or {}).P1480 or {}) do
if q.snaktype == 'value' and q.datavalue
and q.datavalue.type == 'wikibase-entityid'
and q.datavalue.value
and q.datavalue.value.id == Q.CIRCA then
circa = '|circa'
break
end
end
return 'time|' .. (v.time or '') .. '|P' .. tostring(v.precision or '') .. circa
end
if ms.snaktype == 'somevalue' then
for _, q in ipairs((stmt.qualifiers or {}).P1932 or {}) do
if q.snaktype == 'value' and q.datavalue
and q.datavalue.type == 'string' then
local s = (q.datavalue.value or ''):gsub('^%s+', ''):gsub('%s+$', '')
if s == '' then return nil end
local ya, yb = parseDateRange(s)
if ya then
return 'range|' .. ya .. '|' .. yb
end
return 'p1932|' .. s
end
end
end
return nil
end
local function dateText(entity, statementsFn, frame)
statementsFn = statementsFn or dplaStatements
if not entity then return '' end
-- When rendering the yellow (non-DPLA) box, build a set of
-- DPLA-attributed comparables so non-DPLA statements that encode
-- the same fact (e.g. an inferred-from-Wikitext range claim left
-- behind by an older legacy-migration run) are suppressed at
-- render time. The Python reconciler removes these on the next
-- sync — see ``_reconcile_inferred_from_wikitext_dupes`` in
-- tools/sdc_sync.py — but the module needs to dedup between
-- syncs so the page reads cleanly today.
--
-- Gate positively on the yellow box's selector (``nonDplaStatements``)
-- rather than "anything that isn't ``dplaStatements``": the blue box
-- may now pass a per-item-scoped selector (``statementsMatching`` of an
-- item filter), and deduping those against ALL DPLA dates would wrongly
-- blank the blue-box date field.
local dplaComparables
if statementsFn == nonDplaStatements then
dplaComparables = {}
for _, dplaStmt in ipairs(dplaStatements(entity, P.DATE)) do
local key = dateStatementComparable(dplaStmt)
if key then dplaComparables[key] = true end
end
end
local out = {}
for _, stmt in ipairs(statementsFn(entity, P.DATE)) do
local skip = false
if dplaComparables then
local key = dateStatementComparable(stmt)
if key and dplaComparables[key] then
skip = true
end
end
if not skip then
local ms = stmt.mainsnak
local isCirca = false
for _, sc in ipairs(qualifierValues(stmt, P.SOURCING_CIRCUMSTANCES)) do
if type(sc) == 'table' and sc.id == Q.CIRCA then
isCirca = true
break
end
end
if ms.snaktype == 'value' and ms.datavalue and ms.datavalue.type == 'time' then
local v = ms.datavalue.value
local timeStr = (v.time or ''):gsub('^%+', '')
local rendered = formatWikibaseTime(timeStr, v.precision or 11)
if isCirca then
rendered = localizedLabel('circa') .. ' ' .. rendered
end
table.insert(out, rendered)
elseif ms.snaktype == 'somevalue' then
-- ``P1932`` (stated as) preserves the editor's original source
-- string verbatim. Legacy {{Artwork}} migration imports it
-- raw, so the value can legitimately contain wikitext markup
-- (e.g. ``{{other date|~|1911}}``). MediaWiki does NOT re-
-- expand templates inside Scribunto output, so emit through
-- ``frame:preprocess`` to render any embedded template /
-- parser-function. Plain-text passes through unchanged. Same
-- pattern Module:Information / Module:Artwork use for their
-- editor-contributed text fields.
local stated = firstQualifierValue(stmt, P.STATED_AS)
local text
if type(stated) == 'string' then
text = stated
elseif type(stated) == 'table' and stated.text then
text = stated.text
end
if text then
if frame then text = frame:preprocess(text) end
table.insert(out, text)
end
end
end -- close ``if not skip then``
end
return table.concat(out, ', ')
end
local function institutionQidOf(entity, statementsFn)
statementsFn = statementsFn or dplaStatements
if not entity then return nil end
for _, stmt in ipairs(statementsFn(entity, P.INSTITUTION)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid' then
return ms.datavalue.value.id
end
end
return nil
end
local function subjectsText(entity, statementsFn, filterFn)
statementsFn = statementsFn or dplaStatements
filterFn = filterFn or isDplaDetermined
if not entity then return '' end
local seen = {}
local out = {}
for _, stmt in ipairs(statementsFn(entity, P.SUBJECT_ENTITY)) do
local label = renderEntitySnak(stmt)
-- Case-insensitive dedup key: a P921 entity label and a P4272
-- string term that differ only in case (e.g. "assassination" vs
-- "Assassination") are the same subject to a reader.
local key = label and mw.ustring.lower(label)
if label and not seen[key] then
seen[key] = true
table.insert(out, label)
end
end
for _, s in ipairs(reassembleChunkedValues(entity, P.SUBJECT_STRING, stringOf, filterFn)) do
local key = mw.ustring.lower(s)
if not seen[key] then
seen[key] = true
table.insert(out, s)
end
end
return table.concat(out, ', ')
end
-- DPLA ID rendered as an interwiki link to the DPLA item page.
-- The ``dpla:`` interwiki prefix on Commons resolves to
-- https://dp.la/item/<id>, so ``[[dpla:<id>|<id>]]`` produces an
-- internal-looking link with the bare ID as visible text.
-- Render a DPLA item id as a ``dpla:`` interwiki link, or '' when blank.
-- Shared by the blue box (``dplaIdText``, reading SDC) and the yellow box
-- (reading the wikitext ``dpla_id`` param) so both render identically.
local function dplaIdLink(id)
if not id or id == '' then return '' end
return '[[dpla:' .. id .. '|' .. id .. ']]'
end
local function dplaIdText(entity, filterFn)
local values = reassembleChunkedValues(entity, P.DPLA_ID, stringOf, filterFn)
return dplaIdLink(values[1])
end
-- Build the Source-row icon cells from explicit URL arguments. Extracted
-- from ``sourceFieldText`` so the yellow box can call the same renderer
-- after parsing the user-side ``{{DPLA|...}}`` (legacy) or flat-param
-- (new uploads) shape — sharing one cell builder keeps the two
-- representations indistinguishable in the rendered HTML.
--
-- ``naraInterwikiLink`` is the optional pre-built ``[[w:..]]`` wikilink
-- the NARA-specific catalog cell uses; pass nil for any other partner
-- and the cell becomes a plain external link to ``catalogUrl``.
local function sourceFieldFromUrls(fileUrl, iiifUrl, catalogUrl, naraInterwikiLink)
local cells = {}
if fileUrl and fileUrl ~= '' then
table.insert(cells,
'[[File:Codex icon download color-progressive.svg|24px|alt=|class=skin-invert|link=]] [' ..
fileUrl .. ' ' .. localizedLabel('file_url') .. ']'
)
end
if iiifUrl and iiifUrl ~= '' then
table.insert(cells,
'[[File:International Image Interoperability Framework logo.png|24px|alt=|link=]] [' ..
iiifUrl .. ' ' .. localizedLabel('iiif_manifest') .. ']'
)
end
if catalogUrl and catalogUrl ~= '' then
if naraInterwikiLink then
table.insert(cells,
'[[File:Breezeicons-actions-22-database-index.svg|24px|alt=|class=skin-invert|link=]] ' ..
naraInterwikiLink
)
else
table.insert(cells,
'[[File:Breezeicons-actions-22-database-index.svg|24px|alt=|class=skin-invert|link=]] [' ..
catalogUrl .. ' ' .. localizedLabel('catalog_record') .. ']'
)
end
end
local parts = {}
for i, cell in ipairs(cells) do
local style = 'display:inline-block; padding: 4px 16px; vertical-align: middle;'
if i > 1 then
style = style .. ' border-left: 2px solid #a2a9b1;'
end
table.insert(parts, '<span style="' .. style .. '">' .. cell .. '</span>')
end
return table.concat(parts)
end
local function sourceFieldText(entity, statementsFn, filterFn)
local fileUrl, iiifUrl, catalogUrl = sourceUrls(entity, statementsFn)
return sourceFieldFromUrls(
fileUrl, iiifUrl, catalogUrl,
naraCatalogInterwiki(entity, statementsFn, filterFn)
)
end
-- Partnership card rendered as pure HTML (no wikitable). The legacy
-- ``{{DPLA/layout}}`` template uses wikitable syntax (``{| ... |}``)
-- which MediaWiki only parses at certain top-level positions; embedding
-- it inside a surrounding <td> via frame:expandTemplate left the
-- ``{|`` literal in the rendered output. Building the 3-column layout
-- as a single <div> with flex avoids the parse issue.
--
-- ``partnershipFromQids`` is the parametric core, callable from either
-- the SDC entity path (blue box) or the wikitext-param path (yellow
-- box for new uploads or legacy-shape pages whose source param the
-- module parses below). Same HTML, same accessibility behaviour:
-- the alt text on both logos is empty because the institution name is
-- already inside the adjacent text, and the DPLA name is in the banner
-- above the row.
local function partnershipFromQids(instQid, hubQid)
if (not instQid or instQid == '') and (not hubQid or hubQid == '') then
return ''
end
local instLabel = (instQid and instQid ~= '')
and (mw.wikibase.getLabel(instQid) or instQid)
or 'an institution'
local hubLabel = (hubQid and hubQid ~= '')
and (mw.wikibase.getLabel(hubQid) or hubQid)
or ''
local instMarkup = (instQid and instQid ~= '')
and ('[[d:' .. instQid .. '|' .. instLabel .. ']]')
or instLabel
local text
if hubLabel ~= '' then
local hubMarkup = '[[d:' .. hubQid .. '|' .. hubLabel .. ']]'
text = substituteParams(
localizedLabel('partnership_text_with_hub'), instMarkup, hubMarkup
)
else
text = substituteParams(
localizedLabel('partnership_text_without_hub'), instMarkup
)
end
local logo = ((instQid and instQid ~= '') and imageFileFor(instQid, P.LOGO_IMAGE))
or ((instQid and instQid ~= '') and imageFileFor(instQid, P.IMAGE))
or DEFAULT_PARTNER_LOGO
return '<div style="display:flex; align-items:center; gap:15px; padding:0.4em;">'
.. '<div>[[' .. logo .. '|70px|alt=]]</div>'
.. '<div style="flex:1;">' .. text .. '</div>'
.. '<div>[[File:DPLA square logo.svg|70px|alt=]]</div>'
.. '</div>'
end
local function partnershipMarkup(entity, _frame)
if not entity then return '' end
return partnershipFromQids(
institutionQidOf(entity),
partnerByRole(entity, Q.ROLE_AGGREGATOR)
)
end
-- P6426 Q-IDs that we have to emit a Commons template for directly,
-- because Module:License (the engine behind ``{{License from structured
-- data}}``) misreads our DPLA-attributed statements — it picks up the
-- determination-method qualifier value (Q61848113) instead of the
-- mainsnak Q-ID and produces a 'missing main template (P1424)' error
-- in the permission row. Each template takes one positional arg = the
-- contributing institution Q-ID, matching the legacy ``permission =
-- {{NoC-US|Q...}}`` / ``{{NKC|Q...}}`` shape uploaded files use. Other
-- eligible NoC-* values (NoC-CR, NoC-NC, NoC-OKLR) have no Commons
-- template yet and are also not used by any DPLA file today; add them
-- here if either condition changes.
local RIGHTS_TEMPLATE_BY_P6426 = {
['Q47530911'] = 'NoC-US', -- No Copyright - United States
['Q47530955'] = 'NKC', -- No Known Copyright
}
-- Resolve a copyright-license item (e.g. Q20007257 for CC BY 4.0) to the
-- corresponding Commons template basename (``Cc-by-4.0``) via its Wikidata
-- ``P1424`` (topic's main template) sitelink to commonswiki. Returns nil
-- when the license item has no ``P1424`` or its sitelink is missing / not a
-- template. Used as a fallback when Module:License bails out on
-- multi-P6216 files (see permissionMarkup).
local function licenseTemplateFromLicenseQid(qid)
if not qid then return nil end
local ok, licenseEntity = pcall(mw.wikibase.getEntity, qid)
if not ok or not licenseEntity then return nil end
local stmts = licenseEntity:getBestStatements('P1424')
for _, stmt in ipairs(stmts) do
local ms = stmt.mainsnak
if ms.snaktype == 'value' and ms.datavalue and ms.datavalue.type == 'wikibase-entityid' then
local sitelink = mw.wikibase.getSitelink(ms.datavalue.value.id, 'commonswiki')
if sitelink and sitelink:sub(1, 9) == 'Template:' then
return sitelink:sub(10)
end
end
end
return nil
end
local function permissionMarkup(entity, frame)
if not entity then return '' end
-- DPLA writes the rights cluster across two properties depending on
-- the source URI: ``P6426`` (copyright status as a creator) for
-- public-domain / no-known-copyright items, ``P275`` (copyright
-- license) for Creative Commons items. Detect either — the row
-- should render whenever DPLA has contributed any rights claim.
local p6426
for _, stmt in ipairs(dplaStatements(entity, 'P6426')) do
local ms = stmt.mainsnak
if ms.snaktype == 'value' and ms.datavalue and ms.datavalue.type == 'wikibase-entityid' then
p6426 = ms.datavalue.value.id
break
end
end
local p275Qid
for _, stmt in ipairs(dplaStatements(entity, 'P275')) do
local ms = stmt.mainsnak
if ms.snaktype == 'value' and ms.datavalue and ms.datavalue.type == 'wikibase-entityid' then
p275Qid = ms.datavalue.value.id
break
end
end
-- DPLA-attributed P6216 (copyright status). The write shape for source
-- rights = CC Public Domain Mark (PDM) is ``P6216 = Q19652`` (public
-- domain) with no P275 or P6426 — PDM is a rights-statement declaration,
-- not a copyright license, so Module:License can't dispatch it. Detect
-- the shape here and route directly to ``{{PD-US}}``.
local p6216Qid
for _, stmt in ipairs(dplaStatements(entity, 'P6216')) do
local ms = stmt.mainsnak
if ms.snaktype == 'value' and ms.datavalue and ms.datavalue.type == 'wikibase-entityid' then
p6216Qid = ms.datavalue.value.id
break
end
end
-- Suppress the row entirely when DPLA hasn't contributed any rights
-- claim. Without this guard the row would fall through to
-- {{License from structured data}} and render whatever license is on
-- the file regardless of DPLA provenance — defeating the
-- "DPLA-attributed fields only" contract of this template.
if not p6426 and not p275Qid and not p6216Qid then return '' end
local templateName = RIGHTS_TEMPLATE_BY_P6426[p6426]
if templateName then
-- Each template requires the institution Q-ID as its first
-- positional arg; without one it renders the raw ``{{{1}}}``
-- placeholder. Fall back to DPLA's own Q-ID when the file has
-- no DPLA-authored P195 — semantically accurate (DPLA bot is
-- the entity making the rights determination; the contributing
-- institution is just the original source).
local instQid = institutionQidOf(entity)
if not instQid or instQid == '' then
instQid = Q.DPLA
end
return frame:expandTemplate{title = templateName, args = {[1] = instQid}}
end
-- PDM shape: DPLA-attributed P6216=Q19652 (public domain) with no
-- P275/P6426 rights template on the entity. Module:License can't route
-- this — no Wikidata item sitelinks to ``Template:PD-US``, so its
-- P1424-based dispatch has nothing to follow. Emit ``{{PD-US}}``
-- directly. The neutral wording ("This work is in the public domain in
-- the United States") matches what the source institution has
-- declared via PDM without asserting a specific reason (e.g. >95
-- years old) that our upstream data doesn't warrant. Also emits
-- ``{{SDC-PD-US}}`` for the machine-readable license marker so files
-- clear ``Category:Files with no machine-readable license``.
if p6216Qid == 'Q19652' and not p275Qid then
return frame:expandTemplate{title = 'PD-US'}
end
-- ``{{License from structured data}}`` dispatches to the correct
-- license template (Cc-by-4.0, PD-USGov, etc.) from whichever of
-- P275/P6426/P6216 is set. Pass the entity's own id so it reads the
-- SAME MediaInfo the rest of the box rendered from: under the
-- {{Extracted from}} fallback that's the source file, so a cropped
-- CC-licensed derivative still gets its licence rather than reading
-- its own empty SDC. In the normal case ``entity.id`` is just the
-- current file and the result is unchanged.
local rendered = frame:expandTemplate{
title = 'License from structured data',
args = {id = entity.id},
}
-- Fallback: {{License from structured data}} (Module:License) bundles
-- rights via a branch table that assumes one of two mutually exclusive
-- shapes — N × P6216 with license/etc. as qualifiers (Wikidata style),
-- or 1 × P6216 with N × P275 top-level (Commons style). A community
-- edit that adds a second, contradictory P6216 statement (e.g.
-- ``Q19652`` public domain alongside our DPLA-attributed
-- ``Q50423863`` copyrighted) breaks both assumptions and Module:License
-- returns empty rather than pick a bundle. When that happens, expand
-- the DPLA-attributed P275 license template directly, taking the same
-- Wikidata P1424 (main template) → commonswiki sitelink path
-- Module:License uses internally. Loses author attribution and
-- ``{{Self}}`` wrapping, but DPLA files don't set either.
if p275Qid and (rendered == '' or rendered:match('^%s*$')) then
local fallback = licenseTemplateFromLicenseQid(p275Qid)
if fallback then
return frame:expandTemplate{title = fallback}
end
end
return rendered
end
-- Template-parameter alias map. The yellow user-contributed box accepts
-- either {{Information}} param names (description, author, source,
-- permission) or {{Artwork}} param names (description, artist,
-- institution, source, permission); ``firstParam`` resolves the first
-- non-empty match from a list of candidate names.
local function firstParam(args, ...)
if not args then return nil end
for _, name in ipairs({...}) do
local v = args[name]
if v and v ~= '' then return v end
end
return nil
end
-- Return true when ``wikitext_value`` is the same value the DPLA-attributed
-- SDC for ``prop`` already carries — i.e. when the wikitext is a verbatim
-- duplicate of authoritative DPLA data, not a user contribution. The
-- comparison depends on ``kind``:
--
-- * 'monolingualtext' — compare to ``mainsnak.datavalue.value.text`` on
-- each DPLA-determined statement (P1476 title, P10358 description).
-- * 'string' — compare to ``mainsnak.datavalue.value`` (P2093 stated-as
-- creator, P217 local identifier).
-- * 'time' — compare to the P1932 (stated as) qualifier on each
-- DPLA-determined statement. The mainsnak's structured time value
-- would have to be re-formatted to the original string for a direct
-- comparison; the P1932 qualifier preserves the original DPLA-supplied
-- display string verbatim (see ingest_wikimedia.sdc._build_date_claim),
-- so comparing to it is exact.
-- * 'wikibase-item' — wikitext value is expected to be a Q-ID string;
-- compare to ``mainsnak.datavalue.value.id`` on each DPLA-determined
-- statement (P195 institution, P9126 hub).
--
-- All comparisons trim surrounding whitespace to match the renderer's
-- behaviour. Returns false on missing entity / unknown kind so an
-- unrecognised property kind is "play it safe and render".
local function valueMatchesDplaSdc(entity, prop, wikitext_value, kind)
if not entity or not prop or not wikitext_value then return false end
local stripped = wikitext_value:gsub('^%s+', ''):gsub('%s+$', '')
if stripped == '' then return false end
for _, stmt in ipairs(dplaStatements(entity, prop)) do
local ms = stmt.mainsnak
if kind == 'monolingualtext' then
if ms.snaktype == 'value' and ms.datavalue
and ms.datavalue.type == 'monolingualtext'
and ms.datavalue.value
and ms.datavalue.value.text == stripped then
return true
end
elseif kind == 'string' then
-- Top-level string mainsnak (e.g. P217 local identifier).
-- For the creator field, the canonical SDC shape is a
-- P170 ``somevalue`` mainsnak with a P2093 qualifier, not
-- a top-level P2093 statement — see ``creator-name``
-- below; the ``string`` kind here only matches a direct
-- string mainsnak on the queried property.
if ms.snaktype == 'value' and ms.datavalue
and ms.datavalue.type == 'string'
and ms.datavalue.value == stripped then
return true
end
elseif kind == 'creator-name' then
-- DPLA's creator SDC: P170 mainsnak is typically
-- ``somevalue`` (we don't have a Wikidata item for the
-- creator), with the user-visible name carried on the
-- P2093 (stated as) qualifier (see sdc.py's
-- _build_creator_claim). Compare the wikitext creator
-- string to each DPLA-attributed P170 statement's P2093
-- qualifier; a direct P2093 statement is rare but is
-- caught by the ``string`` kind path when callers pass
-- prop='P2093' instead.
for _, q in ipairs((stmt.qualifiers or {}).P2093 or {}) do
if q.snaktype == 'value' and q.datavalue
and q.datavalue.value == stripped then
return true
end
end
elseif kind == 'time' then
-- The original display string lives on the P1932 qualifier;
-- the time mainsnak may be at year precision while the
-- editor wrote a full ISO date, so the mainsnak isn't a
-- reliable comparand.
--
-- Two-stage compare: direct byte equality first (covers
-- "1934 - 1948" vs. "1934 - 1948"), then range canonical
-- equivalence so ``{{other date|between|1934|1948}}`` /
-- "between 1934 and 1948" / "1934-1948" / "1934/1948" all
-- dedup against each other. The range fallback only runs
-- when both sides parse as canonical ranges, so single
-- dates and free prose are unaffected.
local strippedRangeA, strippedRangeB = parseDateRange(stripped)
for _, q in ipairs((stmt.qualifiers or {}).P1932 or {}) do
if q.snaktype == 'value' and q.datavalue then
if q.datavalue.value == stripped then
return true
end
if strippedRangeA then
local a, b = parseDateRange(q.datavalue.value or '')
if a == strippedRangeA and b == strippedRangeB then
return true
end
end
end
end
elseif kind == 'wikibase-item' then
if ms.snaktype == 'value' and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid'
and ms.datavalue.value
and ms.datavalue.value.id == stripped then
return true
end
end
end
return false
end
-- Special-case institution lookup that walks P195 *and* P9126
-- (partnership) statements for a matching Q-ID. The DPLA SDC writer
-- emits the hub-level institution on P195 (e.g. Q518155 = NARA for
-- every NARA-contributed file) and the more specific data-provider /
-- contributing institution on P9126 with a role qualifier (e.g.
-- Q59661040 = "National Archives at College Park - Still Pictures").
-- The wikitext ``institution =`` param holds the *data provider* Q-ID
-- — which matches the P9126 statement, not P195 — so checking only
-- P195 would miss the equality and leak a redundant Institution row
-- into the yellow box. Walking both properties catches either form.
local function institutionQidMatchesDplaSdc(entity, wikitext_value)
if valueMatchesDplaSdc(entity, P.INSTITUTION, wikitext_value, 'wikibase-item') then
return true
end
return valueMatchesDplaSdc(entity, P.PARTNERSHIP, wikitext_value, 'wikibase-item')
end
-- Pick the user-contributed value for a field: explicit template
-- parameter wins, otherwise fall back to the non-DPLA SDC renderer.
--
-- When ``dplaProp`` (and ``dplaKind``) is supplied, the wikitext value
-- is first checked against the DPLA-attributed SDC for that property
-- — if it matches, the value is treated as not user-contributed and
-- the SDC fallback runs instead. This catches the post-SDC pre-strip
-- case where the wikitext still carries a value DPLA wrote at upload
-- time and SDC sync has already mirrored. Module:DPLA's yellow box
-- is for user contributions only; a wikitext value that's identical
-- to authoritative DPLA SDC is by definition not a contribution.
--
-- Without ``dplaProp``, behavior is unchanged from the original
-- ``userValue`` (no redundancy check), which is the right default
-- for fields whose redundancy can't be reliably detected (e.g.
-- ``subject``, where the SDC representation is a mix of P921 items
-- and P4272 strings the renderer would have to reassemble).
--
local function userValue(args, paramNames, sdcFallback, entity, dplaProp, dplaKind)
for _, name in ipairs(paramNames) do
local v = args and args[name]
if v and v ~= '' then
if dplaProp and valueMatchesDplaSdc(entity, dplaProp, v, dplaKind) then
break -- drop into SDC fallback
end
-- Prepend a newline so block-level wikitext (----, bulleted
-- lists, blank-line paragraph breaks, headings) at the
-- start of the value renders as HTML. MediaWiki's parser
-- only fires block-level transformations on lines starting
-- at column 0; ``tableRow`` concatenates value directly
-- after ``<td>``, so a leading ``----`` in the value would
-- otherwise sit at column-6 relative to the cell start and
-- render as literal characters. A leading newline places
-- the first content line at column 0. Do NOT add a trailing
-- newline — that promotes the whole value to a block and
-- MediaWiki wraps it in a spurious ``<p>``, breaking the
-- ordinary scalar-row rendering. Extension tags like
-- <gallery> already fire in inline context and are
-- unaffected. SDC-fallback content is already-rendered
-- HTML and never touches this path.
return '\n' .. v
end
end
return sdcFallback() or ''
end
-- Legacy creator-shape extractor for files where the creator value
-- lives inside ``Other fields N = {{InFi | Creator | <value> |
-- id=fileinfotpl_aut}}`` (the pre-#291 legacy template carried it
-- this way; some files still have it post-{{Artwork}}→{{DPLA metadata}}
-- migration because the strip pass doesn't know how to normalise
-- ``Other fields N`` keys yet). Without this fallback the renderer
-- never sees a creator value for these files, the creator row is
-- omitted, and the ``fileinfotpl_aut`` machine-readable id never
-- makes it into the rendered HTML — putting the file in
-- ``Files with no machine-readable author``.
--
-- Reads the *raw* page wikitext via ``mw.title:getContent()`` because
-- by the time Lua sees ``args``, Scribunto has already pre-expanded
-- the ``{{InFi|...}}`` sub-template into its rendered HTML — there's
-- no longer a literal ``{{InFi|Creator|...}}`` string to match. The
-- raw-content path is the same one ``parseLegacyFromPageContent``
-- uses for the legacy ``source = {{DPLA|...}}`` block; ``getContent``
-- is cached within a single parse so the additional call is
-- effectively free when both legacy shapes coexist on the page.
-- Wrap a list of <tr> rows in the same outer table/div structure the
-- legacy renderer used so Commons CSS still styles the labels.
local function wrapInTable(rows)
if #rows == 0 then return nil end
return '<div class="hproduct commons-file-information-table">\n'
.. '<table class="fileinfotpl-type-information vevent" dir="ltr">\n'
.. table.concat(rows, '\n')
.. '\n</table></div>'
end
-- Banner strings now live in [[Module:DPLA/i18n]] keyed as 'banner_dpla'
-- and 'banner_user'. Resolved at render time via ``localizedLabel`` so the
-- viewer sees them in their preferred language (falling back to English).
-- Build the DPLA (blue) table rows from DPLA-determined SDC only.
--
-- ``filterFn`` (default ``isDplaDetermined``) scopes which DPLA statements
-- feed this box. On a cross-item duplicate — one Commons file carrying
-- statements from several DPLA items — ``render_metadata_table`` draws one
-- box per contributing item, passing ``isDplaDeterminedForItem(id)`` so
-- each box stays cohesive (only that item's statements) and merged metadata
-- from different items doesn't bleed together. Scoping the chunk path per
-- item also prevents P1545 series-letter collisions between items (two
-- items both using letter 'A' would otherwise be concatenated). The
-- list-path helpers (creator/date/institution/subject entities) take the
-- equivalent ``statementsFn`` derived from ``filterFn``.
--
-- ``emitMachineIds`` (default true) controls whether the ``fileinfotpl_*``
-- machine-readable ids are placed on the label cells. Only the FIRST box on
-- a cross-item file emits them; the others must not, because CommonsMetadata
-- requires those ids to be unique per page.
local function buildDplaRows(entity, frame, suppressKeys, filterFn, emitMachineIds)
filterFn = filterFn or isDplaDetermined
if emitMachineIds == nil then emitMachineIds = true end
-- Statement selector for the list-path helpers, equivalent to
-- ``dplaStatements`` (default) or ``dplaStatementsForItem(id)`` (per-item).
local statementsFn = statementsMatching(filterFn)
local rows = {}
-- ``add`` takes a label *key* (looked up in i18n via
-- ``localizedLabel``) rather than the already-resolved label
-- string, so the same key can also drive the optional
-- ``fileinfotpl_*`` machine-readable id on the value cell. Keys
-- with no entry in ``MACHINE_READABLE_ID`` render as plain rows.
--
-- ``suppressKeys`` (optional set of label keys) is populated only by
-- the {{Extracted from}} fallback: when the blue box is drawn from a
-- *source* file's SDC, any field the user supplied on the derivative
-- itself must take precedence, so the matching fallback field is
-- dropped here and surfaces in the yellow box instead. Nil/empty in
-- the normal single-entity render, where the file's own DPLA SDC is
-- authoritative over user-contributed values.
local function add(labelKey, value)
if suppressKeys and suppressKeys[labelKey] then return end
local fieldId = emitMachineIds and MACHINE_READABLE_ID[labelKey] or nil
local row = tableRow(
localizedLabel(labelKey),
value,
nil,
fieldId
)
if row then table.insert(rows, row) end
end
local title = titlesText(entity, nil, filterFn)
-- Drop the big title banner too when the derivative carries its own
-- (user-contributed) title — it renders as a yellow row instead.
if not (suppressKeys and suppressKeys['title']) then
local banner = titleBanner(title)
if banner then table.insert(rows, banner) end
end
add('title', title)
add('creator', creatorText(entity, statementsFn, frame))
add('description', descriptionText(entity, nil, filterFn))
add('date', dateText(entity, statementsFn, frame))
local instQid = institutionQidOf(entity, statementsFn)
if instQid then
add('institution', frame:expandTemplate{
title = 'Institution',
args = {wikidata = instQid},
})
end
add('subject', subjectsText(entity, statementsFn, filterFn))
add('source', sourceFieldText(entity, statementsFn, filterFn))
add('dpla_id', dplaIdText(entity, filterFn))
add('other_pages', p.other_pages(frame))
add('partnership', partnershipMarkup(entity, frame))
add('permission', permissionMarkup(entity, frame))
return rows
end
-- Parse a legacy ``source = {{DPLA|<inst>|hub=...|url=...|dpla_id=...|local_id=...}}``
-- invocation, given the raw (unexpanded) wikitext value of the source
-- param. Returns a flat dict keyed by hub / inst_qid / url / dpla_id /
-- local_id, or nil when the value doesn't match the legacy shape.
-- Tolerant of inner whitespace and newlines (the upload template_string
-- emits a multi-line form).
local function parseLegacySourceParam(s)
if not s or s == '' then return nil end
local body = s:match('{{%s*[Dd][Pp][Ll][Aa]%s*|(.-)}}%s*$')
if not body then return nil end
local out = {}
for piece in (body .. '|'):gmatch('(.-)|') do
piece = piece:match('^%s*(.-)%s*$') or ''
if piece ~= '' then
local eq = piece:find('=', 1, true)
if eq then
local k = piece:sub(1, eq - 1):match('^%s*(.-)%s*$'):lower()
local v = piece:sub(eq + 1):match('^%s*(.-)%s*$')
if k == 'hub' then out.hub = v end
if k == 'url' then out.url = v end
if k == 'dpla_id' then out.dpla_id = v end
if k == 'local_id' then out.local_id = v end
elseif not out.inst_qid then
out.inst_qid = piece
end
end
end
return out
end
-- Locate the body of a top-level ``{{<name>|...}}`` template invocation
-- in ``content``, returning the wikitext between the opening ``{{`` and
-- the matching closing ``}}`` (not including the braces themselves).
-- Handles nesting — an inner ``{{...}}`` within the template's args
-- counts up the depth so the matching close belongs to the outer
-- envelope, not to the inner sub-template's close.
--
-- Lua patterns don't support balanced matching, so this is a manual
-- scan. Linear in content length; fine for the ~kilobyte file pages
-- this runs on at render time.
local function extractTemplateBody(content, name)
local i = content:find('{{%s*' .. name, 1)
if not i then return nil end
-- Skip past the opening ``{{`` and the template name itself.
local _, openEnd = content:find('{{%s*' .. name, i)
local j = openEnd + 1
local depth = 1
while j <= #content do
local two = content:sub(j, j + 1)
if two == '{{' then
depth = depth + 1
j = j + 2
elseif two == '}}' then
depth = depth - 1
if depth == 0 then
return content:sub(openEnd + 1, j - 1)
end
j = j + 2
else
j = j + 1
end
end
return nil
end
-- Walk the body of a ``{{DPLA metadata|...}}`` invocation and return
-- the value of each top-level (depth-0) named argument as a flat
-- dict. Nested ``{{...}}`` and ``[[...]]`` get carried inside the
-- containing arg's value; only ``|`` characters at depth 0 separate
-- top-level args.
local function splitTopLevelArgs(body)
local args = {}
local depth = 0
local linkDepth = 0
local current = {}
local positional = 0
local function flush()
local piece = table.concat(current)
current = {}
local eq = piece:find('=', 1, true)
if eq then
local k = piece:sub(1, eq - 1):match('^%s*(.-)%s*$'):lower()
local v = piece:sub(eq + 1):match('^%s*(.-)%s*$')
args[k] = v
else
-- Positional argument: store under its 1-based index. Never
-- clobber an explicit ``N=`` form (explicit wins, matching
-- MediaWiki template-argument semantics); the empty piece
-- before the first ``|`` is skipped.
local v = piece:match('^%s*(.-)%s*$')
if v ~= '' then
positional = positional + 1
local key = tostring(positional)
if args[key] == nil then args[key] = v end
end
end
end
local i = 1
while i <= #body do
local two = body:sub(i, i + 1)
if two == '{{' then
depth = depth + 1
table.insert(current, two)
i = i + 2
elseif two == '}}' then
depth = depth - 1
table.insert(current, two)
i = i + 2
elseif two == '[[' then
linkDepth = linkDepth + 1
table.insert(current, two)
i = i + 2
elseif two == ']]' then
linkDepth = linkDepth - 1
table.insert(current, two)
i = i + 2
elseif body:sub(i, i) == '|' and depth == 0 and linkDepth == 0 then
flush()
i = i + 1
else
table.insert(current, body:sub(i, i))
i = i + 1
end
end
flush()
return args
end
-- ============================================================
-- {{Extracted from}} SDC fallback (CropTool derivatives)
-- ============================================================
--
-- CropTool (and similar derivative-creation gadgets) copy a file's
-- *wikitext* — now just ``{{DPLA metadata}}`` — onto the new cropped file,
-- but do NOT copy its structured data. The derivative is left with the
-- template and an empty MediaInfo, so ``{{DPLA metadata}}`` renders blank
-- with no licence and the file gets tagged "no licence". The derivative
-- does, though, carry a ``{{Extracted from|<source>}}`` pointer back to the
-- file it was cropped from. When the current file has no DPLA SDC of its
-- own, we follow that pointer to the source file's MediaInfo and render
-- from it, so the derivative inherits the original's description,
-- categories and licence.
-- Maximum number of {{Extracted from}} pointers to follow (crop-of-a-crop
-- chains). Bounds the expensive getContent/getEntity calls and guards
-- against cyclic pointers.
local MAX_EXTRACTED_FROM_HOPS = 4
-- True iff the entity carries DPLA-authored structured data of its own.
-- Keys on the two markers every DPLA upload writes — P760 (DPLA ID) and
-- P7482 (source of file), each stamped with the DPLA P123 reference. A
-- file with neither is either a non-DPLA file or an un-synced derivative
-- (e.g. a fresh CropTool crop), and so a candidate for the fallback.
local function hasOwnDplaSdc(entity)
return #dplaStatements(entity, P.DPLA_ID) > 0
or #dplaStatements(entity, P.SOURCE_OF_FILE) > 0
end
-- Return the source filename named by an {{Extracted from}} template in
-- ``content`` (its first parameter, written positionally or as ``1=``),
-- or nil. Returned verbatim — may or may not carry a File:/Image: prefix.
local function extractedFromSource(content)
if not content then return nil end
local body = extractTemplateBody(content, '[Ee]xtracted from')
if not body then return nil end
local src = splitTopLevelArgs(body)['1']
if not src or src == '' then return nil end
return src
end
-- Follow {{Extracted from}} pointers up the derivative chain and return
-- the first ancestor file that carries its own DPLA SDC, or nil. ``content``
-- is the starting file's raw wikitext (the derivative's). Bounded to
-- MAX_EXTRACTED_FROM_HOPS so a crop-of-a-crop still resolves but cyclic or
-- runaway pointers can't. Each hop reads one page's wikitext and one
-- MediaInfo entity — both expensive parser functions, hence the bound.
local function resolveExtractedFromEntity(content)
for _ = 1, MAX_EXTRACTED_FROM_HOPS do
local src = extractedFromSource(content)
if not src then return nil end
local title = mw.title.new(src, 'File')
if not title or not title.id or title.id == 0 then return nil end
local entity = mw.wikibase.getEntity('M' .. tostring(title.id))
if hasOwnDplaSdc(entity) then return entity end
-- Ancestor is itself an un-synced derivative — climb to its source.
content = title:getContent()
if not content then return nil end
end
return nil
end
-- Read the raw (unexpanded) wikitext of the current file page and
-- extract the legacy ``source = {{DPLA|...}}`` and ``Institution =
-- {{Institution|wikidata=Q...}}`` values, parsing each into the flat
-- dict the parametric renderers consume.
--
-- This indirection exists because Scribunto's ``frame:getParent().args``
-- returns pre-expanded values: by the time the Lua module sees
-- ``args.source``, the inner ``{{DPLA|...}}`` has been expanded to its
-- rendered wikitext (which contains ``{|...|}`` table syntax that
-- leaks raw inside the surrounding HTML <td>). Fetching the raw
-- wikitext via mw.title gives us back the literal sub-template
-- invocation, which we can parse confidently.
--
-- Returns an empty dict when getContent fails (the current title isn't
-- a file page, or the page doesn't exist yet during preview); the
-- caller's behaviour then degrades gracefully to "no parsed legacy
-- params" — same effect as a flat-shape file with no source fields.
local function parseLegacyFromPageContent()
local title = mw.title.getCurrentTitle()
if not title then return {} end
local content = title:getContent()
if not content then return {} end
local envelope = extractTemplateBody(content, 'DPLA metadata')
if not envelope then return {} end
local topArgs = splitTopLevelArgs(envelope)
local out = {}
local sourceVal = topArgs['source']
if sourceVal then
local parsed = parseLegacySourceParam(sourceVal)
if parsed then
out.hub = parsed.hub
out.inst_qid = parsed.inst_qid
out.url = parsed.url
out.dpla_id = parsed.dpla_id
out.local_id = parsed.local_id
end
end
-- The Institution param is conventionally written with a capital
-- ``I`` in the legacy form (``Institution = {{Institution|...}}``);
-- splitTopLevelArgs lowercases the key.
local instVal = topArgs['institution']
if instVal and not out.inst_qid then
out.inst_qid = instVal:match(
'{{%s*[Ii]nstitution%s*|%s*[Ww]ikidata%s*=%s*([^|}%s]+)'
)
end
return out
end
-- Legacy creator-shape extractor for files where the creator value
-- lives inside ``Other fields N = {{InFi | Creator | <value> |
-- id=fileinfotpl_aut}}`` (the pre-#291 legacy template carried it
-- this way; some files still have it post-{{Artwork}}→{{DPLA metadata}}
-- migration because the strip pass doesn't know how to normalise
-- ``Other fields N`` keys yet). Without this fallback the renderer
-- never sees a creator value for these files, the creator row is
-- omitted, and the ``fileinfotpl_aut`` machine-readable id never
-- makes it into the rendered HTML — putting the file in
-- ``Files with no machine-readable author``.
--
-- Reads the *raw* page wikitext via ``mw.title:getContent()`` because
-- by the time Lua sees ``args``, Scribunto has already pre-expanded
-- the ``{{InFi|...}}`` sub-template into its rendered HTML — there's
-- no longer a literal ``{{InFi|Creator|...}}`` string to match. The
-- raw-content path is the same one ``parseLegacyFromPageContent``
-- uses for the legacy ``source = {{DPLA|...}}`` block; ``getContent``
-- is cached within a single parse so the additional call is
-- effectively free when both legacy shapes coexist on the page.
local function legacyOtherFieldsCreator()
local title = mw.title.getCurrentTitle()
if not title then return nil end
local content = title:getContent()
if not content then return nil end
local envelope = extractTemplateBody(content, 'DPLA metadata')
if not envelope then return nil end
local topArgs = splitTopLevelArgs(envelope)
for i = 1, 9 do
local v = topArgs['other fields ' .. i]
if v and v ~= '' then
-- ``{{ InFi | <Field> | <Value> | id=<id> }}`` shape;
-- the field label preceding the value is ``Creator``,
-- ``Author``, or ``Artist`` (case-insensitive, with
-- optional surrounding whitespace) on the InFi
-- invocations used by the legacy DPLA uploader.
local extracted = v:match(
'{{%s*[Ii]n[Ff]i%s*|%s*[Cc]reator%s*|%s*(.-)%s*|'
) or v:match(
'{{%s*[Ii]n[Ff]i%s*|%s*[Aa]uthor%s*|%s*(.-)%s*|'
) or v:match(
'{{%s*[Ii]n[Ff]i%s*|%s*[Aa]rtist%s*|%s*(.-)%s*|'
)
if extracted and extracted ~= '' then
return extracted
end
end
end
return nil
end
-- Resolve the user-side source/institution data into the flat shape the
-- parametric renderers consume, dual-pathed across:
-- 1. Flat params on ``{{DPLA metadata}}`` (hub, institution, url,
-- dpla_id, local_id) — what new uploads emit.
-- 2. Legacy nested params: ``source = {{DPLA|...}}`` and
-- ``Institution = {{Institution|wikidata=...}}`` — what historic
-- and pre-flat uploads carry. The literal sub-template invocations
-- are pulled from ``mw.title.getCurrentTitle():getContent()``
-- because Scribunto's args are pre-expanded.
--
-- Returns a table with hub/inst_qid/url/dpla_id/local_id; any field
-- may be nil. Flat-param values take precedence over legacy-parsed
-- ones so a file that's been migrated to flat shape but still has
-- stale legacy params (mid-transition) renders using the new shape.
local function resolveUserSourceFields(args)
local hub = firstParam(args, 'hub')
local instQid = firstParam(args, 'institution')
local url = firstParam(args, 'url')
local dplaId = firstParam(args, 'dpla_id')
local localId = firstParam(args, 'local_id')
if hub and url and dplaId and localId then
return {
hub = hub, inst_qid = instQid, url = url,
dpla_id = dplaId, local_id = localId,
}
end
-- Only pay the page-content fetch cost when there's actually a
-- legacy ``source`` or ``Institution`` param to resolve. Files in
-- the flat-shape pipeline never hit this path; files lacking any
-- source/institution context (a rare hand-written edit) also skip
-- it. mw.title:getContent() registers a transclusion dependency
-- whose invalidation pulse triggers on every page edit, so the
-- gate keeps the cache-invalidation graph minimal.
local hasLegacySourceArg = firstParam(args, 'source') ~= nil
local hasLegacyInstArg = firstParam(args, 'Institution') ~= nil
or firstParam(args, 'institution') ~= nil
if not hasLegacySourceArg and not hasLegacyInstArg then
return {
hub = hub, inst_qid = instQid, url = url,
dpla_id = dplaId, local_id = localId,
}
end
local legacy = parseLegacyFromPageContent()
return {
hub = hub or legacy.hub,
inst_qid = instQid or legacy.inst_qid,
url = url or legacy.url,
dpla_id = dplaId or legacy.dpla_id,
local_id = localId or legacy.local_id,
}
end
-- Build the user-contributed (yellow) table rows. Each field prefers
-- an explicit template parameter; if absent, falls back to non-DPLA
-- SDC statements on the same property. For DPLA-bot uploads (legacy
-- ``{{DPLA|...}}`` shape or new flat-param shape), the source and
-- partnership rows render from the parsed wikitext params via the
-- same parametric helpers the blue box uses — keeping the yellow-box
-- presentation aligned with the blue box and avoiding the ``{|``
-- raw-markup leak the legacy sub-template caused inside an HTML cell.
local function buildUserRows(entity, args, frame, hasDplaBox)
local rows = {}
-- ``add`` mirrors ``buildDplaRows``'s helper — takes a label
-- *key* so the same key drives both the localized label and
-- the optional ``fileinfotpl_*`` machine-readable id on the
-- value cell. ``emittedKeys`` records every field that actually
-- produced a row (i.e. the user contributed it); it is returned so
-- the {{Extracted from}} fallback can suppress those same fields in
-- the source-derived blue box.
local emittedKeys = {}
local function add(labelKey, value)
local row = tableRow(
localizedLabel(labelKey),
value,
nil,
MACHINE_READABLE_ID[labelKey]
)
if row then
table.insert(rows, row)
emittedKeys[labelKey] = true
end
end
local title = userValue(args, {'title'}, function()
return entity and titlesText(entity, nil, isNonDplaDetermined) or ''
end, entity, P.TITLE, 'monolingualtext')
-- Only render the yellow box's own title banner when the blue box
-- isn't present. When the blue box is rendering above, it already
-- emits the file's title in the big-bold banner; duplicating it (or
-- worse, promoting a user-contributed translation to a secondary
-- big header) reads as two coequal titles. The user-contributed
-- title still renders below as a normal row via add('title', ...).
if not hasDplaBox then
local banner = titleBanner(title)
if banner then table.insert(rows, banner) end
end
add('title', title)
add('creator', userValue(
args, {'author', 'artist', 'creator'},
function()
-- SDC fallback: prefer a non-DPLA P170 statement, then
-- fall back to the legacy ``Other fields N = {{InFi |
-- Creator | ...}}`` shape some mid-migration files still
-- carry. The InFi extraction is critical for the
-- machine-readable ``fileinfotpl_aut`` id: without it,
-- the creator row never renders on legacy-shape files
-- and the page lands in
-- ``Files with no machine-readable author``.
local sdcVal = entity and creatorText(entity, nonDplaStatements, frame) or ''
if sdcVal and sdcVal ~= '' then return sdcVal end
return legacyOtherFieldsCreator() or ''
end,
-- Check P170 (creator) statements for a P2093 (stated as)
-- qualifier matching the wikitext value — that's the canonical
-- DPLA SDC shape. See the ``creator-name`` branch of
-- ``valueMatchesDplaSdc`` for the comparison logic.
entity, P.CREATOR, 'creator-name'
))
add('description', userValue(
args, {'description'},
function()
return entity and descriptionText(entity, nil, isNonDplaDetermined) or ''
end,
entity, P.DESCRIPTION, 'monolingualtext'
))
add('date', userValue(
args, {'date'},
function()
return entity and dateText(entity, nonDplaStatements, frame) or ''
end,
entity, P.DATE, 'time'
))
-- Source-row fields (hub/institution/url/dpla_id/local_id) are
-- consolidated once because the institution Q-ID is used by both
-- the Institution row and the Partnership row, and the legacy
-- ``source = {{DPLA|...}}`` carried it alongside the URL fields.
-- Resolving once also means the legacy-shape parse only runs once
-- per file.
local userSource = resolveUserSourceFields(args)
-- Institution row. Prefers the resolved Q-ID (works for both flat
-- ``institution = Q...`` uploads and legacy ``Institution =
-- {{Institution|wikidata=Q...}}`` uploads), falling back to a
-- non-DPLA P195 SDC statement when neither template form is
-- present. The wikitext Q-ID is suppressed when it matches the
-- DPLA-attributed P195 — same redundancy rule the scalar rows
-- above apply: a wikitext value identical to DPLA SDC is by
-- definition not a user contribution.
local userInstQid = userSource.inst_qid
if userInstQid and institutionQidMatchesDplaSdc(entity, userInstQid) then
userInstQid = nil -- fall through to non-DPLA SDC below
end
if userInstQid then
add('institution', frame:expandTemplate{
title = 'Institution',
args = {wikidata = userInstQid},
})
elseif entity then
-- Skip a non-DPLA P195 whose Q-ID is already a DPLA-sourced
-- P9126 custodial unit (P3831 = ROLE_CONTRIBUTING). Same fact,
-- different property — the P9126 custodial-unit statement is
-- the canonical shape for a departmental unit, and rendering
-- a matching P195 in the yellow box surfaces a redundant
-- Institution row that looks like a community override.
local custodialQids = allPartnersByRole(entity, Q.ROLE_CONTRIBUTING)
local isCustodialUnit = {}
for _, q in ipairs(custodialQids) do
isCustodialUnit[q] = true
end
local sdcInstQid
for _, stmt in ipairs(nonDplaStatements(entity, P.INSTITUTION)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid'
and not isCustodialUnit[ms.datavalue.value.id] then
sdcInstQid = ms.datavalue.value.id
break
end
end
if sdcInstQid then
add('institution', frame:expandTemplate{
title = 'Institution',
args = {wikidata = sdcInstQid},
})
end
end
add('subject', userValue(args, {'subject'}, function()
return entity and subjectsText(entity, nonDplaStatements, isNonDplaDetermined) or ''
end))
-- Source row. Builds the icon-cell HTML directly from the resolved
-- catalog URL — same parametric helper the SDC-driven blue box
-- uses. The legacy ``source = {{DPLA|...}}`` sub-template path
-- used to fall through to a verbatim pass-through here, which
-- leaked the sub-template's ``{|`` wikitable syntax into the
-- surrounding HTML <td> (MediaWiki doesn't restart table scanning
-- inside an HTML cell). ``resolveUserSourceFields`` parses both
-- the legacy and the flat shape into the same flat dict, so the
-- icon-cell renderer handles either identically.
--
-- Only ``catalogUrl`` is populated from wikitext params — the
-- per-ordinal direct-file URL and IIIF manifest URL live only in
-- SDC (P2699 / P6108) and aren't carried in the upload-time
-- wikitext. The yellow box therefore shows just the catalog-link
-- cell; the blue box shows up to three cells once SDC is written.
-- Source-row redundancy guard: if there's any DPLA-attributed
-- P7482 (described at) statement, the blue box already shows the
-- canonical catalog/file/IIIF URLs. A wikitext-derived source row
-- showing the same catalog URL would be redundant — suppress.
local hasDplaSource = entity and #dplaStatements(entity, P.SOURCE_OF_FILE) > 0
if userSource.url and not hasDplaSource then
local naraInterwiki
if userSource.inst_qid == Q.NARA and userSource.local_id then
naraInterwiki = '[[nara:' .. userSource.local_id
.. '|' .. localizedLabel('catalog_record') .. ']]'
end
add('source', sourceFieldFromUrls(
nil, nil, userSource.url, naraInterwiki
))
elseif not userSource.url then
-- Pass-through fallback: a Commons editor may have written a
-- free-text source description that's neither the DPLA-bot
-- ``{{DPLA|...}}`` invocation nor a flat-param shape. Preserve
-- it verbatim — the editor put it there deliberately, and
-- losing the row would silently discard their contribution.
-- The ``{|`` parsing problem only affects the specific
-- ``{{DPLA|...}}`` legacy shape (its inner {{DPLA/layout}}
-- emits wikitable syntax); free-text or other-template
-- values render fine in an HTML cell.
local rawSource = firstParam(args, 'source')
if rawSource then
add('source', rawSource)
end
end
-- DPLA ID row. Mirrors the blue box's ``dpla:`` interwiki link, built
-- from the wikitext ``dpla_id`` param (legacy ``{{DPLA|...}}`` or flat
-- shape) so a file rendering from upload-time wikitext — before SDC
-- sync runs — still shows its DPLA ID instead of silently dropping it.
-- Suppress when a DPLA-attributed P760 exists: the blue box renders
-- the canonical ID then and a wikitext duplicate would be redundant
-- (same redundancy rule the Source row above applies).
local hasDplaId = entity and #dplaStatements(entity, P.DPLA_ID) > 0
if userSource.dpla_id and not hasDplaId then
add('dpla_id', dplaIdLink(userSource.dpla_id))
end
-- Partnership row. Renders from the resolved institution + hub
-- Q-IDs the Institution row used. Surfaced in the yellow box for
-- the upload → SDC-sync interim window (no SDC yet, wikitext
-- params are the only source). Once SDC is written and carries
-- the canonical P9126 partnership, the wikitext-derived row would
-- be a redundant duplicate — suppress when any DPLA-attributed
-- P9126 statement exists. The blue box's partnership card renders
-- from that same SDC.
--
-- Partnership is added BEFORE Permission so the yellow box's row
-- order matches the blue box's (see ``buildDplaRows`` above). The
-- mismatch was a real, visible reordering on files with no SDC
-- yet — the rendered yellow box put the big Permission block
-- above the Partnership card, which read as Partnership being
-- demoted to a footer.
local hasDplaPartnership = entity
and #dplaStatements(entity, P.PARTNERSHIP) > 0
if (userSource.inst_qid or userSource.hub) and not hasDplaPartnership then
add('partnership',
partnershipFromQids(userSource.inst_qid, userSource.hub))
end
-- Permission row. A wikitext ``permission = {{NoC-US|...}}`` /
-- ``{{Cc-zero}}`` / similar template renders to a clean permission
-- box inside a cell when present. Suppress when any DPLA-attributed
-- P6426 (copyright license) statement is on the entity — the blue
-- box already renders the permission from that SDC. Comparing the
-- specific wikitext template name (``NoC-US``, ``Cc-zero``, etc.)
-- to the P6426 Q-ID (Q47530911, Q6938433, etc.) requires a mapping
-- table that doesn't exist here; the existence-based suppression
-- is a safe over-suppression because the wikitext permission was,
-- in practice, always written by the bot to mirror SDC.
local hasDplaPermission = entity
and #dplaStatements(entity, 'P6426') > 0
if not hasDplaPermission then
add('permission', firstParam(args, 'permission') or '')
end
-- Other versions row. Preserves the ``{{Artwork}}`` "Other versions"
-- field a community editor may have populated with ``{{other
-- version|Filename.jpg}}`` links. The wikitext value passes through
-- verbatim; the SDC fallback iterates P6802 (related image) statements
-- on the entity — including inferred-from-Wikitext claims written by
-- our migration — and expands each as ``{{other version|<filename>}}``.
-- ``nonDplaStatements`` is correct here because inferred-from-Wikitext
-- references (P887=Q131783016 + P4656=<permalink>) don't carry the
-- DPLA-publisher marker that ``isDplaDetermined`` keys on, so they
-- correctly surface in the yellow user-contributed box.
add('other_versions', userValue(args, {'other_versions', 'other versions', 'Other versions'},
function()
if not entity then return '' end
local parts = {}
for _, stmt in ipairs(nonDplaStatements(entity, P.OTHER_VERSIONS)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'string' then
table.insert(parts, frame:expandTemplate{
title = 'other version',
args = {[1] = ms.datavalue.value},
})
end
end
return table.concat(parts, ' ')
end
))
-- other_fields: arbitrary user-added display rows, mirroring
-- {{Information}}. Each {{Information field}}/{{InFi}} transcludes to a
-- <tr>, so the arg arrives as already-rendered row HTML — inject it
-- verbatim (NOT via add(), which would wrap it in a second <tr>). Supports
-- the canonical single ``other_fields`` and the legacy numbered ``Other
-- fields N`` shape. Values are opaque to us, so multiple fields (several
-- InFi calls concatenated) and multi-value cells (e.g. {{ubl|...}}) work
-- with no special handling.
--
-- Strip any ``id="fileinfotpl_*"`` a passthrough row carries (an
-- {{InFi|Creator|...}} sets id="fileinfotpl_aut"): the DPLA box owns those
-- machine-readable ids, and a duplicate on the page trips CommonsMetadata's
-- error categories. other_fields is for fields DPLA doesn't already model,
-- so the scrub is a no-op for intended use. Not recorded in emittedKeys —
-- these are free display fields, not DPLA-SDC-suppressible keys.
local function addPassthroughRows(raw)
if type(raw) ~= 'string' or not raw:match('%S') then return end
table.insert(rows, (raw:gsub('id%s*=%s*"fileinfotpl_[%w_]*"', '')))
end
addPassthroughRows(args.other_fields)
for i = 1, 20 do
addPassthroughRows(args['other fields ' .. i] or args['Other fields ' .. i])
end
return rows, emittedKeys
end
local function divbox(color, banner, body, frame)
return frame:expandTemplate{
title = 'divbox',
args = {[1] = color, [2] = banner, [3] = body},
}
end
--- p.render_metadata_table — entry point invoked from the
-- ``Template:DPLA metadata`` template. Builds up to two stacked boxes:
--
-- * Blue box: DPLA-determined SDC, with the standard provenance
-- banner. Rendered iff at least one DPLA field is present.
-- * Yellow box: explicit template parameters (matching {{Information}}
-- or {{Artwork}} param names) plus any non-DPLA SDC on the same
-- properties. Rendered iff at least one user-contributed value
-- exists.
--
-- Either box is silently omitted when its rows are empty; on a file
-- with no DPLA SDC and no template params, render_metadata_table
-- returns the empty string.
function p.render_metadata_table(frame)
local invokeArgs = (frame and frame.args) or {}
local parent = frame and frame:getParent() or nil
local userArgs = (parent and parent.args) or {}
local selfEntity = getEntity(invokeArgs)
-- {{Extracted from}} fallback: a derivative (e.g. a CropTool crop)
-- inherits its source's ``{{DPLA metadata}}`` wikitext but not its
-- SDC, so on its own it renders blank and unlicensed. When the file
-- has no DPLA SDC of its own, follow any ``{{Extracted from}}`` pointer
-- in its wikitext to the source file and use its MediaInfo as a
-- *fallback*. Only engaged for a wholly un-synced file.
--
-- The "subject" file is the ``page=`` target when given (so
-- /testcases can exercise this path against a real derivative), else
-- the page being rendered on. ``getEntity`` already honours ``page=``
-- for ``selfEntity``; we mirror it here for the wikitext lookup so
-- ``page=`` is a complete simulation rather than reading whichever
-- page the test happens to render on.
local subject = (invokeArgs.page and invokeArgs.page ~= '')
and mw.title.new(invokeArgs.page)
or mw.title.getCurrentTitle()
local sourceEntity = nil
if subject and not hasOwnDplaSdc(selfEntity) then
local content = subject:getContent()
if content then
sourceEntity = resolveExtractedFromEntity(content)
end
end
-- Effective entity for the categories and missing-SDC tracking below:
-- the fallback source when engaged (so the derivative is categorised
-- with its source and isn't flagged as missing required SDC), else the
-- file's own MediaInfo.
local entity = sourceEntity or selfEntity
-- Register the ``{{Infobox template tag}}`` transclusion so
-- ``{{DPLA metadata}}`` is recognised by YiFeiBot's
-- "Media missing infobox template" scanner. The tag is an
-- empty marker template; the ``expandTemplate`` call has no
-- observable output but registers the transclusion edge the
-- bot looks for. Same pattern ``Module:Information`` and
-- ``Module:Artwork`` use — see the comment in Module:Information:
-- "all official infoboxes transclude {{Infobox template tag}}
-- so files without that tag do not have an infobox".
if frame then
frame:expandTemplate{ title = 'Infobox template tag' }
end
-- The blue box(es) render from the file's own DPLA SDC (``selfEntity``),
-- or from a resolved {{Extracted from}} source when the file has none.
-- ``suppressKeys`` is populated only in the fallback case.
local blueEntity = sourceEntity or selfEntity
local suppressKeys = nil
local userRows
if sourceEntity then
-- Fallback precedence: the file's own DPLA SDC is what normally
-- outranks user-contributed values, and here the file has none —
-- so the derivative's OWN user content (its wikitext params and
-- any non-DPLA SDC on the file itself) wins over the source's
-- fallback SDC. Build the yellow box from the derivative first, then
-- suppress those same fields in the source-derived blue box. A
-- user-contributed value therefore shows in the yellow box and
-- replaces the blue-box field; the source fills in only the
-- fields the user left blank. ``hasDplaBox`` is forced true: the
-- source supplies the primary (blue) box, so a user-contributed
-- title renders as a normal yellow row rather than a banner.
local userKeys
userRows, userKeys = buildUserRows(selfEntity, userArgs, frame, true)
suppressKeys = userKeys
end
-- One blue box per contributing DPLA item. A single-item file (every
-- file today) yields exactly one box built with the default filter —
-- behaviour-identical to before. A cross-item duplicate (statements from
-- several DPLA items merged onto one file) yields one cohesive box per
-- item, so merged metadata from different items stays separated. The
-- machine-readable ``fileinfotpl_*`` ids are emitted only on the FIRST
-- box (CommonsMetadata requires them unique per page).
local ids = dplaItemIds(blueEntity)
local blueBoxes = {} -- list of { id = <item id>, table = <html>, single = <bool> }
if #ids > 1 then
for i, id in ipairs(ids) do
local rows = buildDplaRows(
blueEntity, frame, suppressKeys,
isDplaDeterminedForItem(id),
i == 1
)
local tbl = wrapInTable(rows)
if tbl then
table.insert(blueBoxes, { id = id, table = tbl })
end
end
else
-- Single-item (or, defensively, zero-item) file: the original
-- single-box path — default filter, machine ids on.
local rows = blueEntity and buildDplaRows(blueEntity, frame, suppressKeys) or {}
local tbl = wrapInTable(rows)
if tbl then
table.insert(blueBoxes, { table = tbl, single = true })
end
end
local hasDplaBox = #blueBoxes > 0
-- In the normal (non-fallback) render the yellow box's banner depends
-- on whether any blue box rendered, so build it now.
if not sourceEntity then
userRows = buildUserRows(selfEntity, userArgs, frame, hasDplaBox)
end
local out = {}
local userTable = wrapInTable(userRows)
-- ID for the anchor link the blue banner points at when both
-- boxes render. Kept as a stable string so translations of the
-- jump-link text land on the same anchor and any external
-- deep-links from documentation continue to resolve. Suggested
-- by [[User:Jmabel]] on the DPLA-bot talk thread 2026-07-07.
local USER_BOX_ANCHOR = 'dpla-user-contributions'
for i, box in ipairs(blueBoxes) do
-- Blue banner: provenance sentence + maintenance note, on a single
-- line because Template:Divbox embeds arg 2 into an HTML ``title=""``
-- attribute — a ``<br />`` inside it breaks the outer div. On a
-- cross-item file each box names its own contributing item via a
-- ``dpla:`` interwiki link.
local banner
if box.single then
banner = localizedLabel('banner_dpla')
else
banner = substituteParams(
localizedLabel('banner_dpla_item'), dplaIdLink(box.id)
)
end
banner = banner .. ' ' .. localizedLabel('banner_dpla_maintenance_note')
-- Jump-link at the END of the LAST blue box's banner — the box
-- sitting directly above the yellow box it points at — after the
-- maintenance note.
if userTable and i == #blueBoxes then
banner = banner
.. ' [[#' .. USER_BOX_ANCHOR .. '|'
.. localizedLabel('banner_dpla_jump_to_user')
.. ']]'
end
table.insert(out, divbox('blue', banner, box.table, frame))
end
if userTable then
-- Anchor placed ABOVE the yellow divbox (not inside its body)
-- so the jump-link scrolls to the top of the yellow box —
-- including the "The following metadata was added by Wikimedia
-- users" heading — rather than skipping it. Suggested by
-- [[User:Jmabel]] after eyeballing the earlier sandbox render
-- where the anchor landed BELOW that heading and readers
-- missed the disclaimer.
table.insert(out, '<span id="' .. USER_BOX_ANCHOR .. '"></span>')
-- The yellow box's "added by Wikimedia users" disclaimer only
-- makes sense as a contrast against the blue DPLA-attributed
-- box. When the blue box is suppressed (no DPLA SDC), drop the
-- banner so the yellow box stands on its own.
local yellowBanner = hasDplaBox and localizedLabel('banner_user') or ''
table.insert(out, divbox('yellow', yellowBanner, userTable, frame))
end
-- "Media contributed by..." categories — DPLA, hub, institution.
-- Dual-source: prefer SDC-resolved Qids when present; otherwise
-- fall back to the parsed wikitext source (legacy
-- ``source = {{DPLA|...}}`` or flat ``hub`` / ``institution``
-- params). The wikitext fallback covers legacy ``{{DPLA}}`` /
-- dual-render files that haven't had their SDC synced yet — they
-- still need to appear in the hub / institution maintenance
-- categories the legacy ``{{DPLA/hub cat}}`` / ``{{DPLA/inst cat}}``
-- templates emitted. A file with neither source identifying it
-- as a DPLA upload stays uncategorised.
local catHubQid, catInstQid = resolveCategoryQidsFromEntity(entity)
if not catHubQid and not catInstQid then
local src = resolveUserSourceFields(userArgs)
if src.hub or src.inst_qid then
catHubQid = src.hub or src.inst_qid
catInstQid = src.inst_qid
end
end
if catHubQid or catInstQid then
table.insert(out, buildCategoryList(catHubQid, catInstQid))
end
-- Tracking category for "files with non-DPLA metadata enhancements":
-- emit iff the yellow box renders WITH its banner — i.e. there's
-- a DPLA-attributed blue box AND user-contributed yellow rows on
-- top of it. The banner is suppressed (``hasDplaBox`` false) on a
-- just-uploaded file where SDC sync hasn't run yet; the yellow
-- box still renders there because the wikitext params haven't
-- been stripped, but those values aren't real "enhancements" —
-- they're the upload's own metadata pending SDC migration. Gating
-- on ``hasDplaBox and userTable`` keeps the category tied to the
-- post-SDC steady state where genuine community contributions
-- show up.
if hasDplaBox and userTable then
table.insert(out, '[[Category:' .. ENHANCED_CAT .. ']]')
end
-- Tracking category for centralized (multi-item / multi-page) files.
-- Multi-item: more than one contributing DPLA item, i.e. an extra blue
-- box renders (``#ids > 1`` — the same list that drove the box loop
-- above). Multi-page: a single item's P760 carries more than one P304
-- page position (``allPageNumbers`` returns >1), i.e. the file is a
-- within-item duplicate with multi-line "Other pages" navigation. Read
-- from ``blueEntity`` so the category matches exactly what rendered
-- (including a resolved {{Extracted from}} source). Either condition
-- alone is sufficient; a file that is both still gets the one category.
if #ids > 1 or #allPageNumbers(blueEntity) > 1 then
table.insert(out, '[[Category:' .. MULTI_CAT .. ']]')
end
-- Missing-SDC tracking categories — mirror the legacy
-- {{DPLA}} template's ``{{#invoke:SDC_tracking|SDC_statement_exist}}``
-- block. Files lacking any of the canonical required properties
-- land in the "missing required SDC statements" maintenance
-- category (operators backfill from there); ``P170`` (creator)
-- gets its own bucket so its backlog can be triaged separately.
-- The check matches ``Module:SDC_tracking._statement_exist`` —
-- simple ``entity.statements[property]`` existence, no
-- DPLA-attribution filter — so wikitext-contributed claims also
-- count as "has statement".
--
-- Always fires on DPLA-rendered files (the template is
-- bot-only); a file rendering through Module:DPLA is by
-- definition a DPLA file and gets the tracking. Categorylinks
-- dedupes, so multiple missing required props still emit the
-- "missing required" category just once.
local missingRequired = false
if not entity or not entity.statements then
missingRequired = true
else
for _, prop in ipairs(REQUIRED_SDC_PROPS) do
if not entity.statements[prop] then
missingRequired = true
break
end
end
end
if missingRequired then
table.insert(
out,
'[[Category:Digital Public Library of America files missing required SDC statements]]'
)
end
local hasCreator = entity
and entity.statements
and entity.statements[P.CREATOR]
if not hasCreator then
table.insert(
out,
'[[Category:Digital Public Library of America files missing creator]]'
)
end
-- Surface files whose DPLA-authored P275/P6426 rights statement carries
-- a Q-ID that is not in our Commons-eligibility allowlist. Typically the
-- contributing institution updated their rights metadata to a non-free
-- license (CC-BY-NC*, CC-BY-*-ND, InC*) after the file was already
-- uploaded under an eligible license; DPLA's SDC sync correctly mirrors
-- the institution's authoritative state, so the file ends up with
-- accurate-but-Commons-incompatible structured data. Silent — no
-- rendered output, just the tracking category for human review.
local hasIneligibleRights = false
if entity and entity.statements then
for _, prop in ipairs({'P275', 'P6426'}) do
for _, stmt in ipairs(dplaStatements(entity, prop)) do
local ms = stmt.mainsnak
if ms.snaktype == 'value'
and ms.datavalue
and ms.datavalue.type == 'wikibase-entityid'
and not ELIGIBLE_RIGHTS[ms.datavalue.value.id] then
hasIneligibleRights = true
break
end
end
if hasIneligibleRights then break end
end
end
if hasIneligibleRights then
table.insert(
out,
'[[Category:Media from the Digital Public Library of America with potential copyright issues]]'
)
end
return table.concat(out, '\n')
end
return p