Module:DescriptionFromDataItem

From OpenStreetMap Wiki
Revision as of 19:48, 29 April 2023 by Chris2map (talk | contribs) (added dealing with spaces " " and underscores "_" in osmcarto-rendering filenames, so maintenance category "Mismatched osmcarto-rendering" won't be applied if only difference between page values and item values are " " and "_".)
Jump to navigation Jump to search
[Edit] [Purge] Documentation

This module is used for {{KeyDescription}} and {{ValueDescription}} templates.

It operates as a pass-through module -- it takes whatever parameters were specified on the KeyDescription or ValueDescription templates, compares them with the values stored in the data items, modifies parameters as needed, and passes them on to the {{Description}} template. It also adds a few maintenance categories to make it easier to find some issues.

Please help translate it here.

Useful Queries
Number of key and tag descriptions per language

☑Y All tests passed.

Name Expected Actual
☑Y test_english
☑Y test_french
☑Y test_german
☑Y test_no_dataitem
☑Y test_no_dataitem_key
☑Y test_no_dataitem_value
☑Y test_polish
☑Y test_polish_group
☑Y test_portuguese

See testcases

local getArgs = require('Module:Arguments').getArgs
local titleParser = require('Module:OsmPageTitleParser')
local data = mw.loadData('Module:DescriptionFromDataItem/data')
local i18n = data.translations
local ns = mw.title.getCurrentTitle().namespace
local p = {}

-- USEFUL DEBUGGING:
--   =p.dbg{title='Key:bridge:movable'}
--   =p.dbg{title='Tag:theatre:type=amphi'}
--   =p.dbg{title='Tag:theatre:type=amphi', key='theatre:type', value='amphi'}
--   =p.dbg{title='Key:bridge:movable', status='accepted'}
--   =p.dbg{title='Tag:noexit=yes'}
--   =p.dbg{qid='Q104'}
--   =p.dbg{qid='Q888'}
--   =p.stmt(p.GROUP, 'Q501', 'en')
--   =p.stmt(p.STATUS, 'Q5846')  -- status with ref

--   =mw.text.jsonEncode(mw.wikibase.getBestStatements('Q104', p.IMAGE), mw.text.JSON_PRETTY)
--   mw.log(mw.text.jsonEncode(stmt, mw.text.JSON_PRETTY))


-- ##########################################################################
--                             CONSTANTS
-- ##########################################################################

-- "fallback" - if this property is not set on the Tag item, check the corresponding Key item
-- "qid" - for item values, output item's Q ID
-- "en" - for item values, output english label
-- "map" - converts claim's value to the corresponding value in the given map
p.INSTANCE_OF = { id = 'P2', qid = true }
p.FORMATTER_URL = { id = 'P8' }
p.GROUP = { id = 'P25', fallback = true }
p.RENDER_IMAGE = { id = 'P39', fallback = true }
p.Q_EXCEPT = { id = 'P27' }
p.Q_LIMIT = { id = 'P26' }
p.KEY_ID = { id = 'P16', fallback = true }
p.TAG_ID = { id = 'P19' }
p.TAG_KEY = { id = 'P10' }
p.REL_ID = { id = 'P41' }
p.REL_TAG = { id = 'P40' }
p.ROLE_REL = { id = 'P43' }
p.INCOMPATIBLE_WITH = { id = 'P44', fallback = true, multi = true, strid = true }
p.IMPLIES = { id = 'P45', multi = true, strid = true }
p.COMBINATION = { id = 'P46', multi = true, strid = true }
p.SEE_ALSO = { id = 'P18', multi = true, strid = true }
p.REQUIRES = { id = 'P22', multi = true, strid = true }

p.STATUS_REF = { id = 'P11', is_reference = true }
p.IMG_CAPTION = { id = 'P47', is_qualifier = true }

p.STATUS = { id = 'P6', en = true, extra = p.STATUS_REF }
p.IMAGE = { id = 'P28', extra = p.IMG_CAPTION }

local use_on_values = {
    Q8000 = 'yes',
    Q8001 = 'no',
}

local instance_types = {
    Q7 = { type = 'key', templatename = 'Template:KeyDescription' },
    Q2 = { type = 'value', templatename = 'Template:ValueDescription' },
    Q6 = { type = 'relation', templatename = 'Template:RelationDescription' },
}

p.USE_ON_NODES = { id = 'P33', fallback = true, map = use_on_values }
p.USE_ON_WAYS = { id = 'P34', fallback = true, map = use_on_values }
p.USE_ON_AREAS = { id = 'P35', fallback = true, map = use_on_values }
p.USE_ON_RELATIONS = { id = 'P36', fallback = true, map = use_on_values }

-- Makes it possible to override by unit tests
p.trackedLanguages = data.trackedLanguages

-- ##########################################################################
--                                   UTILITIES
-- ##########################################################################

local function startswith(self, str)
    return self:sub(1, #str) == str
end

local formatKeyVal = function(key, value)
    if value then
        return key .. '=' .. value
    else
        return key
    end
end

-- Normalizes yes/no/maybe into "yes", "no", nil
local function normalizeBoolean(val)
    if val then
        val = string.lower(val)
        if val == 'yes' or val == 'no' then
            return val
        end
    end
    return nil
end

local function localize(key, langCode, params)
    local msgTable = i18n[key]
    local msg
    if msgTable then
        msg = msgTable[langCode] or msgTable['en']
    end
    if not msg then
        return '<' .. key .. '>'
    end
    return mw.message.newRawMessage(msg, unpack(params or {})):plain()
end

local function getItemValue(self, prop)
    -- Only get the first returned value, so need an extra local var step
    local value = p.getClaimValue(prop, self.langCode, self.entity, self.fallbackEntity)
    return value
end

-- Format as an edit link. Target is either a relative url that starts with a slash, or an item ID (e.g. Q104)
local function editLink(self, target, msgKey)
    local file
    if msgKey == 'desc_edit_mismatch_page' then
        file = 'Red pencil.svg'
    else
        file = 'Arbcom ru editing.svg'
    end
    if not startswith(target, '/') then
        target = 'Item:' .. target
    end
    return ('&nbsp;<span class=wb-edit-pencil>[[File:' .. file .. '|12px|' ..
            localize(msgKey, self.langCode) .. '|link=' .. target .. ']]</span>')
end

-- Convert  key:... and tag:...  into {{key|...}} and {{tag|...}} in a description
-- Debug: =p.dbgFmtDesc('abc key:xyz aaa tag:ttt:bbb=yyy:123_k, bbb')
local function formatDescription(description, frame)
	if startswith(description, '<span') then
		-- FIXME: in case description in dataitem and wiki is different,
		-- do not perform expansion. Otherwise we would break the title="..."
		-- for the span element, creating invalid HTML.
		return description
	end
    local function repl(typ, key, value)
        local title = typ == 'key' and 'TagKey' or 'Tag'
        return frame:expandTemplate { title = title, args = {key, value} }
    end
    description = string.gsub(description, '(key):([-:_a-zA-Z0-9]+)', repl)
    description = string.gsub(description, '(tag):([-:_a-zA-Z0-9]+)=([-:_a-zA-Z0-9]+)', repl)
    return description
end

-- ##########################################################################
--                              DATA ITEM PARSING
-- ##########################################################################

-- p.Q_LIMIT  "limited to region qualifier"  if qualifier is present, include the statement
--      only if self.region equals any of the listed regions
-- p.Q_EXCEPT "excluding region qualifier"   if qualifier is present, include the statement
--      only if self.region does not equal all of the listed regions
local regionQualifiers = { { prop = p.Q_LIMIT, include = true }, { prop = p.Q_EXCEPT, include = false } }

-- Test if qualifiers indicate that current statement should be
-- included or excluded based on the rules table
-- Returns true/false if it should be included, and true/false if it was based on qualifiers
local function allowRegion(region, statement)
    if statement.rank ~= 'preferred' and statement.rank ~= 'normal' then
        return false, false
    end
    local qualifiers = statement.qualifiers
    if qualifiers then
        for _, value in pairs(regionQualifiers) do
            local qualifier = qualifiers[value.prop.id]
            if qualifier then
                local include = not value.include
                for _, q in pairs(qualifier) do
                    if region == q.datavalue.value.id then
                        include = value.include
                    end
                end
                -- return after the first found rule, because multiple rules
                -- do not make any sense on the same statement
                return include, true
            end
        end
    end
    return true, false -- by default, the statement should be included
end

local function qidToStrid(qid)
    local entity = p.wbGetEntity(qid)
    if not entity then return end

    local tag = p.getClaimValue(p.TAG_ID, 'en', entity)
    local eKey, eValue = titleParser.splitKeyValue(tag)
    if not eKey then
        eKey = p.getClaimValue(p.KEY_ID, 'en', entity)
        if eKey then
            return { eKey }
        end
    else
        return { eKey, eValue }
    end
end

-- Convert claim value into a string
-- property object specifies what value to get:
--  'qid'    - returns data item id
--  'strid'  - return referenced item
--  'map'    - use a map to convert qid into a string
--  'en'     - only english label
--   default - first try local, then english, then qid
local function claimToValue(datavalue, prop, langCode)
    local result = false
    if not datavalue then
    	return nil
	elseif datavalue.type == 'wikibase-entityid' then
        local qid = datavalue.value.id
        if prop.map then
            result = prop.map[qid]
        elseif prop.strid then
            result = qidToStrid(qid)
            if not result then
                result = { 'Bad item: ' .. qid }
            end
        elseif not prop.qid then
            if not prop.en then
                result = p.wbGetLabelByLang(qid, langCode)
            end
            if not result then
                result = p.wbGetLabel(qid)
            end
        end
        if not result then
            result = qid
        end
    elseif datavalue.type == 'string' then
        result = datavalue.value
    else
        -- TODO:  handle other property types
        result = "Unknown datatype " .. datavalue.type
    end
    return result
end

local function getStatements(entity, prop)
    if prop.multi then
        return entity:getBestStatements(prop.id)
    else
        return entity:getAllStatements(prop.id)
    end
end

-- From a monolingual property, get either the given language or English
local function getMonoString(snakList, langCode)
    local enVal, val
    if snakList then
        for _, snak in pairs(snakList) do
            local lang = snak.datavalue.value.language
            val = snak.datavalue.value.text
            if langCode == lang then
                return val
            elseif langCode == 'en' then
                enVal = val
            end
        end
    end
    return enVal or val
end

-- Debug:  =mw.text.jsonEncode(p.getClaimValue(p.GROUP, 'en', mw.wikibase.getEntity('Q501')),0)
function p.getClaimValue(prop, langCode, entity, fallbackEntity)
    local usedFallback = false
    local region = data.regions[langCode]
    local statements = getStatements(entity, prop)
    if fallbackEntity and prop.fallback and next(statements) == nil then
        usedFallback = true
        statements = getStatements(fallbackEntity, prop)
    end

    if prop.multi then
        local result = {}
        for _, stmt in pairs(statements) do
            local val = claimToValue(stmt.mainsnak.datavalue, prop, langCode)
            if val then
                table.insert(result, val)
            end
        end
        return result
    end

    local match
    for _, stmt in pairs(statements) do
        local include, qualified = allowRegion(region, stmt)
        if include then
            match = stmt
            if qualified then
                -- Keep non-qualified statement until we look through all claims,
                -- if we see a qualified one (limited to the current region), we found the best match
                break
            end
        end
    end

    local result
    local extra
    if match then
        -- Get extra value if available (e.g. reference or image caption)
        if prop.extra then
            if prop.extra.is_reference and match.references then
                for _, ref in pairs(match.references) do
                    local snak = ref.snaks[prop.extra.id]
                    if snak and snak[1] then
                        extra = snak[1].datavalue.value
                        break
                    end
                end
            elseif prop.extra.is_qualifier and match.qualifiers then
                extra = getMonoString(match.qualifiers[prop.extra.id])
            end
        end
        result = claimToValue(match.mainsnak.datavalue, prop, langCode)
    end

    return result, usedFallback, extra
end

local function validateKeyValue(self)
    if self.args.type == 'relation' then
        -- Ignore for relations
        return
    end
    -- Ensure key and value are set properly in the template params
    local args = self.args
    local tag = getItemValue(self, p.TAG_ID)
    local eKey, eValue = titleParser.splitKeyValue(tag)
    if not eKey then
        eKey = getItemValue(self, p.KEY_ID)
    end

    if not args.key then
        args.key = eKey
    end
    if not args.value then
        args.value = eValue
    end
    if args.key ~= eKey or args.value ~= eValue then
        table.insert(self.categories, 'Mismatched Key or Value')
    end
end

-- Get categories string, e.g. "[[category:xx]][[category:yy]]"
local function getCategories(self)
    if next(self.categories) ~= nil then
        local sortkey = formatKeyVal(self.key, self.value)
        local prefix = '[[Category:'
        local suffix = sortkey and '|' .. sortkey .. ']]' or ']]'
        return prefix .. table.concat(self.categories, suffix .. prefix) .. suffix
    else
        return nil
    end
end

local function formatValue(self, value, editLinkRef)
    if not editLinkRef then
        return value
    else
        return value .. editLink(self, editLinkRef, 'desc_edit')
    end
end

-- Process a single property, comparing old and new values
-- add tracking categories as needed
-- if qid is set, shows a pencil icon next to this value
local function processValue(self, argname, entityVal, pageVal, qid)
    local args = self.args
    if pageVal == nil then
        pageVal = args[argname]
    end
    if pageVal == '' then
        pageVal = nil
    end
    if entityVal == '' then
        entityVal = nil
    end
    if not pageVal then
        if entityVal then
            -- value is only present in the entity
            if self.langCode == 'en' and ns == 0 then
            	if argname == 'status' or argname == 'description' or argname == 'image' then
            		table.insert(self.categories, 'Pages loading ' .. argname .. ' from data item')
            	end
        		if argname == 'onNode' or argname == 'onWay' or argname == 'onArea' or argname == 'onRelation' then
        			table.insert(self.categories, 'Pages loading applicabilities from data item')
            	end
            end
            args[argname] = formatValue(self, entityVal, qid)
        else
            -- value is not set in template nor in the entity
            args[argname] = nil
        end
    elseif not entityVal then
        -- value has not been copied to the entity yet
        table.insert(self.categories, 'Not copied ' .. argname)
        args[argname] = formatValue(self, pageVal, qid)
    elseif entityVal == pageVal or
            (argname ~= 'description' and
                    self.language:caseFold(entityVal) == self.language:caseFold(pageVal)) then
        -- value is identical in both entity and the page
        -- comparison is case-insensitive except for the description

        -- For now, do not track this -- there are too many of them.
        -- Once we start cleaning them up, uncomment this tracking category
        -- table.insert(self.categories, 'Redundant ' .. argname)
        args[argname] = formatValue(self, pageVal, qid)
    elseif argname == 'image' and pageVal:gsub(" ", "_") == 'Image:' .. getItemValue(self, p.IMAGE):gsub(" ", "_") then
        -- Doesn't add "Category:Mismatched image" if "|image=" on wiki page is set with "Image:" instead of "File:"
        args[argname] = 'File:' .. getItemValue(self, p.IMAGE):gsub("_", " ")
    elseif argname == 'image' and pageVal:gsub(" ", "_") == 'File:' .. getItemValue(self, p.IMAGE):gsub(" ", "_") then
        args[argname] = formatValue(self, pageVal, qid)
    elseif argname == 'osmcarto-rendering' and pageVal:gsub(" ", "_") == 'File:' .. getItemValue(self, p.RENDER_IMAGE):gsub(" ", "_") then
    	-- Doesn't add "Category:Mismatched osmcarto-rendering" if only difference in filename is " " and "_".
        args[argname] = formatValue(self, pageVal, qid)
    elseif argname == 'statuslink' and entityVal == 'https://wiki.openstreetmap.org/wiki/' .. pageVal:gsub(" ", "_") then
        -- Doesn't add "Category:Mismatched statuslink" just because the type of url notation is different but leads to the same page.
        -- Notation on page: Proposed_features/foo bar
        -- Notation on item: https://wiki.openstreetmap.org/wiki/Proposed_features/foo_bar
        args[argname] = formatValue(self, pageVal, qid)
    else
        -- value in the page and in the entity do not match
        -- Don't apply maintenance categories if page is in namespace "Talk:" "User:" "Template:" "File:" "Help:" etc.
        if ns == 0 or ns > 15 then
        	if self.langCode == 'en' then
        		table.insert(self.categories, 'Mismatched ' .. argname .. ' in default namespace')
			end
        	table.insert(self.categories, 'Mismatched ' .. argname)
        end
        if not qid then
            args[argname] = pageVal
        else
            -- Format when value differs between Wiki and Wikibase, with a pencil
            -- For now, show pageVal with two pencils: a red one to wiki page and gray one to Wikibase
            local editPageLink = editLink(self, self.currentTitle:fullUrl('action=edit'), 'desc_edit_mismatch_page')
            local editItemLink = editLink(self, qid, 'desc_edit_mismatch_item')

            local span = mw.html.create('span')
            span:attr('title', localize('desc_mismatch', self.langCode, { entityVal }))
                :wikitext(pageVal)
            args[argname] = tostring(span) .. editPageLink .. editItemLink

            -- In the future, switch to showing mismatched old value as red, with an edit link
            -- to the wiki page, plus new value with an edit link to Wikibase
            -- :attr('style', 'color:red')
            -- args[argname] = tostring(span) .. editPageLink .. '<br>' .. entityVal .. editItemLink
        end
    end
end

local function processEntity(self)
    local args = self.args
    local qid = self.entity:getId()

    validateKeyValue(self)

    -- Compare all known parameters against the data item entity
    processValue(self, 'description',
            self.entity:getDescription(self.langCode) or self.entity:getDescription(),
            args.description, qid) -- add edit links to description

    processValue(self, 'group', getItemValue(self, p.GROUP))

    -- For status we must use english label (special processing inside the template)
    local status, _, statuslink = p.getClaimValue(p.STATUS, self.langCode, self.entity, self.fallbackEntity)
    processValue(self, 'status', status)
    processValue(self, 'statuslink', statuslink)

    local image, _, image_caption = p.getClaimValue(p.IMAGE, self.langCode, self.entity, self.fallbackEntity)
    processValue(self, 'image', image and 'File:' .. image or nil)
    processValue(self, 'image_caption', image_caption)

    local render = getItemValue(self, p.RENDER_IMAGE)
    processValue(self, 'osmcarto-rendering', render and 'File:' .. render or nil)

    -- Handle onRelation, onArray, onWay, and onNode
    processValue(self, 'onNode', getItemValue(self, p.USE_ON_NODES), normalizeBoolean(args.onNode))
    processValue(self, 'onWay', getItemValue(self, p.USE_ON_WAYS), normalizeBoolean(args.onWay))
    processValue(self, 'onArea', getItemValue(self, p.USE_ON_AREAS), normalizeBoolean(args.onArea))
    processValue(self, 'onRelation', getItemValue(self, p.USE_ON_RELATIONS), normalizeBoolean(args.onRelation))
    
    processValue(self, 'url_pattern', getItemValue(self, p.FORMATTER_URL), args.url_pattern)

    -- Not yet possible to compare these data item values with the template params, so just use if missing
    args.combination = args.combination or getItemValue(self, p.COMBINATION)
    args.implies = args.implies or getItemValue(self, p.IMPLIES)
    args.seeAlso = args.seeAlso or getItemValue(self, p.SEE_ALSO)
    args.requires = args.requires or getItemValue(self, p.REQUIRES)

    -- Values that are coming only from the data items
    args.incompatibleWith = getItemValue(self, p.INCOMPATIBLE_WITH)
end

local function constructor(args)
    local self = {
        categories = {},
        args = args,
    }

    if args.currentTitle then
        self.currentTitle = mw.title.new(args.currentTitle)
    else
        self.currentTitle = mw.title.getCurrentTitle()
    end

    -- sets self.key, self.value, and self.language from the current title
    titleParser.parseTitleToObj(self, self.currentTitle)

    -- if lang parameter is set, overrides the one detected from the title
    if args.lang and mw.language.isSupportedLanguage(args.lang) then
        self.language = mw.getLanguage(args.lang)
    end
    self.langCode = self.language:getCode()

    toSitelink = function(key, value)
        return (value and 'Tag:' or 'Key:') .. formatKeyVal(key, value)
    end

    local entity
    local typeGuess
    if args.qid then
        entity = p.wbGetEntity(args.qid)
    elseif args.key then
        -- template caller gave a key param (with optional value)
        entity = p.wbGetEntity(p.wbGetEntityIdForTitle(toSitelink(args.key, args.value)))
        self.key = args.key
        self.value = args.value
        typeGuess = self.value and 'Q2' or 'Q7'
    elseif args.type then
        -- template caller gave type param, guessing a relation
        entity = p.wbGetEntity(p.wbGetEntityIdForTitle('Relation:' .. args.type))
        args.key = 'type'
        args.value = args.type
        args.rtype = args.type
        args.type = 'relation'
        typeGuess = 'Q6'
    else
        if self.currentTitle then
            -- template caller gave currentTitle param (probably debugging)
            entity = p.wbGetEntity(p.wbGetEntityIdForTitle(self.currentTitle.text))
        else
            entity = p.wbGetEntity()
        end

        -- If there is no associated entity, try to deduce it from the title (e.g. translated pages)
        -- note that we cannot guess relations the same way
        if not entity and self.key then
            entity = p.wbGetEntity(p.wbGetEntityIdForTitle(toSitelink(self.key, self.value)))
        end

        -- No data item exists
        if not entity and self.key then
            typeGuess = self.value and 'Q2' or 'Q7'
        end
    end

    -- Try to get a fallback entity - key for tag, tag for relation, relation for rel role
    self.entity = entity
    if entity then
        local _, fbStmt = next(entity:getBestStatements(p.TAG_KEY.id))
        if not fbStmt then
            _, fbStmt = next(entity:getBestStatements(p.REL_TAG.id))
            if not fbStmt then
                _, fbStmt = next(entity:getBestStatements(p.ROLE_REL.id))
            end
        end
        if fbStmt then
            self.fallbackEntity = p.wbGetEntity(fbStmt.mainsnak.datavalue.value.id)
        end
    else
    	if self.langCode == 'en' then
        	table.insert(self.categories, 'Missing data item in default namespace')
		end
        table.insert(self.categories, 'Missing data item')
    end

    local instance_of = entity and getItemValue(self, p.INSTANCE_OF)
    local types = instance_types[instance_of or typeGuess]
    if types then
        if not args.templatename then
            args.templatename = types.templatename
        end
        if not args.type then
            args.type = types.type
        end
        if instance_of == 'Q6' then
            -- Relations are tricky - ther remap "temp" to "rtemp" and "value"
            if not args.rtype then
                args.rtype = getItemValue(self, p.REL_ID)
            end
            if not args.value then
                args.value = args.rtype
            end
        end
    end
    -- Template:Description needs these to properly format language bar and template links
    --   templatename = Template:ValueDescription | ...
    --   type = key|value|relation
    if not args.templatename then
        local frame2 = mw.getCurrentFrame():getParent()
        args.templatename = frame2 and frame2:getTitle() or 'Unknown'
    end
    if not args.type then
        if args.templatename and string.find(args.templatename, 'KeyDescription', 1, true) then
            args.type = 'key'
        elseif args.templatename and string.find(args.templatename, 'ValueDescription', 1, true) then
            args.type = 'value'
        elseif args.templatename and string.find(args.templatename, 'RelationDescription', 1, true) then
            args.type = 'relation'
        end
    end
    if args.onClosedWay then
        table.insert(self.categories, 'Obsolete description template parameters')
    end

    return self
end

-- ##########################################################################
--                                 ENTRY POINTS
-- ##########################################################################

-- If we found data item for this key/tag, compare template parameters
-- with what we have in the item, and add some extra formatting/links/...
-- If the values are not provided, just use the ones from the data item
function p.main(frame)
	if not mw.wikibase then
		return frame:expandTemplate { title = 'Warning', args = { text = "The OSM wiki is experiencing technical difficulties. Infoboxes will be restored soon." } }
	end
	
    local args = getArgs(frame)

    -- initialize self - parse title, language, and get relevant entities
    local self = constructor(args)

    if self.entity then
        processEntity(self)
    end

    if self.entity then
        -- If this module is included from [[Template:Deprecated]],
        -- omit to check if description is set or not
        if args.status ~= 'deprecated' and args.status ~= 'obsolete' then
            -- If this is an English item, check if description is set for
            -- the tracked languages, and if not, add a tracking category
            if self.langCode == 'en' then
                for _, lng in ipairs(p.trackedLanguages) do
                    if not self.entity:getDescription(lng) then
                        table.insert(self.categories,
                                'Item with no description in language ' .. mw.ustring.upper(lng))
                    end
                end
            elseif not self.entity:getDescription('en') then
                table.insert(self.categories, 'Item with no description in language EN')
            end
        end
    end

    -- Create a group category. Use language-prefixed name if category page exists
    if self.args.group then
        local group = self.language:ucfirst(self.args.group)
        local prefix = titleParser.langPrefix(self.langCode)
        local title = mw.title.new('Category:' .. prefix .. group)
        if title and title.exists then
            table.insert(self.categories, title.text)
        else
            table.insert(self.categories, group)
        end
    end

    local categories = getCategories(self)

    local baseTemplate = args.basetemplate or 'Template:Description'
    if args.debuglua then
        -- debug and unit test support
        return {
            template = baseTemplate,
            args = args,
            categories = categories,
        }
    end

    for _, arg in pairs({ 'combination', 'implies', 'seeAlso', 'requires', 'incompatibleWith' }) do
        if type(args[arg]) == 'table' then
            local result = ''
            for _, val in pairs(args[arg]) do
                result = result .. '* ' .. frame:expandTemplate { title = 'Tag', args = val } .. '\n'
            end
            if result ~= '' then
                args[arg] = result
            else
                args[arg] = nil
            end
        end
    end

    if args.description then
        args.description = formatDescription(args.description, frame)
    end

    local result = frame:expandTemplate { title = baseTemplate, args = args }
    if categories then
        result = result .. categories
    end
    if args.debugargs then
        result = result ..
                '<br><pre>' ..
                mw.text.nowiki(mw.text.jsonEncode(args, mw.text.JSON_PRETTY)) ..
                '</pre><br>'
    end
    return result
end


-- Create a table row to describe a specific value
-- Usually rendered as key | value | element | comment | rendering | photo
function p.row(frame)
    local args = getArgs(frame)

    if args[1] and not args.key then
        args.key = args[1]
    end
    if args[2] and not args.value then
        args.value = args[2]
    end

    -- Unlike sidecard, table could be used in different types of pages, and should not rely on auto-guessing
    assert(args.key, 'Missing key=... parameter')
    assert(args.value, 'Missing value=... parameter')

    -- initialize self - parse title, language, and get relevant entities
    local self = constructor(args)

    if self.entity then
        local qid = self.entity:getId()

        validateKeyValue(self)

        -- Compare all known parameters against the data item entity
        processValue(self, 'description',
                self.entity:getDescription(self.langCode) or self.entity:getDescription(),
                args.description, qid) -- add edit links to description

        value, usedFb, ref = p.getClaimValue(p.IMAGE, self.langCode, self.entity, self.fallbackEntity)
        processValue(self, 'photo', value and 'File:' .. value or nil)

        local render = getItemValue(self, p.RENDER_IMAGE)
        processValue(self, 'osmcarto-rendering', render and 'File:' .. render or nil)

        -- Handle onRelation, onArray, onWay, and onNode
        processValue(self, 'onNode', getItemValue(self, p.USE_ON_NODES), normalizeBoolean(args.onNode))
        processValue(self, 'onWay', getItemValue(self, p.USE_ON_WAYS), normalizeBoolean(args.onWay))
        processValue(self, 'onArea', getItemValue(self, p.USE_ON_AREAS), normalizeBoolean(args.onArea))
        processValue(self, 'onRelation', getItemValue(self, p.USE_ON_RELATIONS), normalizeBoolean(args.onRelation))
    end

    local elems = {}
    if 'yes' == args.onNode then
        table.insert(elems, 'iconNode')
    end
    if 'yes' == args.onWay then
        table.insert(elems, 'iconWay')
    end
    if 'yes' == args.onArea then
        table.insert(elems, 'iconArea')
    end
    if 'yes' == args.onRelation then
        table.insert(elems, 'iconRelation')
    end

    args.render = args.render and '[[' .. args.render .. '|100px]]'
    args.photo = args.photo and '[[' .. args.photo .. '|100px]]'

    if args.description2 then
        args.description = args.description .. '<br>' .. args.description2
    end

    -- key | value | element | comment | render | photo
    local resultTbl = {
        args.key,
        args.value,
        elems,
        args.description or '',
        args.render or '',
        args.photo or '',
    }
    local categories = getCategories(self)
    if args.debuglua then
        -- debug and unit test support
        return { args = args, categories = categories, row = resultTbl }
    end

    -- expand templates
    local lang = self.langCode
    for i, v in ipairs(elems) do
        elems[i] = frame:expandTemplate { title = v }
    end
    local result = '|-\n| ' .. table.concat({
        frame:expandTemplate { title = 'TagKey/exists', args = { resultTbl[1], lang = lang } },
        frame:expandTemplate { title = 'TagValue/exists', args = { resultTbl[1], resultTbl[2], lang = lang } },
        table.concat(elems, ''),
        resultTbl[4],
        resultTbl[5],
        resultTbl[6],
    }, '\n| ')
    if categories then
        result = result .. categories
    end
    if args.debugargs then
        result = result ..
                '<br><pre>' ..
                mw.text.nowiki(mw.text.jsonEncode(resultTbl, mw.text.JSON_PRETTY)) ..
                '</pre><br><pre>' ..
                mw.text.nowiki(result) ..
                '</pre><br>'
    end
    return result
end

-- ##########################################################################
--                        DEBUGGING AND TESTING SUPPORT
-- ##########################################################################
-- From the debug console, use   =p.dbg{title='Key:bridge'}
function p.dbg(args)
    args.currentTitle = args.title or 'Key:bridge:movable'
    args.debuglua = args.debuglua == nil and true or args.debuglua
    local frame = mw.getCurrentFrame():newChild { title = 'Module:DescriptionFromDataItem', args = args }
    return mw.text.jsonEncode(p.main(frame), mw.text.JSON_PRETTY)
end

function p.dbgFmtDesc(desc)
    return formatDescription(desc, mw.getCurrentFrame():newChild { title = 'Module:DescriptionFromDataItem' })
end

-- From the debug console, use   =p.dbgrow{key='bridge', value='movable'}
function p.dbgrow(args)
    local frame = mw.getCurrentFrame():newChild { title = 'Module:DescriptionFromDataItem', args = args }
    return p.row(frame)
end

-- Debug helper for statements
-- =p.stmt(p.GROUP, 'Q501', 'en')
function p.stmt(prop, id, lang)
    return mw.text.jsonEncode(
            { p.getClaimValue(prop, lang, mw.wikibase.getEntity(id)) },
            mw.text.JSON_PRETTY)
end

-- These methods could be overwritten by unit tests
function p.wbGetEntity(entity)
    return mw.wikibase.getEntity(entity)
end

function p.wbGetEntityIdForTitle(title)
    return mw.wikibase.getEntityIdForTitle(title)
end

function p.wbGetLabel(qid)
    return mw.wikibase.getLabel(qid)
end

function p.wbGetLabelByLang(qid, langCode)
    return mw.wikibase.getLabelByLang(qid, langCode)
end

return p