lib/wiki_assignment_output.rb
# frozen_string_literal: true
require_dependency "#{Rails.root}/lib/wiki_api"
require_dependency "#{Rails.root}/lib/wiki_output_templates"
#= Class for generating wikitext for updating assignment details on talk pages
class WikiAssignmentOutput
include WikiOutputTemplates
def initialize(course, title, talk_title, assignments, templates)
@course = course
@course_page = course.wiki_title
@wiki = course.home_wiki
@dashboard_url = ENV['dashboard_url']
@templates = templates
@assignments = assignments
@title = title
@talk_title = talk_title
end
###############
# Entry point #
###############
def self.wikitext(course:, title:, talk_title:, assignments:, templates:)
new(course, title, talk_title, assignments, templates).build_talk_page_update
end
################
# Main routine #
################
def build_talk_page_update
# If a course changed state so that it has no on-wiki course page, don't post.
return nil if @course_page.nil?
initial_page_content = WikiApi.new(@wiki).get_page_content(@talk_title)
# This indicates an API failure, which may happen because of rate-limiting.
# A nonexistent page will return empty string instead of nil.
return nil if initial_page_content.nil?
# Do not post templates to disambugation pages
return nil if includes_disambiguation_template?(initial_page_content)
# We only want to add assignment tags to non-existent talk pages if the
# article page actually exists, and is not a disambiguation page.
return nil unless normal_article?
# We only want to remove assignments if talk page already exists.
# This is to avoid creating a new empty talk page.
return nil if @assignments.empty? && initial_page_content.empty?
build_assignment_page_content(assignments_tag, initial_page_content)
end
###################
# Helper methods #
###################
# Normal article means the article page actually exists, and is not a disambiguation page.
def normal_article?
article_content = WikiApi.new(@wiki).get_page_content(@title)
article_content.present? && !includes_disambiguation_template?(article_content)
end
def assignments_tag
return '' if @assignments.empty?
# Make a list of the assignees, role 0
tag_assigned = build_wikitext_user_list(Assignment::Roles::ASSIGNED_ROLE)
# Make a list of the reviwers, role 1
tag_reviewing = build_wikitext_user_list(Assignment::Roles::REVIEWING_ROLE)
# Build new tag
# NOTE: If the format of this tag gets changed, then the dashboard may
# post duplicate tags for the same page, unless we update the way that
# we check for the presense of existging tags to account for both the new
# and old formats.
tag = "{{#{template_name(@templates, 'course_assignment')} | course = #{@course_page}"
tag += " | assignments = #{tag_assigned}" if tag_assigned.present?
tag += " | reviewers = #{tag_reviewing}" if tag_reviewing.present?
tag += " | start_date = #{@course.start.to_date}"
tag += " | end_date = #{@course.end.to_date}"
tag += ' }}'
tag
end
# This method creates updated wikitext for an article talk page, for when
# the set of assigned users for the article for a single course changes.
# The strategy here is to only update the tag for one course at a time, so
# that the user who updates the assignments for a course only introduces data
# for that course. We also want to make as minimal a change as possible, and
# to make sure that we're not disrupting the format of existing content.
def build_assignment_page_content(new_tag, page_content)
page_content = page_content.dup.force_encoding('utf-8')
# Return if tag already exists on page.
# However, if the tag is empty, that means to blank the prior tag (if any).
if new_tag.present?
return nil if page_content.include? new_tag
end
# If we're removing the tag, also try to remove the immediately preceding
# header, if it's there.
header = new_tag.present? ? '' : section_header
existing_tag = "{{#{template_name(@templates, 'course_assignment')} | course = #{@course_page}"
# We're looking for an existing instance of the tag template, in the case of removing the tag,
# we're also looking for the (optional) preceding section header. This way, when we're
# removing a tag that is in the standard-format section, we remove the whole section rather
# than leaving an empty one.
if new_tag.present?
replace_or_add_assignment_tag(page_content, existing_tag, new_tag)
else
remove_assignment_tag(page_content, existing_tag, header)
end
end
def replace_or_add_assignment_tag(page_content, existing_tag, new_tag)
tag_matcher = /
#{Regexp.quote(existing_tag)}[^}]*\}\} # assignment template
([\n\r]+#{updated_by_signature_pattern})? # optional linebreaks and signature
/x
new_tag_with_signature = if en_wiki?
"#{new_tag}\n\n#{updated_by_signature}"
else
new_tag
end
page_content.gsub!(tag_matcher, new_tag_with_signature)
# If we replaced an existing tag with the new version of it, we're done.
return page_content if page_content.include?(new_tag_with_signature)
# Otherwise, we need to add the tag to the right place.
page_content = insert_tag_into_talk_page(page_content, new_tag_with_signature)
page_content
end
def remove_assignment_tag(page_content, existing_tag, header)
tag_matcher = /
(#{Regexp.quote(header)}[\n\r]+)? # optional header and linebreaks
#{Regexp.quote(existing_tag)}[^}]*\}\} # assignment template
([\n\r]+#{updated_by_signature_pattern})? # optional linebreaks and signature
/x
page_content.gsub!(tag_matcher, '')
page_content
end
def starts_with_template?(page_content)
initial_template_matcher = /
\A # beginning of page
\s* # optional whitespace
\{\{ # beginning of a template
/x
initial_template_matcher.match(page_content)
end
# Regex to match "}}" at the end of a line where the next line does
# NOT start with (optional whitespace and then) "|" or "{" or "}".
# That covers the main syntax patterns of heavily-bannered talk pages,
# which typically use something like the {{WikiProject banner shell}}
# template that includes other templates within it.
def end_of_templates_pattern
/
\}\} # End of a template
\n # then a newline
(?! # that does not start with
\s* # optional whitespace
\*? # an optional bullet (used in some shell templates)
\s* # optional whitespace, then
[{|}] # any of these characters: {|}
)
/x
end
def matches_talk_template_pattern?(page_content)
end_of_templates_pattern.match(page_content)
end
def build_wikitext_user_list(role)
user_ids = @assignments.select { |assignment| assignment.role == role }
.map(&:user_id)
User.where(id: user_ids).pluck(:username)
.map { |username| "[[User:#{username}|#{username}]]" }.join(', ')
end
private
DISAMBIGUATION_TEMPLATE_FRAGMENTS = [
'{{WikiProject Disambiguation',
'{{disambig',
'{{Disambig',
'{{Dab}}',
'{{dab}}',
'disambiguation}}',
'{{Hndis',
'{{hndis',
'{{Geodis',
'{{geodis'
].freeze
def includes_disambiguation_template?(page_content)
DISAMBIGUATION_TEMPLATE_FRAGMENTS.any? do |template_fragment|
page_content.include?(template_fragment)
end
end
def insert_tag_into_talk_page(page_content, new_tag)
if en_wiki?
add_template_in_new_section(page_content, new_tag)
else
add_template_to_page_top(page_content, new_tag)
end
end
def en_wiki?
@wiki.language == 'en' && @wiki.project == 'wikipedia'
end
# This is what do on wikis other than English Wikipedia
def add_template_to_page_top(page_content, template)
# Append after existing templates, but only if there is no additional content
# on the line where the templates end.
if starts_with_template?(page_content) && matches_talk_template_pattern?(page_content)
# Insert the assignment tag the end of the page-top templates
page_content.sub!(end_of_templates_pattern, "}}\n#{template}\n")
else # Add the tag to the top of the page
page_content = "#{template}\n\n#{page_content}"
end
page_content
end
# This is what we do on English Wikipedia
# based on the RfC here:
# https://en.wikipedia.org/w/index.php?title=Wikipedia:Education_noticeboard&oldid=1072013453#How_should_Wiki_Education_assignments_be_announced_on_article_talk_page?
def add_template_in_new_section(page_content, template)
"#{page_content}\n\n#{section_header}\n#{template}\n"
end
def section_header
"==Wiki Education assignment: #{@course.title}=="
end
def updated_by_signature
return '' unless en_wiki?
# rubocop:disable Layout/LineLength
'<span class="wikied-assignment" style="font-size:85%;">— Assignment last updated by ~~~~</span>'
# rubocop:enable Layout/LineLength
end
# rubocop:disable Layout/LineLength
# Regex to match the wikied-assignment span tag of a signature, like this:
# <span class="wikied-assignment" style="font-size:85%;">— Assignment last updated by [[User:Sage (Wiki Ed)|Sage (Wiki Ed)]] ([[User talk:Sage (Wiki Ed)|talk]]) 18:02, 11 May 2022 (UTC)</span>
def updated_by_signature_pattern
return '' unless en_wiki?
opening_tag = '<span class="wikied-assignment" style="font-size:85%;">'
closing_tag = '</span>'
/#{Regexp.quote(opening_tag)}— Assignment last updated by .+#{Regexp.quote(closing_tag)}/
end
# rubocop:enable Layout/LineLength
end