<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://www.rosssurnameproject.net/wiki/index.php?action=history&amp;feed=atom&amp;title=Module%3ALocalitySubject</id>
	<title>Module:LocalitySubject - Revision history</title>
	<link rel="self" type="application/atom+xml" href="https://www.rosssurnameproject.net/wiki/index.php?action=history&amp;feed=atom&amp;title=Module%3ALocalitySubject"/>
	<link rel="alternate" type="text/html" href="https://www.rosssurnameproject.net/wiki/index.php?title=Module:LocalitySubject&amp;action=history"/>
	<updated>2026-10-25T01:39:40Z</updated>
	<subtitle>Revision history for this page on the wiki</subtitle>
	<generator>MediaWiki 1.44.3</generator>
	<entry>
		<id>https://www.rosssurnameproject.net/wiki/index.php?title=Module:LocalitySubject&amp;diff=4164&amp;oldid=prev</id>
		<title>Redheadkelly at 01:01, 5 August 2026</title>
		<link rel="alternate" type="text/html" href="https://www.rosssurnameproject.net/wiki/index.php?title=Module:LocalitySubject&amp;diff=4164&amp;oldid=prev"/>
		<updated>2026-08-05T01:01:25Z</updated>

		<summary type="html">&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;b&gt;New page&lt;/b&gt;&lt;/p&gt;&lt;div&gt;--[[&lt;br /&gt;
Module:LocalitySubject&lt;br /&gt;
&lt;br /&gt;
Parses a locality_subjects= parameter value containing one or more&lt;br /&gt;
&amp;quot;Breadcrumb - Subject&amp;quot; lines, copied directly from FamilySearch&amp;#039;s Locality&lt;br /&gt;
Subject display (hard returns and all), and produces:&lt;br /&gt;
&lt;br /&gt;
  * a deduplicated list of location strings, suitable for Has location&lt;br /&gt;
  * a set of category names, one per line, with no cross-product between&lt;br /&gt;
    locations and subjects&lt;br /&gt;
&lt;br /&gt;
See Locality_Subject_Module_Spec.md for the full design rationale.&lt;br /&gt;
&lt;br /&gt;
Usage from a template:&lt;br /&gt;
&lt;br /&gt;
  {{#invoke:LocalitySubject|locations|{{{locality_subjects|}}}}}&lt;br /&gt;
  {{#invoke:LocalitySubject|categories|{{{locality_subjects|}}}}}&lt;br /&gt;
&lt;br /&gt;
Optional second positional argument on |categories is a suffix appended to&lt;br /&gt;
each category (space-joined), used by {{FamilySearch document}} to keep&lt;br /&gt;
Source-namespace document categories separate from the Main-namespace&lt;br /&gt;
research-source tree (a single volume can spawn many more document pages&lt;br /&gt;
than the volume itself, so they need their own branch):&lt;br /&gt;
&lt;br /&gt;
  {{#invoke:LocalitySubject|categories|{{{locality_subjects|}}}|(documents)}}&lt;br /&gt;
--]]&lt;br /&gt;
&lt;br /&gt;
local p = {}&lt;br /&gt;
&lt;br /&gt;
-- Countries treated as having at least one confirmed self-contained tier&lt;br /&gt;
-- immediately below them (US states, Scottish shires/parishes, Canadian&lt;br /&gt;
-- provinces, Australian states, etc.). The country token is dropped from&lt;br /&gt;
-- the breadcrumb when present. This is NOT a claim that every place under&lt;br /&gt;
-- these countries is self-contained -- exceptions (e.g. England, London)&lt;br /&gt;
-- are caught by the override table, which is checked first.&lt;br /&gt;
local COUNTRY_DROP_LIST = {&lt;br /&gt;
	[&amp;quot;United States&amp;quot;] = true,&lt;br /&gt;
	[&amp;quot;Scotland&amp;quot;] = true,&lt;br /&gt;
	[&amp;quot;England&amp;quot;] = true,&lt;br /&gt;
	[&amp;quot;Wales&amp;quot;] = true,&lt;br /&gt;
	[&amp;quot;Ireland&amp;quot;] = true,&lt;br /&gt;
	[&amp;quot;Canada&amp;quot;] = true,&lt;br /&gt;
	[&amp;quot;Australia&amp;quot;] = true,&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
-- US states that don&amp;#039;t use &amp;quot;County&amp;quot; for their county-equivalent unit.&lt;br /&gt;
-- Applied to the token immediately after the state, since FamilySearch&amp;#039;s&lt;br /&gt;
-- hierarchy is always Country, State, County-equivalent, [smaller places...]&lt;br /&gt;
-- -- that position is reliable regardless of how many more specific tokens&lt;br /&gt;
-- follow it.&lt;br /&gt;
local US_COUNTY_SUFFIX_BY_STATE = {&lt;br /&gt;
	[&amp;quot;Louisiana&amp;quot;] = &amp;quot;Parish&amp;quot;,&lt;br /&gt;
	[&amp;quot;Alaska&amp;quot;] = &amp;quot;Borough&amp;quot;,&lt;br /&gt;
}&lt;br /&gt;
local DEFAULT_US_COUNTY_SUFFIX = &amp;quot;County&amp;quot;&lt;br /&gt;
&lt;br /&gt;
local JURISDICTION_WORDS = { &amp;quot;County&amp;quot;, &amp;quot;Parish&amp;quot;, &amp;quot;Borough&amp;quot;, &amp;quot;Census Area&amp;quot;, &amp;quot;Municipality&amp;quot; }&lt;br /&gt;
&lt;br /&gt;
-- True if this token needs a jurisdiction word appended -- i.e. it doesn&amp;#039;t&lt;br /&gt;
-- already carry an explicit qualifier (a parenthetical like&lt;br /&gt;
-- &amp;quot;(Independent City)&amp;quot;) and doesn&amp;#039;t already end in a jurisdiction word.&lt;br /&gt;
local function needsJurisdictionSuffix(token)&lt;br /&gt;
	if token:find(&amp;quot;(&amp;quot;, 1, true) then&lt;br /&gt;
		return false&lt;br /&gt;
	end&lt;br /&gt;
	for _, word in ipairs(JURISDICTION_WORDS) do&lt;br /&gt;
		if token:sub(-#word) == word then&lt;br /&gt;
			return false&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return true&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Scotland: the parish-equivalent token sits right after the shire tier,&lt;br /&gt;
-- the same structural position as the US county fix above.&lt;br /&gt;
local DEFAULT_SCOTLAND_PARISH_SUFFIX = &amp;quot;Parish&amp;quot;&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	return mw.text.trim(s or &amp;quot;&amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Split on newlines or semicolons, drop empty lines, trim whitespace.&lt;br /&gt;
local function splitInputLines(text)&lt;br /&gt;
	text = text or &amp;quot;&amp;quot;&lt;br /&gt;
	text = text:gsub(&amp;quot;\r\n&amp;quot;, &amp;quot;\n&amp;quot;):gsub(&amp;quot;\r&amp;quot;, &amp;quot;\n&amp;quot;)&lt;br /&gt;
	-- normalize semicolons to newlines so either delimiter style works&lt;br /&gt;
	text = text:gsub(&amp;quot;;&amp;quot;, &amp;quot;\n&amp;quot;)&lt;br /&gt;
	local lines = {}&lt;br /&gt;
	for line in text:gmatch(&amp;quot;[^\n]+&amp;quot;) do&lt;br /&gt;
		local t = trim(line)&lt;br /&gt;
		if t ~= &amp;quot;&amp;quot; then&lt;br /&gt;
			table.insert(lines, t)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return lines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isBreadcrumb(left)&lt;br /&gt;
	if COUNTRY_DROP_LIST[left] then&lt;br /&gt;
		return true&lt;br /&gt;
	end&lt;br /&gt;
	return left:find(&amp;quot;,&amp;quot;, 1, true) ~= nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Convert a raw FamilySearch breadcrumb (e.g. &amp;quot;United States, Virginia,&lt;br /&gt;
-- Hampton (Independent City)&amp;quot;) into the wiki&amp;#039;s location string&lt;br /&gt;
-- (e.g. &amp;quot;Hampton (Independent City), Virginia&amp;quot;). Checks&lt;br /&gt;
-- {{Locality subject location overrides}} first (a wiki page, kept in the&lt;br /&gt;
-- same format/spirit as {{Resource category switch values}} so both are&lt;br /&gt;
-- editable without touching Lua); falls back to drop-country-and-reverse&lt;br /&gt;
-- when there&amp;#039;s no override entry.&lt;br /&gt;
--&lt;br /&gt;
-- Returns (location, countryRecognized). countryRecognized is false when&lt;br /&gt;
-- the leading token wasn&amp;#039;t matched by the override table AND wasn&amp;#039;t in&lt;br /&gt;
-- COUNTRY_DROP_LIST -- e.g. a breadcrumb pasted without its country, which&lt;br /&gt;
-- looks structurally identical to a single-tier country (like &amp;quot;Scotland&amp;quot;&lt;br /&gt;
-- alone) and would otherwise fail silently rather than triggering the&lt;br /&gt;
-- county/parish suffix fix.&lt;br /&gt;
local function deriveLocation(frame, left)&lt;br /&gt;
	local overridden = frame:expandTemplate{&lt;br /&gt;
		title = &amp;quot;Locality subject location overrides&amp;quot;,&lt;br /&gt;
		args = { left },&lt;br /&gt;
	}&lt;br /&gt;
	overridden = trim(overridden)&lt;br /&gt;
	if overridden ~= &amp;quot;&amp;quot; then&lt;br /&gt;
		return overridden, true&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local tokens = {}&lt;br /&gt;
	for tok in left:gmatch(&amp;quot;[^,]+&amp;quot;) do&lt;br /&gt;
		table.insert(tokens, trim(tok))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #tokens == 0 then&lt;br /&gt;
		return nil, false&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local remaining = tokens&lt;br /&gt;
	local droppedCountry = nil&lt;br /&gt;
	if COUNTRY_DROP_LIST[tokens[1]] and #tokens &amp;gt; 1 then&lt;br /&gt;
		droppedCountry = tokens[1]&lt;br /&gt;
		remaining = {}&lt;br /&gt;
		for i = 2, #tokens do&lt;br /&gt;
			table.insert(remaining, tokens[i])&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- US-specific: fix up the county-equivalent token, which sits right&lt;br /&gt;
	-- after the state regardless of how many smaller-place tokens follow it.&lt;br /&gt;
	if droppedCountry == &amp;quot;United States&amp;quot; and #remaining &amp;gt;= 2 then&lt;br /&gt;
		local state = remaining[1]&lt;br /&gt;
		local countyToken = remaining[2]&lt;br /&gt;
		if needsJurisdictionSuffix(countyToken) then&lt;br /&gt;
			local suffix = US_COUNTY_SUFFIX_BY_STATE[state] or DEFAULT_US_COUNTY_SUFFIX&lt;br /&gt;
			remaining[2] = countyToken .. &amp;quot; &amp;quot; .. suffix&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- Scotland-specific: fix up the parish-equivalent token, which sits&lt;br /&gt;
	-- right after the shire tier, same structural position as the US fix.&lt;br /&gt;
	if droppedCountry == &amp;quot;Scotland&amp;quot; and #remaining &amp;gt;= 2 then&lt;br /&gt;
		local parishToken = remaining[2]&lt;br /&gt;
		if needsJurisdictionSuffix(parishToken) then&lt;br /&gt;
			remaining[2] = parishToken .. &amp;quot; &amp;quot; .. DEFAULT_SCOTLAND_PARISH_SUFFIX&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- reverse&lt;br /&gt;
	local reversed = {}&lt;br /&gt;
	for i = #remaining, 1, -1 do&lt;br /&gt;
		table.insert(reversed, remaining[i])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- countryRecognized covers two legitimate cases: a country was actually&lt;br /&gt;
	-- dropped, OR the breadcrumb is genuinely a single bare country/state&lt;br /&gt;
	-- name with nothing more specific (#tokens == 1) -- there&amp;#039;s nothing&lt;br /&gt;
	-- wrong with those, just nothing to flag. What&amp;#039;s NOT legitimate is a&lt;br /&gt;
	-- multi-token breadcrumb whose first token isn&amp;#039;t a recognized country&lt;br /&gt;
	-- at all -- that&amp;#039;s the missing-&amp;quot;United States&amp;quot;-style mistake. (A&lt;br /&gt;
	-- single unrecognized token never reaches here, since isBreadcrumb()&lt;br /&gt;
	-- only accepts it if it&amp;#039;s a known country or contains a comma.)&lt;br /&gt;
	local countryRecognized = (droppedCountry ~= nil) or (#tokens == 1)&lt;br /&gt;
&lt;br /&gt;
	return table.concat(reversed, &amp;quot;, &amp;quot;), countryRecognized&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Split a single line into (location-or-nil, subject-string, countryRecognized).&lt;br /&gt;
-- location is nil when the line has no recognizable place breadcrumb&lt;br /&gt;
-- (a bare subject, as used on non-cataloged {{Book source}} entries).&lt;br /&gt;
-- countryRecognized is always true for bare-subject lines (nothing to flag).&lt;br /&gt;
local function parseLine(frame, line)&lt;br /&gt;
	local dashPos = line:find(&amp;quot; %- &amp;quot;)&lt;br /&gt;
	if not dashPos then&lt;br /&gt;
		return nil, line, true&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local left = trim(line:sub(1, dashPos - 1))&lt;br /&gt;
	local right = trim(line:sub(dashPos + 3))&lt;br /&gt;
&lt;br /&gt;
	if isBreadcrumb(left) then&lt;br /&gt;
		local location, countryRecognized = deriveLocation(frame, left)&lt;br /&gt;
		return location, right, countryRecognized&lt;br /&gt;
	else&lt;br /&gt;
		return nil, line, true&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Look up the subject in {{Resource category switch values}} via the&lt;br /&gt;
-- existing template, so that table stays the single source of truth for&lt;br /&gt;
-- subject-name normalization (including the reordering already applied to&lt;br /&gt;
-- entries like census years and military/war subjects).&lt;br /&gt;
local function normalizeSubject(frame, subject)&lt;br /&gt;
	local result = frame:expandTemplate{&lt;br /&gt;
		title = &amp;quot;Resource category switch values&amp;quot;,&lt;br /&gt;
		args = { subject },&lt;br /&gt;
	}&lt;br /&gt;
	result = trim(result)&lt;br /&gt;
	if result == &amp;quot;&amp;quot; then&lt;br /&gt;
		-- Not found in the switch table -- surface this clearly rather than&lt;br /&gt;
		-- silently emitting a blank/missing category.&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
	return result&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function joinCategory(location, subjectNormalized, suffix)&lt;br /&gt;
	local base&lt;br /&gt;
	if location == nil then&lt;br /&gt;
		base = subjectNormalized&lt;br /&gt;
	elseif location:find(&amp;quot;,&amp;quot;, 1, true) then&lt;br /&gt;
		base = location .. &amp;quot;, &amp;quot; .. subjectNormalized&lt;br /&gt;
	else&lt;br /&gt;
		base = location .. &amp;quot; &amp;quot; .. subjectNormalized&lt;br /&gt;
	end&lt;br /&gt;
	if suffix and suffix ~= &amp;quot;&amp;quot; then&lt;br /&gt;
		base = base .. &amp;quot; &amp;quot; .. suffix&lt;br /&gt;
	end&lt;br /&gt;
	return base&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- Shared parse across both entry points, so |locations and |categories stay&lt;br /&gt;
-- in sync from a single pass over the input.&lt;br /&gt;
local function parseAll(frame, text)&lt;br /&gt;
	local lines = splitInputLines(text)&lt;br /&gt;
	local locations = {}&lt;br /&gt;
	local seenLocations = {}&lt;br /&gt;
	local categories = {}&lt;br /&gt;
	local missingSubjects = {}&lt;br /&gt;
	local unrecognizedCountryLines = {}&lt;br /&gt;
&lt;br /&gt;
	for _, line in ipairs(lines) do&lt;br /&gt;
		local location, subject, countryRecognized = parseLine(frame, line)&lt;br /&gt;
&lt;br /&gt;
		if location and not seenLocations[location] then&lt;br /&gt;
			seenLocations[location] = true&lt;br /&gt;
			table.insert(locations, location)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not countryRecognized then&lt;br /&gt;
			table.insert(unrecognizedCountryLines, line)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local normalized = normalizeSubject(frame, subject)&lt;br /&gt;
		if normalized then&lt;br /&gt;
			table.insert(categories, { location = location, subject = normalized, rawSubject = subject })&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(missingSubjects, subject)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return locations, categories, missingSubjects, unrecognizedCountryLines&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- {{#invoke:LocalitySubject|locations|{{{locality_subjects|}}}}}&lt;br /&gt;
-- Returns a semicolon-separated list of location strings, for feeding into&lt;br /&gt;
-- ArrayMapPropertyBulletedListLink / Has location, exactly as a hand-typed&lt;br /&gt;
-- location= parameter would have.&lt;br /&gt;
function p.locations(frame)&lt;br /&gt;
	local text = frame.args[1] or &amp;quot;&amp;quot;&lt;br /&gt;
	local locations = parseAll(frame, text)&lt;br /&gt;
	return table.concat(locations, &amp;quot;;&amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- {{#invoke:LocalitySubject|subjects|{{{locality_subjects|}}}}}&lt;br /&gt;
-- Returns a semicolon-separated list of RAW subject terms (FamilySearch&amp;#039;s&lt;br /&gt;
-- own text, e.g. &amp;quot;Land and property&amp;quot;), NOT run through the switch table.&lt;br /&gt;
-- This preserves the previous Has document type semantics, which took the&lt;br /&gt;
-- raw locality_subjects= value directly -- other queries on the wiki that&lt;br /&gt;
-- filter on Has document type expect FamilySearch&amp;#039;s own vocabulary, not the&lt;br /&gt;
-- wiki&amp;#039;s lowercased category-name text.&lt;br /&gt;
function p.subjects(frame)&lt;br /&gt;
	local text = frame.args[1] or &amp;quot;&amp;quot;&lt;br /&gt;
	local _, categories = parseAll(frame, text)&lt;br /&gt;
	local subjects = {}&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	for _, entry in ipairs(categories) do&lt;br /&gt;
		if not seen[entry.rawSubject] then&lt;br /&gt;
			seen[entry.rawSubject] = true&lt;br /&gt;
			table.insert(subjects, entry.rawSubject)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return table.concat(subjects, &amp;quot;;&amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- {{#invoke:LocalitySubject|categories|{{{locality_subjects|}}}|optionalSuffix}}&lt;br /&gt;
-- Returns wikitext category tags, one per parsed line. If a subject isn&amp;#039;t&lt;br /&gt;
-- found in the switch table, that line is skipped (no blank/broken&lt;br /&gt;
-- category) and a tracking category is added so the gap is visible rather&lt;br /&gt;
-- than silent. Likewise, if a line&amp;#039;s leading breadcrumb token isn&amp;#039;t a&lt;br /&gt;
-- recognized country (e.g. &amp;quot;United States&amp;quot; was left off by accident), a&lt;br /&gt;
-- separate tracking category is added -- the location/category still get&lt;br /&gt;
-- generated with the best guess available, but flagged for review since&lt;br /&gt;
-- the county/parish suffix fix never had a chance to run.&lt;br /&gt;
function p.categories(frame)&lt;br /&gt;
	local text = frame.args[1] or &amp;quot;&amp;quot;&lt;br /&gt;
	local suffix = frame.args[2]&lt;br /&gt;
	local _, categories, missingSubjects, unrecognizedCountryLines = parseAll(frame, text)&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, entry in ipairs(categories) do&lt;br /&gt;
		local catName = joinCategory(entry.location, entry.subject, suffix)&lt;br /&gt;
		table.insert(out, &amp;quot;[[Category:&amp;quot; .. catName .. &amp;quot;]]&amp;quot;)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #missingSubjects &amp;gt; 0 then&lt;br /&gt;
		table.insert(out, &amp;quot;[[Category:Locality subjects needing switch table entries]]&amp;quot;)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #unrecognizedCountryLines &amp;gt; 0 then&lt;br /&gt;
		table.insert(out, &amp;quot;[[Category:Locality subjects missing recognized country]]&amp;quot;)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot;&amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Redheadkelly</name></author>
	</entry>
</feed>