Jump to content

Module:Feedback

From The Petit Planet Wiki
Revision as of 10:23, 16 September 2026 by ReisuDesign (talk | contribs)
Module documentation

This module implements {{Feedback}}.

local p = {}

------------------------------------------------------------
-- Helpers
------------------------------------------------------------

local function isTruthy(value)
	if value == nil then
		return false
	end

	value = mw.text.trim(tostring(value)):lower()

	return value ~= ''
		and value ~= '0'
		and value ~= 'false'
		and value ~= 'no'
		and value ~= 'off'
end


local function trim(value)
	return mw.text.trim(
		tostring(value or '')
	)
end


local function formatUser(user)
	user = trim(user)

	if user == '' or user:lower() == 'anonymous' then
		return 'Anonymous'
	end

	return string.format(
		'[[User:%s|%s]]',
		user,
		user
	)
end


local function currentSubjectPage()
	local title =
		mw.title.getCurrentTitle()

	return title.subjectPageTitle.fullText
end


local function getBucket()
	if not (mw.ext and mw.ext.bucket) then
		return nil, 'Bucket extension is not available'
	end

	local bucket =
		mw.ext.bucket('feedback')

	if not bucket then
		return nil, 'Feedback bucket could not be opened'
	end

	return bucket
end


local function sortNewestFirst(results)
	if not results then
		return {}
	end

	table.sort(
		results,
		function(a, b)

			local aDate =
				trim(a.createddate)

			local bDate =
				trim(b.createddate)

			-- Entries without a date go last.
			if aDate == '' and bDate ~= '' then
				return false
			end

			if aDate ~= '' and bDate == '' then
				return true
			end

			if aDate ~= bDate then
				return aDate > bDate
			end

			-- Stable-ish fallback.
			return trim(a.id) > trim(b.id)
		end
	)

	return results
end


local function queryFeedback(page, status)
	local bucket, err =
		getBucket()

	if not bucket then
		return nil, err
	end

	local query =
		bucket.select(
			'page',
			'user',
			'status',
			'feedback',
			'id',
			'createddate',
			'link',
			'image',
			'resolvedby',
			'resolveddate'
		)

	if page and page ~= '' then
		query.where(
			'page',
			page
		)
	end

	if status
		and status ~= ''
		and status ~= 'all'
	then

		query.where(
			'status',
			status
		)

	end

	local success, results =
		pcall(function()
			return query.run()
		end)

	if not success then
		return nil, tostring(results)
	end

	return sortNewestFirst(
		results or {}
	)
end


------------------------------------------------------------
-- Save feedback
------------------------------------------------------------

local function saveFeedback(data)

	local bucket, err =
		getBucket()

	if not bucket then
		return false, err
	end

	local success, saveError =
		pcall(function()

			bucket.put({
				page = data.page,
				user = data.user,
				status = data.status,
				feedback = data.feedback,
				id = data.id,
				createddate = data.createddate,
				link = data.link,
				image = data.image,
				resolvedby = data.resolvedby,
				resolveddate = data.resolveddate
			})

		end)

	if not success then
		return false, tostring(saveError)
	end

	return true
end


------------------------------------------------------------
-- Shared metadata
------------------------------------------------------------

local function renderMetadata(root, entry)

	local meta =
		root:tag('div')
			:addClass('feedback-meta')

	if entry.user and entry.user ~= '' then

		meta:tag('span')
			:addClass('feedback-user')
			:wikitext(
				formatUser(entry.user)
			)

	end

	if entry.createddate
		and entry.createddate ~= ''
	then

		meta:tag('span')
			:addClass('feedback-meta-date')
			:wikitext(
				' · '
				.. mw.text.nowiki(
					entry.createddate
				)
			)

	end
end


------------------------------------------------------------
-- Attachments
------------------------------------------------------------

local function renderAttachments(root, entry)

	local link =
		trim(entry.link)

	local image =
		trim(entry.image)

	if link == ''
		and image == ''
	then
		return
	end

	local attachments =
		root:tag('div')
			:addClass('feedback-attachments')

	if link ~= '' then

		attachments:tag('span')
			:addClass('feedback-attachment')
			:wikitext(
				'🔗 '
				.. '['
				.. link
				.. ' Link]'
			)

	end

	if image ~= '' then

		attachments:tag('span')
			:addClass('feedback-attachment')

		attachments:tag('span')
			:addClass('feedback-image-icon')
			:wikitext('▣')

		attachments:tag('span')
			:addClass('feedback-image-link')
			:wikitext(
				'[[Media:'
				.. image
				.. '|Picture]]'
			)

	end
end


------------------------------------------------------------
-- Resolution information
------------------------------------------------------------

local function renderResolution(root, entry)

	local resolvedBy =
		trim(entry.resolvedby)

	local resolvedDate =
		trim(entry.resolveddate)

	if resolvedBy == ''
		and resolvedDate == ''
	then
		return
	end

	local resolution =
		root:tag('div')
			:addClass('feedback-resolution')

	resolution:wikitext('↳ Resolved by ')

	if resolvedBy ~= '' then
		resolution:wikitext(
			formatUser(resolvedBy)
		)
	else
		resolution:wikitext('Unknown')
	end

	if resolvedDate ~= '' then
		resolution:wikitext(
			' on '
			.. mw.text.nowiki(resolvedDate)
		)
	end
end


------------------------------------------------------------
-- Resolve control
------------------------------------------------------------

local function renderResolveControl(
	parent,
	id,
	compact
)

	if not id or id == '' then
		return
	end

	local control =
		parent:tag('span')
			:addClass('feedback-resolve-button')

	if compact then

		control
			:addClass('feedback-resolve-button--compact')
			:attr('aria-label', 'Resolve feedback')
			:attr('title', 'Resolve feedback')
			:wikitext('✓')

	else

		control
			:attr('role', 'button')
			:attr('tabindex', '0')
			:attr('aria-label', 'Resolve feedback')
			:wikitext('Resolve')

	end

	control:attr(
		'data-feedback-id',
		id
	)
end


------------------------------------------------------------
-- Full feedback card
------------------------------------------------------------

local function renderFullCard(entry)

	local resolved =
		entry.status == 'resolved'

	local id =
		trim(entry.id)

	local root =
		mw.html.create('div')
			:addClass('feedback-box')
			:addClass(
				resolved
					and 'feedback-box--resolved'
					or 'feedback-box--unresolved'
			)

	if id ~= '' then
		root:attr(
			'data-feedback-id',
			id
		)
	end

	--------------------------------------------------------
	-- Main line
	--------------------------------------------------------

	local main =
		root:tag('div')
			:addClass('feedback-main-line')

	main:tag('span')
		:addClass('feedback-status')
		:addClass(
			resolved
				and 'feedback-status--resolved'
				or 'feedback-status--unresolved'
		)
		:wikitext(
			resolved
				and 'RESOLVED'
				or 'UNRESOLVED'
		)

	main:tag('span')
		:addClass('feedback-text')
		:wikitext(
			entry.feedback
				or 'No feedback provided'
		)

	--------------------------------------------------------
	-- Metadata
	--------------------------------------------------------

	local metadata =
		main:tag('span')
			:addClass('feedback-inline-meta')

	if entry.user
		and entry.user ~= ''
	then

		metadata:wikitext(
			' · '
			.. formatUser(entry.user)
		)

	end

	if entry.createddate
		and entry.createddate ~= ''
	then

		metadata:wikitext(
			' '
			.. mw.text.nowiki(
				entry.createddate
			)
		)

	end

	--------------------------------------------------------
	-- Resolve button
	--------------------------------------------------------

	if not resolved
		and id ~= ''
	then

		renderResolveControl(
			main,
			id,
			false
		)

	end

	--------------------------------------------------------
	-- Attachments
	--------------------------------------------------------

	renderAttachments(
		root,
		entry
	)

	--------------------------------------------------------
	-- Resolution
	--------------------------------------------------------

	if resolved then
		renderResolution(
			root,
			entry
		)
	end

	--------------------------------------------------------
	-- Missing ID warning
	--------------------------------------------------------

	if not resolved
		and id == ''
	then

		root:tag('div')
			:addClass('feedback-missing-id')
			:wikitext(
				'Add an ID to enable the Resolve button.'
			)

	end

	return tostring(root)
end


------------------------------------------------------------
-- Compact article card
------------------------------------------------------------

local function renderCompactCard(entry)

	local resolved =
		entry.status == 'resolved'

	local id =
		trim(entry.id)

	local root =
		mw.html.create('div')
			:addClass('feedback-page-card')
			:addClass(
				resolved
					and 'feedback-page-card--resolved'
					or 'feedback-page-card--unresolved'
			)

	if id ~= '' then
		root:attr(
			'data-feedback-id',
			id
		)
	end

	local content =
		root:tag('div')
			:addClass('feedback-page-card-content')

	local firstLine =
		content:tag('div')
			:addClass('feedback-page-card-line')

	firstLine:tag('span')
		:addClass('feedback-status')
		:addClass(
			resolved
				and 'feedback-status--resolved'
				or 'feedback-status--unresolved'
		)
		:wikitext(
			resolved
				and 'RESOLVED'
				or 'UNRESOLVED'
		)

	firstLine:tag('span')
		:addClass('feedback-page-card-text')
		:wikitext(
			entry.feedback
				or 'No feedback provided'
		)

	local meta =
		content:tag('div')
			:addClass('feedback-page-card-meta')

	if entry.user
		and entry.user ~= ''
	then

		meta:wikitext(
			formatUser(entry.user)
		)

	end

	if entry.createddate
		and entry.createddate ~= ''
	then

		meta:wikitext(
			' · '
			.. mw.text.nowiki(
				entry.createddate
			)
		)

	end

	if resolved then

		local resolvedBy =
			trim(entry.resolvedby)

		local resolvedDate =
			trim(entry.resolveddate)

		if resolvedBy ~= ''
			or resolvedDate ~= ''
		then

			local resolution =
				content:tag('div')
					:addClass(
						'feedback-page-card-resolution'
					)

			resolution:wikitext(
				'↳ Resolved by '
			)

			if resolvedBy ~= '' then
				resolution:wikitext(
					formatUser(resolvedBy)
				)
			else
				resolution:wikitext(
					'Unknown'
				)
			end

			if resolvedDate ~= '' then
				resolution:wikitext(
					' · '
					.. mw.text.nowiki(
						resolvedDate
					)
				)
			end
		end

	else

		if id ~= '' then

			renderResolveControl(
				root,
				id,
				true
			)

		end

	end

	renderAttachments(
		content,
		entry
	)

	return tostring(root)
end


------------------------------------------------------------
-- Page feedback section
------------------------------------------------------------

function p.page(frame)

	local page =
		currentSubjectPage()

	local allEntries, err =
		queryFeedback(page)

	if not allEntries then
		return ''
	end

	if #allEntries == 0 then
		return ''
	end

	local unresolved = {}
	local resolved = {}

	for _, row in ipairs(allEntries) do

		if row.status == 'resolved' then

			table.insert(
				resolved,
				row
			)

		else

			table.insert(
				unresolved,
				row
			)

		end
	end

	local root =
		mw.html.create('section')
			:addClass('feedback-page-section')
			:attr('id', 'feedback-section')
			:attr(
				'data-unresolved-count',
				#unresolved
			)
			:attr(
				'data-resolved-count',
				#resolved
			)

	--------------------------------------------------------
	-- Heading
	--------------------------------------------------------

	root:tag('h2')
		:addClass('feedback-page-heading')
		:wikitext('Feedbacks')

	--------------------------------------------------------
	-- Filter controls
	--------------------------------------------------------

	local filters =
		root:tag('div')
			:addClass('feedback-page-filters')

	local unresolvedFilter =
		filters:tag('span')
			:addClass('feedback-page-filter')
			:addClass(
				'feedback-page-filter--active'
			)
			:attr(
				'role',
				'button'
			)
			:attr(
				'tabindex',
				'0'
			)
			:attr(
				'data-feedback-filter',
				'unresolved'
			)

	unresolvedFilter:wikitext(
		'Unresolved '
	)

	unresolvedFilter:tag('span')
		:addClass('feedback-page-filter-count')
		:wikitext(
			tostring(#unresolved)
		)

	if #resolved > 0 then

		local resolvedFilter =
			filters:tag('span')
				:addClass('feedback-page-filter')
				:attr(
					'role',
					'button'
				)
				:attr(
					'tabindex',
					'0'
				)
				:attr(
					'data-feedback-filter',
					'resolved'
				)

		resolvedFilter:wikitext(
			'Resolved '
		)

		resolvedFilter:tag('span')
			:addClass('feedback-page-filter-count')
			:wikitext(
				tostring(#resolved)
			)

	end

	--------------------------------------------------------
	-- Unresolved list
	--------------------------------------------------------

	local unresolvedList =
		root:tag('div')
			:addClass(
				'feedback-page-list'
			)
			:addClass(
				'feedback-page-list--unresolved'
			)

	if #unresolved > 0 then

		for _, row in ipairs(unresolved) do
			unresolvedList:node(
				renderCompactCard(row)
			)
		end

	else

		unresolvedList:tag('div')
			:addClass(
				'feedback-page-empty'
			)
			:wikitext(
				'No unresolved feedback.'
			)

	end

	--------------------------------------------------------
	-- Resolved list
	--------------------------------------------------------

	if #resolved > 0 then

		local resolvedList =
			root:tag('div')
				:addClass(
					'feedback-page-list'
				)
				:addClass(
					'feedback-page-list--resolved'
				)

		for _, row in ipairs(resolved) do
			resolvedList:node(
				renderCompactCard(row)
			)
		end

	end

	return tostring(root)
end


------------------------------------------------------------
-- Feedback counts
------------------------------------------------------------

function p.counts(frame)

	local page =
		currentSubjectPage()

	local entries =
		queryFeedback(page)

	if not entries then
		return ''
	end

	local unresolved = 0
	local resolved = 0

	for _, row in ipairs(entries) do

		if row.status == 'resolved' then
			resolved = resolved + 1
		else
			unresolved = unresolved + 1
		end

	end

	return tostring(
		mw.html.create('span')
			:addClass('feedback-count-data')
			:attr(
				'data-unresolved',
				unresolved
			)
			:attr(
				'data-resolved',
				resolved
			)
	)
end


------------------------------------------------------------
-- Main / individual feedback
------------------------------------------------------------

function p.main(frame)

	local parent =
		frame:getParent()

	local args =
		parent and parent.args
		or frame.args

	local feedback =
		args.feedback
		or 'No feedback provided'

	local user =
		args.user
		or 'Anonymous'

	local id =
		trim(args.id)

	local createdDate =
		trim(args.createddate)

	local link =
		trim(args.link)

	local image =
		trim(args.image)

	local resolvedBy =
		trim(args.resolvedby)

	local resolvedDate =
		trim(args.resolveddate)

	local resolved =
		isTruthy(args.resolved)

	local status =
		resolved
			and 'resolved'
			or 'unresolved'

	local page =
		currentSubjectPage()

	local saved, saveError =
		saveFeedback({
			page = page,
			user = user,
			status = status,
			feedback = feedback,
			id = id,
			createddate = createdDate,
			link = link,
			image = image,
			resolvedby = resolvedBy,
			resolveddate = resolvedDate
		})

	if not saved then

		return
			'<strong style="color:red;">'
			.. 'Feedback bucket error: '
			.. mw.text.nowiki(
				saveError or 'Unknown error'
			)
			.. '</strong>'

	end

	local card =
		renderFullCard({
			page = page,
			user = user,
			status = status,
			feedback = feedback,
			id = id,
			createddate = createdDate,
			link = link,
			image = image,
			resolvedby = resolvedBy,
			resolveddate = resolvedDate
		})

	local categories = {}

	if resolved then

		table.insert(
			categories,
			'[[Category:Resolved Feedback]]'
		)

	else

		table.insert(
			categories,
			'[[Category:Unresolved Feedback]]'
		)

	end

	if id == '' then

		table.insert(
			categories,
			'[[Category:Feedbacks Missing ID]]'
		)

	end

	return card
		.. table.concat(categories)
end


------------------------------------------------------------
-- Feedback table
------------------------------------------------------------

local function renderTable(entries, includeResolved)

	local tableRoot =
		mw.html.create('table')
			:addClass('feedback-table')

	local header =
		tableRoot:tag('tr')

	if includeResolved then

		header:tag('th')
			:wikitext('Status')

	end

	header:tag('th')
		:wikitext('Page')

	header:tag('th')
		:wikitext('Username')

	header:tag('th')
		:wikitext('Date')

	header:tag('th')
		:wikitext('Feedback')

	if includeResolved then

		header:tag('th')
			:wikitext('Resolved by')

		header:tag('th')
			:wikitext('Resolved date')

	end

	if #entries == 0 then

		local row =
			tableRoot:tag('tr')

		local colspan =
			includeResolved
				and '7'
				or '4'

		row:tag('td')
			:attr(
				'colspan',
				colspan
			)
			:addClass(
				'feedback-table-empty'
			)
			:wikitext(
				'No feedback entries found.'
			)

		return tostring(tableRoot)
	end

	for _, entry in ipairs(entries) do

		local row =
			tableRoot:tag('tr')
				:addClass(
					entry.status == 'resolved'
						and 'feedback-table-row--resolved'
						or 'feedback-table-row--unresolved'
				)

		if includeResolved then

			row:tag('td')
				:addClass('feedback-table-status')
				:wikitext(
					entry.status == 'resolved'
						and 'Resolved'
						or 'Unresolved'
				)

		end

		row:tag('td')
			:addClass('feedback-table-page')
			:wikitext(
				'[['
				.. entry.page
				.. ']]'
			)

		row:tag('td')
			:addClass('feedback-table-user')
			:wikitext(
				formatUser(entry.user)
			)

		row:tag('td')
			:addClass('feedback-table-date')
			:wikitext(
				mw.text.nowiki(
					entry.createddate
						or '—'
				)
			)

		row:tag('td')
			:addClass('feedback-table-feedback')
			:wikitext(
				entry.feedback
					or ''
			)

		if includeResolved then

			row:tag('td')
				:addClass(
					'feedback-table-resolved-by'
				)
				:wikitext(
					entry.resolvedby
						and formatUser(
							entry.resolvedby
						)
						or '—'
				)

			row:tag('td')
				:addClass(
					'feedback-table-resolved-date'
				)
				:wikitext(
					entry.resolveddate
						and mw.text.nowiki(
							entry.resolveddate
						)
						or '—'
				)

		end
	end

	return tostring(tableRoot)
end


------------------------------------------------------------
-- Global feedback page
------------------------------------------------------------

function p.view(frame)

	local parent =
		frame:getParent()

	local args =
		parent and parent.args
		or frame.args

	local status =
		trim(
			args.status
				or ''
		):lower()

	--------------------------------------------------------
	-- Specific table
	--------------------------------------------------------

	if status == 'resolved'
		or status == 'unresolved'
	then

		local entries =
			queryFeedback(
				nil,
				status
			)

		if not entries then
			return ''
		end

		return renderTable(
			entries,
			false
		)
	end

	--------------------------------------------------------
	-- Dashboard
	--------------------------------------------------------

	local unresolved =
		queryFeedback(
			nil,
			'unresolved'
		)

	local all =
		queryFeedback(
			nil,
			'all'
		)

	if not unresolved then
		unresolved = {}
	end

	if not all then
		all = {}
	end

	local root =
		mw.html.create('div')
			:addClass('feedback-dashboard')

	root:tag('h2')
		:addClass('feedback-dashboard-heading')
		:wikitext('Unresolved Feedback')

	root:node(
		renderTable(
			unresolved,
			false
		)
	)

	root:tag('h2')
		:addClass('feedback-dashboard-heading')
		:wikitext('All Feedback')

	root:node(
		renderTable(
			all,
			true
		)
	)

	return tostring(root)
end


------------------------------------------------------------
-- Aliases
------------------------------------------------------------

p.table = p.view

return p