Module:Embedded

From Fallen London Wiki

Documentation for this module may be created at Module:Embedded/doc

local p = {}

local EMBEDDED_OPTIONS_FORMAT = '|%sEmbeddedOption|heading=%s|caption=%s|SmallHeading=%s%s|'
-- Taken from {{FontFate}} so we don't have to call out to the template
local FONT_FATE = "[[Fate|<font color=\"#2A9944\">'''FATE'''</font>]]"

--Pre-declare options so it can be called from find_options
local options

local function get_arg_or_nil(args, name)
	local arg = args[name]
	if arg == '' then
		return nil
	end
	return arg
end

local function get_arg(args, parent_args, name, default)
	return get_arg_or_nil(args, name) or get_arg_or_nil(parent_args, name) or default
end

local function get_title_from_heading(heading, default)
	if heading then
		return heading:gsub('|.+', '', 1)
	end
	return default
end

local function get_page_contents(frame, title)
	local title_obj = mw.title.new(title, '')
	local full_title = title_obj.nsText .. ':' .. title_obj.text
	return frame:expandTemplate{title = full_title, args = {}}
end

--[[
Find embedded options in the "pseudo-template" format and expand into list items.

Input parameters:
	@frame: frame object
	@contents: a string with the contents of the page being embedded

Returns a string of all expanded options, wrapped in list items.
]]
local function find_options(frame, contents)
	local search_pattern = string.format(
		EMBEDDED_OPTIONS_FORMAT, '%%%%', '(.-)', '(.-)', '(.-)', '%%%%')
	local actions = ''
	for heading, caption, small_heading in mw.ustring.gmatch(contents, search_pattern) do
		local use_small_heading = small_heading == 'yes'
		actions = actions .. '<li style="margin-top: 0px;">' ..
			options(frame, heading, caption, use_small_heading, false) .. '</li>'
	end
	return actions
end

--[==[
Find the main page image.

The expected default image specification is "[[File:Filename.png|right]]". Any
additional parameters specified should appear ''after'' the "|right", not before.

Input parameters:
	@contents: a string with the contents of the page being embedded

Returns a string with the image name, including the leading "File:" and trailing
".png".
]==]
local function get_img(contents)
	img = contents:match('File:([^%]]-png)%|right[^%[]-%]%]') or 'Question.png'
	return 'File:' .. img
end

--[[
Invert {{UnB}} effects.

Since info lines are bolded when unembedded but not bolded in embedcontext,
keeping {{UnB}} unaltered creates unbalanced tags that leak past the info line.
Inverting them solves this while maintaining the intended emphasis effect.

We do this here instead of by testing embedcontext in {{UnB}} because {{UnB}}s
used outside of info lines should not be altered.

Input parameters:
	@str: a string with the info line

Returns a string with the detected {{UnB}}s inverted.
]]
local function invert_unb(str)
	--[[
		NB: This approach sacrifices edge case support for simplicity. In particular:
		* Weird stuff like "{{UnB|{{UnB|text}}}}"" will convert even more weirdly
		* The first <b> that doesn't correspond to an earlier </b> will stop
		  further conversion, so something like "{{UnB|1}} <b>2</b> {{UnB|3}}"
		  will become "<b>1</b> <b>2</b> {{UnB|3}}" whereas "<b>X</b> {{UnB|Y}}"
		  will be completely unaltered.
		Neither case is expected to be necessary, but a more complex approach can
		be considered if that expectation ever proves incorrect.
	]]
	if (str:find('</b>') or str:len()) < (str:find('<b>') or 0) then
		return str
			:gsub('</b>(.-)<b>', '<b>%1</b>')
	end
	return str
end

local function get_info_line(contents, heading)
	local info_line = contents:match('<b><span>(' .. heading .. ' .-)</span></b>')
	if not info_line then
		return ''
	end
	return invert_unb(info_line)
			 :gsub('\n%*', ', ')  -- condense lists to one line
			 :gsub('%[%[Category:.-%]%]', '')  -- remove categories
			 .. '<br/>'  -- break between lines
end

local function transform_action_contents(frame, contents)
	-- Scale down small images from 40px to 20px
	local pc = contents:gsub('small%.png', 'small.png|20px')
	-- Remove paragraphs
	pc = pc:gsub('^.-(==+ ?)', '\n&nbsp;\n%1')
	-- Remove Success/Failure Instructions
	pc = pc:gsub('...<i>[SF]?[au]?.- Instructions.-</i>\'+', '')
	-- Remove categories
	pc = pc:gsub('%[%[Category:.-%]%]', '')
	-- Remove SMW sets
	pc = pc:gsub('%[%[.-::.-%]%]', '')
	pc = pc:gsub('%{%{#set:.-%}%}', '')
	-- Trim down carriage returns
	pc = pc:gsub('\n\n', '\n')
	-- Trim trailing whitespace
	pc = pc:gsub('\n+%s*$', '')
	-- Replace headers (<h2>-<h6>) with fake headers (<xh2>-<xh6>)
	for i = 6, 2, -1 do
		-- HTML headers
		local tag = 'h' .. i
		pc = pc:gsub('<%s*' .. tag .. '%s*>', '<x' .. tag .. '>')
		pc = pc:gsub('</%s*' .. tag .. '%s*>', '</x' .. tag .. '>')

		-- Wikitext headers
		local pat = ''
		for j = 1, i do
			pat = pat .. '='
		end
		pc = pc:gsub(
			pat .. '%s*([^\n]-)%s*' .. pat,
			'<x' .. tag .. '>%1</x' .. tag .. '>'
		)

		-- Now fake headers must be expanded as tag extensions
		tag = 'x' .. tag
		while pc:find('<' .. tag .. '>.-</' .. tag .. '>') do
			local _, header
			_, _, header = pc:find('<' .. tag .. '>(.-)</' .. tag .. '>')
			local header_clean = mw.text.trim(
				mw.text.killMarkers(header)
			)
			-- `header` will be used in a pattern, escaping non-word characters
			header = header:gsub('(%W)', '%%%1')
			pc = pc:gsub(
				'<' .. tag .. '>' .. header .. '</' .. tag .. '>',
				frame:extensionTag(tag, header_clean)
			)
		end
	end
	return pc
end

local function format_heading(heading, caption, use_small_heading)
	local out = ''
	if use_small_heading then
		out = out .. '<small>[[' .. heading .. ']]</small>'
	else
		out = out .. '[[' .. heading .. ']]'
	end
	if caption and caption ~= '' then
		out = out .. '<small> ' .. caption .. '</small>'
	end
	return out
end

local function make_option(heading, caption, use_small_heading, title, img, contents)
	return '<div class="mw-option" style="display:flex;margin:.5em 0"><div ' ..
		   'style="flex-grow:0; flex-shrink:0;">[[' .. img .. '|45px|link=' ..
		   title .. '|class=option-img]]</div><div style="display:flex;' ..
		   'flex-direction:column;flex-grow:1"><h5 style="margin:0 0 .3em">' ..
		   '<span style="display:inline;">' .. format_heading(heading, caption, use_small_heading) ..
		   '</span></h5><ul style="margin:0"><li class="mw-collapsible mw-collapsed"' ..
		   'style="list-style-type:none;border-left:2px solid #565646;margin-top:0px;' ..
		   'padding-left:0.5em;"><span class="fl-spoiler-tag">' ..
		   ' Spoiler </span><div class="mw-collapsible-content">' .. contents ..
		   '\n[[#' .. title .. '|^]]</div></li></ul></div></div>'
end

--[[
Turn Options into a "pseudo-template" format for use in Embed.

This is a sort of hack to prevent needing to fully re-render Options multiple times.

Input parameters:
	@heading: A string with the options heading, potentially including an alternate appearance
	@caption: A string with the non-linkified caption
	@use_small_heading: A boolean, true if the heading should be rendered small

Returns a string with the formatted pseudo-template.
]]
local function embedded_options(heading, caption, use_small_heading)
	local small_heading = ''
	if use_small_heading then
		small_heading = 'yes'
	end
	return string.format(
		EMBEDDED_OPTIONS_FORMAT, '%%', heading, caption, small_heading, '%%')
end

--[[
Render an {{Options}}

Input parameters:
	@frame: frame object
	@heading: A string with the options heading, potentially including an alternate appearance
	@caption: A string with the non-linkified caption
	@use_small_heading: A boolean, true if the heading should be rendered small
	@only_contents: A boolean, true if the HTML of make_options() is undesired

A string with the fully rendered {{Options}}
]]
options = function(frame, heading, caption, use_small_heading, only_contents)
	local title = get_title_from_heading(heading, 'Scheme: Set up a Salon')
	heading = heading or ''
	caption = caption or ''
	if caption == '' then
		-- Automatically format Fate costs when possible
		local name, cost = mw.ustring.match(title, '^(.-) %((%d+) FATE%)$')
		if name then
			caption = '(' .. cost .. ' ' .. FONT_FATE .. ')'
			if heading == title then
				heading = title .. '|' .. name
			end
		end
	end
	local success, contents = pcall(get_page_contents, frame, title)
	if not success then
		return make_option(heading, caption, use_small_heading, title, 'File:Question.png', '')
	end

	local img = get_img(contents)
	local action_cost = get_info_line(contents, 'Action Cost:')
	local unlocked_with = get_info_line(contents, 'Unlocked with')
	local locked_with = get_info_line(contents, 'Locked with')
	local friend_unlocked_with = get_info_line(contents, 'Your friend needs')
	local transformed = transform_action_contents(frame, contents)
	
	local inner = '[[' .. img .. '|right]]'
	inner = inner .. action_cost .. unlocked_with .. locked_with ..
			friend_unlocked_with .. transformed
	if only_contents then
		return inner
	else
		return make_option(heading, caption, use_small_heading, title, img, inner)
	end
end

--[[
Render an {{Embed}}

Input parameters:
	@frame: frame object
	@heading: A string with the embed heading, potentially including an alternate appearance
	@caption: A string with the non-linkified caption
	@use_small_heading: A boolean, true if the heading should be rendered small

A string with the fully rendered {{Embed}}
]]
local function embed(frame, heading, caption, use_small_heading)
	local title = get_title_from_heading(heading, 'A fine day in the Flit')
	heading = heading or ''
	local success, contents = pcall(get_page_contents, frame, title)
	if not success then
		return make_option(heading, caption, use_small_heading, title, 'File:Question.png', '')
	end
	
	local img = get_img(contents)
 
	local success, options_list = pcall(find_options, frame, contents)
	if not success then
		return make_option(heading, caption, use_small_heading, title, img, '')
	end

	local appears_in = get_info_line(contents, 'Storylet appears in')
	local drawn_in = get_info_line(contents, 'Card drawn in')
	local unlocked_with = get_info_line(contents, 'Unlocked with')
	local locked_with = get_info_line(contents, 'Locked with')
	local frequency = get_info_line(contents, 'Occurs with')
	
	--local inner = '[[' .. img .. '|right]]'
	local inner = appears_in .. drawn_in .. unlocked_with .. locked_with .. frequency
	inner = inner .. '<ul style="margin:0; list-style-type:none;">' ..
			find_options(frame, contents) .. '</ul>'
	return make_option(heading, caption, use_small_heading, title, img, inner)
end

function p.options(frame)
	local args = frame.args
	local parent_args = frame:getParent().args
	local heading = get_arg(args, parent_args, 1, nil)
	local caption = get_arg(args, parent_args, 2, nil)
	local use_small_heading = get_arg(args, parent_args, 'SmallHeading', false)
	local only_contents = get_arg(args, parent_args, 'OnlyContents', false)
	local embedded = frame:callParserFunction('#var', 'embeddedoptions') ~= ''
	if embedded then
		return embedded_options(heading or '', caption or '', use_small_heading)
	end
	return options(frame, heading, caption, use_small_heading, only_contents)
end

function p.embed(frame)
	local args = frame.args
	local parent_args = frame:getParent().args
	local heading = get_arg(args, parent_args, 1, nil)
	local caption = get_arg(args, parent_args, 2, nil)
	local use_small_heading = get_arg(args, parent_args, 'SmallHeading', false)
	return embed(frame, heading, caption, use_small_heading)
end

return p