Jump to content

Module:Feedback: Difference between revisions

From The Petit Planet Wiki
ReisuDesign (talk | contribs)
No edit summary
ReisuDesign (talk | contribs)
No edit summary
 
(15 intermediate revisions by the same user not shown)
Line 2: Line 2:


------------------------------------------------------------
------------------------------------------------------------
-- Helpers
-- HELPERS
------------------------------------------------------------
------------------------------------------------------------


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


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


return v ~= ''
return value == 'true'
and v ~= '0'
or value == 'yes'
and v ~= 'false'
or value == '1'
and v ~= 'no'
or value == 'resolved'
and v ~= 'off'
end
 
 
local function trim(value)
if value == nil then
return ''
end
 
return mw.text.trim(tostring(value))
end
end




local function formatUser(user)
local function formatUser(user)
user = mw.text.trim(tostring(user or ''))
user = trim(user)


if user == '' or user:lower() == 'anonymous' then
if user == '' then
return 'Anonymous'
return 'Anonymous'
end
end
Line 32: Line 40:
user
user
)
)
end
local function currentSubjectPage()
local title = mw.title.getCurrentTitle()
if title.subjectPageTitle then
return title.subjectPageTitle.fullText
end
return title.fullText
end
local function getBucket()
if not (mw.ext and mw.ext.bucket) then
return nil
end
return mw.ext.bucket('feedback')
end
local function sortNewestFirst(entries)
table.sort(
entries,
function(a, b)
local aDate = trim(a.createddate)
local bDate = trim(b.createddate)
return aDate > bDate
end
)
return entries
end
local function queryFeedback(page)
local bucket = getBucket()
if not bucket then
return {}
end
local query = bucket
query.select(
'page',
'user',
'status',
'feedback',
'id',
'createddate',
'link',
'image',
'resolvedby',
'resolveddate'
)
query.where(
'page',
page
)
local success, results = pcall(function()
return query.run()
end)
if not success or not results then
return {}
end
return sortNewestFirst(results)
end
end




------------------------------------------------------------
------------------------------------------------------------
-- Feedback card
-- ATTACHMENTS
------------------------------------------------------------
------------------------------------------------------------


local function renderCard(entry, showInstruction)
local function renderAttachments(entry)
local wrapper =
mw.html.create('div')
:addClass('feedback-attachments')
 
local link = trim(entry.link)
 
if link ~= '' then
local linkWrapper =
wrapper:tag('span')
:addClass('feedback-attachment-link')
linkWrapper:tag('span')
:addClass('feedback-attachment-icon')
linkWrapper:wikitext(
'[' .. link .. ' ' .. mw.text.nowiki(link) .. ']'
)
end
 
local image = trim(entry.image)
 
if image ~= '' then
image = mw.ustring.gsub(
image,
'^File:',
''
)
 
local title =
mw.title.new('File:' .. image)
 
if title then
local imageWrapper =
wrapper:tag('span')
:addClass('feedback-attachment-image')
imageWrapper:tag('span')
:addClass('feedback-attachment-icon')
imageWrapper:wikitext(
'[' ..
title:fullUrl() ..
' ' ..
mw.text.nowiki(image) ..
']'
)
end
end
 
return wrapper
end


local resolved = (entry.status == 'resolved')


local user =
------------------------------------------------------------
entry.user
-- RESOLUTION
or 'Anonymous'
------------------------------------------------------------
 
local function renderResolution(parent, entry)
local resolvedBy = trim(entry.resolvedby)
local resolvedDate = trim(entry.resolveddate)


local feedback =
if resolvedBy == '' and resolvedDate == '' then
entry.feedback
return
or 'No feedback provided'
end


local page =
local resolution =
entry.page
parent:tag('div')
:addClass('feedback-resolution')


local id =
resolution:wikitext('Resolved by ')
mw.text.trim(
tostring(entry.id or '')
)


local resolvedBy =
if resolvedBy ~= '' then
mw.text.trim(
resolution:wikitext(
tostring(entry.resolvedby or '')
formatUser(resolvedBy)
)
)
else
resolution:wikitext('Unknown')
end


local resolvedDate =
if resolvedDate ~= '' then
mw.text.trim(
resolution:wikitext(
tostring(entry.resolveddate or '')
' on ' .. mw.text.nowiki(resolvedDate)
)
)
end
end


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


--------------------------------------------------------
------------------------------------------------------------
-- Meta
-- RESOLVE CONTROL
--------------------------------------------------------
------------------------------------------------------------


local meta = root:tag('div')
local function renderResolveControl(
:addClass('feedback-meta')
parent,
id,
page,
compact
)
if trim(id) == '' then
return
end


if page and page ~= '' then
local wrapper =
parent:tag('div')
:addClass('feedback-resolve')


meta:tag('span')
local control =
:addClass('feedback-page')
wrapper:tag('span')
:wikitext(
:addClass('feedback-resolve-button')
string.format(
:attr(
'[[%s]]',
'data-feedback-id',
page
id
)
)
:attr(
'data-feedback-page',
page or ''
)
:attr(
'role',
'button'
)
:attr(
'tabindex',
'0'
)
:attr(
'aria-label',
'Resolve feedback'
)
)


meta:tag('span')
if compact then
:addClass('feedback-separator')
control
:wikitext(' • ')
:addClass(
'feedback-resolve-button--compact'
)
:attr(
'title',
'Resolve feedback'
)
:wikitext('✓')
else
control:wikitext('Resolve')
end
end
end


meta:tag('span')
 
:addClass('feedback-user')
------------------------------------------------------------
:wikitext(
-- FULL FEEDBACK CARD
formatUser(user)
------------------------------------------------------------
 
local function renderFullCard(entry)
local resolved =
trim(entry.status):lower() == 'resolved'
 
local id = trim(entry.id)
local page = trim(entry.page)
local feedback = trim(entry.feedback)
local user = trim(entry.user)
local createddate = trim(entry.createddate)
 
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 content column
--------------------------------------------------------
local content =
root:tag('div')
:addClass('feedback-content')
--------------------------------------------------------
-- Main line
--------------------------------------------------------
local main =
content:tag('div')
:addClass('feedback-main-line')


meta:tag('span')
--------------------------------------------------------
:addClass('feedback-separator')
-- Status
:wikitext(' • ')
--------------------------------------------------------


meta:tag('span')
main:tag('span')
:addClass('feedback-status')
:addClass('feedback-status')
:addClass(
:addClass(
Line 124: Line 331:


--------------------------------------------------------
--------------------------------------------------------
-- Feedback
-- Feedback text
--------------------------------------------------------
--------------------------------------------------------


root:tag('div')
main:tag('span')
:addClass('feedback-body')
:addClass('feedback-text')
:wikitext(feedback)
:wikitext(
feedback ~= ''
and feedback
or 'No feedback provided'
)


--------------------------------------------------------
--------------------------------------------------------
-- Resolution information
-- Metadata
--------------------------------------------------------
--------------------------------------------------------


if resolved and resolvedBy ~= '' then
local meta =
main:tag('span')
:addClass('feedback-inline-meta')


local resolution =
if user ~= '' then
root:tag('div')
meta:wikitext(
:addClass('feedback-resolution')
' · ' .. formatUser(user)
)
end


resolution:wikitext(
if createddate ~= '' then
'Resolved by '
meta:wikitext(
.. formatUser(resolvedBy)
' · ' .. mw.text.nowiki(createddate)
)
)
if resolvedDate ~= '' then
resolution:wikitext(
' on '
.. mw.text.nowiki(resolvedDate)
)
end
end
end


--------------------------------------------------------
--------------------------------------------------------
-- Resolve instruction
-- Attachments
--------------------------------------------------------
--------------------------------------------------------
content:node(
    renderAttachments(entry)
)


if showInstruction and not resolved then
--------------------------------------------------------
-- Resolution information
--------------------------------------------------------


local instruction =
renderResolution(
root:tag('div')
content,
:addClass('feedback-instruction')
entry
)


if id ~= '' then
--------------------------------------------------------
-- Missing ID warning
--------------------------------------------------------


instruction:wikitext(
if id == '' then
'If this feedback has been resolved, click the button below. '
content:tag('div')
:addClass('feedback-missing-id')
:wikitext(
'This feedback entry is missing an ID.'
)
)
end


-- Use an <a> instead of <button>.
--------------------------------------------------------
-- MediaWiki sanitizes <button> when returned from wikitext.
-- Resolve button
instruction:tag('a')
--
:addClass('feedback-resolve-button')
-- IMPORTANT:
:attr('href', '#')
-- This is outside .feedback-content so it
:attr('role', 'button')
-- remains on the right.
:attr('data-feedback-id', id)
--------------------------------------------------------
:wikitext('Resolve')


else
if not resolved then
 
renderResolveControl(
instruction:wikitext(
root,
'Add an ID to this feedback to enable the Resolve button.'
id,
)
page,
 
false
end
)
end
end


Line 193: Line 413:


------------------------------------------------------------
------------------------------------------------------------
-- Save feedback
-- COMPACT CARD
------------------------------------------------------------
------------------------------------------------------------


local function saveFeedback(data)
local function renderCompactCard(entry)
return renderFullCard(entry)
end
 


if not (mw.ext and mw.ext.bucket) then
------------------------------------------------------------
return false, 'Bucket extension is not available'
-- SAVE FEEDBACK
end
------------------------------------------------------------


local bucket =
local function saveFeedback(data)
mw.ext.bucket('feedback')
local bucket = getBucket()


if not bucket then
if not bucket then
return false, 'Feedback bucket could not be opened'
return false, 'Bucket extension is not available'
end
end


local success, err = pcall(function()
local success, err = pcall(function()
bucket.put({
bucket.put({
page = data.page,
page = data.page,
Line 217: Line 439:
feedback = data.feedback,
feedback = data.feedback,
id = data.id,
id = data.id,
createddate = data.createddate,
link = data.link,
image = data.image,
resolvedby = data.resolvedby,
resolvedby = data.resolvedby,
resolveddate = data.resolveddate
resolveddate = data.resolveddate
})
})
end)
end)


Line 232: Line 456:


------------------------------------------------------------
------------------------------------------------------------
-- Main
-- MAIN
--
-- Used by Template:Feedback
------------------------------------------------------------
------------------------------------------------------------


function p.main(frame)
function p.main(frame)
 
local parent = frame:getParent()
local parent =
frame:getParent()


local args =
local args =
Line 247: Line 471:


if parent and parent.getTitle then
if parent and parent.getTitle then
 
local title = parent:getTitle()
local title =
parent:getTitle()


if title
if title
Line 267: Line 489:


function p._main(args, pageTitle)
function p._main(args, pageTitle)
local feedback =
trim(args.feedback)


local feedback =
if feedback == '' then
args.feedback
feedback = 'No feedback provided'
or 'No feedback provided'
end


local user =
local user =
args.user
trim(args.user)
or 'Anonymous'


local id =
if user == '' then
args.id
user = 'Anonymous'
or ''
end
 
local resolvedBy =
args.resolvedby
or ''
 
local resolvedDate =
args.resolveddate
or ''


local resolved =
local resolved =
Line 297: Line 512:


local currentPage =
local currentPage =
pageTitle
trim(pageTitle)
or mw.title.getCurrentTitle().fullText
 
if currentPage == '' then
currentPage =
mw.title.getCurrentTitle().fullText
end
 
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)


--------------------------------------------------------
--------------------------------------------------------
Line 311: Line 548:
feedback = feedback,
feedback = feedback,
id = id,
id = id,
resolvedby = resolvedBy,
createddate = createddate,
resolveddate = resolvedDate
link = link,
image = image,
resolvedby = resolvedby,
resolveddate = resolveddate
})
})


if not saved then
if not saved then
return '<strong style="color:red;">Feedback bucket error: '
return
'<strong style="color:red;">Feedback bucket error: '
.. mw.text.nowiki(
.. mw.text.nowiki(
saveError or 'Unknown error'
saveError or 'Unknown error'
Line 324: Line 565:


--------------------------------------------------------
--------------------------------------------------------
-- Render card
-- Render
--------------------------------------------------------
--------------------------------------------------------


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


--------------------------------------------------------
--------------------------------------------------------
Line 342: Line 586:
--------------------------------------------------------
--------------------------------------------------------


local categories = {}
local category


if resolved then
if resolved then
 
category =
table.insert(
categories,
'[[Category:Resolved Feedback]]'
'[[Category:Resolved Feedback]]'
)
else
else
 
category =
table.insert(
categories,
'[[Category:Unresolved Feedback]]'
'[[Category:Unresolved Feedback]]'
)
end
end


if mw.text.trim(
if id == '' then
tostring(id)
category =
) == '' then
category ..
 
table.insert(
categories,
'[[Category:Feedbacks Missing ID]]'
'[[Category:Feedbacks Missing ID]]'
)
end
end


return card
return card .. category
.. table.concat(categories)
end
end




------------------------------------------------------------
------------------------------------------------------------
-- Render feedback list
-- RENDER FEEDBACK LIST
------------------------------------------------------------
------------------------------------------------------------


local function renderFeedbackList(results)
local function renderFeedbackList(results)
if not results or #results == 0 then
if not results or #results == 0 then
return '<em>No feedback entries found.</em>'
return '<em>No feedback entries found.</em>'
Line 389: Line 618:


for _, row in ipairs(results) do
for _, row in ipairs(results) do
table.insert(
table.insert(
list,
list,
renderCard(
renderFullCard(row)
row,
false
)
)
)
end
end


Line 408: Line 632:


------------------------------------------------------------
------------------------------------------------------------
-- View feedback
-- PAGE FEEDBACK
------------------------------------------------------------
------------------------------------------------------------


function p.view(frame)
function p.page(frame)
local page =
currentSubjectPage()


local parent =
local entries =
frame:getParent()
queryFeedback(page)


local args
local unresolved = {}
local resolved = {}


if frame
for _, entry in ipairs(entries) do
and frame.args
if trim(entry.status):lower()
and frame.args.status
== 'resolved'
then
then
table.insert(
resolved,
entry
)
else
table.insert(
unresolved,
entry
)
end
end


args =
local root =
frame.args
mw.html.create('section')
:attr(
'id',
'feedback-section'
)
:addClass('feedback-page')


elseif parent
--------------------------------------------------------
and parent.args
-- Heading
and parent.args.status
--------------------------------------------------------
then


args =
root:tag('h2')
parent.args
:addClass('feedback-page-title')
:wikitext('Feedbacks')


elseif frame
--------------------------------------------------------
and frame.args
-- Filters
then
--------------------------------------------------------


args =
local filters =
frame.args
root:tag('div')
:addClass('feedback-filters')


elseif parent
local function addFilter(
and parent.args
label,
then
value,
active
)
return filters:tag('button')
:attr(
'type',
'button'
)
:attr(
'data-feedback-filter',
value
)
:addClass('feedback-filter')
:addClass(
active
and 'feedback-filter--active'
or nil
)
:wikitext(label)
end


args =
addFilter(
parent.args
'Unresolved',
'unresolved',
true
)


else
addFilter(
'Resolved',
'resolved',
false
)


args = {}
addFilter(
 
'All',
end
'all',
 
false
return p._view(
args,
frame
)
)
end


--------------------------------------------------------
-- Unresolved
--------------------------------------------------------


function p._view(args, frame)
local unresolvedList =
root:tag('div')
:addClass(
'feedback-page-list'
)
:addClass(
'feedback-page-list--unresolved'
)
:addClass(
'feedback-page-list--visible'
)


if not (mw.ext and mw.ext.bucket) then
if #unresolved == 0 then
return '<em>Bucket extension is not available.</em>'
unresolvedList:tag('div')
:addClass('feedback-empty')
:wikitext(
'No unresolved feedback.'
)
else
for _, entry in ipairs(unresolved) do
unresolvedList:wikitext(
renderCompactCard(entry)
)
end
end
end
frame =
frame
or mw.getCurrentFrame()
local rawStatus =
args.status
or args[1]
local filterStatus =
rawStatus
and mw.text.trim(rawStatus):lower()
or nil


--------------------------------------------------------
--------------------------------------------------------
-- Filtered views
-- Resolved
--------------------------------------------------------
--------------------------------------------------------


if filterStatus == 'resolved'
local resolvedList =
or filterStatus == 'unresolved'
root:tag('div')
then
:addClass(
'feedback-page-list'
)
:addClass(
'feedback-page-list--resolved'
)


local query =
if #resolved == 0 then
mw.ext.bucket('feedback')
resolvedList:tag('div')
 
:addClass('feedback-empty')
query.select(
:wikitext(
'page',
'No resolved feedback.'
'user',
)
'status',
else
'feedback',
for _, entry in ipairs(resolved) do
'id',
resolvedList:wikitext(
'resolvedby',
renderCompactCard(entry)
'resolveddate'
)
)
end
 
query.where(
'status',
filterStatus
)
 
return renderFeedbackList(
query.run()
)
end
end


Line 515: Line 787:
--------------------------------------------------------
--------------------------------------------------------


if filterStatus == 'all' then
local allList =
root:tag('div')
:addClass(
'feedback-page-list'
)
:addClass(
'feedback-page-list--all'
)
 
if #entries == 0 then
allList:tag('div')
:addClass('feedback-empty')
:wikitext(
'No feedback.'
)
else
for _, entry in ipairs(entries) do
allList:wikitext(
renderCompactCard(entry)
)
end
end
 
return tostring(root)
end


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


query.select(
------------------------------------------------------------
'page',
-- COUNTS
'user',
------------------------------------------------------------
'status',
'feedback',
'id',
'resolvedby',
'resolveddate'
)


return renderFeedbackList(
function p.counts(frame)
query.run()
local entries =
queryFeedback(
currentSubjectPage()
)
)
end


--------------------------------------------------------
local unresolved = 0
-- Default tabbed view
local resolved = 0
--------------------------------------------------------


local qAll =
for _, entry in ipairs(entries) do
mw.ext.bucket('feedback')
if trim(entry.status):lower()
== 'resolved'
then
resolved = resolved + 1
else
unresolved = unresolved + 1
end
end


qAll.select(
return tostring(
'page',
mw.html.create('span')
'user',
:addClass('feedback-count-data')
'status',
:attr(
'feedback',
'data-unresolved',
'id',
unresolved
'resolvedby',
)
'resolveddate'
:attr(
'data-resolved',
resolved
)
)
)
end


local allEntries =
qAll.run()


local allList = {}
------------------------------------------------------------
local unresolvedList = {}
-- TABLE / DASHBOARD
local resolvedList = {}
------------------------------------------------------------


if allEntries and #allEntries > 0 then
local function renderTable(entries)
if not entries or #entries == 0 then
return '<em>No feedback entries found.</em>'
end


for _, row in ipairs(allEntries) do
local table =
mw.html.create('table')
:addClass('feedback-table')


local card =
local head =
renderCard(
table:tag('thead')
row,
:tag('tr')
false
)


table.insert(
head:tag('th')
allList,
:wikitext('Status')
card
)


if row.status == 'resolved' then
head:tag('th')
:wikitext('Page')


table.insert(
head:tag('th')
resolvedList,
:wikitext('Feedback')
card
)


else
head:tag('th')
:wikitext('User')


table.insert(
head:tag('th')
unresolvedList,
:wikitext('Date')
card
)


end
local body =
end
table:tag('tbody')
end


--------------------------------------------------------
for _, entry in ipairs(entries) do
-- All content
local row =
--------------------------------------------------------
body:tag('tr')


local allContent
local resolved =
trim(entry.status):lower()
== 'resolved'


if #allList > 0 then
row:tag('td')
:addClass(
resolved
and 'feedback-table-status--resolved'
or 'feedback-table-status--unresolved'
)
:wikitext(
resolved
and 'Resolved'
or 'Unresolved'
)


allContent =
row:tag('td')
table.concat(
:addClass('feedback-table-page')
allList,
:wikitext(
'\n'
entry.page or ''
)
)


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


allContent =
row:tag('td')
'<em>No feedback entries found.</em>'
:wikitext(
formatUser(entry.user)
)


row:tag('td')
:wikitext(
entry.createddate or ''
)
end
end


--------------------------------------------------------
return tostring(table)
-- Unresolved content
end
--------------------------------------------------------
 
 
function p.view(frame)
local parent =
frame:getParent()
 
local args
 
if frame
and frame.args
and frame.args.status
then
args = frame.args


local unresolvedContent
elseif parent
and parent.args
and parent.args.status
then
args = parent.args


if #unresolvedList > 0 then
elseif frame
and frame.args
then
args = frame.args


unresolvedContent =
elseif parent
table.concat(
and parent.args
unresolvedList,
then
'\n'
args = parent.args
)


else
else
args = {}
end


unresolvedContent =
return p._view(
'<em>No unresolved feedback entries found.</em>'
args,
frame
)
end


function p._view(args, frame)
local bucket =
getBucket()
if not bucket then
return
'<em>Bucket extension is not available.</em>'
end
end
local rawStatus =
args.status
or args[1]
local filterStatus =
rawStatus
and trim(rawStatus):lower()
or nil


--------------------------------------------------------
--------------------------------------------------------
-- Resolved content
-- Query
--------------------------------------------------------
--------------------------------------------------------


local resolvedContent
local query =
bucket


if #resolvedList > 0 then
query.select(
'page',
'user',
'status',
'feedback',
'id',
'createddate',
'link',
'image',
'resolvedby',
'resolveddate'
)


resolvedContent =
if filterStatus == 'resolved'
table.concat(
or filterStatus == 'unresolved'
resolvedList,
then
'\n'
query.where(
)
'status',
filterStatus
)
end


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


resolvedContent =
if not success or not entries then
'<em>No resolved feedback entries found.</em>'
return
'<em>Could not load feedback.</em>'
end


end
entries =
sortNewestFirst(entries)


--------------------------------------------------------
--------------------------------------------------------
-- Tabber
-- Specific status
--------------------------------------------------------
--------------------------------------------------------


local tabberBody =
if filterStatus == 'resolved'
string.format(
or filterStatus == 'unresolved'
'All=\n%s\n|-|Unresolved=\n%s\n|-|Resolved=\n%s',
then
allContent,
return renderFeedbackList(
unresolvedContent,
entries
resolvedContent
)
)
end


return frame:callParserFunction(
--------------------------------------------------------
'#tag',
-- All
{
--------------------------------------------------------
'tabber',
 
tabberBody
return renderTable(entries)
}
)
end
end
p.table = p.view


return p
return p

Latest revision as of 13:57, 16 September 2026

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 == 'true'
		or value == 'yes'
		or value == '1'
		or value == 'resolved'
end


local function trim(value)
	if value == nil then
		return ''
	end

	return mw.text.trim(tostring(value))
end


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

	if user == '' then
		return 'Anonymous'
	end

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


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

	if title.subjectPageTitle then
		return title.subjectPageTitle.fullText
	end

	return title.fullText
end


local function getBucket()
	if not (mw.ext and mw.ext.bucket) then
		return nil
	end

	return mw.ext.bucket('feedback')
end


local function sortNewestFirst(entries)
	table.sort(
		entries,
		function(a, b)
			local aDate = trim(a.createddate)
			local bDate = trim(b.createddate)

			return aDate > bDate
		end
	)

	return entries
end


local function queryFeedback(page)
	local bucket = getBucket()

	if not bucket then
		return {}
	end

	local query = bucket

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

	query.where(
		'page',
		page
	)

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

	if not success or not results then
		return {}
	end

	return sortNewestFirst(results)
end


------------------------------------------------------------
-- ATTACHMENTS
------------------------------------------------------------

local function renderAttachments(entry)
	local wrapper =
		mw.html.create('div')
			:addClass('feedback-attachments')

	local link = trim(entry.link)

	if link ~= '' then
		local linkWrapper =
			wrapper:tag('span')
				:addClass('feedback-attachment-link')
	
		linkWrapper:tag('span')
			:addClass('feedback-attachment-icon')
	
		linkWrapper:wikitext(
			'[' .. link .. ' ' .. mw.text.nowiki(link) .. ']'
		)
	end

	local image = trim(entry.image)

	if image ~= '' then
		image = mw.ustring.gsub(
			image,
			'^File:',
			''
		)

		local title =
			mw.title.new('File:' .. image)

		if title then
			local imageWrapper =
				wrapper:tag('span')
					:addClass('feedback-attachment-image')
		
			imageWrapper:tag('span')
				:addClass('feedback-attachment-icon')
		
			imageWrapper:wikitext(
				'[' ..
				title:fullUrl() ..
				' ' ..
				mw.text.nowiki(image) ..
				']'
			)
		end
	end

	return wrapper
end


------------------------------------------------------------
-- RESOLUTION
------------------------------------------------------------

local function renderResolution(parent, entry)
	local resolvedBy = trim(entry.resolvedby)
	local resolvedDate = trim(entry.resolveddate)

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

	local resolution =
		parent: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,
	page,
	compact
)
	if trim(id) == '' then
		return
	end

	local wrapper =
		parent:tag('div')
			:addClass('feedback-resolve')

	local control =
		wrapper:tag('span')
			:addClass('feedback-resolve-button')
			:attr(
				'data-feedback-id',
				id
			)
			:attr(
				'data-feedback-page',
				page or ''
			)
			:attr(
				'role',
				'button'
			)
			:attr(
				'tabindex',
				'0'
			)
			:attr(
				'aria-label',
				'Resolve feedback'
			)

	if compact then
		control
			:addClass(
				'feedback-resolve-button--compact'
			)
			:attr(
				'title',
				'Resolve feedback'
			)
			:wikitext('✓')
	else
		control:wikitext('Resolve')
	end
end


------------------------------------------------------------
-- FULL FEEDBACK CARD
------------------------------------------------------------

local function renderFullCard(entry)
	local resolved =
		trim(entry.status):lower() == 'resolved'

	local id = trim(entry.id)
	local page = trim(entry.page)
	local feedback = trim(entry.feedback)
	local user = trim(entry.user)
	local createddate = trim(entry.createddate)

	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 content column
	--------------------------------------------------------

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

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

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

	--------------------------------------------------------
	-- Status
	--------------------------------------------------------

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

	--------------------------------------------------------
	-- Feedback text
	--------------------------------------------------------

	main:tag('span')
		:addClass('feedback-text')
		:wikitext(
			feedback ~= ''
				and feedback
				or 'No feedback provided'
		)

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

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

	if user ~= '' then
		meta:wikitext(
			' · ' .. formatUser(user)
		)
	end

	if createddate ~= '' then
		meta:wikitext(
			' · ' .. mw.text.nowiki(createddate)
		)
	end

	--------------------------------------------------------
	-- Attachments
	--------------------------------------------------------
	
	content:node(
	    renderAttachments(entry)
	)

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

	renderResolution(
		content,
		entry
	)

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

	if id == '' then
		content:tag('div')
			:addClass('feedback-missing-id')
			:wikitext(
				'This feedback entry is missing an ID.'
			)
	end

	--------------------------------------------------------
	-- Resolve button
	--
	-- IMPORTANT:
	-- This is outside .feedback-content so it
	-- remains on the right.
	--------------------------------------------------------

	if not resolved then
		renderResolveControl(
			root,
			id,
			page,
			false
		)
	end

	return tostring(root)
end


------------------------------------------------------------
-- COMPACT CARD
------------------------------------------------------------

local function renderCompactCard(entry)
	return renderFullCard(entry)
end


------------------------------------------------------------
-- SAVE FEEDBACK
------------------------------------------------------------

local function saveFeedback(data)
	local bucket = getBucket()

	if not bucket then
		return false, 'Bucket extension is not available'
	end

	local success, err = 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(err)
	end

	return true
end


------------------------------------------------------------
-- MAIN
--
-- Used by Template:Feedback
------------------------------------------------------------

function p.main(frame)
	local parent = frame:getParent()

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

	local pageTitle = nil

	if parent and parent.getTitle then
		local title = parent:getTitle()

		if title
			and title ~= ''
			and not title:find('^Template:')
		then
			pageTitle = title
		end
	end

	return p._main(
		args,
		pageTitle
	)
end


function p._main(args, pageTitle)
	local feedback =
		trim(args.feedback)

	if feedback == '' then
		feedback = 'No feedback provided'
	end

	local user =
		trim(args.user)

	if user == '' then
		user = 'Anonymous'
	end

	local resolved =
		isTruthy(args.resolved)

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

	local currentPage =
		trim(pageTitle)

	if currentPage == '' then
		currentPage =
			mw.title.getCurrentTitle().fullText
	end

	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)

	--------------------------------------------------------
	-- Save to Bucket
	--------------------------------------------------------

	local saved, saveError =
		saveFeedback({
			page = currentPage,
			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

	--------------------------------------------------------
	-- Render
	--------------------------------------------------------

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

	--------------------------------------------------------
	-- Categories
	--------------------------------------------------------

	local category

	if resolved then
		category =
			'[[Category:Resolved Feedback]]'
	else
		category =
			'[[Category:Unresolved Feedback]]'
	end

	if id == '' then
		category =
			category ..
			'[[Category:Feedbacks Missing ID]]'
	end

	return card .. category
end


------------------------------------------------------------
-- RENDER FEEDBACK LIST
------------------------------------------------------------

local function renderFeedbackList(results)
	if not results or #results == 0 then
		return '<em>No feedback entries found.</em>'
	end

	local list = {}

	for _, row in ipairs(results) do
		table.insert(
			list,
			renderFullCard(row)
		)
	end

	return table.concat(
		list,
		'\n'
	)
end


------------------------------------------------------------
-- PAGE FEEDBACK
------------------------------------------------------------

function p.page(frame)
	local page =
		currentSubjectPage()

	local entries =
		queryFeedback(page)

	local unresolved = {}
	local resolved = {}

	for _, entry in ipairs(entries) do
		if trim(entry.status):lower()
			== 'resolved'
		then
			table.insert(
				resolved,
				entry
			)
		else
			table.insert(
				unresolved,
				entry
			)
		end
	end

	local root =
		mw.html.create('section')
			:attr(
				'id',
				'feedback-section'
			)
			:addClass('feedback-page')

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

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

	--------------------------------------------------------
	-- Filters
	--------------------------------------------------------

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

	local function addFilter(
		label,
		value,
		active
	)
		return filters:tag('button')
			:attr(
				'type',
				'button'
			)
			:attr(
				'data-feedback-filter',
				value
			)
			:addClass('feedback-filter')
			:addClass(
				active
					and 'feedback-filter--active'
					or nil
			)
			:wikitext(label)
	end

	addFilter(
		'Unresolved',
		'unresolved',
		true
	)

	addFilter(
		'Resolved',
		'resolved',
		false
	)

	addFilter(
		'All',
		'all',
		false
	)

	--------------------------------------------------------
	-- Unresolved
	--------------------------------------------------------

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

	if #unresolved == 0 then
		unresolvedList:tag('div')
			:addClass('feedback-empty')
			:wikitext(
				'No unresolved feedback.'
			)
	else
		for _, entry in ipairs(unresolved) do
			unresolvedList:wikitext(
				renderCompactCard(entry)
			)
		end
	end

	--------------------------------------------------------
	-- Resolved
	--------------------------------------------------------

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

	if #resolved == 0 then
		resolvedList:tag('div')
			:addClass('feedback-empty')
			:wikitext(
				'No resolved feedback.'
			)
	else
		for _, entry in ipairs(resolved) do
			resolvedList:wikitext(
				renderCompactCard(entry)
			)
		end
	end

	--------------------------------------------------------
	-- All
	--------------------------------------------------------

	local allList =
		root:tag('div')
			:addClass(
				'feedback-page-list'
			)
			:addClass(
				'feedback-page-list--all'
			)

	if #entries == 0 then
		allList:tag('div')
			:addClass('feedback-empty')
			:wikitext(
				'No feedback.'
			)
	else
		for _, entry in ipairs(entries) do
			allList:wikitext(
				renderCompactCard(entry)
			)
		end
	end

	return tostring(root)
end


------------------------------------------------------------
-- COUNTS
------------------------------------------------------------

function p.counts(frame)
	local entries =
		queryFeedback(
			currentSubjectPage()
		)

	local unresolved = 0
	local resolved = 0

	for _, entry in ipairs(entries) do
		if trim(entry.status):lower()
			== '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


------------------------------------------------------------
-- TABLE / DASHBOARD
------------------------------------------------------------

local function renderTable(entries)
	if not entries or #entries == 0 then
		return '<em>No feedback entries found.</em>'
	end

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

	local head =
		table:tag('thead')
			:tag('tr')

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

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

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

	head:tag('th')
		:wikitext('User')

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

	local body =
		table:tag('tbody')

	for _, entry in ipairs(entries) do
		local row =
			body:tag('tr')

		local resolved =
			trim(entry.status):lower()
				== 'resolved'

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

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

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

		row:tag('td')
			:wikitext(
				formatUser(entry.user)
			)

		row:tag('td')
			:wikitext(
				entry.createddate or ''
			)
	end

	return tostring(table)
end


function p.view(frame)
	local parent =
		frame:getParent()

	local args

	if frame
		and frame.args
		and frame.args.status
	then
		args = frame.args

	elseif parent
		and parent.args
		and parent.args.status
	then
		args = parent.args

	elseif frame
		and frame.args
	then
		args = frame.args

	elseif parent
		and parent.args
	then
		args = parent.args

	else
		args = {}
	end

	return p._view(
		args,
		frame
	)
end


function p._view(args, frame)
	local bucket =
		getBucket()

	if not bucket then
		return
			'<em>Bucket extension is not available.</em>'
	end

	local rawStatus =
		args.status
		or args[1]

	local filterStatus =
		rawStatus
		and trim(rawStatus):lower()
		or nil

	--------------------------------------------------------
	-- Query
	--------------------------------------------------------

	local query =
		bucket

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

	if filterStatus == 'resolved'
		or filterStatus == 'unresolved'
	then
		query.where(
			'status',
			filterStatus
		)
	end

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

	if not success or not entries then
		return
			'<em>Could not load feedback.</em>'
	end

	entries =
		sortNewestFirst(entries)

	--------------------------------------------------------
	-- Specific status
	--------------------------------------------------------

	if filterStatus == 'resolved'
		or filterStatus == 'unresolved'
	then
		return renderFeedbackList(
			entries
		)
	end

	--------------------------------------------------------
	-- All
	--------------------------------------------------------

	return renderTable(entries)
end


p.table = p.view

return p