Jump to content
Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Works out and prints the list on Places to check and the briefs on Places to check/Briefs.

Use

  • {{#invoke:Places to check|list}}: a sentence giving the number of places, then the sortable table.
  • {{#invoke:Places to check|briefs}}: one brief per listed place, in the list's order. {{#invoke:Places to check|briefs|1=Ganja Mahal}} prints one.

Styles: Template:Places to check/styles.css. A brief's anchor is its place name, so Places to check/Briefs#Ganja_Mahal opens and prints that brief alone; #All_briefs prints them all, one to a page.

What it reads

  • the places named on Growing places in historical sources (its subobjects: Foaf:name, Has country, Has place as recorded, Has description, Has year described, Has date as recorded, Has months as recorded, Has plant names as recorded, Reported in, Has coordinates)
  • the pages whose Has growing status is Historical or No longer grown (Has place type, Has country, Has first grown, Has last grown, Has last checked, Has coordinates)
  • the accessions filed under those pages (Has growing area, Has growing region, Has appellation)
  • the historical sources whose Has region documented names one of those pages
  • for each place with a position, the accessions within 100 km (SMW's distance query on Has coordinates)

Each query is a plain #ask; the join is done here.

The rule

  • An accession counts as recent when it was collected in 2000 or later (p.CUTOFF). Without a collection date it counts by the year in a standard accession number (XXX-CCC-RRR-NNYYYYNNNN), the year the material is first recorded; otherwise it does not count.
  • A page with growing status Historical or No longer grown is listed while no recent accession is filed under it.
  • A place named only on the census (no page of the same name with such a status) is listed while no recent accession lies within 25 km of its position (p.RADIUS). A census place with the same name as such a page is shown with that page.
  • A brief lists up to five recent accessions within 100 km (p.NEAR, p.NEAR_MAX), and the accessions from before 2000 filed under the page or lying within 25 km.

The page is worked out when it is rendered, so a new accession appears after the page is next rendered (a purge does it at once).


-- Places to check: places that old sources say grew cannabis, and growing places last recorded as
-- Historical or No longer grown, where no accession has been collected since 2000.
-- Two entry points: p.list (the table on "Places to check") and p.briefs (the printable briefs).
-- The join is computed here from plain #ask results (no chain printouts).
local p = {}

p.CUTOFF = 2000          -- an accession collected in this year or later counts as recent
p.RADIUS = 25            -- km: a recent accession this close to a census position counts as "in" the place
p.NEAR = 100             -- km: recent accessions listed as nearby in a brief
p.NEAR_MAX = 5           -- at most this many nearby accessions in a brief
p.CENSUS = 'Growing places in historical sources'
p.BRIEFS = 'Places to check/Briefs'
p.PAST = '[[Has growing status::Historical||No longer grown]]'

local SEP_R, SEP_P, SEP_V = '⟦R⟧', '⟦P⟧', '⟦V⟧'

local function trim(s) return mw.text.trim(s or '') end
local function split(s, sep) return mw.text.split(s, sep, true) end

-- printouts: list of {key, property, format}; returns list of {subject, fields = {key = {values}}}
local function ask(frame, cond, printouts, extra)
	local parts = { '{{#ask:', cond }
	for _, po in ipairs(printouts) do
		parts[#parts + 1] = '|?' .. po[2] .. '#' .. (po[3] or '-') .. '=⟪' .. po[1] .. '⟫'
	end
	parts[#parts + 1] = '|format=plainlist|headers=plain|link=none|searchlabel=|default='
	parts[#parts + 1] = '|sep=' .. SEP_R .. '|propsep=' .. SEP_P .. '|valuesep=' .. SEP_V
	parts[#parts + 1] = (extra or '|limit=500') .. '}}'
	local out = trim(frame:preprocess(table.concat(parts)))
	local rows = {}
	if out == '' then return rows end
	for _, row in ipairs(split(out, SEP_R)) do
		local subject, rest = row:match('^(.-) %((⟪.*)%)%s*$')
		if not subject then subject, rest = trim(row), '' end
		local fields = {}
		for _, part in ipairs(split(rest, SEP_P)) do
			local k, v = part:match('^⟪([%w_]+)⟫: (.*)$')
			if k then
				local vals = {}
				for _, x in ipairs(split(v, SEP_V)) do
					x = trim(x)
					if x ~= '' then vals[#vals + 1] = x end
				end
				fields[k] = vals
			end
		end
		rows[#rows + 1] = { subject = trim(subject), fields = fields }
	end
	return rows
end
p._ask = ask

local function one(f, k) local v = f[k]; return v and v[1] or nil end

-- "24° 48' 0.00\" N, 89° 4' 12.00\" E" -> {24.8, 89.07}
local function parseCoord(s)
	if not s then return nil end
	local function part(txt)
		local d, m, sec, h = txt:match('([%d%.]+)°%s*([%d%.]*)\'?%s*([%d%.]*)"?%s*([NSEW])')
		if not d then return nil end
		local v = tonumber(d) + (tonumber(m) or 0) / 60 + (tonumber(sec) or 0) / 3600
		if h == 'S' or h == 'W' then v = -v end
		return v
	end
	local a, b = s:match('^(.-[NS]),%s*(.-[EW])$')
	if not a then
		local la, lo = s:match('^%s*(%-?[%d%.]+)%s*,%s*(%-?[%d%.]+)%s*$')
		if la then return { tonumber(la), tonumber(lo) } end
		return nil
	end
	local la, lo = part(a), part(b)
	if la and lo then return { la, lo } end
	return nil
end
p._parseCoord = parseCoord

local function km(a, b)
	local r = math.pi / 180
	local dla, dlo = (b[1] - a[1]) * r, (b[2] - a[2]) * r
	local h = math.sin(dla / 2) ^ 2 + math.cos(a[1] * r) * math.cos(b[1] * r) * math.sin(dlo / 2) ^ 2
	return 6371 * 2 * math.asin(math.min(1, math.sqrt(h)))
end
p._km = km

-- the year an accession counts for: its collection year, else the year in a standard accession
-- number (XXX-CCC-RRR-NNYYYYNNNN, the year the material is first attested), else nil
local function accYear(title, dateYear)
	local y = tonumber(dateYear or '')
	if y then return y end
	local id = title:match('^%u+%-%u+%-%u+%-%d%d(%d%d%d%d)%d+$')
	y = tonumber(id or '')
	if y and y > 1000 then return y end
	return nil
end
p._accYear = accYear

local lang = mw.language.getContentLanguage()
local function fmtKm(d) return lang:formatNum(math.floor(d + 0.5)) .. ' km' end
local function fmtPos(c)
	return string.format('%.2f° %s, %.2f° %s', math.abs(c[1]), c[1] < 0 and 'S' or 'N',
		math.abs(c[2]), c[2] < 0 and 'W' or 'E')
end

local function anchor(s) return mw.uri.anchorEncode(s) end

local function grownSpan(first, last)
	if first and last then
		return first == last and first or (first .. '–' .. last)
	elseif last then return 'until ' .. last
	elseif first then return 'from ' .. first end
	return nil
end

----------------------------------------------------------------------------------------------------
-- data
----------------------------------------------------------------------------------------------------

local function gather0(frame)
	-- 1. census places (one subobject per place and source)
	local census = ask(frame, '[[-Has subobject::' .. p.CENSUS .. ']]', {
		{ 'name', 'Foaf:name' }, { 'country', 'Has country' }, { 'spelled', 'Has place as recorded' },
		{ 'desc', 'Has description' }, { 'year', 'Has year described' }, { 'date', 'Has date as recorded' },
		{ 'months', 'Has months as recorded' }, { 'names', 'Has plant names as recorded' },
		{ 'src', 'Reported in' }, { 'coord', 'Has coordinates' }, { 'pageof', 'Has place page' },
	}, '|sort=|limit=500')
	-- 2. growing places last recorded as Historical or No longer grown
	local hubs = ask(frame, p.PAST, {
		{ 'status', 'Has growing status' }, { 'ptype', 'Has place type' }, { 'country', 'Has country' },
		{ 'first', 'Has first grown', '-F[Y]' }, { 'last', 'Has last grown', '-F[Y]' },
		{ 'checked', 'Has last checked', '-F[Y]' }, { 'coord', 'Has coordinates' },
	}, '|sort=|limit=500')
	-- 3. accessions linked to those places
	local linkProps = { 'Has growing area', 'Has growing region', 'Has appellation' }
	local linked = {}
	for _, prop in ipairs(linkProps) do
		local rows = ask(frame, '[[Category:Accessions]][[' .. prop .. '::<q>' .. p.PAST .. '</q>]]', {
			{ 'place', prop }, { 'date', 'Has collection date', '-F[Y]' },
		}, '|sort=|limit=500')
		for _, r in ipairs(rows) do
			for _, pl in ipairs(r.fields.place or {}) do
				linked[pl] = linked[pl] or {}
				linked[pl][r.subject] = accYear(r.subject, one(r.fields, 'date'))
			end
		end
	end
	-- 4. old sources that document a past growing place
	local documented = {}
	for _, r in ipairs(ask(frame, '[[Has region documented::<q>' .. p.PAST .. '</q>]]', {
		{ 'regions', 'Has region documented' }, { 'pub', 'Has publication year', '-F[Y]' },
	}, '|sort=|limit=500')) do
		for _, reg in ipairs(r.fields.regions or {}) do
			documented[reg] = documented[reg] or {}
			table.insert(documented[reg], { title = r.subject, year = one(r.fields, 'pub') })
		end
	end

	-- build the places
	local places, byName = {}, {}
	for _, h in ipairs(hubs) do
		local f = h.fields
		local pl = {
			key = h.subject, name = h.subject, page = h.subject, hub = true,
			status = one(f, 'status'), ptype = one(f, 'ptype'), country = one(f, 'country'),
			first = one(f, 'first'), last = one(f, 'last'), checked = one(f, 'checked'),
			coord = parseCoord(one(f, 'coord')), census = {}, sources = documented[h.subject] or {},
			linkedOld = {}, linkedNew = {},
		}
		for t, y in pairs(linked[h.subject] or {}) do
			if y and y >= p.CUTOFF then table.insert(pl.linkedNew, { title = t, year = y })
			else table.insert(pl.linkedOld, { title = t, year = y }) end
		end
		places[#places + 1] = pl
		byName[h.subject] = pl
	end
	for _, c in ipairs(census) do
		local f = c.fields
		local name = one(f, 'name') or (c.subject:match('#(.+)$')) or c.subject
		local row = {
			anchor = anchor(c.subject:match('#(.+)$') or name), spelled = one(f, 'spelled'), desc = one(f, 'desc'),
			year = one(f, 'year'), date = one(f, 'date'), months = f.months or {}, names = f.names or {},
			src = one(f, 'src'),
		}
		local pl = byName[name]
		if not pl then
			pl = { key = name, name = name, hub = false, country = one(f, 'country'),
				coord = parseCoord(one(f, 'coord')), census = {}, sources = {}, linkedOld = {}, linkedNew = {} }
			places[#places + 1] = pl
			byName[name] = pl
		end
		pl.country = pl.country or one(f, 'country')
		pl.placePage = pl.placePage or one(f, 'pageof')
		pl.coord = pl.coord or parseCoord(one(f, 'coord'))
		table.insert(pl.census, row)
	end

	-- 5. accessions within NEAR km of each place (SMW's distance query; one scan of every accession
	-- position costs more time than a page may take)
	for _, pl in ipairs(places) do
		pl.nearNew, pl.nearOld, pl.nearest = {}, {}, nil
		if pl.coord then
			local accs = {}
			for _, r in ipairs(ask(frame, string.format('[[Category:Accessions]][[Has coordinates::%.4f, %.4f (%d km)]]',
					pl.coord[1], pl.coord[2], p.NEAR), {
				{ 'date', 'Has collection date', '-F[Y]' }, { 'coord', 'Has coordinates' },
			}, '|sort=|limit=500')) do
				local c = parseCoord(one(r.fields, 'coord'))
				if c then
					accs[#accs + 1] = { title = r.subject, year = accYear(r.subject, one(r.fields, 'date')), coord = c }
				end
			end
			for _, a in ipairs(accs) do
				local d = km(pl.coord, a.coord)
				-- the distance query's box reaches past NEAR at its corners: keep only what lies within NEAR
				if d > p.NEAR then
					-- outside
				elseif a.year and a.year >= p.CUTOFF then
					if not pl.nearest or d < pl.nearest.d then pl.nearest = { title = a.title, year = a.year, d = d } end
					table.insert(pl.nearNew, { title = a.title, year = a.year, d = d })
				elseif d <= p.RADIUS then
					table.insert(pl.nearOld, { title = a.title, year = a.year, d = d })
				end
			end
			table.sort(pl.nearNew, function(x, y) return x.d < y.d end)
			table.sort(pl.nearOld, function(x, y) return x.d < y.d end)
		end
		-- the rule: a past growing place counts as checked when an accession collected since the cutoff is
		-- linked to it; a place named only in an old source, when one lies within RADIUS of its position
		if pl.hub then
			pl.toCheck = #pl.linkedNew == 0
		else
			pl.toCheck = not (pl.nearest and pl.nearest.d <= p.RADIUS)
		end
	end
	table.sort(places, function(a, b)
		if (a.country or '') ~= (b.country or '') then return (a.country or '~') < (b.country or '~') end
		return a.name < b.name
	end)
	return places
end

-- one run of the join per page render: the list and the foot table both use it
function p.gather(frame)
	if not p._places then p._places = gather0(frame) end
	return p._places
end

local function placeLinkU(pl)
	return '[[' .. p.CENSUS .. '#' .. pl.census[1].anchor .. '|' .. pl.name .. ']]'
end

-- a place named in an old source whose position cannot be given (a district, a region, a revenue unit) is
-- not a place to visit: it goes in the short list at the foot, with no brief and no distance query
local function unpositioned(pl) return (not pl.hub) and not pl.coord end

local function listed(frame)
	local out = {}
	for _, pl in ipairs(p.gather(frame)) do
		if pl.toCheck and not unpositioned(pl) then out[#out + 1] = pl end
	end
	return out
end

function p.unpositioned(frame)
	local rows = {}
	for _, pl in ipairs(p.gather(frame)) do
		if unpositioned(pl) then rows[#rows + 1] = pl end
	end
	if #rows == 0 then return '' end
	local t = {
		'<div style="overflow-x:auto;">',
		'{| class="wikitable sortable lw-ptc-list"',
		'! Place !! Country !! Recorded !! Page',
	}
	for _, pl in ipairs(rows) do
		local years, seen = {}, {}
		for _, r in ipairs(pl.census) do
			if r.year and not seen[r.year] then seen[r.year] = true; years[#years + 1] = r.year end
		end
		t[#t + 1] = '|-'
		t[#t + 1] = '| ' .. placeLinkU(pl) .. ' || ' .. (pl.country and ('[[' .. pl.country .. ']]') or '')
			.. ' || ' .. table.concat(years, '; ') .. ' || ' .. (pl.placePage and ('[[' .. pl.placePage .. ']]') or '')
	end
	t[#t + 1] = '|}'
	t[#t + 1] = '</div>'
	return table.concat(t, '\n')
end

----------------------------------------------------------------------------------------------------
-- output
----------------------------------------------------------------------------------------------------

local function placeLink(pl)
	if pl.hub then return '[[' .. pl.page .. ']]' end
	return '[[' .. p.CENSUS .. '#' .. pl.census[1].anchor .. '|' .. pl.name .. ']]'
end

local function kindText(pl)
	if pl.hub then
		local t = pl.status or ''
		if pl.ptype then t = t .. ' (' .. lang:lcfirst(pl.ptype) .. ')' end
		return t
	end
	return 'Named in an old source'
end

local function recordedText(pl)
	local bits = {}
	if pl.hub then
		local g = grownSpan(pl.first, pl.last)
		if g then bits[#bits + 1] = 'grown ' .. g end
		return table.concat(bits, '; ')
	end
	for _, r in ipairs(pl.census) do
		if r.year then bits[#bits + 1] = r.year end
	end
	return table.concat(bits, '; ')
end

local function nearestText(pl)
	if not pl.coord then return 'no position' end
	if not pl.nearest then return 'over ' .. p.NEAR .. ' km' end
	return fmtKm(pl.nearest.d)
end

local function plural(n, one, many) return n .. ' ' .. (n == 1 and one or many) end

-- the count sentence and the table, from one run of the join
function p.list(frame)
	local rows = listed(frame)
	local census, hubs = 0, 0
	for _, pl in ipairs(rows) do
		if pl.hub then hubs = hubs + 1 else census = census + 1 end
	end
	local t = {
		'The list holds ' .. plural(#rows, 'place', 'places') .. ': ' .. plural(census, 'place', 'places')
			.. ' named only in an old source and ' .. plural(hubs, 'growing place', 'growing places') .. '.',
		'',
		'<div style="overflow-x:auto;">',
		'{| class="wikitable sortable lw-ptc-list"',
		'! Place !! Country !! Recorded !! Status !! data-sort-type="number" | Nearest accession since ' .. p.CUTOFF .. ' !! Brief',
	}
	for _, pl in ipairs(rows) do
		local sortKm = pl.nearest and math.floor(pl.nearest.d + 0.5) or 999999
		t[#t + 1] = '|-'
		t[#t + 1] = '| ' .. placeLink(pl) .. ' || ' .. (pl.country and ('[[' .. pl.country .. ']]') or '')
			.. ' || ' .. recordedText(pl) .. ' || ' .. kindText(pl)
			.. ' || data-sort-value="' .. sortKm .. '" | ' .. nearestText(pl)
			.. ' || [[' .. p.BRIEFS .. '#' .. anchor(pl.key) .. '|Brief]]'
	end
	t[#t + 1] = '|}'
	t[#t + 1] = '</div>'
	local foot = p.unpositioned(frame)
	if foot ~= '' then
		t[#t + 1] = ''
		t[#t + 1] = '== Named only at district or region level =='
		t[#t + 1] = 'Some old sources name only a district, a region or a unit of the old revenue survey, from which no position can be worked out. These are not places to visit, so they have no brief; each links to its row on [[' .. p.CENSUS .. ']].'
		t[#t + 1] = ''
		t[#t + 1] = foot
	end
	return table.concat(t, '\n')
end

local function quoteOf(s) return '“' .. s .. '”' end

local function accLine(a, withKm)
	local s = '[[' .. a.title .. ']]'
	if a.year then s = s .. ' (' .. a.year .. ')' end
	if withKm then s = s .. ', ' .. fmtKm(a.d) end
	return s
end

local function brief(pl)
	local b = {}
	local function add(s) b[#b + 1] = s end
	add('<div class="lw-ptc-brief" id="' .. anchor(pl.key) .. '">')
	add('<div class="lw-ptc-title" role="heading" aria-level="2">' .. pl.name .. '</div>')
	local sub = { kindText(pl) }
	if pl.country then sub[#sub + 1] = pl.country end
	add('<div class="lw-ptc-sub">' .. table.concat(sub, ' · ') .. '</div>')

	-- the place
	add('{| class="lw-ptc-facts"')
	if pl.hub then
		add('! Page\n| [[' .. pl.page .. ']]')
		add('|-\n! Status\n| ' .. (pl.status or ''))
		local g = grownSpan(pl.first, pl.last)
		if g then add('|-\n! Recorded grown\n| ' .. g) end
		add('|-\n! Last checked\n| ' .. (pl.checked or 'not yet'))
	else
		add('! Listed on\n| [[' .. p.CENSUS .. '#' .. pl.census[1].anchor .. '|' .. p.CENSUS .. ']]')
	end
	local spelled = {}
	for _, r in ipairs(pl.census) do
		if r.spelled and r.spelled ~= pl.name then spelled[#spelled + 1] = "''" .. r.spelled .. "''" end
	end
	if #spelled > 0 then add('|-\n! Spelled\n| ' .. table.concat(spelled, ', ')) end
	if pl.coord then
		add('|-\n! Position\n| ' .. fmtPos(pl.coord) .. (pl.hub and '' or ' (the place named, not a field)'))
	else
		add('|-\n! Position\n| not recorded')
	end
	add('|}')

	-- what the old sources say
	if #pl.census > 0 or #pl.sources > 0 then
		add('<div class="lw-ptc-h">What the old sources say</div>')
		for _, r in ipairs(pl.census) do
			local head = "'''" .. (r.src or 'Source') .. "'''"
			if r.year then head = head .. ', describing ' .. r.year end
			if r.date then head = head .. ' (' .. quoteOf(r.date) .. ')' end
			add('* ' .. head .. ': ' .. (r.desc or '') .. (r.desc and not r.desc:match('[%.!?]$') and '.' or ''))
			if #r.months > 0 then
				local m = {}
				for _, x in ipairs(r.months) do m[#m + 1] = quoteOf(x) end
				add('*: Months: ' .. table.concat(m, ' '))
			end
			if #r.names > 0 then add("*: Names for the plant: ''" .. table.concat(r.names, "'', ''") .. "''") end
		end
		for _, s in ipairs(pl.sources) do
			add('* [[' .. s.title .. ']]' .. (s.year and (', published ' .. s.year) or '') .. ', which documents the place')
		end
	end

	-- collections
	local old = {}
	local seen = {}
	for _, a in ipairs(pl.linkedOld) do old[#old + 1] = { a, false }; seen[a.title] = true end
	for _, a in ipairs(pl.nearOld) do
		if not seen[a.title] then old[#old + 1] = { a, true }; seen[a.title] = true end
	end
	if #old > 0 then
		add('<div class="lw-ptc-h">Collected here before ' .. p.CUTOFF .. '</div>')
		for _, o in ipairs(old) do add('* ' .. accLine(o[1], o[2])) end
	end
	add('<div class="lw-ptc-h">Accessions collected since ' .. p.CUTOFF .. ' nearby</div>')
	if not pl.coord then
		add('No position is recorded for this place, so nothing is measured.')
	elseif #pl.nearNew == 0 then
		add('None within ' .. p.NEAR .. ' km.')
	else
		for i, a in ipairs(pl.nearNew) do
			if i > p.NEAR_MAX then break end
			add('* ' .. accLine(a, true))
		end
	end

	-- field notes, printed only
	add('<div class="lw-ptc-notes">')
	add('<div class="lw-ptc-h">Field notes</div>')
	for _, l in ipairs({ 'Date of visit', 'Village or field visited', 'Grown there now? Yes / No / Could not tell',
			'Names heard for the plant', 'Sowing and harvest months given', 'Seed brought in from elsewhere?',
			'Photographs taken', 'Notes' }) do
		add('<div class="lw-ptc-line">' .. l .. '</div>')
	end
	add('<div class="lw-ptc-line">&nbsp;</div>\n<div class="lw-ptc-line">&nbsp;</div>')
	add('</div>')
	add('</div>')
	return table.concat(b, '\n')
end
p._brief = brief

function p.briefs(frame)
	local out = { '<div class="lw-ptc-briefs" id="All_briefs">' }
	local only = trim(frame.args[1])
	for _, pl in ipairs(listed(frame)) do
		if only == '' or only == pl.key then out[#out + 1] = brief(pl) end
	end
	out[#out + 1] = '</div>'
	return table.concat(out, '\n')
end

return p