Vai al contenuto

Modulo:Coord/sandbox

Da Wikipedia, l'enciclopedia libera.
--[[
* Modulo che implementa il template Coord.
]]

require('strict')

local mWikidata = require('Modulo:Wikidata')
local cfg = mw.loadData('Modulo:Coord/Configurazione')
local errorCategory = '[[Categoria:Errori di compilazione del template Coord]]'

-- =============================================================================
--                            Funzioni di utilità
-- =============================================================================

-- Error handler per xpcall, formatta l'errore.
--
-- @param {string} msg
-- @return {string}
local function errhandler(msg)
	local cat = mw.title.getCurrentTitle().namespace == 0 and errorCategory or ''
	return string.format('<div style="color:red">Il template {{Coord}} ha riscontrato degli errori ' ..
						 '([[Template:Coord|istruzioni]]):\n%s</div>%s', msg, cat)
end

-- Restituisce il numero arrotondato al numero di cifre decimali richiesto.
-- http://lua-users.org/wiki/SimpleRound
--
-- @param {number} num
-- @param {number} idp
-- @return {number}
local function round(num, idp)
	local mult = 10^(idp or 0)
	return math.floor(num * mult   0.5) / mult
end

-- Restituisce la stringa "0   numero" quando il numero è di una sola cifra, altrimenti lo stesso numero.
--
-- @param {number} num
-- @return {string}
local function padleft0(num)
	return (num < 10 and '0' or '') .. num
end

-- Converte un numero in stringa senza usare la notazione scientifica, esempio tostring(0.00001).
--
-- @param {number} num
-- @return {string}
local function numberToString(num)
	-- la parentesi () extra serve per non restituire anche il gsub.count
	return (string.format('%f', num):gsub('%.?0 $', ''))
end

-- Parsifica il parametro display e restituisce una table con chiavi inline, title e debug.
--
-- @param {table} args
-- @return {table}
local function getDisplay(args)
	return {
		inline = not args.display or args.display == 'inline' or args.display == 'inline,title',
		title = args.display == 'title' or args.display == 'inline,title',
		debug = args.display == 'debug'
	}
end

local function getZoom( extraparams )
	local scale = extraparams:match( '%f[%w]scale: ?(%d )' )
	if scale then
		return math.floor(math.log10( 5 / tonumber( scale ) ) * 3   25)
	end

	local extraType = extraparams:match( '%f[%w]type: ?(%w )' )
	if extraType then
		local zoomType = {
			country = 5,
			state = 6,
			adm1st = 7,
			adm2nd = 8,
			city = 9,
			isle = 10,
			mountain = 10,
			waterbody = 10,
			airport = 12,
			landmark = 13,
		}
		return zoomType[ extraType ]
	end
	
	return 13
end

-- Legge i parametri passati al modulo.
--
-- @param {table} frame
-- @return {table}
local function getArgs(frame)
	local args = {}

	-- copia i parametri ricevuti, eccetto quelli con nome valorizzati a stringa vuota
	for k, v in pairs(frame:getParent().args) do
		if v ~= '' or tonumber(k) then
			args[k] = string.gsub(v, '^%s*(.-)%s*$', '%1')
		end
	end
	-- retrocompatibilità con una funzionalità nascosta del precedente template:
	-- ignorava qualunque parametro posizionale vuoto dopo longitudine e parametri geohack
	for i = #args, 1, -1 do
		if args[i] == '' then
			table.remove(args, i)
		else
			break
		end
	end
	-- rimuove i parametri posizionali vuoti front to back fermandosi al primo non vuoto
	while args[1] == '' do
		table.remove(args, 1)
	end
	-- se l'utente non ha fornito lat e long con i posizionali ma con latdec e longdec
	if (#args == 0 or (#args == 1 and not tonumber(args[1]))) and
	   tonumber(args.latdec) and tonumber(args.longdec) then
		table.insert(args, 1, numberToString(args.latdec))
		table.insert(args, 2, numberToString(args.longdec))
	end

	return args
end

-- =============================================================================
--                            Classe DecCoord
-- =============================================================================

-- La classe DecCoord rappresenta una coordinata (latitudine o longitudine) in gradi decimali.

local DecCoord = {}
local DmsCoord = {} -- dichiarata qui per le conversioni

-- Costruttore della classe DecCoord.
--
-- @param {string} deg - i gradi decimali, positivi o negativi, se negativi viene
--                       cambiato il segno e la direzione cardinale eventualmente invertita
-- @param {string} card - la direzione cardinale (N|S|E|W)
-- @return {table} un nuovo oggetto DecCoord
function DecCoord:new(deg, card)
	local self = {}

	setmetatable(self, { __index = DecCoord,
						 __tostring = function(t) return self:__tostring() end,
						 __concat = function(t, t2) return tostring(t) .. tostring(t2) end })

	self.deg = tonumber(deg)
	if self.deg < 0 then
		self.card = card == 'N' and 'S' or (card == 'E' and 'W' or card)
		self.deg = -self.deg
	else
		self.card = card
	end

	return self
end

-- Richiamata automaticamente ogni volta che è richiesto un tostring o un concatenamento.
--
-- @return {string}
function DecCoord:__tostring()
	return numberToString(self.deg) .. '°' .. self.card
end

-- Restituisce i gradi con segno.
--
-- @return {string}
function DecCoord:getDeg()
	local deg = self.deg * ((self.card == 'N' or self.card =='E') and 1 or -1)
	return numberToString(deg)
end

-- Restituisce un nuovo oggetto DmsCoord, convertendo in gradi/minuti/secondi.
--
-- @return {table} un nuovo oggetto DmsCoord
function DecCoord:toDms()
	local deg, min, sec

	deg = round(self.deg * 3600, 2)
	sec = round(math.floor(deg) % 60   deg - math.floor(deg), 2)
	deg = math.floor((deg - sec) / 60)
	min = deg % 60
	deg = math.floor((deg - min) / 60) % 360

	return DmsCoord:new(deg, min, sec, self.card)
end

-- =============================================================================
--                            Classe DmsCoord
-- =============================================================================

-- La classe DmsCoord rappresenta una coordinata (latitudine o longitudine) in gradi/minuti/secondi.

-- Costruttore della classe DmsCoord.
--
-- @param {string} deg - i gradi
-- @param {string} min - i minuti, può essere nil
-- @param {string} sec - i secondi, può essere nil
-- @param {string} card - la direzione cardinale (N|S|E|W)
-- @return {table} un nuovo oggetto DmsCoord
function DmsCoord:new(deg, min, sec, card)
	local self = {}

	setmetatable (self, { __index = DmsCoord,
						  __tostring = function(t) return self:__tostring() end,
						  __concat = function(t, t2) return tostring(t) .. tostring(t2) end })

	self.deg = tonumber(deg)
	self.min = min and tonumber(min)
	self.sec = sec and tonumber(sec)
	self.card = card

	return self
end

-- Richiamata automaticamente ogni volta che è richiesto un tostring o un concatenamento.
--
-- @return {string}
function DmsCoord:__tostring()
	return self.deg .. '°' ..
		   (self.min and (padleft0(self.min) .. '′') or '') ..
		   (self.sec and (padleft0(self.sec) .. '″') or '') ..
		   self.card
end

-- Restituisce un nuovo oggetto DecCoord, convertendo in gradi decimali.
--
-- @return {table} un nuovo oggetto DecCoord
function DmsCoord:toDec()
	local deg = round((self.deg   ((self.min or 0)   (self.sec or 0) / 60) / 60), 6)
	return DecCoord:new(deg, self.card)
end

-- =============================================================================
--                            Classe Coord
-- =============================================================================

-- La classe Coord è la classe principale del modulo.
-- Al suo interno ha un riferimento alla latitudine e longitudine in ogni formato.

local Coord = {}

-- Costruttore della classe Coord.
--
-- @param {table} args
-- @return {table} un nuovo oggetto Coord
function Coord:new(args)
	local decLat, decLong, dmsLat, dmsLong
	local display = getDisplay(args)
	local self = { args = args }

	setmetatable(self, { __index = Coord })

	if args.from and display.title then
		error('il parametro "from" è valido solo con display=inline', 3)
	end
	-- con display=title o con i parametri "prop" o "from"
	-- legge le coordinate da P625 per utilizzarle o per confrontarle con quelle inserite
	if getDisplay(self.args).title or self.args.prop or args.from then
		self:_checkWikidata()
		-- con "from", senza coordinate utente e su Wikidata non esegue i controlli successivi
		if self.args.from and #self.args < 2 and not tonumber(args[1]) then
			return self
		end
	end

	-- identifica il tipo di chiamata
	self:_checkRequestFormat()

	-- in base al tipo di chiamata crea gli oggetti DecCoord o DmsCoord
	if self.reqFormat == 'dec' then
		-- {{coord|1.111|2.222}}
		decLat = DecCoord:new(args[1], 'N')
		decLong = DecCoord:new(args[2], 'E')
	elseif self.reqFormat == 'd' then
		-- {{coord|1.111|N|3.333|W}}
		decLat = DecCoord:new(args[1], args[2])
		decLong = DecCoord:new(args[3], args[4])
	elseif self.reqFormat == 'dm' then
		-- {{coord|1|2|N|4|5|W}}
		dmsLat = DmsCoord:new(args[1], args[2], nil, args[3])
		dmsLong = DmsCoord:new(args[4], args[5], nil, args[6])
	elseif self.reqFormat == 'dms' then
		-- {{coord|1|2|3|N|5|6|7|W}}
		dmsLat = DmsCoord:new(args[1], args[2], args[3], args[4])
		dmsLong = DmsCoord:new(args[5], args[6], args[7], args[8])
	end

	-- effettua le conversioni dec <=> dms
	if self.reqFormat == 'dec' or self.reqFormat == 'd' then
		dmsLat = decLat:toDms()
		dmsLong = decLong:toDms()
		-- rimuove secondi e minuti se zero e presenti in lat e long
		if dmsLat.sec == 0 and dmsLong.sec == 0 then
			dmsLat.sec, dmsLong.sec = nil, nil
			if dmsLat.min == 0 and dmsLong.min == 0 then
				dmsLat.min, dmsLong.min = nil, nil
			end
		end
	elseif self.reqFormat == 'dm' or self.reqFormat == 'dms' then
		decLat = dmsLat:toDec()
		decLong = dmsLong:toDec()
	end
	
	-- se presente args.catuguali e non è stato usato Wikidata verifica se uguali
	if args.catuguali and self.wdLat and self.wdLong and
	   self.wdCat == nil and
	   self.wdLat == round(decLat:getDeg(), 6) and
	   self.wdLong == round(decLong:getDeg(), 6) then
		self.wdCat = '[[Categoria:P625 uguale su Wikidata]]'
	end

	self.decLat = decLat
	self.decLong = decLong
	self.dmsLat = dmsLat
	self.dmsLong = dmsLong

	return self
end

-- Legge la P625 e la utilizza come latitudine e longitudine se non fornite dall'utente.
function Coord:_checkWikidata()
	self.wdEntityId = self.args.from or mw.wikibase.getEntityIdForCurrentPage();
	if self.args.prop then
		self.wdLat = mWikidata._getQualifier({ self.args.prop, 'P625', coord = 'latitude', n = 1, nq = 1, from = self.wdEntityId })
		self.wdLong = mWikidata._getQualifier({ self.args.prop, 'P625', coord = 'longitude', n = 1, nq = 1, from = self.wdEntityId })
	else
		self.wdLat = mWikidata._getProperty({ 'P625', coord = 'latitude', n = 1, from = self.wdEntityId })
		self.wdLong = mWikidata._getProperty({ 'P625', coord = 'longitude', n = 1, from = self.wdEntityId })
	end
	if self.wdLat and self.wdLong then
		self.wdLat = round(self.wdLat, 6)
		self.wdLong = round(self.wdLong, 6)
		-- se l'utente non ha fornito lat e long usa quelli di Wikidata
		if #self.args == 0 or (#self.args == 1 and not tonumber(self.args[1])) then
			table.insert(self.args, 1, numberToString(self.wdLat))
			table.insert(self.args, 2, numberToString(self.wdLong))
			self.wdCat = '[[Categoria:P625 letta da Wikidata]]'
		end
	else
		self.wdCat = '[[Categoria:P625 assente su Wikidata]]'
	end
end

-- Riconosce il tipo di richiesta: dec, d, dm o dms.
function Coord:_checkRequestFormat()
	local errorTable = {}

	-- riconoscimento tipo di richiesta
	if #self.args < 2 then
		error('* coordinate non specificate', 4)
	elseif #self.args < 4 then
		self.reqFormat = 'dec'
	elseif #self.args < 6 then
		self.reqFormat = 'd'
	elseif #self.args < 8 then
		self.reqFormat = 'dm'
	elseif #self.args < 10 then
		self.reqFormat = 'dms'
	else
		error('* errato numero di parametri', 4)
	end

	-- con le richieste dm e dms verifica se ci sono parametri lasciati vuoti in modo valido.
	if self.reqFormat == 'dms' then
		-- {{coord|1|2||N|5|6||E}} valido
		if self.args[3] == '' and self.args[7] == '' then
			table.remove(self.args, 7)
			table.remove(self.args, 3)
			self.reqFormat = 'dm'
		-- {{coord|1|2|3|N|5|6||E}} non valido
		elseif self.args[3] == '' or self.args[7] == '' then
			error('* lat e long hanno diversa precisione', 4)
		-- {{coord|1||3|N|5||7|E}} valido
		elseif self.args[2] == '' and self.args[6] == '' then
			self.args[2], self.args[6] = 0, 0
		-- {{coord|1|2|3|N|5||7|E}} non valido
		elseif self.args[2] == '' or self.args[6] == '' then
			error('* lat e long hanno diversa precisione', 4)
		end
	end
	if self.reqFormat == 'dm' then
		-- {{coord|1||N|4||E}} valido
		if self.args[2] == '' and self.args[5] == '' then
			table.remove(self.args, 5)
			table.remove(self.args, 2)
			self.reqFormat = 'd'
		-- {{coord|1|2|N|4||E}} non valido
		elseif self.args[2] == '' or self.args[5] == '' then
			error('* lat e long hanno diversa precisione', 4)
		end
	end

	-- validazione parametri posizionali
	local currFormat = cfg.params[self.reqFormat]
	local globe = self.args[#self.args]:match('globe:(%w )')
	self.isEarth = not globe or globe == 'earth'
	for k, v in ipairs(self.args) do
		if currFormat[k] then
			local err
			local parType = currFormat[k][1]
			local parName = currFormat[k][2]
			local parMin = currFormat[k][3]
			local parMax = currFormat[k][4]
			-- valida un parametro di tipo numero
			if parType == 'number' then
				local num = tonumber(v)
				if num then
					if self.isEarth and num < parMin then
						err = string.format('* %s format: %s < %s', self.reqFormat, parName, parMin)
					elseif self.isEarth and math.floor(num) > parMax then
						err = string.format('* %s format: %s > %s', self.reqFormat, parName, parMax)
					end
				else
					err = string.format('* %s format: %s non è un numero', self.reqFormat, parName)
				end
			-- valida un parametro di tipo stringa
			elseif parType == 'string' then
				if v ~= parMin and v ~= parMax then
					err = string.format('* %s format: %s diverso da %s e da %s',
										self.reqFormat, parName, parMin, parMax)
				end
			end
			if err then
				table.insert(errorTable, err)
			end
		end
	end

	if #errorTable > 0 then
		error(table.concat(errorTable, '\n'), 4)
	end
end

-- Utilizza l'estensione [[mw:Extension:GeoData]].
--
-- @param {table} display
-- @return {string}
function Coord:_setGeoData(display)
	local gdStr = string.format('{{#coordinates:%s|%s|name=%s}}',
						table.concat(self.args, '|'),
						(display.title and mw.title.getCurrentTitle().namespace == 0) and 'primary' or '',
						self.args.name or '')
	return mw.getCurrentFrame():preprocess(gdStr)
end

-- Funzione di debug, restituisce latitudine e longitudine in entrambi i formati.
--
-- @return {string}
function Coord:getDebugCoords()
	-- con args.from restitusce una stringa vuota se non c'è nessun dato
	if self.args.from and #self.args < 2 and not tonumber(self.args[1]) then
		return ''
	end
	return self.decLat .. ' ' .. self.decLong .. ' ' .. self.dmsLat .. ' ' .. self.dmsLong
end

-- Restituisce l'HTML di un elemento <maplink> di Kartographer per le coordinate
--
-- @return {string}
function Coord:_buildMaplinkHTML()
	local defaultFormat
	if self.args.format then
		defaultFormat = self.args.format
	elseif self.reqFormat == 'dec' then
		defaultFormat = 'dec'
	else
		defaultFormat = 'dms'
	end

	local linkText;
	if defaultFormat == 'dec' then
		linkText = self.decLat .. ' ' .. self.decLong
	else
		linkText = tostring(self.dmsLat) .. ' ' .. tostring(self.dmsLong)
	end

	local jsonParams = {
		type = 'Feature',
		geometry = {
			type ='Point',
			coordinates = {
				round( self.decLong:getDeg(), 6 ), -- max precision in GeoJSON format
				round( self.decLat:getDeg(), 6 )
			}
		},
		properties = {
			['marker-color'] = "228b22",
		}
	}

	if self.wdEntityId then
		-- geoshape da Wikidata
		jsonParams = {
			jsonParams,
			{
				type = 'ExternalData',
				service = 'geoshape',
				ids = self.wdEntityId,
				properties = {
					['fill-opacity'] = 0.2
				}
			}
		}
	end

	return mw.getCurrentFrame():extensionTag{
		name = 'maplink',
		content = mw.text.jsonEncode( jsonParams ),
		args = {
			text = linkText,
			zoom = getZoom( self.args[#self.args] or '' ),
			latitude = self.decLat:getDeg(),
			longitude = self.decLong:getDeg(),
		}
	}
end


-- Restituisce l'HTML di un link a geohack con le coordinate (usato solo quando
--  non è possibile usare maplink).
--
-- @return {string}
function Coord:_buildGeohackLink()
	-- crea la stringa per il parametro params di geohack.php
	local geohackParams

	if self.reqFormat == 'dec' then
		geohackParams = string.format('%s_N_%s_E', self.args[1], self.args[2])
		if self.args[3] then
			geohackParams = geohackParams .. '_' .. self.args[3]
		end
	else
		-- concatena solo i posizionali
		geohackParams = table.concat(self.args, '_')
	end
	
	-- geohack url e parametri
	local url = string.format('%s&pagename=%s&params=%s', cfg.geohackUrl,
						 mw.uri.encode(mw.title.getCurrentTitle().prefixedText, 'WIKI'), geohackParams)
	if self.args.name then
		url = url .. '&title=' .. mw.uri.encode(self.args.name)
	end
	
	local linkNode = mw.html.create('span')
		:addClass('plainlinks nourlexpansion')
		:wikitext('[' .. url .. ' ' .. tostring(self.dmsLat) .. ' ' .. tostring(self.dmsLong) .. ']')
		:done()
	return tostring(linkNode)
end

-- Restituisce l'HTML per il microformat Geo.
-- FIXME Serve ancora nel 2024? La documentazione in proposito è scarsissima, e tutti
--   i link a strumenti che lo usano non vanno più. Inoltre, qui usiamo un elemento nascosto;
--   va bene lo stesso? Come verificarlo? Le specifiche non ne parlano.
--
-- @return {string}
function Coord:_buildGeoMarkup()
	return mw.html.create('')
		:wikitext(self.args.name and '<span class="vcard">' or '')
		:tag('span')
			:attr('style', 'display:none')
			:addClass('geo')
			:tag('span')
				:addClass('latitude')
				:wikitext(tostring(self.dmsLat))
				:done()
			:wikitext( ', ' )
			:tag('span')
				:addClass('longitude')
				:wikitext(tostring(self.dmsLong))
				:done()
		:wikitext(self.args.name and ('<span style="display:none"> (<span class="fn org">' ..
			  self.args.name .. '</span>)</span></span>') or '')
			:done()
end

-- Restituisce l'HTML contenente le coordinate con link che utilizza <maplink> (Kartographer)
--  se possibile, e geohack altrimenti.
--
-- @return {string}
function Coord:getHTML()
	-- con args.from restitusce una stringa vuota se non c'è nessun dato
	if self.args.from and #self.args < 2 and not tonumber(self.args[1]) then
		return ''
	elseif self.args.display == 'debug' then
		return self:getDebugCoords()
	end

	local display = getDisplay(self.args)

	local coordLink
	if self.isEarth then
		-- Usa maplink se possibile. A maggio 2024 supporta solo coordinate terrestri,
		-- vedi T151138.
		coordLink = self:_buildMaplinkHTML()
	else
		coordLink = self:_buildGeohackLink()
	end

	local geo = self:_buildGeoMarkup();

	local html = coordLink .. tostring(geo) .. (self.args.notes or '')

	-- formatta il risultato a seconda di args.display (nil, 'inline', 'title', 'inline,title')
	-- se inline e title, in stampa visualizza solo il primo
	local htmlTitleInner = string.format(
		'<div style="font-size: small"><span %s id="coordinates">[[Coordinate geografiche|Coordinate]]: %s</span></div>',
		display.inline and 'class="noprint"' or '',
		html
	)
	
	local frame = mw.getCurrentFrame()
	local htmlTitle = frame:extensionTag( 'indicator', htmlTitleInner, { name = 'coordinates' } )

	return frame:extensionTag('templatestyles', '', {src = 'Modulo:Coord/styles.css'}) ..
		   (display.inline and html or '') .. 
		   (display.title and htmlTitle or '') ..
		   self:_setGeoData(display) .. 
		   (mw.title.getCurrentTitle().namespace == 0 and self.wdCat or '')
end

-- =============================================================================
--                            Funzioni esportate
-- =============================================================================

local p = {}

-- Funzione importata da https://en.wikipedia.org/w/index.php?title=Module:Coordinates&oldid=789126031
-- per estrarre lat, long, type, scale, dim, region, globe, source, dal link a geohack.php generato da coord.
function p.coord2text(frame)
	if frame.args[1] == '' or frame.args[2] == '' or not frame.args[2] then return nil end
	frame.args[2] = mw.text.trim(frame.args[2])
	if frame.args[2] == 'lat' or frame.args[2] == 'long' then
		local result, negative = mw.text.split((mw.ustring.match(frame.args[1],'[%.%d] °[NS] [%.%d] °[EW]') or ''), ' ')
		if frame.args[2] == 'lat' then
			result, negative = result[1], 'S'
		else
			result, negative = result[2], 'W'
		end
		result = mw.text.split(result, '°')
		if result[2] == negative then result[1] = '-'..result[1] end
		return result[1]
	else
		return mw.ustring.match(frame.args[1], 'params=.-_'..frame.args[2]..':(.-)[ _]')
	end
end

-- Funzione per eventuale template {{dms2dec}}.
function p.dms2dec(frame)
	local args = frame.args
	-- {{dms2dec|N|2|3|4}}
	return DmsCoord:new(args[2], args[3], args[4], args[1]):toDec():getDeg()
end

-- Funzione per eventuale template {{dec2dms}}.
function p.dec2dms(frame)
	local args = frame.args
	-- {{dec2dms|1.111|N|S}}
	return DecCoord:new(args[1], tonumber(args[1]) >= 0 and args[2] or args[3]):toDms()
end

-- Funzione per l'utilizzo da un altro modulo.
function p._main(args)
	return Coord:new(args):getHTML()
end

-- Funzione per il template {{Coord}}.
function p.main(frame)
	return select(2, xpcall(function()
		return p._main(getArgs(frame))
	end, errhandler))
end

return p