Module:Data/Inventaire

La documentation pour ce module peut être créée à Module:Data/Inventaire/doc

--[[----------------------------------------------------------------------
  Module:Data/Inventaire
  À copier sur le wiki dans la page « Module:Data/Inventaire ».

  RÔLE
  ----
  Afficher les listes de marchandises d'UN VENDEUR, quel qu'il soit, depuis
  une page de données JSON — sans que personne n'ait à recopier ces données
  ailleurs, et sans qu'aucune mise en forme wiki ait à être analysée.

  Un vendeur, une page de données :

      Minerva          →  Module:Data/Inventaire/Minerva.json
      Vendeur Bot Peco →  Module:Data/Inventaire/Vendeur Bot Peco.json

  Cette page est lue par ce module — pour l'affichage wiki — ET par
  l'extension PHP — pour le flux de la carte, l'API et Discord. Une seule
  saisie alimente les quatre surfaces.

  POURQUOI DES DONNÉES ET NON DU WIKITEXTE ANALYSÉ
  -----------------------------------------------
  L'inventaire a d'abord vécu en boîtes déroulantes rédigées à la main, que
  l'extension relisait à l'expression régulière. Ça marche jusqu'au jour où
  quelqu'un réorganise la page : la mise en forme change, l'analyse ne
  reconnaît plus rien, et la carte annonce « pas encore publié » devant une
  liste complète. Une page de données ne se casse pas en la remettant en
  forme, parce qu'elle n'a pas de forme — et MediaWiki REFUSE de
  l'enregistrer si le JSON est invalide.

  UN NOMBRE QUELCONQUE DE LISTES, NOMMÉES COMME ON VEUT
  ----------------------------------------------------
  Minerva a vingt-quatre listes numérotées qui tournent. Un autre marchand
  peut n'en avoir qu'une, ou trois nommées par thème. Les clés de liste sont
  donc des CHAÎNES libres (« 21 », « Armes », « Ateliers »), et l'ordre
  d'affichage est celui du fichier — un tableau JSON, pas un objet, parce
  qu'un objet JSON n'a pas d'ordre défini.

  mw.loadJsonData() est délibéré plutôt qu'un require() : il déclare la
  dépendance de page auprès du parseur, donc modifier la page de données
  invalide automatiquement les pages qui l'affichent. Sans ça, une correction
  de prix resterait invisible jusqu'à la purge suivante.

  UTILISATION
  -----------
    {{#invoke:Data/Inventaire|toutes}}              toutes les listes du vendeur
    {{#invoke:Data/Inventaire|liste|21}}            une liste
    {{#invoke:Data/Inventaire|liste|Armes}}         une liste nommée
    {{#invoke:Data/Inventaire|liste|21|brut=oui}}   sans la boîte déroulante
    {{#invoke:Data/Inventaire|nombre|21}}           nombre d'articles
    {{#invoke:Data/Inventaire|cles}}                clés disponibles, séparées par des virgules

  Paramètres communs :
    |vendeur=  nom du vendeur (défaut : le titre de la page courante)
    |donnees=  page de données explicite, court-circuite |vendeur=
    |brut=oui  rend les puces sans boîte déroulante

  FORMAT DE LA PAGE DE DONNÉES (schéma « modus.inventaire/2 »)
  -----------------------------------------------------------
    {
      "schema": "modus.inventaire/2",
      "vendeur": "Minerva",
      "devise_defaut": "lingot",
      "listes": [
        {
          "cle": "21",
          "titre": "Liste 21",           // optionnel, sinon généré
          "mensuelle": false,            // optionnel, annoté dans le titre
          "articles": [
            { "plan": "Plan : Minigun de Gauss", "cout": 563 },
            { "plan": "Plan : Casseur de têtes" },
            "Plan : Poulailler"          // forme abrégée
          ]
        }
      ]
    }

  Le schéma « modus.inventaire/1 » (« listes » en objet indexé par numéro)
  reste lu tel quel : les pages déjà écrites continuent de fonctionner, et
  rien n'oblige à les convertir toutes le même jour.
--]]------------------------------------------------------------------------

local p = {}

local SCHEMAS_CONNUS = {
	['modus.inventaire/1'] = 1,
	['modus.inventaire/2'] = 2,
}

--- Préfixe des pages de données. Un vendeur = une sous-page.
local PREFIXE_DONNEES = 'Module:Data/Inventaire/'

-- Devises connues. Doit rester aligné avec includes/Data/Currency.php ;
-- une clé absente ici affiche le nombre seul plutôt qu'une mauvaise devise.
local DEVISES = {
	lingot = { singulier = 'lingot', pluriel = 'lingots', modele = 'Lingot76' },
}

-- ========================================================================
-- Chargement et normalisation
-- ========================================================================

local function erreur( message )
	return mw.html.create( 'strong' )
		:addClass( 'error' )
		:wikitext( 'Module:Data/Inventaire — ' .. message )
		:done()
end

--- Normalise une entrée de liste, quelle que soit sa provenance.
local function normaliserListe( cle, brut )
	if type( brut ) ~= 'table' then
		return nil
	end
	return {
		cle       = tostring( cle ),
		titre     = brut.titre,
		mensuelle = brut.mensuelle and true or false,
		articles  = type( brut.articles ) == 'table' and brut.articles or {},
	}
end

--- Charge la page de données et rend TOUJOURS la même structure, quel que
--- soit le schéma d'origine : une séquence ordonnée de listes.
---
--- La normalisation vit ici, en un seul endroit, et non dans chaque point
--- d'entrée : sans ça, chaque fonction devrait connaître les deux schémas et
--- l'une d'elles finirait par en oublier un.
local function charger( pageDonnees )
	local ok, donnees = pcall( mw.loadJsonData, pageDonnees )
	if not ok or type( donnees ) ~= 'table' then
		return nil, 'page de données introuvable ou JSON invalide : ' .. pageDonnees
	end

	local version = SCHEMAS_CONNUS[donnees.schema or '']
	if not version then
		return nil, 'schéma inattendu sur ' .. pageDonnees .. ' (« '
			.. tostring( donnees.schema ) .. " »)."
	end
	if type( donnees.listes ) ~= 'table' then
		return nil, 'champ « listes » absent de ' .. pageDonnees .. '.'
	end

	local listes = {}

	if version >= 2 then
		-- Tableau : l'ordre du fichier EST l'ordre d'affichage. C'est tout
		-- l'intérêt du passage au tableau — un vendeur dont les listes
		-- s'appellent « Armes », « Armures », « Ateliers » n'a aucun ordre
		-- naturel à retrouver, seulement celui que le rédacteur a voulu.
		for i, entree in ipairs( donnees.listes ) do
			local liste = normaliserListe(
				( type( entree ) == 'table' and entree.cle ) or i, entree )
			if liste then
				table.insert( listes, liste )
			end
		end
	else
		-- Objet indexé : les clés d'un objet JSON n'ont pas d'ordre, on trie
		-- donc numériquement — ce qui n'a de sens que parce que le schéma 1
		-- ne connaissait que des listes numérotées.
		local cles = {}
		for cle in pairs( donnees.listes ) do
			table.insert( cles, tostring( cle ) )
		end
		table.sort( cles, function ( a, b )
			local na, nb = tonumber( a ), tonumber( b )
			if na and nb then
				return na < nb
			end
			return a < b
		end )
		for _, cle in ipairs( cles ) do
			local liste = normaliserListe( cle, donnees.listes[cle] )
			if liste then
				table.insert( listes, liste )
			end
		end
	end

	return {
		vendeur      = donnees.vendeur,
		deviseDefaut = donnees.devise_defaut,
		listes       = listes,
	}, nil
end

--- Retrouve une liste par sa clé. La comparaison est faite sur la forme
--- textuelle : « 21 » saisi dans le wikitexte doit trouver la clé 21 écrite
--- en nombre dans le JSON, et inversement.
local function trouver( donnees, cle )
	cle = mw.text.trim( tostring( cle ) )
	for _, liste in ipairs( donnees.listes ) do
		if liste.cle == cle then
			return liste
		end
	end
	-- Second passage, insensible à la casse : « armes » doit trouver
	-- « Armes ». Un rédacteur ne devrait pas avoir à deviner la casse d'une
	-- clé qu'il ne voit pas.
	local bas = mw.ustring.lower( cle )
	for _, liste in ipairs( donnees.listes ) do
		if mw.ustring.lower( liste.cle ) == bas then
			return liste
		end
	end
	return nil
end

-- ========================================================================
-- Mise en forme
-- ========================================================================

-- « 1 234 » avec des espaces insécables fines : sur mobile, une espace
-- ordinaire coupe le nombre en fin de ligne.
--
-- ATTENTION AU PIÈGE, il a déjà mordu une fois : string.reverse travaille sur
-- des OCTETS, pas sur des caractères. Insérer l'espace fine insécable (U+202F,
-- soit les trois octets E2 80 AF) AVANT le second reverse ressortait « AF 80
-- E2 » — donc de l'UTF-8 invalide sur tout nombre de trois chiffres ou plus,
-- c'est-à-dire sur pratiquement chaque prix. On groupe donc avec un marqueur
-- ASCII, qui survit à l'inversion, et on ne pose le vrai séparateur qu'à la
-- toute fin, quand plus aucune opération sur octets ne le suivra.
local function formaterNombre( n )
	local s = tostring( math.floor( n ) )
	local r = s:reverse():gsub( '(%d%d%d)', '%1,' ):reverse()
	r = r:gsub( '^,', '' )   -- 1 000 → « ,1 000 » sans ça
	return ( r:gsub( ',', '\226\128\175' ) )
end

local function formaterCout( cout, devise )
	if cout == nil then
		return nil
	end
	local d = DEVISES[devise or '']
	local nombre = formaterNombre( cout )
	if not d then
		return nombre
	end
	local libelle = ( math.abs( cout ) == 1 ) and d.singulier or d.pluriel
	-- Le modèle d'icône est utilisé s'il existe sur le wiki ; sinon on écrit
	-- le mot. On ne teste pas son existence ici — un modèle manquant produit
	-- un lien rouge visible, ce qui est le bon signal pour un rédacteur.
	if d.modele then
		return nombre .. ' {{' .. d.modele .. '}}'
	end
	return nombre .. ' ' .. libelle
end

local function ligneArticle( article, deviseDefaut )
	if type( article ) == 'string' then
		article = { plan = article }
	end
	if type( article ) ~= 'table' or not article.plan then
		return nil
	end

	local nom = article.nom
	if not nom or nom == '' then
		nom = mw.ustring.gsub( article.plan, '^[Pp]lan%s*:%s*', '' )
	end

	local ligne = '[[' .. article.plan .. '|' .. nom .. ']]'
	local cout = formaterCout( article.cout, article.devise or deviseDefaut )
	if cout then
		ligne = ligne .. ' (' .. cout .. ')'
	end
	return ligne
end

--- Titre d'une boîte. Une clé purement numérique donne « Liste 21 » ; une
--- clé nommée est reprise telle quelle, parce qu'inventer « Liste Armes »
--- serait plus laid que ce que le rédacteur a écrit.
local function titreListe( liste, nb )
	if liste.titre and liste.titre ~= '' then
		return liste.titre
	end

	local base = tonumber( liste.cle ) and ( 'Liste ' .. liste.cle ) or liste.cle

	local details = {}
	if liste.mensuelle then
		table.insert( details, 'mensuelle' )
	end
	if nb > 0 then
		table.insert( details, nb .. ' plan' .. ( nb > 1 and 's' or '' ) )
	end
	if #details == 0 then
		return base
	end
	return base .. ' (' .. table.concat( details, ', ' ) .. ')'
end

local function rendreListe( frame, liste, deviseDefaut, brut )
	local puces = {}
	for _, article in ipairs( liste.articles ) do
		local ligne = ligneArticle( article, deviseDefaut )
		if ligne then
			table.insert( puces, '* ' .. ligne )
		end
	end

	local contenu = table.concat( puces, '\n' )
	if contenu == '' then
		contenu = "''Liste non encore détaillée.''"
	end

	-- PRÉPROCESSER AVANT DE PASSER À expandTemplate, ET NON APRÈS.
	--
	-- frame:expandTemplate développe le MODÈLE, mais laisse les valeurs
	-- d'arguments telles quelles : elles ne repassent pas par le préprocesseur.
	-- Les icônes de devise, écrites {{Lingot76}}, ressortaient donc en toutes
	-- lettres dans les boîtes — « (1 500 {{Lingot76}}) » au lieu du lingot.
	--
	-- Le défaut ne se voyait que sur ce chemin : |brut=oui préprocessait déjà,
	-- et affichait correctement. C'est ce qui le rendait discret — la même
	-- liste était juste ou fausse selon un paramètre d'affichage.
	--
	-- On préprocesse donc UNE fois, ici, pour les deux chemins.
	contenu = frame:preprocess( contenu )

	-- ANCRE STABLE, INDÉPENDANTE DE LA MISE EN FORME.
	--
	-- Les boîtes écrites à la main offraient « #Liste_21 », et des liens
	-- extérieurs y pointent — la carte joint à chaque passage de Minerva un lien
	-- « 📖 Liste N » construit ainsi. Rendre les listes autrement supprimait ces
	-- ancres sans prévenir : les liens continuaient d'exister, ils ne menaient
	-- simplement plus nulle part, ce qui ne se voit qu'en cliquant.
	--
	-- L'ancre est donc posée EXPLICITEMENT, et ne dépend plus de ce que le
	-- modèle de boîte fabrique de son titre : refondre l'affichage ne peut plus
	-- casser un lien entrant.
	-- DEUX ancres : le titre complet, tel qu'une section l'aurait produit, et la
	-- forme courte « Liste 21 » — celle que les liens extérieurs utilisent, et
	-- qui ne bouge pas quand le nombre de plans change. Un identifiant HTML ne
	-- doit pas être présent deux fois : on n'écrit la seconde que si elle diffère.
	local titre = titreListe( liste, #puces )
	local court = tonumber( liste.cle ) and ( 'Liste ' .. liste.cle ) or liste.cle
	local ancre = '<span id="' .. mw.uri.anchorEncode( titre ) .. '"></span>'
	if mw.uri.anchorEncode( court ) ~= mw.uri.anchorEncode( titre ) then
		ancre = ancre .. '<span id="' .. mw.uri.anchorEncode( court ) .. '"></span>'
	end

	if brut then
		return ancre .. contenu
	end

	-- On réutilise le modèle de boîte déroulante déjà en place sur le wiki :
	-- l'habillage reste celui du wiki, seul le CONTENU vient des données.
	-- L'ancre est posée AVANT la boîte, donc atteignable même repliée.
	return ancre .. frame:expandTemplate{
		title = 'Boîte déroulante',
		args = {
			titre = titre,
			contenu = contenu,
		},
	}
end

-- ========================================================================
-- Points d'entrée
-- ========================================================================

local function options( frame )
	local args = frame.args
	local parent = frame:getParent()
	local function arg( nom )
		if args[nom] ~= nil and args[nom] ~= '' then
			return args[nom]
		end
		if parent and parent.args[nom] ~= nil and parent.args[nom] ~= '' then
			return parent.args[nom]
		end
		return nil
	end

	-- VENDEUR PAR DÉFAUT : le titre de la page courante. Sur l'article
	-- « Minerva », {{#invoke:Data/Inventaire|toutes}} suffit donc, sans
	-- paramètre — et le jour où un autre marchand a sa page, le même appel y
	-- fonctionne sans rien changer. C'est ce qui rend le module réutilisable
	-- sans configuration.
	local vendeur = arg( 'vendeur' ) or mw.title.getCurrentTitle().baseText

	return {
		vendeur = vendeur,
		pageDonnees = arg( 'donnees' ) or ( PREFIXE_DONNEES .. vendeur .. '.json' ),
		brut = ( arg( 'brut' ) or '' ):lower() == 'oui',
		cle = arg( 'liste' ) or args[1] or ( parent and parent.args[1] ),
	}
end

--- Une liste donnée.
function p.liste( frame )
	local o = options( frame )
	if not o.cle or mw.text.trim( tostring( o.cle ) ) == '' then
		return erreur( 'clé de liste manquante (ex. « |liste|21 »).' )
	end

	local donnees, err = charger( o.pageDonnees )
	if err then
		return erreur( err )
	end

	local liste = trouver( donnees, o.cle )
	if not liste then
		return "''La liste « " .. mw.text.nowiki( tostring( o.cle ) )
			.. " » n'est pas encore documentée pour " .. mw.text.nowiki( o.vendeur ) .. ".''"
	end

	return rendreListe( frame, liste, donnees.deviseDefaut, o.brut )
end

--- Toutes les listes du vendeur, dans l'ordre de la page de données.
function p.toutes( frame )
	local o = options( frame )
	local donnees, err = charger( o.pageDonnees )
	if err then
		return erreur( err )
	end

	local morceaux = {}
	for _, liste in ipairs( donnees.listes ) do
		table.insert( morceaux, rendreListe( frame, liste, donnees.deviseDefaut, o.brut ) )
	end
	if #morceaux == 0 then
		return "''Aucune liste documentée pour " .. mw.text.nowiki( o.vendeur ) .. ".''"
	end
	return table.concat( morceaux, '\n' )
end

--- Nombre d'articles d'une liste — pratique dans un titre.
--
-- Renvoyer « 0 » quand la page de données est introuvable serait un mensonge
-- silencieux : le titre afficherait « Liste 21 (0 plan) » et personne ne
-- saurait que c'est la SOURCE qui manque, pas les plans. p.liste et p.toutes
-- crient dans la même situation ; celle-ci criait dans le vide.
--
-- « 0 » reste la réponse juste pour une liste réellement vide ou non encore
-- documentée : c'est un fait sur les données, pas une panne.
function p.nombre( frame )
	local o = options( frame )
	if not o.cle or mw.text.trim( tostring( o.cle ) ) == '' then
		return erreur( 'clé de liste manquante.' )
	end

	local donnees, err = charger( o.pageDonnees )
	if err then
		return erreur( err )
	end

	local liste = trouver( donnees, o.cle )
	if not liste then
		return '0'
	end
	return tostring( #liste.articles )
end

--- Clés disponibles, séparées par des virgules.
---
--- Sert à écrire une page qui ne présume pas du nombre de listes : un modèle
--- peut boucler dessus plutôt que d'appeler vingt-quatre fois « liste » en
--- dur, et un vendeur à liste unique n'oblige alors à rien réécrire.
function p.cles( frame )
	local o = options( frame )
	local donnees, err = charger( o.pageDonnees )
	if err then
		return erreur( err )
	end

	local cles = {}
	for _, liste in ipairs( donnees.listes ) do
		table.insert( cles, liste.cle )
	end
	return table.concat( cles, ',' )
end

return p