Module:Sandbox/Aurelain/SkillsOverview: Difference between revisions

From Heroes of Might and Magic: Olden Era Official Wiki
No edit summary
No edit summary
 
(107 intermediate revisions by the same user not shown)
Line 82: Line 82:
     wiki_skills_faction = 'Faction Skills',
     wiki_skills_faction = 'Faction Skills',
}
}
local PATCH_NAME = {
    ['Summon Avatar'] = 'Summon Avatar (Skill)',
}
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
-- Debugs a variable
-- Debugs a variable
Line 90: Line 95:


------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
-- Searches by id for an item in array
-- Spawns a safe robotic url from an English name, fit for adding it before the visible name
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
local function findInArray(list, id)
local function getUrl(nameEn, lang)
     for _, item in ipairs(list) do
     local url = PATCH_NAME[nameEn] or nameEn
        if item and item.id == id then
    if lang ~= 'en' then
            return item
         url = url .. '/' .. lang
         end
     end
     end
     return nil
     return url
end
end


Line 104: Line 108:
-- Retrieves the text for some specific ids from Cargo Translations.
-- Retrieves the text for some specific ids from Cargo Translations.
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
local function translateIds(ids, lang, extra, please)
local function translateIds(ids, lang, extra)
     -- Key list
     -- Key list
     local list = {}
     local list = {}
Line 127: Line 131:
     local dictionary = mw.clone(ids)
     local dictionary = mw.clone(ids)
     for key, value in pairs(dictionary) do
     for key, value in pairs(dictionary) do
         dictionary[key] = value .. please
         dictionary[key] = value .. '[[Data:WikiTranslations/' .. lang .. '#' .. key .. '|💬]]'
     end
     end
     if results then
     for _, row in ipairs(results) do
        for _, row in ipairs(results) do
        dictionary[row['target_id']] = row['name']
            dictionary[row['target_id']] = row['name']
        end
     end
     end
     return dictionary
     return dictionary
Line 147: Line 149:
     table.insert(fields, 'Translation.name = name');
     table.insert(fields, 'Translation.name = name');
     table.insert(fields, 'Translation.description = description');
     table.insert(fields, 'Translation.description = description');
    table.insert(fields, 'Translation.language = language');


     -- where:
     -- where:
Line 154: Line 157:
     end
     end
     table.insert(where, 'Skill.variant = "production"')
     table.insert(where, 'Skill.variant = "production"')
     table.insert(where, 'Translation.language = "' .. lang .. '"')
     table.insert(where, '(Translation.language = "en" OR Translation.language = "' .. lang .. '")')


     -- query:
     -- query:
Line 160: Line 163:
         join = 'Skill.id = Translation.target_id',
         join = 'Skill.id = Translation.target_id',
         where = table.concat(where, ' AND '),
         where = table.concat(where, ' AND '),
         limit = 200
         limit = 500
     })
     })
end
end
Line 201: Line 204:
     table.insert(fields, 'Translation.name = name');
     table.insert(fields, 'Translation.name = name');
     table.insert(fields, 'Translation.description = description');
     table.insert(fields, 'Translation.description = description');
    table.insert(fields, 'Translation.language = language');


     -- where:
     -- where:
Line 208: Line 212:
     end
     end
     table.insert(where, 'SubSkill.variant = "production"')
     table.insert(where, 'SubSkill.variant = "production"')
     table.insert(where, 'Translation.language = "' .. lang .. '"')
     table.insert(where, '(Translation.language = "en" OR Translation.language = "' .. lang .. '")')


     -- query:
     -- query:
Line 214: Line 218:
         join = 'SubSkill.id = Translation.target_id',
         join = 'SubSkill.id = Translation.target_id',
         where = table.concat(where, ' AND '),
         where = table.concat(where, ' AND '),
         limit = 500
         limit = 1000
     })
     })
end
end
Line 221: Line 225:
-- Folds various skill results from Cargo into a single table
-- Folds various skill results from Cargo into a single table
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
local function consolidateSkills(main, iconAndOffers, subs)
local function consolidateSkills(main, iconAndOffers, subs, lang)
     local hub = {}
     local hub = {}
     for _, row in ipairs(main) do
     for _, row in ipairs(main) do
Line 229: Line 233:
         local entry = hub[id]
         local entry = hub[id]
         if variant then
         if variant then
             entry.ranks[tonumber(variant)] = {
            -- Level 1, 2 or 3
                 name = row.name or '',
            local i = tonumber(variant) or 1
                 description = row.description or '',
             entry.ranks[i] = entry.ranks[i] or {}
             }
            local rank = entry.ranks[i]
            if row.language == lang then
                 rank.name = row.name or ''
                 rank.description = row.description or ''
             end
            if row.language == 'en' then
                rank.nameEn = row.name or ''
                rank.descriptionEn = row.description or ''
            end
         else
         else
             entry.name = row.name
             -- Root level (the group/family)
            if row.language == lang then
                entry.name = row.name or ''
            end
            if row.language == 'en' then
                entry.nameEn = row.name or ''
            end
         end
         end
     end
     end
Line 257: Line 275:
         for _, rank in ipairs(entry.ranks) do
         for _, rank in ipairs(entry.ranks) do
             local subSkills = rank.subSkills or {}
             local subSkills = rank.subSkills or {}
             local subSkill = findInArray(subSkills, id)
             for _, subSkill in ipairs(subSkills) do
            if subSkill then
                if subSkill.id == id then
                subSkill.icon = row.icon
                    if row.language == lang then
                subSkill.name = row.name
                        subSkill.icon = row.icon or ''
                subSkill.description = row.description
                        subSkill.name = row.name or ''
                 break
                        subSkill.description = row.description or ''
                    end
                    if row.language == 'en' then
                        subSkill.descriptionEn = row.description or ''
                    end
                 end
             end
             end
         end
         end
Line 315: Line 338:
     text = mw.ustring.gsub(text, pattern, "%2%1")
     text = mw.ustring.gsub(text, pattern, "%2%1")
     return text
     return text
end
------------------------------------------------------------------------------------------------------------------------
-- Returns a "Foo/XX" string based on the first quoted word in a text, if any.
------------------------------------------------------------------------------------------------------------------------
local function findUrl(descriptionInEnglish, lang)
    local name = descriptionInEnglish:match("“(.-)”")
    if name and name ~= '' then
        return getUrl(name, lang)
    end
    return nil
end
end


Line 320: Line 354:
-- Converts quoted text into wiki links
-- Converts quoted text into wiki links
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
local function replaceQuotesWithLinks(text, lang)
local function replaceQuotesWithLinks(text, lang, url)
     local allQuotes = [=["„“”«»「」『』]=]
     local allQuotes = [=["„“”«»「」『』]=]
     local pattern = "([" .. allQuotes .. "])([^" .. allQuotes .. "]+)([" .. allQuotes .. "])"
     local pattern = "([" .. allQuotes .. "])([^" .. allQuotes .. "]+)([" .. allQuotes .. "])"
     text = mw.ustring.gsub(text, pattern, "[[%2]]")
     text = mw.ustring.gsub(text, pattern, "%1[[" .. url .. "|%2]]%3")


     local quotePairs = {
     local quotePairs = {
Line 339: Line 373:


     return text
     return text
end
------------------------------------------------------------------------------------------------------------------------
-- Converts the first capitalized mid-sentence words into wiki links
------------------------------------------------------------------------------------------------------------------------
local function replaceCapitalizedWithLinks(text, url)
    text = mw.ustring.gsub(text, "([^%.]) (%u[%w ’]+)", "%1 [[" .. url .. "|%2]]")
    return text
end
------------------------------------------------------------------------------------------------------------------------
-- Adds [[link]] inside a description
------------------------------------------------------------------------------------------------------------------------
local function linkifyDescription(description, descriptionEn, lang)
    descriptionEn = repairPunctuation(descriptionEn)
    local url = findUrl(descriptionEn, lang)
    if not url then return description end
    description = lang == 'en' and descriptionEn or repairPunctuation(description)
    local adapted = replaceQuotesWithLinks(description, lang, url)
    if description ~= adapted then
        description = adapted
    else
        description = replaceCapitalizedWithLinks(description, url)
    end
    return description
end
------------------------------------------------------------------------------------------------------------------------
-- Adds [[links]] inside descriptions
------------------------------------------------------------------------------------------------------------------------
local function linkifyDescriptions(skills, lang)
    for _, group in ipairs(skills) do
        local ranks = group.ranks or {}
        for _, rank in ipairs(ranks) do
            local subSkills = rank.subSkills or {}
            for _, subSkill in pairs(subSkills) do
                if subSkill.description then
                    subSkill.description = linkifyDescription(subSkill.description, subSkill.descriptionEn, lang)
                end
            end
        end
    end
end
end


Line 344: Line 422:
-- Displays a single skill (icon+name+description)
-- Displays a single skill (icon+name+description)
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
local function createSkill(target, class, size, lang)
local function createSkill(target, class, size)
     local root = mw.html.create('div'):addClass('box'):addClass(class)
     local root = mw.html.create('div'):addClass('box'):addClass(class)
     root:tag('div'):addClass('icon-container'):wikitext('[[File:' .. target.icon .. '.png|' .. size .. 'px]]')
     root:tag('div'):addClass('icon-container'):wikitext('[[File:' .. target.icon .. '.png|' .. size .. 'px]]')
     local textContainer = root:tag('div'):addClass('text-container')
     local textContainer = root:tag('div'):addClass('text-container')
     textContainer:tag('div'):addClass('box-name'):wikitext('<b>' .. target.name .. '</b>')
     textContainer:tag('div'):addClass('box-name'):wikitext('<b>' .. target.name .. '</b>')
 
     textContainer:tag('div'):addClass('box-description'):wikitext(target.description)
    -- Description:
    local description = target.description
    --description = hideTags(description)
    description = repairPunctuation(description)
    description = replaceQuotesWithLinks(description, lang)
    --description = restoreTags(description)
     textContainer:tag('div'):addClass('box-description'):wikitext(description)
 
     return root
     return root
end
end
Line 367: Line 437:
     local root = mw.html.create()
     local root = mw.html.create()
     local currentCategory = nil
     local currentCategory = nil
     for _, group in pairs(skills) do
    local isAll = #skills > 1
     for _, group in ipairs(skills) do
         local id = group.id
         local id = group.id


Line 373: Line 444:
         if category and category ~= currentCategory then
         if category and category ~= currentCategory then
             currentCategory = category
             currentCategory = category
             root:tag('h2'):addClass('category'):wikitext(words[category])
             if isAll then
                root:tag('h2'):addClass('category'):wikitext(words[category])
            end
         end
         end


         local ranks = group.ranks or {}
         local ranks = group.ranks or {}
         root:tag('h3'):addClass('group'):wikitext('[[' .. group.name .. ']]')
         if isAll then
            local url = getUrl(group.nameEn, lang)
            root:tag('h3'):addClass('group'):wikitext('[[' .. url .. '|' .. group.name .. ']]')
        end


         for _, rank in ipairs(ranks) do
         for _, rank in ipairs(ranks) do
             root:node(createSkill(rank, 'rank', 64, lang))
             root:node(createSkill(rank, 'rank', 64))


             local subSkills = rank.subSkills or {}
             local subSkills = rank.subSkills or {}
             for _, subSkill in pairs(subSkills) do
             for _, subSkill in pairs(subSkills) do
                 root:node(createSkill(subSkill, 'sub', 32, lang))
                 root:node(createSkill(subSkill, 'sub', 32))
             end
             end
         end
         end
     end
     end
     return tostring(root)
     return tostring(root)
end
------------------------------------------------------------------------------------------------------------------------
-- Bundles together all cargo skill queries:
-- Also called by Module:SkillSynergies
-- Also called by Module:SkillArtifacts
------------------------------------------------------------------------------------------------------------------------
function p.getSkillsFromCargo(lang, skill)
    local main = queryMain(lang, skill)
    --if 1 then return main end
    local iconAndOffers = queryIconAndOffers(skill)
    local subs = querySubs(lang, skill)
    local hub = consolidateSkills(main, iconAndOffers, subs, lang)
    return hub
end
end


Line 395: Line 485:
------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------------------------------------
function p.display(frame)
function p.display(frame)
     --if 1 then
     --if 1 then return dump(frame) end
    --    return dump(frame)
    --end


     -- Args
     -- Args
     local args = frame.args
     local args = frame.args
     local lang = args.lang or 'fr'
     local lang = args.lang or 'en'
     local skill = args.skill
     local skill = args.skill
    --lang = 'fr'
    --skill = 'skill_formation'


     -- Language
     -- Language
    local please = frame:preprocess('{{Hint|[[Data:WikiTranslations/fr|⚠️]]|Please translate}}')
     local words = translateIds(TRANSLATION_IDS, lang)
     local words = translateIds(TRANSLATION_IDS, lang, nil, please)


     -- Cargo
     -- Cargo
     local main = queryMain(lang, skill)
     local skillsHub = p.getSkillsFromCargo(lang, skill)
     if #main == 0 then
    local skills = flatten(skillsHub)
    --if 1 then return dump(#skills) end
     if #skills == 0 then
         return 'No skills found!'
         return 'No skills found!'
     end
     end
    local iconAndOffers = queryIconAndOffers(skill)
    local subs = querySubs(lang, skill)
    local hub = consolidateSkills(main, iconAndOffers, subs)
    local skills = flatten(hub);


     -- Various manipulations
     -- Various manipulations
     table.sort(skills, compareSkills)
     table.sort(skills, compareSkills)
    linkifyDescriptions(skills, lang)


     -- Content
     -- Content
     local markup = renderSkills(skills, words, lang)
     local markup = renderSkills(skills, words, lang)
    --if 1 then return dump(markup) end


     -- Output
     -- Output
     local styleTag = frame:extensionTag('templatestyles', '', { src = frame:getTitle() .. '/styles.css' })
     local styleTag = frame:extensionTag('templatestyles', '', { src = frame:getTitle() .. '/styles.css' })
    --return dump(skills)
    --return please
     return styleTag .. markup
     return styleTag .. markup
end
end


return p
return p

Latest revision as of 09:03, 15 June 2026

Documentation for this module may be created at Module:Sandbox/Aurelain/SkillsOverview/doc

-- Usage: {{#invoke:SkillsOverview|display|lang=en}}
local p = {}

local CATEGORY_MIGHT = 'wiki_skills_might'
local CATEGORY_MAGIC = 'wiki_skills_magic'
local CATEGORY_GENERAL = 'wiki_skills_general'
local CATEGORY_FACTION = 'wiki_skills_faction'

local CATEGORIES = {
    skill_assault = CATEGORY_MIGHT,            -- Offense
    skill_battle_artistry = CATEGORY_MIGHT,    -- Combat
    skill_battlemage = CATEGORY_MAGIC,         -- Battle Magic
    skill_diplomacy = CATEGORY_GENERAL,        -- Diplomacy
    skill_economy = CATEGORY_GENERAL,          -- Economy
    skill_enlightenment = CATEGORY_GENERAL,    -- Insight
    skill_faction_demons = CATEGORY_FACTION,   -- Hive
    skill_faction_dungeon = CATEGORY_FACTION,  -- Dungeon
    skill_faction_humans = CATEGORY_FACTION,   -- Temple
    skill_faction_nature = CATEGORY_FACTION,   -- Grove
    skill_faction_undead = CATEGORY_FACTION,   -- Necropolis
    skill_faction_unfrozen = CATEGORY_FACTION, -- Schism
    skill_first_aid = CATEGORY_GENERAL,        -- currently unused!
    skill_formation = CATEGORY_MIGHT,          -- Battlecraft
    skill_leadership = CATEGORY_MIGHT,         -- Leadership
    skill_logistic = CATEGORY_GENERAL,         -- Logistics
    skill_luck = CATEGORY_MIGHT,               -- Luck
    skill_magic_day = CATEGORY_MAGIC,          -- Daylight
    skill_magic_night = CATEGORY_MAGIC,        -- Nightshade
    skill_magic_primal = CATEGORY_MAGIC,       -- Primal
    skill_magic_space = CATEGORY_MAGIC,        -- Arcane
    skill_mastery = CATEGORY_MAGIC,            -- Wisdom
    skill_protection = CATEGORY_MIGHT,         -- Defense
    skill_resistance = CATEGORY_MIGHT,         -- Resistance
    skill_scouting = CATEGORY_GENERAL,         -- Scouting
    skill_siege = CATEGORY_MIGHT,              -- Siegecraft
    skill_sorcery = CATEGORY_MAGIC,            -- Sorcery
    skill_summoner = CATEGORY_MAGIC,           -- Summon Avatar
    skill_tactics = CATEGORY_MIGHT,            -- Tactics
    skill_trainer = CATEGORY_MIGHT,            -- Recruitment
    skill_wisdom = CATEGORY_MAGIC,             -- Thaumaturgy
}

local ORDER = {
    'skill_assault',          -- Offense
    'skill_protection',       -- Defense
    'skill_leadership',       -- Leadership
    'skill_luck',             -- Luck
    'skill_resistance',       -- Resistance
    'skill_tactics',          -- Tactics
    'skill_formation',        -- Battlecraft
    'skill_siege',            -- Siegecraft
    'skill_trainer',          -- Recruitment
    'skill_battle_artistry',  -- Combat
    'skill_battlemage',       -- Battle Magic
    'skill_sorcery',          -- Sorcery
    'skill_summoner',         -- Summon Avatar
    'skill_mastery',          -- Wisdom
    'skill_magic_day',        -- Daylight
    'skill_magic_night',      -- Nightshade
    'skill_magic_space',      -- Arcane
    'skill_magic_primal',     -- Primal
    'skill_wisdom',           -- Thaumaturgy
    'skill_diplomacy',        -- Diplomacy
    'skill_logistic',         -- Logistics
    'skill_scouting',         -- Scouting
    'skill_enlightenment',    -- Insight
    'skill_economy',          -- Economy
    'skill_faction_humans',   -- Temple
    'skill_faction_undead',   -- Necropolis
    'skill_faction_nature',   -- Grove
    'skill_faction_demons',   -- Hive
    'skill_faction_unfrozen', -- Schism
    'skill_faction_dungeon',  -- Dungeon
    'skill_first_aid',        -- currently unused!
}

-- Note: The right side is just a fallback, it will seldom be used.
local TRANSLATION_IDS = {
    wiki_skills_might = 'Might Skills',
    wiki_skills_magic = 'Magic Skills',
    wiki_skills_general = 'General Skills',
    wiki_skills_faction = 'Faction Skills',
}

local PATCH_NAME = {
    ['Summon Avatar'] = 'Summon Avatar (Skill)',
}

------------------------------------------------------------------------------------------------------------------------
-- Debugs a variable
------------------------------------------------------------------------------------------------------------------------
local function dump(target)
    return '<pre>' .. mw.dumpObject(target) .. '</pre>'
end

------------------------------------------------------------------------------------------------------------------------
-- Spawns a safe robotic url from an English name, fit for adding it before the visible name
------------------------------------------------------------------------------------------------------------------------
local function getUrl(nameEn, lang)
    local url = PATCH_NAME[nameEn] or nameEn
    if lang ~= 'en' then
        url = url .. '/' .. lang
    end
    return url
end

------------------------------------------------------------------------------------------------------------------------
-- Retrieves the text for some specific ids from Cargo Translations.
------------------------------------------------------------------------------------------------------------------------
local function translateIds(ids, lang, extra)
    -- Key list
    local list = {}
    for key, _ in pairs(ids) do
        list[#list + 1] = key
    end
    local idListString = '"' .. table.concat(list, '", "') .. '"'

    -- Cargo
    local where = {}
    table.insert(where, 'target_id IN (' .. idListString .. ')')
    table.insert(where, 'language = "' .. lang .. '"')
    if extra then
        table.insert(where, extra)
    end
    local results = mw.ext.cargo.query('Translation', 'target_id, name', {
        where = table.concat(where, ' AND '),
        limit = 100
    })

    -- Dictionary
    local dictionary = mw.clone(ids)
    for key, value in pairs(dictionary) do
        dictionary[key] = value .. '[[Data:WikiTranslations/' .. lang .. '#' .. key .. '|💬]]'
    end
    for _, row in ipairs(results) do
        dictionary[row['target_id']] = row['name']
    end
    return dictionary
end

------------------------------------------------------------------------------------------------------------------------
-- Retrieves the main skill translations from Cargo
------------------------------------------------------------------------------------------------------------------------
local function queryMain(lang, forcedSkill)
    -- fields:
    local fields = {}
    table.insert(fields, 'Skill.id = id');
    table.insert(fields, 'Translation.variant = variant');
    table.insert(fields, 'Translation.name = name');
    table.insert(fields, 'Translation.description = description');
    table.insert(fields, 'Translation.language = language');

    -- where:
    local where = {}
    if forcedSkill then
        table.insert(where, 'Skill.id="' .. forcedSkill .. '"')
    end
    table.insert(where, 'Skill.variant = "production"')
    table.insert(where, '(Translation.language = "en" OR Translation.language = "' .. lang .. '")')

    -- query:
    return mw.ext.cargo.query('Skill, Translation', table.concat(fields, ','), {
        join = 'Skill.id = Translation.target_id',
        where = table.concat(where, ' AND '),
        limit = 500
    })
end

------------------------------------------------------------------------------------------------------------------------
-- Retrieves the icons and the offered sub-skills from Cargo
------------------------------------------------------------------------------------------------------------------------
local function queryIconAndOffers(forcedSkill)
    -- fields:
    local fields = {}
    table.insert(fields, 'SkillLevel.skill_id = id');
    table.insert(fields, 'SkillLevel.level = level');
    table.insert(fields, 'SkillLevel.icon = icon');
    table.insert(fields, 'SkillLevel.offered_sub_skills = offered_sub_skills');

    -- where:
    local where = {}
    if forcedSkill then
        table.insert(where, 'SkillLevel.skill_id="' .. forcedSkill .. '"')
    end
    table.insert(where, 'SkillLevel.skill_id NOT LIKE "arena%"')
    table.insert(where, 'SkillLevel.skill_id NOT LIKE "campaign%"')

    -- query:
    return mw.ext.cargo.query('SkillLevel', table.concat(fields, ','), {
        where = table.concat(where, ' AND '),
        limit = 200
    })
end

------------------------------------------------------------------------------------------------------------------------
-- Retrieves the most important data from Cargo
------------------------------------------------------------------------------------------------------------------------
local function querySubs(lang, forcedSkill)
    -- fields:
    local fields = {}
    table.insert(fields, 'SubSkill.id = id');
    table.insert(fields, 'SubSkill.parent_skill_id = parent_skill_id');
    table.insert(fields, 'SubSkill.icon = icon');
    table.insert(fields, 'Translation.name = name');
    table.insert(fields, 'Translation.description = description');
    table.insert(fields, 'Translation.language = language');

    -- where:
    local where = {}
    if forcedSkill then
        table.insert(where, 'SubSkill.parent_skill_id="' .. forcedSkill .. '"')
    end
    table.insert(where, 'SubSkill.variant = "production"')
    table.insert(where, '(Translation.language = "en" OR Translation.language = "' .. lang .. '")')

    -- query:
    return mw.ext.cargo.query('SubSkill, Translation', table.concat(fields, ','), {
        join = 'SubSkill.id = Translation.target_id',
        where = table.concat(where, ' AND '),
        limit = 1000
    })
end

------------------------------------------------------------------------------------------------------------------------
-- Folds various skill results from Cargo into a single table
------------------------------------------------------------------------------------------------------------------------
local function consolidateSkills(main, iconAndOffers, subs, lang)
    local hub = {}
    for _, row in ipairs(main) do
        local id = row.id
        local variant = row.variant
        hub[id] = hub[id] or { id = id, ranks = {} }
        local entry = hub[id]
        if variant then
            -- Level 1, 2 or 3
            local i = tonumber(variant) or 1
            entry.ranks[i] = entry.ranks[i] or {}
            local rank = entry.ranks[i]
            if row.language == lang then
                rank.name = row.name or ''
                rank.description = row.description or ''
            end
            if row.language == 'en' then
                rank.nameEn = row.name or ''
                rank.descriptionEn = row.description or ''
            end
        else
            -- Root level (the group/family)
            if row.language == lang then
                entry.name = row.name or ''
            end
            if row.language == 'en' then
                entry.nameEn = row.name or ''
            end
        end
    end
    for _, row in ipairs(iconAndOffers) do
        local id = row.id
        local entry = hub[id] or { ranks = {} }
        local rank = entry.ranks[tonumber(row.level)] or {}
        rank.icon = row.icon or ''
        local csv = row.offered_sub_skills
        local ids = csv and mw.text.split(csv, ',') or {}
        rank.subSkills = {}
        for _, subId in ipairs(ids) do
            table.insert(rank.subSkills, {
                id = subId
            })
        end
    end
    for _, row in ipairs(subs) do
        local id = row.id
        local parentId = row.parent_skill_id
        local entry = hub[parentId] or { ranks = {} }
        for _, rank in ipairs(entry.ranks) do
            local subSkills = rank.subSkills or {}
            for _, subSkill in ipairs(subSkills) do
                if subSkill.id == id then
                    if row.language == lang then
                        subSkill.icon = row.icon or ''
                        subSkill.name = row.name or ''
                        subSkill.description = row.description or ''
                    end
                    if row.language == 'en' then
                        subSkill.descriptionEn = row.description or ''
                    end
                end
            end
        end
    end
    return hub
end

------------------------------------------------------------------------------------------------------------------------
-- Converts a dictionary into a linear array
------------------------------------------------------------------------------------------------------------------------
local function flatten(hub)
    local list = {}
    for _, entry in pairs(hub) do
        list[#list + 1] = entry
    end
    return list
end

------------------------------------------------------------------------------------------------------------------------
-- Converts an array into a dictionary, where the array.value becomes dic.key and array.index becomes dic.value
------------------------------------------------------------------------------------------------------------------------
local function flipArray(array)
    local dic = {}
    for index, value in ipairs(array) do
        dic[value] = index
    end
    return dic
end
local ID_TO_ORDER = flipArray(ORDER)

------------------------------------------------------------------------------------------------------------------------
-- Decides the order between two skills.
------------------------------------------------------------------------------------------------------------------------
local function compareSkills(a, b)
    local rankA = ID_TO_ORDER[a.id] or 99
    local rankB = ID_TO_ORDER[b.id] or 99

    if rankA ~= rankB then
        return rankA < rankB
    end

    return a.name < b.name
end

------------------------------------------------------------------------------------------------------------------------
-- Moves punctuation outside the quotes
------------------------------------------------------------------------------------------------------------------------
local function repairPunctuation(text)
    local allQuotes = [=["„“”‘’«»「」『』]=]
    local punctuation = "%,%.%!%?"
    local pattern = "([" .. punctuation .. "])([" .. allQuotes .. "])"
    text = mw.ustring.gsub(text, pattern, "%2%1")
    return text
end

------------------------------------------------------------------------------------------------------------------------
-- Returns a "Foo/XX" string based on the first quoted word in a text, if any.
------------------------------------------------------------------------------------------------------------------------
local function findUrl(descriptionInEnglish, lang)
    local name = descriptionInEnglish:match("“(.-)”")
    if name and name ~= '' then
        return getUrl(name, lang)
    end
    return nil
end

------------------------------------------------------------------------------------------------------------------------
-- Converts quoted text into wiki links
------------------------------------------------------------------------------------------------------------------------
local function replaceQuotesWithLinks(text, lang, url)
    local allQuotes = [=["„“”«»「」『』]=]
    local pattern = "([" .. allQuotes .. "])([^" .. allQuotes .. "]+)([" .. allQuotes .. "])"
    text = mw.ustring.gsub(text, pattern, "%1[[" .. url .. "|%2]]%3")

    local quotePairs = {
        { '‘', '’' }, -- English "smart" single quotes
    }
    if lang == 'ko' then
        table.insert(quotePairs, { "'", "'" }) -- we're checking for single quotes only in Korean
    end
    for _, pair in ipairs(quotePairs) do
        local openQuote = pair[1]
        local closeQuote = pair[2]
        local pairedPattern = openQuote .. "([^" .. closeQuote .. "]+)" .. closeQuote
        text = mw.ustring.gsub(text, pairedPattern, "[[%1]]")
    end

    return text
end

------------------------------------------------------------------------------------------------------------------------
-- Converts the first capitalized mid-sentence words into wiki links
------------------------------------------------------------------------------------------------------------------------
local function replaceCapitalizedWithLinks(text, url)
    text = mw.ustring.gsub(text, "([^%.]) (%u[%w ’]+)", "%1 [[" .. url .. "|%2]]")
    return text
end

------------------------------------------------------------------------------------------------------------------------
-- Adds [[link]] inside a description
------------------------------------------------------------------------------------------------------------------------
local function linkifyDescription(description, descriptionEn, lang)
    descriptionEn = repairPunctuation(descriptionEn)
    local url = findUrl(descriptionEn, lang)
    if not url then return description end

    description = lang == 'en' and descriptionEn or repairPunctuation(description)
    local adapted = replaceQuotesWithLinks(description, lang, url)
    if description ~= adapted then
        description = adapted
    else
        description = replaceCapitalizedWithLinks(description, url)
    end

    return description
end

------------------------------------------------------------------------------------------------------------------------
-- Adds [[links]] inside descriptions
------------------------------------------------------------------------------------------------------------------------
local function linkifyDescriptions(skills, lang)
    for _, group in ipairs(skills) do
        local ranks = group.ranks or {}
        for _, rank in ipairs(ranks) do
            local subSkills = rank.subSkills or {}
            for _, subSkill in pairs(subSkills) do
                if subSkill.description then
                    subSkill.description = linkifyDescription(subSkill.description, subSkill.descriptionEn, lang)
                end
            end
        end
    end
end

------------------------------------------------------------------------------------------------------------------------
-- Displays a single skill (icon+name+description)
------------------------------------------------------------------------------------------------------------------------
local function createSkill(target, class, size)
    local root = mw.html.create('div'):addClass('box'):addClass(class)
    root:tag('div'):addClass('icon-container'):wikitext('[[File:' .. target.icon .. '.png|' .. size .. 'px]]')
    local textContainer = root:tag('div'):addClass('text-container')
    textContainer:tag('div'):addClass('box-name'):wikitext('<b>' .. target.name .. '</b>')
    textContainer:tag('div'):addClass('box-description'):wikitext(target.description)
    return root
end

------------------------------------------------------------------------------------------------------------------------
-- Displays the main content
------------------------------------------------------------------------------------------------------------------------
local function renderSkills(skills, words, lang)
    local root = mw.html.create()
    local currentCategory = nil
    local isAll = #skills > 1
    for _, group in ipairs(skills) do
        local id = group.id

        local category = CATEGORIES[id]
        if category and category ~= currentCategory then
            currentCategory = category
            if isAll then
                root:tag('h2'):addClass('category'):wikitext(words[category])
            end
        end

        local ranks = group.ranks or {}
        if isAll then
            local url = getUrl(group.nameEn, lang)
            root:tag('h3'):addClass('group'):wikitext('[[' .. url .. '|' .. group.name .. ']]')
        end

        for _, rank in ipairs(ranks) do
            root:node(createSkill(rank, 'rank', 64))

            local subSkills = rank.subSkills or {}
            for _, subSkill in pairs(subSkills) do
                root:node(createSkill(subSkill, 'sub', 32))
            end
        end
    end
    return tostring(root)
end

------------------------------------------------------------------------------------------------------------------------
-- Bundles together all cargo skill queries:
-- Also called by Module:SkillSynergies
-- Also called by Module:SkillArtifacts
------------------------------------------------------------------------------------------------------------------------
function p.getSkillsFromCargo(lang, skill)
    local main = queryMain(lang, skill)
    --if 1 then return main end
    local iconAndOffers = queryIconAndOffers(skill)
    local subs = querySubs(lang, skill)
    local hub = consolidateSkills(main, iconAndOffers, subs, lang)
    return hub
end

------------------------------------------------------------------------------------------------------------------------
-- Main public function
------------------------------------------------------------------------------------------------------------------------
function p.display(frame)
    --if 1 then return dump(frame) end

    -- Args
    local args = frame.args
    local lang = args.lang or 'en'
    local skill = args.skill
    --lang = 'fr'
    --skill = 'skill_formation'

    -- Language
    local words = translateIds(TRANSLATION_IDS, lang)

    -- Cargo
    local skillsHub = p.getSkillsFromCargo(lang, skill)
    local skills = flatten(skillsHub)
    --if 1 then return dump(#skills) end
    if #skills == 0 then
        return 'No skills found!'
    end

    -- Various manipulations
    table.sort(skills, compareSkills)
    linkifyDescriptions(skills, lang)

    -- Content
    local markup = renderSkills(skills, words, lang)
    --if 1 then return dump(markup) end

    -- Output
    local styleTag = frame:extensionTag('templatestyles', '', { src = frame:getTitle() .. '/styles.css' })
    return styleTag .. markup
end

return p