Die Dokumentation für dieses Modul kann unter Modul:Belege/Doku erstellt werden

-- Modul:Belege
-- Kompaktes Utility-Modul für Beleg-Vorlagen.
-- Alle öffentlichen Funktionen sind direkt via #invoke nutzbar.

local p = {}

-----------------------------------------------------
-- Basis-Helpers
-----------------------------------------------------

local function getArg(frameOrValue, index, name)
    -- Wenn es ein frame-Objekt ist:
    if type(frameOrValue) == "table" and frameOrValue.args then
        local args = frameOrValue.args
        if name and args[name] ~= nil then
            return tostring(args[name])
        end
        if index and args[index] ~= nil then
            return tostring(args[index])
        end
        return ""
    end
    -- Direkter Aufruf aus einem Modul mit String
    if frameOrValue == nil then
        return ""
    end
    return tostring(frameOrValue)
end

local function trim(s)
    if not s then return "" end
    return mw.text.trim(tostring(s))
end

local function splitAuthors(str)
    local t = {}
    str = trim(str)
    if str == "" then
        return t
    end
    for part in mw.text.gsplit(str, ";", true) do
        table.insert(t, trim(part))
    end
    return t
end

-----------------------------------------------------
-- 1. esc
-----------------------------------------------------

function p.esc(frame)
    local s = getArg(frame, 1)
    return mw.text.nowiki(s)
end

-----------------------------------------------------
-- 2. Autorenformatierung
-----------------------------------------------------

function p.formatAuthors(frame)
    local str = getArg(frame, 1, "autor")
    str = trim(str)
    if str == "" then
        return ""
    end

    local authors = splitAuthors(str)
    local out = {}

    for _, a in ipairs(authors) do
        local first, last = a:match("^(.-)%s+(.-)$")
        if first and last and first ~= "" and last ~= "" then
            table.insert(out, last .. ", " .. first)
        else
            table.insert(out, a)
        end
    end

    return table.concat(out, "; ")
end

-----------------------------------------------------
-- 3. Datumsverarbeitung (intern)
-----------------------------------------------------

local function parseDateInternal(s)
    s = trim(s)
    if s == "" then
        return nil
    end

    -- reines Jahr
    if s:match("^%d%d%d%d$") then
        return {
            year = s,
            month = nil,
            day = nil,
            display = s
        }
    end

    -- Jahr + Monat
    local y, m = s:match("^(%d%d%d%d)%-(%d%d)$")
    if y and m then
        return {
            year = y,
            month = m,
            day = nil,
            display = y .. "-" .. m
        }
    end

    -- vollständiges Datum
    local y2, m2, d2 = s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
    if y2 and m2 and d2 then
        local ok, formatted = pcall(function()
            return mw.language.getContentLanguage():formatDate("j. M Y", s)
        end)
        return {
            year = y2,
            month = m2,
            day = d2,
            display = ok and formatted or (y2 .. "-" .. m2 .. "-" .. d2)
        }
    end

    -- Fallback: gib es einfach so aus
    return {
        year = nil,
        month = nil,
        day = nil,
        display = s
    }
end

-----------------------------------------------------
-- 3a. formatYearOrDate
-----------------------------------------------------

function p.formatYearOrDate(frame)
    local dateStr = getArg(frame, 1, "datum")
    local d = parseDateInternal(dateStr)
    if not d then
        return ""
    end
    return "(" .. (d.display or dateStr) .. ")"
end

-----------------------------------------------------
-- 3b. formatAccess  (abgerufen am …)
-----------------------------------------------------

function p.formatAccess(frame)
    local dateStr = getArg(frame, 1, "zugriff")
    local d = parseDateInternal(dateStr)
    if not d then
        return ""
    end
    return "(abgerufen am " .. (d.display or dateStr) .. ")"
end

-----------------------------------------------------
-- 3c. formatDateStandalone (nur Datum ohne Klammern)
-----------------------------------------------------
function p.formatDateStandalone(frame)
    -- Erwartet |datum= oder 1. Parameter
    local dateStr = getArg(frame, 1, "datum")
    if not dateStr or dateStr == "" then
        return ""
    end

    local d = parseDateInternal(dateStr)
    if not d then
        -- Fallback: gib den Rohwert aus
        return dateStr
    end

    -- "display" ist bereits lokalisiert (z. B. "1. Nov. 2024")
    return d.display or dateStr
end


-----------------------------------------------------
-- 4. Seitenangabe
-----------------------------------------------------

function p.formatPages(frame)
    local s = getArg(frame, 1, "seiten")
    s = trim(s)
    if s == "" then
        return ""
    end

    -- Falls noch kein "S. " vorne steht, ergänzen
    if not s:match("^[Ss]%.%s*") then
        s = "S. " .. s
    end

    -- Am Ende genau EIN Punkt: falls keiner da, hinhängen
    if not s:match("%.%s*$") then
        s = s .. "."
    end

    return s
end

-----------------------------------------------------
-- 5. URL-Bereinigung
-----------------------------------------------------
function p.cleanUrl(frame)
    -- URL besorgen: erst |url=, dann 1. Parameter
    local raw = ""
    if type(frame) == "table" and frame.args then
        raw = frame.args.url or frame.args[1] or ""
        -- falls über eine Vorlage aufgerufen und die eigentlichen
        -- Parameter im Parent-Frame stecken
        if (raw == "" or raw == nil) and frame.getParent then
            local parent = frame:getParent()
            if parent and parent.args then
                raw = parent.args.url or parent.args[1] or ""
            end
        end
    else
        raw = tostring(frame or "")
    end

    raw = trim(raw)
    if raw == "" then
        return ""
    end

    -- Hat die URL überhaupt ein "?"
    local qm = raw:find("?", 1, true)
    if not qm then
        -- keine Query -> unverändert
        return raw
    end

    local base  = raw:sub(1, qm - 1)
    local query = raw:sub(qm + 1)

    -- Nur "?" am Ende -> dann kann das Fragezeichen weg
    if query == "" then
        return base
    end

    local kept = {}

    -- Query in &-Segmente zerlegen
    for part in string.gmatch(query, "[^&]+") do
        local name, value = part:match("^([^=]+)=(.*)$")
        if not name then
            -- kein "=", z.B. "?foo" -> ganzer Part als Name
            name = part
        end

        local lname = string.lower(trim(name))

        -- Tracking-Parameter RAUS
        if not lname:match("^utm_")
            and lname ~= "fbclid"
            and lname ~= "gclid" then
            -- kompletten Part behalten (nicht neu zusammensetzen)
            table.insert(kept, part)
        end
    end

    if #kept == 0 then
        -- alles war Tracking -> nur Basis
        return base
    end

    return base .. "?" .. table.concat(kept, "&")
end

-----------------------------------------------------
-- Q-ID-Formatter (dw:/wikidata:)
-----------------------------------------------------
local function formatQIDInternal(qidRaw)
    qidRaw = trim(qidRaw or "")
    if qidRaw == "" then
        return ""
    end

    -- Erwartet Präfix + ID, z. B. "dw:Q123" oder "wikidata:Q183"
    local prefix, id = qidRaw:match("^([^:]+):(.+)$")
    if not prefix or not id then
        return string.format(
            '<span class="cite-error">FEHLER: Ungültige Q-ID „%s“</span>',
            mw.text.nowiki(qidRaw)
        )
    end

    prefix = mw.ustring.lower(trim(prefix))
    id     = trim(id)

    if prefix == "dw" then
        -- Eigenes DataWiki
        return string.format("[[dw:%s|dw:%s]]", id, id)
    elseif prefix == "wikidata" then
        -- Wikidata – nur Q-ID als Linktext
        return string.format("[[wikidata:%s|%s]]", id, id)
    else
        return string.format(
            '<span class="cite-error">FEHLER: Unbekannter Q-ID-Präfix „%s“</span>',
            mw.text.nowiki(prefix)
        )
    end
end

function p.formatQID(frame)
    -- |qid= oder 1. Parameter
    local qid = getArg(frame, 1, "qid")
    return formatQIDInternal(qid)
end


-----------------------------------------------------
-- 6. Medienplattformen – Whitelist, Badges, Canonicalizer
-----------------------------------------------------

-- Mapping von eingegebener Plattform (wie im TemplateData) auf kanonische Keys
local PLATFORM_CANON = {
    ["youtube"]        = "youtube",
    ["vimeo"]          = "vimeo",
    ["ard"]            = "ard",
    ["ard mediathek"]  = "ard",
    ["zdf"]            = "zdf",
    ["zdf mediathek"]  = "zdf",
    ["arte"]           = "arte",
    ["arte mediathek"] = "arte",
    ["spotify"]        = "spotify",
    ["soundcloud"]     = "soundcloud",
    ["twitch"]         = "twitch",
}

local function normalizePlatform(raw)
    raw = trim(raw or ""):lower()
    if raw == "" then
        return nil
    end
    return PLATFORM_CANON[raw]
end

-- Zulässige Plattformen (canonical lowercase als Schlüssel)
local MEDIA_PLATFORMS = {
    youtube     = true,
    vimeo       = true,
    ard         = true,
    zdf         = true,
    arte        = true,
    spotify     = true,
    soundcloud  = true,
    twitch      = true,
}

-- Mapping zur Anzeige (Name → Label)
local PLATFORM_LABEL = {
    youtube     = "YouTube",
    vimeo       = "Vimeo",
    ard         = "ARD Mediathek",
    zdf         = "ZDF Mediathek",
    arte        = "Arte",
    spotify     = "Spotify",
    soundcloud  = "SoundCloud",
    twitch      = "Twitch",
}

-- Icons pro Plattform (FontAwesome)
local PLATFORM_ICONS = {
    youtube     = "fa-brands fa-youtube",
    vimeo       = "fa-brands fa-vimeo",
    ard         = "fa-solid fa-tv",
    zdf         = "fa-solid fa-tv",
    arte        = "fa-solid fa-tv",
    spotify     = "fa-brands fa-spotify",
    soundcloud  = "fa-brands fa-soundcloud",
    twitch      = "fa-brands fa-twitch",
}

-----------------------------------------------------
-- Hilfsfunktion: Fehlerausgabe bei ungültiger Plattform
-----------------------------------------------------
local function platformError(platform)
    platform = mw.text.nowiki(platform or "")
    return string.format(
        '<span class="cite-error">FEHLER: Ungültige Medienplattform „%s“</span>',
        platform
    )
end

-----------------------------------------------------
-- Badges für Plattformen
-----------------------------------------------------
function p.platformBadge(frame)
    local rawPlatform = getArg(frame, 1, "plattform") or ""
    local platform    = normalizePlatform(rawPlatform)

    if not platform or not MEDIA_PLATFORMS[platform] then
        return platformError(rawPlatform)
    end

    local icon  = PLATFORM_ICONS[platform] or "fa-solid fa-video"
    local label = PLATFORM_LABEL[platform] or platform

    return string.format(
        '<span class="cite-platform"><i class="%s"></i>&nbsp;%s</span>',
        icon,
        mw.text.nowiki(label)
    )
end


-----------------------------------------------------
-- CANONICALIZER – für jede Plattform eigene Logik
-----------------------------------------------------

-- YouTube
local function canonicalYouTube(url, id, start)
    -- ID hat Vorrang
    if id and id ~= "" then
        local base = "https://www.youtube.com/watch?v=" .. id
        if start and start ~= "" then
            return base .. "&t=" .. start
        end
        return base
    end

    -- URL normalisieren
    url = trim(url or "")
    if url == "" then
        return ""
    end

    -- youtu.be/xyz
    local sid = url:match("youtu%.be/([^%?&]+)")
    if sid then
        id = sid
    end

    -- watch?v=xyz
    local wid = url:match("v=([^%&]+)")
    if wid then
        id = wid
    end

    if not id then
        return url -- Fallback
    end

    return canonicalYouTube("", id, start)
end


-- Vimeo
local function canonicalVimeo(url, id)
    if id and id ~= "" then
        return "https://vimeo.com/" .. id
    end

    url = trim(url or "")
    if url == "" then
        return ""
    end

    local vid = url:match("vimeo%.com/(%d+)")
    if vid then
        return "https://vimeo.com/" .. vid
    end

    return url
end


-- ARD / ZDF / Arte – einfache Normalizer
-- (Wir verzichten bewusst auf API-Queries; nur Tracking entfernen)
local function canonicalSimpleClean(url)
    if not url or url == "" then
        return ""
    end
    -- vorhandene cleanUrl-Logik wiederverwenden
    return p.cleanUrl(url)
end

-- Spotify
local function canonicalSpotify(url)
    url = trim(url or "")
    if url == "" then return "" end

    -- Show / Episode IDs extrahieren
    local base, id = url:match("(https://open%.spotify%.com/[%w%/]+)/([^%?]+)")
    if base and id then
        return base .. "/" .. id
    end

    return p.cleanUrl(url)
end

-- SoundCloud
local function canonicalSoundCloud(url)
    return p.cleanUrl(url or "")
end

-- Twitch
local function canonicalTwitch(url, id, channel)
    url = trim(url or "")
    if channel and channel ~= "" then
        return "https://www.twitch.tv/" .. channel
    end
    -- Clips-URL?
    local slug = url:match("clips%.twitch%.tv/([^%?]+)")
    if slug then
        return "https://clips.twitch.tv/" .. slug
    end
    return p.cleanUrl(url)
end


-----------------------------------------------------
-- Dispatcher für canonicalMediaUrl
-----------------------------------------------------
local CANONICAL = {
    youtube    = function(a) return canonicalYouTube(a.url, a.id, a.start) end,
    vimeo      = function(a) return canonicalVimeo(a.url, a.id) end,
    ard        = function(a) return canonicalSimpleClean(a.url) end,
    zdf        = function(a) return canonicalSimpleClean(a.url) end,
    arte       = function(a) return canonicalSimpleClean(a.url) end,
    spotify    = function(a) return canonicalSpotify(a.url) end,
    soundcloud = function(a) return canonicalSoundCloud(a.url) end,
    twitch     = function(a) return canonicalTwitch(a.url, a.id, a.channel) end,
}

-----------------------------------------------------
-- Öffentliche Funktion: canonicalMediaUrl
-----------------------------------------------------
function p.canonicalMediaUrl(frame)
    local rawPlatform = getArg(frame, 1, "plattform")
    local url         = getArg(frame, 2, "url")
    local id          = getArg(frame, 3, "id")       -- YouTube/Vimeo
    local start       = getArg(frame, 4, "zeit")     -- Timestamp
    local channel     = getArg(frame, 5, "kanal")    -- Twitch

    local platform = normalizePlatform(rawPlatform)

    if not platform or platform == "" then
        -- keine Plattform -> keine URL
        return ""
    end

    if not MEDIA_PLATFORMS[platform] then
        return platformError(rawPlatform)
    end

    local fn = CANONICAL[platform]
    if not fn then
        return platformError(rawPlatform)
    end

    return fn({
        url     = url,
        id      = id,
        start   = start,
        channel = channel,
    })
end

-----------------------------------------------------
-- 7. Badges
-----------------------------------------------------

function p.badge(frame)
    local t = getArg(frame, 1, "typ")
    t = trim(t):lower()

    if t == "pdf" then
        return '<span class="beleg-badge beleg-badge-pdf" title="PDF">' ..
               '<i class="fa-solid fa-file-pdf" aria-hidden="true"></i>' ..
               '</span>'

    elseif t == "warn" then
        return '<span class="beleg-badge beleg-badge-warn" title="Hinweis">' ..
               '<i class="fas fa-circle-exclamation" aria-hidden="true"></i>' ..
               '</span>'

    elseif t == "archive" then
        return '<span class="beleg-badge beleg-badge-archive" title="Archiv">' ..
               '<i class="fa-solid fa-box-archive" aria-hidden="true"></i>' ..
               '</span>'

    elseif t == "video" then
        return '<span class="beleg-badge beleg-badge-video" title="Video">' ..
               '<i class="fa-solid fa-play" aria-hidden="true"></i>' ..
               '</span>'
    end

    return ""
end

-----------------------------------------------------
-- 8. Sprach-Badge
-----------------------------------------------------

function p.languageBadge(frame)
    local lang = getArg(frame, 1, "sprache")
    lang = trim(lang)
    if lang == "" or lang == "de" then
        return ""
    end
    return "[" .. lang .. "]"
end

-----------------------------------------------------
-- 9. Archiv-/Offline-Hinweise
-----------------------------------------------------
-- kleines Icon-Helferchen (nur Wikitext/HTML)
local function icon(class, title)
    class = class or ""
    title = title or ""
    return string.format(
        '<i class="%s" title="%s"></i>',
        class,
        title
    )
end

-- (B) Archivhinweis:  (🗄 Archivversion vom 12. Nov 2025)
function p.archiveInfo(frame)
    local archivurl   = getArg(frame, 1, "archivurl")
    local archivdatum = getArg(frame, 2, "archivdatum")

    archivurl   = trim(archivurl)
    archivdatum = trim(archivdatum)

    if archivurl == "" then
        return ""
    end

    local d      = parseDateInternal(archivdatum)
    local when   = d and (d.display or d.year) or ""
    local iconHtml = icon("fas fa-archive", "Archivversion")

    local label = "Archivversion"
    if when ~= "" then
        label = label .. " vom " .. when
    end

    -- Wikitext-Link, Parser kümmert sich um den Rest
    local link = string.format(
        "[%s %s&nbsp;%s]",
        archivurl,
        iconHtml,
        label
    )

    return "(<span class=\"cite-archive\">" .. link .. "</span>)"
end

-- (C) Offline-Hinweis:  (⚠ Original nicht mehr verfügbar – Beleg prüfen)
-- offline= (leer|ja|DATUM)
--  - leer  -> wird i.d.R. gar nicht aufgerufen
--  - "ja"  -> generischer Hinweis
--  - DATUM -> präzisierter Hinweis ("seit … nicht mehr verfügbar")
function p.offlineInfo(frame)
    local v    = trim(getArg(frame, 1, "offline"))
    local mode = trim(getArg(frame, 2, "mode"))  -- "soft" = mit Archiv

    local disp -- formatiertes Datum, falls angegeben
    if v ~= "" and v ~= "ja" and v ~= "true" then
        local d = parseDateInternal(v)
        disp = d and (d.display or d.year) or v
    end

    -- SOFT: Archiv vorhanden -> nur nüchterne Info, kein Alarm
    if mode == "soft" then
        if disp then
            return "Original seit " .. disp .. " nicht mehr verfügbar."
        else
            return "Original nicht mehr verfügbar."
        end
    end

    -- HART: kein Archiv -> Warnhinweis mit Icon + „Beleg prüfen“
    local iconHtml = icon("fas fa-exclamation-circle", "Hinweis")

    local base = "Original"
    if disp then
        base = base .. " seit " .. disp
    end
    local text = base .. " nicht mehr verfügbar – Beleg prüfen."

    return iconHtml .. "&nbsp;" .. text
end

-----------------------------------------------------
-- Data-Badge für BelegData
-----------------------------------------------------
function p.dataBadge(frame)
    -- Optional: eigener Text, z. B. |label= oder 1. Parameter
    local label = getArg(frame, 1, "label")
        or getArg(frame, 2, "text")
        or "Datensatz"

    label = trim(label or "")
    if label == "" then
        label = "Datensatz"
    end

    -- Einheitliches Icon für alle Datenquellen
    local iconHtml = icon("fas fa-chart-bar", "Datensatz / Datenquelle")

    return string.format(
        '<span class="cite-platform">%s&nbsp;%s</span>',
        iconHtml,
        mw.text.nowiki(label)
    )
end


-----------------------------------------------------
-- 10. ISBN-Formatierung
-----------------------------------------------------

local function normalizeIsbn(raw)
    raw = trim(raw or "")

    if raw == "" then
        return ""
    end

    -- Führende "ISBN", "ISBN-10", "ISBN-13" etc. entfernen
    -- inkl. Doppelpunkt, Bindestrich und Leerzeichen danach
    raw = raw:gsub("^[Ii][Ss][Bb][Nn][^0-9Xx]*", "")

    -- Nur Ziffern, X/x, Leerzeichen und Bindestriche behalten
    raw = raw:gsub("[^0-9Xx%s%-]", "")

    -- X normalisieren
    raw = raw:gsub("[Xx]", "X")

    -- Beliebige Kombinationen aus Leerzeichen/Bindestrichen → EIN Bindestrich
    raw = raw:gsub("[%s%-]+", "-")

    -- Leading/trailing "-" entfernen
    raw = raw:gsub("^%-+", ""):gsub("%-+$", "")

    return raw
end

function p.formatIsbn(frame)
    local s = getArg(frame, 1, "isbn")
    s = normalizeIsbn(s)

    if s == "" then
        return ""
    end

    -- Prüfen, ob die "echten" Zeichen 10 oder 13 Stellen haben
    local digits = s:gsub("%-", "")
    local len    = #digits

    if len ~= 10 and len ~= 13 then
        -- ungültige Länge → trotzdem bereinigte Version zurückgeben,
        -- aber ohne weitere Magie
        return s
    end

    -- Optional könnten wir hier noch Checksummen prüfen
    return s
end
-----------------------------------------------------
-- 11. ISSN-Formatierung
-----------------------------------------------------

local function normalizeIssn(raw)
    raw = trim(raw or "")

    if raw == "" then
        return ""
    end

    -- Führende "ISSN" etc. entfernen
    raw = raw:gsub("^[Ii][Ss][Ss][Nn][^0-9Xx]*", "")

    -- Nur Ziffern, X/x, Leerzeichen und Bindestriche behalten
    raw = raw:gsub("[^0-9Xx%s%-]", "")

    -- X normalisieren
    raw = raw:gsub("[Xx]", "X")

    -- Alle Leerzeichen und Bindestriche entfernen
    raw = raw:gsub("[%s%-]+", "")

    -- Jetzt sollten idealerweise 8 Zeichen übrig sein
    if #raw ~= 8 then
        -- Länge falsch → trotzdem bereinigte Variante zurückgeben
        return raw
    end

    -- Standardformat ####-###X
    return raw:sub(1, 4) .. "-" .. raw:sub(5)
end

function p.formatIssn(frame)
    local s = getArg(frame, 1, "issn")
    s = normalizeIssn(s)

    if s == "" then
        return ""
    end

    -- Später: Prüfsumme rechnen und ggf. Wartungskategorie anhängen
    return s
end

-----------------------------------------------------
-- DEBUG: zeigt, was bei cleanUrl eigentlich ankommt
-----------------------------------------------------

function p.debugCleanUrl(frame)
    local raw = ""

    if type(frame) == "table" and frame.args then
        raw = frame.args[1] or frame.args.url or ""
        if (raw == "" or raw == nil) and frame.getParent then
            local parent = frame:getParent()
            if parent and parent.args then
                raw = parent.args[1] or parent.args.url or ""
            end
        end
    else
        raw = tostring(frame or "")
    end

    raw = tostring(raw)

    local out = {}
    table.insert(out, "RAW: " .. mw.text.nowiki(raw))

    local qm = raw:find("?", 1, true)
    if not qm then
        table.insert(out, "KEIN FRAGEZEICHEN GEFUNDEN")
        return table.concat(out, "\n")
    end

    local base  = raw:sub(1, qm - 1)
    local query = raw:sub(qm + 1)

    table.insert(out, "BASE: " .. mw.text.nowiki(base))
    table.insert(out, "QUERY: " .. mw.text.nowiki(query))

    local i = 0
    for part in string.gmatch(query, "[^&]+") do
        i = i + 1
        table.insert(out, "PART " .. i .. ": " .. mw.text.nowiki(part))
    end

    return table.concat(out, "\n")
end


return p