Add Maps.fh_lua
--[[
@Title: Add Maps
@Type: Standard
@Author: Helen Wright
@Contributors: Inspired by Mike Tate's Map Life Facts
@Version: 1.0
@LastUpdated: 25 September 2026
@Licence: This plugin is copyright (c) 2026 Helen Wright & contributors and is licensed under the MIT License which is hereby incorporated by reference (see https://pluginstore.family-historian.co.uk/fh-plugin-licence)
@Description: Geocodes the places and addresses in your project and builds interactive maps of where life events happened, which you can embed into the matching pages of a Family Historian generated website.
]]
--[[ChangeLog:
Version 1.0: Initial release.
Add Maps geocodes the places and addresses in a Family Historian project and builds interactive
maps of where each person's life happened, which it can embed into a Family Historian website.
- Geocoding: Geoapify, OpenCage or LocationIQ (your own free key; consent asked once per service,
with its privacy policy). Geocode one row, the ticked rows or the whole list, for places and
addresses; region bias; Tentative / Not found / Blocked status; Standardised-name editing;
hand-correction on a browser map (Show on Map / Get from Map). Changes can be undone with
Edit > Undo Plugin Updates until Family Historian closes.
- Maps: a page per person or one named combined map; choose any fact types (Options > Events to
Map), plus witnessed events and relatives' timeline events; journey line with numbered pins;
age at each event; time slider; clustering; per-person legend; Street, Satellite and historic
base layers; Google Maps / Street View links; optional temporary maps deleted on close.
- Website embedding: each person's map injected into their generated page, with caption, height,
collapsible and alignment options and a confirmation before any page is changed.
- Historic-map keys: keyless NLS layers for Great Britain; an optional MapTiler key adds more GB
and Ireland maps. Published maps are keyless by default, or use a separate domain-restricted key.
- Privacy: published maps omit individuals flagged Private or Living (configurable) and drop
their events from other people's published maps.
]]
--------------------------------------------------------------
-- INITIALISE (FH8 minimum + recommend saving before we change data)
--------------------------------------------------------------
-- fhInitialise must be the FIRST FH call and must precede any data change. It enforces the minimum
-- FH version and, with "save_recommended", offers to save unsaved changes first (Yes/No/Cancel;
-- Cancel terminates the plugin). Saving first also avoids the OneDrive .ged sync-conflict risk.
fhInitialise(8, 0, 0, "save_recommended")
--------------------------------------------------------------
-- EXTERNAL LIBRARIES
--------------------------------------------------------------
do
require("utf8data")
utf8 = require(".utf8")
utf8.config["conversion"] = { uc_lc = utf8_uc_lc, lc_uc = utf8_lc_uc }
utf8:init()
require("iuplua") -- UI
fh = require("fhUtils") -- FH helpers
fhfu = require("fhFileUtils") -- UTF-8-aware file handling
end
require("luacom") -- exposes a GLOBAL `luacom` (its require returns no value in FH); used for WinHttp
local lpeg = require("lpeg") -- JSON parsing for geocoder responses (FH8 bundles lpeg)
--------------------------------------------------------------
-- ENVIRONMENT CONSTANTS
--------------------------------------------------------------
local cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME")
local cstrPluginVersion = "1.0" -- MUST equal @Version in the header (drives window title + About box); tests/geocode_spec.lua fails the build if the two drift
local cstrPluginDir = fhGetPluginDataFileName("LOCAL_MACHINE", true)
local myHelp, myGeoConfig, myMapConfig, myResults
-- Session region-bias override: seeded from the global Geocoding Options default, editable on the
-- Geocoding pane for this run only, and deliberately NOT written back (unlike the output folder).
local gRegionBias = ""
-- Field validator for the region-bias default (forward-declared here, assigned once gcValidRegion is in
-- scope below). The Geocoding Options dialog calls it on Save so a bad default can't be stored.
local regionFieldValidate
local dlgMain
-- Whether the user actually geocoded / built maps this session, so on close we show only the relevant
-- result window(s) and never the "nothing happened" box for a thing they didn't do.
local didGeocode, didMap = false, false
-- True while a batch geocode is running. The batch loop pumps the UI (fhSleep / LoopStep), so the
-- main window stays live - but closing the plugin mid-batch tears the dialog down underneath the
-- running loop and leaves an orphaned window that blocks FH from exiting. The close paths (title-bar
-- X and File > Exit) check this and refuse while a batch is in flight.
local pluginBusy = false
-- FH supports only ONE result set per plugin run, so geocoding and mapping outcomes share a single
-- Results object (myResults, built near the foot of the file) with an "Activity" column distinguishing
-- the two kinds of row. These helpers keep the combined column order in one place: a geocode row leaves
-- the map-only columns blank, a map row leaves the geocode-only columns blank.
local function addGeocodeRow(ptr, kind, geocoder, result, lat, lng, quality, standardised)
if not myResults then return end
myResults.Update({ ptr, "Geocoded", kind, geocoder, result, lat, lng, quality, standardised, "", "", "", "", "" })
didGeocode = true
end
local function addMapRow(ptr, eventsMapped, notGeocoded, mapFile, embedded, folder)
if not myResults then return end
myResults.Update({ ptr, "Map", "", "", "", "", "", "", "", eventsMapped, notGeocoded, mapFile, embedded, folder })
didMap = true
end
--------------------------------------------------------------
-- SHARED BOILERPLATE (spliced in at build by build/assemble.lua)
--------------------------------------------------------------
-- Each block below is spliced from the canonical "2 Boilerplate/" module by
-- build/assemble.lua (shared engine: "2 Boilerplate/build/fs_splice.lua"). This makes
-- Add Maps a single self-contained .fh_lua at release, with no runtime dofile.
-- DO NOT edit the spliced content between the markers - edit the source module and
-- re-run build/assemble.lua. Order matters (Theme before Dialog; Progress and Facts
-- need Dialog). The nine classic modules set their own global, so their directive has
-- NO name (run-for-effect); HtmlInject returns a module table, so it is named (assign).
--<<FS_SPLICE "../../2 Boilerplate/Theme.lua">>
;(function()
--[[
Theme V1
@Author: Helen Wright
@Version: 1.0
@LastUpdated: 15 June 2026
@Description: Reads the Windows theme so IUP dialogs (and any other consumer)
can follow the user's light / dark / High Contrast preference.
Colours are read from HKCU\Control Panel\Colors via LuaCOM (WScript.Shell
RegRead), degrading gracefully to a light palette when the registry is
unavailable (e.g. under the WINE emulator). The registry reads are the only
Windows-bound part; everything else is pure.
Sets the global `Theme`. Two kinds of consumer:
* IUP dialog theming -> Theme.iupColours() / Theme.isDarkMode() / Theme.isHighContrast()
* a "Match System Theme" colour preset (e.g. for SVG) -> Theme.systemColours()
Load this before Dialog.lua so the dialog helpers pick up the theme; Dialog.lua
also falls back to a light palette if Theme has not been loaded.
@V1.0: Initial version (extracted from the Add Trees plugin).
]]
do
local M = {}
require("luacom")
---@class ThemeSystemColours
---@field window string|nil Background, "R G B" (Control Panel > Colors > Window)
---@field windowText string|nil Foreground, "R G B" (WindowText)
---@field hilight string|nil Selection background, "R G B" (Hilight)
---@field hilightText string|nil Selection text, "R G B" (HilightText)
---@field hotTracking string|nil Hot-tracking colour, "R G B" (HotTrackingColor)
---Cached colours so the registry is read at most once per run.
---@type ThemeSystemColours|nil
local cachedColours
---@type boolean
local cacheLoaded = false
---Read one registry value, returning nil on any failure.
---@param key string Full registry path including the value name.
---@return string|nil
local function getRegKey(key)
local ok, value = pcall(function()
local shell = luacom.CreateObject("WScript.Shell")
return shell:RegRead(key)
end)
if ok then
return value
end
return nil
end
---The raw Windows theme colours as "R G B" strings, or nil when the
---registry is unavailable or malformed (emulators included).
---@return ThemeSystemColours|nil
function M.systemColours()
if cacheLoaded then
return cachedColours
end
cacheLoaded = true
if os.getenv("WINEPREFIX") ~= nil then
cachedColours = nil -- emulator: registry colours unreliable
return nil
end
local root = "HKEY_CURRENT_USER\\Control Panel\\Colors\\"
local window = getRegKey(root .. "Window")
if type(window) ~= "string" or not window:match("^%d+%s+%d+%s+%d+$") then
cachedColours = nil
return nil
end
cachedColours = {
window = window,
windowText = getRegKey(root .. "WindowText"),
hilight = getRegKey(root .. "Hilight"),
hilightText = getRegKey(root .. "HilightText"),
hotTracking = getRegKey(root .. "HotTrackingColor"),
}
return cachedColours
end
---True when Windows is in dark (apps) mode. Modern Windows leaves the legacy
---Control Panel > Colors at the classic white even in dark mode, so this
---reads the AppsUseLightTheme switch instead (0 = dark).
---@return boolean
function M.isDarkMode()
local v = getRegKey(
"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\\AppsUseLightTheme"
)
return tonumber(v) == 0
end
---True when a Windows High Contrast theme is active (bit 0 of the
---Accessibility HighContrast Flags). Those themes set the legacy Control
---Panel colours correctly, so honour them.
---@return boolean
function M.isHighContrast()
local v = getRegKey("HKEY_CURRENT_USER\\Control Panel\\Accessibility\\HighContrast\\Flags")
return (tonumber(v) or 0) % 2 == 1
end
---Colours for theming the IUP dialogs, in IUP's "R G B" form. A High
---Contrast theme sets the Control Panel colours correctly, so use them;
---otherwise modern dark mode gives a dark palette and the default is light.
---@return {bg: string, fg: string}
function M.iupColours()
local colours = M.systemColours()
-- High Contrast theme: the Control Panel colours are set for it; use them.
if M.isHighContrast() then
return {
bg = (colours and colours.window) or "0 0 0",
fg = (colours and colours.windowText) or "255 255 255",
}
end
-- Otherwise the modern light/dark app theme decides. A plain custom
-- Control Panel colour no longer hijacks this (which left dark mode
-- looking half-themed).
if M.isDarkMode() then
return { bg = "32 32 32", fg = "245 245 245" }
end
return { bg = "255 255 255", fg = "0 0 0" }
end
---The system's SELECTION colour pair (Hilight/HilightText), in IUP's "R G B" form (added 10
---Jul 2026: a High Contrast theme's Window colour IS the dialog background, so tinting it -
---darkening it a fixed percentage, as iupColours()-based callers do for a title strip - has
---nothing to darken FROM under pure black/white High Contrast palettes; the strip collapses
---invisibly into the frame. The selection pair is the theme's own answer to "a colour that
---contrasts with the window background", so it survives High Contrast where a computed tint
---cannot. Falls back to iupColours() when the registry has no Hilight/HilightText (e.g. the
---WINE emulator) so a caller never gets a nil.
---@return {bg: string, fg: string}
function M.highlightColours()
local colours = M.systemColours()
if colours and colours.hilight and colours.hilightText then
return { bg = colours.hilight, fg = colours.hilightText }
end
return M.iupColours()
end
_G.Theme = M
end
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/Dialog.lua">>
;(function()
--[[
Dialog V8
@Author: Helen Wright
@Version: 1.10
@LastUpdated: 21 July 2026
@Description: Helper functions for IUP dialogs
@V1.0: Initial version.
@V1.2: Dialogs follow the Windows light / dark / High Contrast theme via the
Theme helper (Theme.lua). Falls back to the light palette when Theme is
not loaded.
@V1.3: EnableContainer/ClearContainer iterate children with iup.GetNextChild.
Integer indexing (ih[i]) only reflects constructor-time children, so
both functions silently skipped anything appended at runtime.
@V1.4: New refloorDialog(dlg): re-floor a dialog at its natural size after a
content change, growing a shown window that now clips (rastersize) and
capping at the screen height. Centralised from the consumer plugins.
@V1.5: RebuildContainer gains destroyOld (detach-only rebuilds leak) and maps
appended children when the container is already shown.
@V1.6: refloorDialog reads the window's real size BEFORE applying the minsize,
and carried a WRONG diagnosis ("the host ignores resizes of shown
dialogs") - corrected in V1.7.
@V1.7: The real culprit (Spike S7, 6 Jul 2026): with shrink=YES - which this
theme sets on every dialog - a SHOWN dialog reports NATURALSIZE equal
to its CURRENT size, so every grow-to-natural computation is a no-op
tautology and resizes "look" ignored. The host resizes shown dialogs
perfectly well. refloorDialog now measures the natural size with
shrink temporarily OFF, then restores it. A dialog whose content can
outgrow the screen still wants a scrollbox (Add Facts's capture
surface), but ordinary content-growth now genuinely grows the window.
Also: handle registration survives re-using a name whose previous
owner was destroyed (safeSetHandle; iuplua's SetHandle errors on the
dead handle - makeButton registers by TITLE, so a destroy-and-recreate
dialog hit this on its second open).
@V1.8: options.on_escape actually fires under the FH host (live 6 Jul 2026):
the dialog k_any never sees Esc there, so on_escape (without a cancel
button) now also creates a hidden, layout-ignored button firing the
handler and points native DEFAULTESC at it - the mechanism the
accessibility pass live-verified. The k_any dispatch is kept for
non-host environments. Call sites are unchanged.
@V1.9: RebuildContainer gains skipRefresh - batched rebuilds refresh once, not
per call.
@V1.10: refloorDialog now also clears the dialog's SIZE user size (dlg.size = iup.NULL)
alongside minsize before re-measuring - leaving it set inflated every re-measure
to the SIZE hint (e.g. a HALFxHALF main dialog), the same family of bug as
Config.lua's V1.3 showTrackedDialog fix. (Shared Config.lua/Dialog.lua
improvement.)
]]
--ENHANCE: Rich Text field. This could be quite difficult -- can't access WebView2 as it isn't available as an OLE control.
---@global iup table The IUP library global variable created by require("iuplua")
do
-- Load necessary libraries
require("iuplua") -- IUP library for GUI components
require("iupluacontrols") --additional iup controls
require("pl.init") -- Penlight library for additional utilities
local tablex = require("pl.tablex") --be explicit about which parts of Penlight I'm using
require("luacom")
fh = require("fhUtils")
-- makeDialog registers each dialog's independent-normalisation re-apply hook here (weak keys, so the
-- entry goes when the dialog does). Declared in the OUTER do-scope so both the theming block (which
-- defines renormalizeDialog) and the Dialog-handling block (makeDialog) can see it.
local dialogRenorm = setmetatable({}, { __mode = "k" })
-- fhUtils.setIupDefaults() is buggy in emulated (WINE) environments: the PDX Font registry read
-- returns nil in some cases and the old code then errored parsing it. Do the equivalent setup inline
-- here, with a nil-guard. This is the ONE place to maintain it; when fhUtils.setIupDefaults is fixed,
-- replace this whole block with a plain fh.setIupDefaults() call again.
do
local stringx = require("pl.stringx") -- splitv, to parse the comma-separated PDX Font value
local function getRegKey(key)
local sh = luacom.CreateObject("WScript.Shell")
local ans
if pcall(function()
ans = sh:RegRead(key)
end) then
return ans
end
return nil, true
end
iup.SetGlobal("CUSTOMQUITMESSAGE", "YES")
local v = getRegKey("HKEY_CURRENT_USER\\Software\\Calico Pie\\Family Historian\\2.0\\Preferences\\PDX Font")
if v == nil then
-- Registry read returned nil (some emulated environments): leave the system default font.
else
local t = { stringx.splitv(v, ",") } -- 1st field is the size (twips), 14th is the font name
iup.SetGlobal("DEFAULTFONT", t[14] .. " " .. t[1] / 20)
end
iup.SetGlobal("UTF8MODE", "YES")
iup.SetGlobal("UTF8MODE_FILE", "YES") -- use UTF8 file names (matches fhUtils.setIupDefaults)
fhSetStringEncoding("UTF-8")
end
-- Use the global help instance resolved by the main plugin (referenced as `help`)
-- Resolve the dark/light theme once for every helper below. Theme is set by
-- Theme.lua; fall back to a light palette if a plugin hasn't loaded it, so
-- this dialog helper still works standalone.
local th = (Theme and Theme.iupColours and Theme.iupColours()) or { bg = "255 255 255", fg = "0 0 0" }
-- Plain dark mode (Windows dark, but NOT High Contrast): native Win32
-- controls (push-buttons, the menu) keep a LIGHT face that IUP can't repaint
-- from a plugin, so their text must stay dark to be readable. High Contrast
-- is themed by the OS, so it uses the normal th colours.
local plainDark = Theme ~= nil and Theme.isDarkMode and Theme.isDarkMode()
and not (Theme.isHighContrast and Theme.isHighContrast())
do --colours, layout, theming
-- Create normalizers for consistent sizing
btnnorm = iup.normalizer({}) --buttons
btnshortnorm = iup.normalizer({}) --short buttons
textnorm = iup.normalizer({}) --text fields and lists
dlgnorm = iup.normalizer({}) --dialogs
donotnorm = iup.normalizer({}) --items that should not be normalized
-- Normalize all GUI components to have a consistent layout
function DoNormalize()
btnnorm.normalize = "HORIZONTAL"
btnshortnorm.normalize = "HORIZONTAL"
textnorm.normalize = "HORIZONTAL"
dlgnorm.normalize = "HORIZONTAL"
end
-- Size a dialog's controls with FRESH per-dialog normalisers, so each dialog normalises
-- INDEPENDENTLY of every other. The shared global normalisers (btnnorm/textnorm) entangle
-- dialogs: re-normalising them when one dialog opens resizes the controls of every other dialog
-- already built (e.g. opening Options reshaped the reused Fact Types picker). This walks the
-- dialog tree, moving its buttons/toggles into a local button normaliser and its text/list
-- controls into a local text normaliser, then sizes them - touching nothing outside this dialog.
-- Labels keep their own handling (makeLabel's normaliser / makeLongLabel's opt-out / Config's
-- labelNorm). Returns a function that re-applies the sizing, for a REUSED dialog whose content
-- changed (call it after repopulating, before showing).
function normalizeIndependently(dlg)
local btnN = iup.normalizer({})
local txtN = iup.normalizer({})
local function walk(el)
local ok, cls = pcall(iup.GetClassName, el)
if ok then
if cls == "button" or cls == "toggle" then
el.normalizergroup = btnN
elseif cls == "text" or cls == "list" then
el.normalizergroup = txtN
end
end
local child = iup.GetNextChild(el)
while child do
walk(child)
child = iup.GetNextChild(el, child)
end
end
walk(dlg)
local function apply()
btnN.normalize = "HORIZONTAL"
txtN.normalize = "HORIZONTAL"
end
apply()
return apply
end
-- makeDialog stores each dialog's re-apply hook in dialogRenorm (declared in the outer scope so
-- the separate Dialog-handling do-block can see it). A REUSED dialog whose content changed calls
-- renormalizeDialog(dlg) after repopulating, before showing.
function renormalizeDialog(dlg)
local fn = dialogRenorm[dlg]
if fn then fn() end
end
--- Re-floor a dialog after its content changed (Stage 4, 5 Jul 2026 - centralised from the
--- consumer plugins, which each carried a copy of this logic): re-measure the natural size,
--- floor future resizes there (minsize), GROW the window when its current size now clips
--- content (minsize alone never grows a shown window - rastersize must be set explicitly),
--- and cap at the screen height so growth never pushes controls off the bottom. Call after
--- rebuilding or appending content in a shown dialog (after renormalizeDialog + iup.Refresh).
function refloorDialog(dlg)
if not dlg then
return
end
-- Read the window's REAL size before measuring (V1.6), and measure the natural
-- size with shrink OFF (V1.7): with shrink=YES - this theme's default - a shown
-- dialog reports NATURALSIZE equal to its CURRENT size, which turned every
-- grow-to-natural below into a no-op tautology (Spike S7, 6 Jul 2026).
local cw, ch = tostring(dlg.rastersize or ""):match("^(%d+)x(%d+)$")
local oldShrink = dlg.shrink
dlg.shrink = "NO"
dlg.minsize = iup.NULL
-- Also clear the SIZE user size (V1.10): it is an initial-open hint, not a floor, and
-- left set it would inflate this re-measure to the SIZE value on a large display (the
-- same family of bug as Config.lua's showTrackedDialog). No restore - once shown, the
-- user size has done its job.
dlg.size = iup.NULL
iup.Refresh(dlg)
local nw, nh = tostring(dlg.naturalsize or ""):match("^(%d+)x(%d+)$")
dlg.shrink = oldShrink
if not nw then
iup.Refresh(dlg)
return
end
nw, nh = tonumber(nw), tonumber(nh)
local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
-- SCREENSIZE is the full monitor; leave an allowance for the taskbar and title bar.
local fitW = scrW and math.min(nw, tonumber(scrW)) or nw
local fitH = scrH and math.min(nh, tonumber(scrH) - 80) or nh
dlg.minsize = fitW .. "x" .. fitH
if cw and (tonumber(cw) < fitW or tonumber(ch) < fitH) then
dlg.rastersize = math.max(tonumber(cw), fitW) .. "x" .. math.max(tonumber(ch), fitH)
end
iup.Refresh(dlg) -- re-lay out with shrink restored (and apply any grow above)
end
-- Set global colours for the dialog theme
iup.SetGlobal("DLGBGCOLOR", th.bg) -- dialog background
iup.SetGlobal("TXTBGCOLOR", th.bg) -- text-field background
iup.SetGlobal("TXTFGCOLOR", th.fg) -- text foreground
-- Create theme objects for all IUP elements with minimal styling
local myTheme = iup.user({
IUPDIALOG = iup.user({
expand = "YES",
resize = "YES",
shrink = "YES",
size = iup.NULL,
menubox = "YES",
}),
IUPBUTTON = iup.user({
alignment = "ACENTER:ACENTER",
padding = "DEFAULTBUTTONPADDING",
normalizergroup = btnnorm,
expand = "NO",
-- Native button faces stay light even in dark mode, so in plain
-- dark mode keep a light face with dark text (readable); light and
-- High Contrast use the theme colours.
bgcolor = plainDark and "240 240 240" or th.bg,
fgcolor = plainDark and "32 32 32" or th.fg,
}),
IUPLIST = iup.user({
normalizergroup = textnorm,
editbox = "NO",
sort = "YES",
dropdown = "YES",
multiple = "NO",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPTEXT = iup.user({
alignment = "ALEFT:ACENTER",
normalizergroup = textnorm,
wordwrap = "YES",
append = "YES",
scrollbar = "NO",
multiline = "NO",
visiblelines = "1",
readonly = "NO",
padding = "2x",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPLABEL = iup.user({
wordwrap = "NO",
alignment = "ALEFT:ACENTER",
expand = "NO",
padding = "20x10",
fgcolor = th.fg, -- readable on the dialog background in every theme
}),
IUPGRIDBOX = iup.user({
gaplin = "10",
gapcol = "10",
alignmentlin = "ACENTER",
alignmentcol = "LEFT",
normalizesize = "YES",
expand = "YES",
expandchildren = "HORIZONTAL",
orientation = "HORIZONTAL",
numdiv = "2",
}),
IUPFRAME = iup.user({
expand = "YES",
expandchildren = "YES",
}),
IUPTABS = iup.user({
margin = "5x5",
gap = "5",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPTOGGLE = iup.user({
alignment = "ALEFT:ACENTER",
normalizergroup = btnnorm,
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPEXPANDER = iup.user({
visible = "YES",
}),
IUPMATRIX = iup.user({
markmode = "CELL",
resizematrix = "YES",
scrollbar = "YES",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
IUPTREE = iup.user({
expand = "YES",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
selection = "SINGLE",
showrename = "NO",
showdragdrop = "NO",
showtoggle = "NO",
addexpanded = "NO",
}),
IUPVBOX = iup.user({
gap = "5",
margin = "5x5",
expandchildren = "NO",
shrink = "YES",
}),
IUPHBOX = iup.user({
gap = "5",
margin = "5x5",
expandchildren = "NO",
shrink = "YES",
}),
IUPZBOX = iup.user({
expand = "YES",
}),
IUPSCROLLBOX = iup.user({
expand = "YES",
}),
IUPBACKGROUNDBOX = iup.user({
expand = "YES",
}),
IUPRADIO = iup.user({
expand = "NO",
}),
IUPPROGRESSBAR = iup.user({
expand = "HORIZONTAL",
bgcolor = th.bg, -- themed background
fgcolor = th.fg, -- themed text
}),
})
iup.SetHandle("myTheme", myTheme)
iup.SetGlobal("DEFAULTTHEME", "myTheme")
-- Tooltip theming for consistency
iup.SetGlobal("TIPBGCOLOR", "255 255 225") -- Light yellow
iup.SetGlobal("TIPFGCOLOR", th.fg) -- tooltip text
iup.SetGlobal("TIPFONT", "Segoe UI, 10")
-- Error styling helpers
function markError(control)
control.bgcolor = "255 0 0" -- Red background for errors
end
function clearError(control)
control.bgcolor = th.bg -- Reset to the themed background
end
-- Tip creation
--- Word-wrap tip text to a sensible width so long descriptions don't run off the screen as one
--- line (IUP tooltips honour \n but never wrap on their own). Existing line breaks are kept, and
--- each paragraph is wrapped on word boundaries; an over-long single word is left intact.
local function wrapTip(text, width)
width = width or 72
local lines = {}
for paragraph in ((text or "") .. "\n"):gmatch("(.-)\n") do
if paragraph == "" then
lines[#lines + 1] = ""
else
local line = ""
for word in paragraph:gmatch("%S+") do
if line == "" then
line = word
elseif #line + 1 + #word <= width then
line = line .. " " .. word
else
lines[#lines + 1] = line
line = word
end
end
lines[#lines + 1] = line
end
end
return table.concat(lines, "\n")
end
--- Sets a tooltip (tipballoon) with a title and appends help info if a help topic is specified.
--- @param control iup.element The control to set the tip for
--- @param tip string The tip text
--- @param tiptitle string|nil The tip title (optional)
--- @param help_topic string|nil The help topic (optional)
function setTipWithHelp(control, tip, tiptitle, help_topic)
local full_tip = wrapTip(tip or "")
if help_topic then
full_tip = full_tip .. "\n\nPress F1 for help."
end
control.tip = full_tip
-- Enable balloon style per-control (Windows only attribute)
control.tipballoon = "YES"
if tiptitle then
-- Set both, to support normal and balloon title variants
control.tiptitle = tiptitle
control.tipballoontitle = tiptitle
end
end
end
--- Register a handle name, surviving the case where the name was last used by a control
--- that has since been DESTROYED: iuplua's SetHandle errors while unregistering the dead
--- handle (live 6 Jul 2026 - a destroy-and-recreate dialog whose buttons register by
--- TITLE failed on its second open). Falls back to a fresh random name.
--- @param name string The preferred handle name
--- @param ih userdata The IUP element to register
function safeSetHandle(name, ih)
if not pcall(iup.SetHandle, name, ih) then
pcall(iup.SetHandle, generateRandomDigitString(), ih)
end
end
--- Generates a random string of digits
--- @return string 10 random digits
function generateRandomDigitString()
--TESTED
-- Seed the random number generator
math.randomseed(os.time())
local digits = ""
for i = 1, 10 do
-- Generate a random digit (0-9) and concatenate to the string
digits = digits .. math.random(0, 9)
end
return digits
end
local specialOptions = tablex.makeset({
"callback",
"name",
"values",
"killfocus",
"close",
"default",
"cancel",
"on_escape",
"accelerators",
}) ---options that aren't handled by mergeOptions
--- Merge user provided options with default settings.
-- This function creates a new configuration table based on default values,
-- where any provided user option overrides the corresponding default.
--- @param defaults table -- A table containing the default configuration options.
--- @param options table -- A table provided by the user that may override the default options.
--- @return table -- A new table with merged values from both input tables.
function mergeOptions(defaults, options)
--TESTED: mergeOptions
local config = {} -- Initialize a new table to avoid modifying the original 'options' table.
-- Iterate over the default options
for k, v in pairs(defaults) do
-- If an option is provided by the user, use it, otherwise use the default value.
config[k] = options[k] or v
end
-- Iterate over the user-provided options
for k, v in pairs(options) do
-- Add the option if it's not already in config and is not disallowed
if config[k] == nil and not specialOptions[k] then
config[k] = v
end
end
-- Return the newly created configuration table.
return config
end
--- Set options for an iup element
---@param element iup.element
---@param options table
function setOptions(element, options)
---TESTED: setOptions
for k, v in pairs(options) do
element[k] = v
end
end
do --Dialog handling
--- Returns a table with either {PARENTDIALOG = name} or {NATIVEPARENT = hwnd}
local function getParentDialogInfo()
local focus = iup.GetFocus()
if focus then
local parentDialogHandle = iup.GetDialog(focus)
if parentDialogHandle then
local parentDialogName = iup.GetName(parentDialogHandle)
if parentDialogName then
return { PARENTDIALOG = parentDialogName }
end
end
end
-- Fallback: use FH's main window handle
return { NATIVEPARENT = fhGetContextInfo("CI_PARENT_HWND") }
end
--- Get parent window handle for FH API calls that require a parent window
--- @param parentWindow? any Optional parent window handle
--- @return any Parent window handle suitable for FH API calls
function getParentWindowHandle(parentWindow)
local hParentWnd = parentWindow
if not hParentWnd then
-- Try to get the active dialog
local activeDialog = identifyActiveWindow()
if activeDialog then
hParentWnd = activeDialog.NATIVEPARENT
else
-- Fallback to FH's main window
hParentWnd = fhGetContextInfo("CI_PARENT_HWND")
end
end
return hParentWnd
end
--- @class DialogOptions
--- @field title string|nil Dialog window title
--- @field size string|nil Initial size (e.g., "HALFxHALF")
--- @field expand string|nil Expansion policy
--- @field resize string|nil Whether dialog is resizable ("YES"|"NO")
--- @field menubox string|nil Whether to show native menu box ("YES"|"NO")
--- @field menu iup.menu|nil Menu bar to attach
--- @field name string|nil Optional handle name to register
--- @field show fun(state:string)|nil Optional callback invoked on show
--- @field close fun(self:iup.dialog)|nil Optional callback invoked on close
--- @field help_topic string|nil Optional help topic for F1 help
--- @field default iup.button|nil Primary button; Enter activates it (native DEFAULTENTER, focus-aware)
--- @field cancel iup.button|nil Cancel button; Esc activates it (native DEFAULTESC)
--- @field on_escape fun(self:iup.dialog):any|nil Esc handler for menu-driven dialogs that have no cancel button; return iup.CLOSE (the default when it returns nil) to close
--- @field accelerators {key:integer, action:function, item:any}[]|nil Window-global key shortcuts (typically menuBarData.accelerators); each fires from anywhere in the dialog via k_any
--- Creates and configures a dialog with customization options.
--- @param content iup.element The content to be included in the dialog.
--- @param options? DialogOptions The options to configure the dialog.
--- @return iup.dialog
function makeDialog(content, options)
--TESTED: makeDialog
-- Default values for options (if not provided)
local defaults = {}
options = options or {}
-- Esc for dialogs with no cancel button (options.on_escape): under the FH host the
-- dialog's k_any never sees Esc (live 6 Jul 2026 - the k_any dispatch below stays for
-- non-host use), but IUP's native DEFAULTESC does. So on_escape gets a stand-in
-- button whose action fires it, and DEFAULTESC points at that button (set further
-- down, same registered-name idiom as options.cancel). The button must be MAPPED
-- AND VISIBLE: on Windows IUP activates the DEFAULTESC button with a real BM_CLICK,
-- which a hidden window discards (live 6 Jul 2026 - visible=NO silently broke it).
-- floating=YES keeps it out of the layout; 1x1 and canfocus=NO make it
-- imperceptible and untabbable.
local d -- forward-declared: the stand-in Esc button's action closes over the dialog
local hiddenEsc
if options.on_escape and not options.cancel then
hiddenEsc = iup.button({
title = "",
floating = "YES",
canfocus = "NO",
rastersize = "1x1",
action = function()
local r = options.on_escape(d)
return r == nil and iup.CLOSE or r
end,
})
content = iup.vbox({ content, hiddenEsc })
end
d = iup.dialog({ content })
setOptions(d, mergeOptions(defaults, options))
-- Wire dialog-level help if a help topic is provided
if options.help_topic and help then
d.help_cb = function()
help:show(options.help_topic)
return iup.IGNORE
end
end
-- Always identify and set the parent dialog
local parentInfo = getParentDialogInfo()
for k, v in pairs(parentInfo) do
iup.SetAttribute(d, k, v)
end
-- Register dialog with a unique handle for later reference. Re-registering a name
-- whose previous dialog was DESTROYED makes iuplua's SetHandle error while it
-- unregisters the dead handle (live 6 Jul 2026: a destroy-and-recreate chooser
-- failed on its second open) - fall back to a fresh random name.
local okHandle = pcall(iup.SetHandle, options.name or options.title or generateRandomDigitString(), d)
if not okHandle then
pcall(iup.SetHandle, generateRandomDigitString(), d)
end
-- Ensure layouts refresh properly on resize across monitors/resolutions
local originalResizeCb = d.resize_cb
d.resize_cb = function(self, width, height)
if originalResizeCb and originalResizeCb(self, width, height) == iup.CLOSE then
return iup.CLOSE
end
iup.Refresh(self)
return iup.DEFAULT
end
-- Default F1 behavior: let focused control handle help if it can; otherwise fall back to dialog help.
-- Also handle Esc for menu-driven dialogs that have no cancel button (see options.on_escape below).
local originalKAny = d.k_any
d.k_any = function(self, c)
if originalKAny then
local r = originalKAny(self, c)
if r == iup.CLOSE or r == iup.IGNORE then
return r
end
end
if c == iup.K_ESC and options.on_escape then
local r = options.on_escape(self)
return r == nil and iup.CLOSE or r
end
-- Window-global menu accelerators: fire the matching item's action from any focus. Return
-- iup.CLOSE if the action asked to close; otherwise consume the key (iup.IGNORE).
if options.accelerators then
for _, a in ipairs(options.accelerators) do
if c == a.key then
if a.action(a.item) == iup.CLOSE then
return iup.CLOSE
end
return iup.IGNORE
end
end
end
if c == iup.K_F1 then
local focused = iup.GetFocus()
if focused and focused.help_cb then
return iup.CONTINUE
end
if self.help_cb then
return self:help_cb()
end
return iup.IGNORE
end
return iup.CONTINUE
end
-- Opt-in keyboard defaults (accessibility). Enter activates the primary button, Esc the cancel
-- button, via IUP's native DEFAULTENTER/DEFAULTESC - which are focus-aware, so Enter is NOT
-- hijacked while the focus is in a multiline text or another button. Menu-driven dialogs that have
-- no cancel button pass options.on_escape instead (handled in k_any above) to get Esc-to-close.
-- DEFAULTENTER/DEFAULTESC are set here via a registered handle NAME. (Assigning the handle
-- directly - d.DEFAULTENTER = btn - also works: iuplua implements IupSetAttributeHandle
-- through attribute assignment, spike S6; there is deliberately no iup.SetAttributeHandle
-- function in the binding.) We register a fresh unique name so title-collisions between
-- buttons (makeButton registers by title, so two "OK"s would clash) can't point Enter at
-- the wrong one.
if options.default then
local defName = "defenter_" .. generateRandomDigitString()
iup.SetHandle(defName, options.default)
d.DEFAULTENTER = defName
end
if options.cancel then
local escName = "defesc_" .. generateRandomDigitString()
iup.SetHandle(escName, options.cancel)
d.DEFAULTESC = escName
elseif hiddenEsc then
-- on_escape without a cancel button: Esc activates the hidden button (see the
-- construction above) via the same native, focus-aware DEFAULTESC mechanism.
local escName = "defesc_" .. generateRandomDigitString()
iup.SetHandle(escName, hiddenEsc)
d.DEFAULTESC = escName
end
-- Normalise EVERY dialog with its own normalisers, so dialogs never share sizing (the global
-- btnnorm/textnorm entanglement that made one dialog's layout shift when another opened). This
-- makes independence the default - no plugin or dialog has to remember to ask for it.
dialogRenorm[d] = normalizeIndependently(d)
return d
end
--- Identifies the currently active window among the open IUP dialogs.
--- @return iup.dialoghandle|nil
function identifyActiveWindow()
--TESTED: IdentifyActiveWindow.
-- Retrieve all dialog names, noting that these names are distinct from their handles.
local tblDialogNames = iup.GetAllDialogs()
-- Loop through all dialog names to find the active window.
for _, dialogName in ipairs(tblDialogNames) do
local dialogHandle = iup.GetHandle(dialogName) -- Convert name to handle.
if dialogHandle.ACTIVEWINDOW == "YES" then
return dialogHandle -- Return the active dialog handle.
end
end
-- If no active dialog is found, return nil.
return nil
end
--- Destroys all currently open IUP dialogs to free up resources.
function destroyAllDialogs()
--TESTED: destroyAllDialogs
-- Retrieve all dialog names as in the identification function.
local tblDialogNames = iup.GetAllDialogs()
-- Loop through all dialog names to destroy each dialog.
for _, dialogName in ipairs(tblDialogNames) do
local dialogHandle = iup.GetHandle(dialogName) -- Convert name to handle.
if dialogHandle then -- Ensure the handle is valid before attempting to destroy.
dialogHandle:destroy() -- Destroy the dialog.
end
end
end
--- Updates a dialog's title and refreshes the display
--- @param dialog iup.dialog The dialog to update
--- @param newTitle string The new title for the dialog
function updateDialogTitle(dialog, newTitle)
if dialog and dialog.title then
dialog.title = newTitle
-- Ensure IUP updates the native window text
iup.Refresh(dialog)
end
end
-- Message boxes and Text prompts
local buttonOrder = {
OK = { "OK" },
OKCANCEL = { "OK", "Cancel" },
RETRYCANCEL = { "Retry", "Cancel" },
YESNO = { "Yes", "No" },
YESNOCANCEL = { "Yes", "No", "Cancel" },
}
local buttonSetMap = {
OK = "OK",
OKCANCEL = "OKCANCEL",
RETRYCANCEL = "RETRYCANCEL",
YESNO = "YESNO",
YESNOCANCEL = "YESNOCANCEL",
}
---Create a customizable pop-up message dialog
---@param messageType "error"|"warning"|"question"|"info"|"message" The type of message
---@param messageText string The text content of the message
---@param buttonSet "OK"|"OKCANCEL"|"RETRYCANCEL"|"YESNO"|"YESNOCANCEL" A set of buttons to include
---@return "OK"|"Cancel"|"Retry"|"Yes"|"No" clickedButton The label of the button that was pressed
function MessageBox(messageType, messageText, buttonSet)
-- Map messageType to IUP dialogtype
local dialogTypeMap = {
error = "ERROR",
warning = "WARNING",
question = "QUESTION",
info = "INFORMATION",
message = "INFORMATION",
}
local dialogtype = dialogTypeMap[messageType] or "INFORMATION"
-- Map buttonSet to IUP buttons
local buttons = buttonSetMap[buttonSet] or "OK"
local dlg = iup.messagedlg({
title = "Message",
value = messageText,
dialogtype = dialogtype,
buttons = buttons,
})
-- Set parent dialog attributes
local parentInfo = getParentDialogInfo()
for k, v in pairs(parentInfo) do
dlg[k] = v
end
-- A topmost progress dialog (Progress.lua) would otherwise sit in front of this native
-- message dialog, hiding it; drop its topmost while we show, then restore.
local restoreTopmost = (Progress and Progress.suspendTopmost) and Progress.suspendTopmost() or nil
dlg:popup(iup.CENTER, iup.CENTER)
local result = dlg.buttonresponse
dlg:destroy()
if restoreTopmost then restoreTopmost() end
local order = buttonOrder[buttonSet] or { "OK" }
local idx = tonumber(result)
if idx and order[idx] then
return order[idx]
end
return "OK"
end
--- @class GetTextParams
--- Parameters for the GetText function.
--- @field strPrompt string The prompt text to display.
--- @field strDefault string The default text to display in the input field.
--- @field strMask? string|nil Optional mask to use for the text input.
--- @field bMultiLine? boolean Optional flag to enable multiline input.
--- @field strTickPrompt? string|nil Optional prompt for the tick box.
--- Retrieves text input from the user.
--- @param params GetTextParams A table containing the parameters for the function.
--- @return boolean, string, boolean The OK status, the input text, and the tick status.
function GetText(params)
--TESTED: GetText
-- Extract parameters from the table
local strPrompt = params.strPrompt
local strDefault = params.strDefault or ""
local strMask = params.strMask or ""
local bMultiLine = params.bMultiLine or false
local strTickPrompt = params.strTickPrompt or ""
-- Initialize variables for text input and tick status
local textInput = strDefault
local tickStatus = false
local isOK = false
-- Create the text element
local textOptions = {
value = strDefault,
multiline = bMultiLine and "YES" or "NO",
mask = strMask ~= "" and strMask or nil,
visiblelines = bMultiLine and 8 or 1,
}
local textElement = makeText(textOptions)
-- Create the toggle element if strTickPrompt is provided
local toggleElement
if strTickPrompt ~= "" then
toggleElement = makeToggle({
title = strTickPrompt,
value = "OFF",
})
end
--Create the buttons
local btnOK = makeButton({
title = "OK",
close = true,
size = "64x",
callback = function()
textInput = textElement.value
tickStatus = toggleElement and toggleElement.value == "ON" or false
isOK = true
return true
end,
})
local btnCancel = makeButton({
title = "Cancel",
close = true,
size = "64x",
})
-- Create the dialog content
local dialogContent = iup.vbox({
textElement,
strTickPrompt ~= "" and toggleElement or nil,
iup.hbox({ iup.fill({}), btnOK, btnCancel }),
})
-- Create the dialog. Enter commits (OK), Esc cancels - both focus-aware via makeDialog's
-- native DEFAULTENTER/DEFAULTESC (Enter still inserts a newline inside a multiline text field).
local dialogOptions = {
title = strPrompt,
size = "QUARTERx",
default = btnOK,
cancel = btnCancel,
}
local dialog = makeDialog(dialogContent, dialogOptions)
-- Avoid global normalization to prevent modality/focus issues in nested dialogs
--TODO: fix layout without normalization
-- Show the dialog
dialog:popup(iup.CENTERPARENT, iup.CENTERPARENT)
dialog:destroy()
-- Return the OK status, the input text, and the tick status
return isOK, isOK and textInput or "", isOK and tickStatus or false
end
end
do --Buttons
--- Enables or disables a list of buttons
--- @param tblButtons table List of button objects to be enabled/disabled
--- @param strSetting string "YES" to enable, "NO" to disable
function enableButtons(tblButtons, strSetting)
--TESTED: EnableButtons
for _, v in ipairs(tblButtons) do
v.ACTIVE = strSetting
end
end
--- @class ButtonOptions
--- @field action? function|nil A function to be called when the button is pressed. If nil, this is a cancel button
--- @field close? boolean|nil Whether the button should close the dialog when pressed
--- @field name? string|nil The name to set for the button handle
--- Creates a button with specified options
--- @param options ButtonOptions Configuration options for the button. Optionally include help_topic for F1 help.
--- @return iup.button The created button element
function makeButton(options)
--TESTED: makeButton
--create button action function
local callback = options.callback
local action = function(self)
if callback then
if options.close then
if callback(self) == true then -- this is a close button
return iup.CLOSE
end
else
callback(self)
end
else --this is a cancel button
return iup.CLOSE
end
end
local defaults = {
action = action,
}
local b = iup.button({})
setOptions(b, mergeOptions(defaults, options))
-- safeSetHandle: buttons register by TITLE, so a destroyed-and-recreated dialog
-- re-registers the same names - plain SetHandle errors on the dead handles.
safeSetHandle(options.name or options.title or generateRandomDigitString(), b)
if options.tip then
setTipWithHelp(b, options.tip, options.tiptitle, options.help_topic)
end
if options.help_topic and help then
help:attach_help_cb(b, options.help_topic)
end
return b
end
--- @class AssistantButtonOptions : ButtonOptions
--- @field help_topic? string Optional help topic for F1 help
--- @field close? boolean Whether button closes dialog (defaults to false)
--- @field canFocus? IupVisibility Whether button can receive focus (defaults to "NO")
--- @param options AssistantButtonOptions Optionally include help_topic for F1 help.
--- Creates an assistant button with specified options
--- @return iup.button The created assistant button element
function makeAssistantButton(options)
--TEST: makeButton
options = options or {}
--create button action function
local defaults = {
normalizergroup = btnshortnorm,
close = false,
title = "...",
canFocus = "NO",
size = "20x", -- comfortable click target; "..." alone is tiny
}
local merged = mergeOptions(defaults, options)
-- mergeOptions strips "special" keys such as callback, but makeButton
-- reads callback from the options it receives; without this the button
-- has no action and behaves as a Cancel button (returns iup.CLOSE),
-- closing whatever dialog contains it.
merged.callback = options.callback
local btn = makeButton(merged)
if options.help_topic and help then
help:attach_help_cb(btn, options.help_topic)
end
if options.tip then
setTipWithHelp(btn, options.tip, options.tiptitle, options.help_topic)
end
return btn
end
end
do --Lists
--- @class ListOptions
--- @field killfocus? function Kill focus callback
--- Create and configure a list
--- @param options ListOptions Configuration options for the list. Optionally include help_topic for F1 help.
--- @return iup.list The created list element
function makeList(options)
--TESTED: makeList
local dropdown_option = options.dropdown or "YES"
local defaults = {
visibleitems = dropdown_option == "YES" and 5 or nil,
visiblecolumns = dropdown_option ~= "YES" and 1 or nil,
visiblelines = dropdown_option ~= "YES" and 1 or nil,
expand = dropdown_option ~= "YES" and "YES" or "HORIZONTAL",
killfocus_cb = options.killfocus or nil,
}
local list = iup.list({})
setOptions(list, mergeOptions(defaults, options))
if type(options.values) == "table" then
populateList(list, options.values)
end
safeSetHandle(options.name or generateRandomDigitString(), list)
if options.tip then
setTipWithHelp(list, options.tip, options.tiptitle, options.help_topic)
end
if options.help_topic and help then
help:attach_help_cb(list, options.help_topic)
end
return list
end
--- Populate a list with values from a table
---@param l table The list to populate
---@param tblVals table The table containing values to populate the list with
function populateList(l, tblVals)
--TESTED: PopulateList
local is_indexed = (rawget(tblVals, 1) ~= nil)
l.REMOVEITEM = "ALL"
if not is_indexed then
local i = 1
for k, _ in pairs(tblVals) do
l[tostring(i)] = k
i = i + 1
end
else
for i, v in ipairs(tblVals) do
l[tostring(i)] = v
end
end
end
--- Check if a multi-selection list has multiple items selected
---@param l table The list to check
---@return boolean True if multiple items are selected, otherwise false
function multiListSelectionTrue(l)
--TESTED: MultiListSelectionTrue
return l.value:match("%+") ~= nil
end
--- Clear the selection in a multi-selection list
---@param l table The list to clear
function multiListSelectionClear(l)
--TESTED: MultiListSelectionClear
l.value = string.rep("%-", l.count)
end
--- Searches for a value in a list and returns its position.
--- @param strValue string The value to search for in the list
--- @param list iup.list The list to search within
--- @return integer position The position of the value in the list (0 if not found)
function goToInList(strValue, list)
--TESTED: GoToInList
-- Ensure the list has a COUNT property
local count = tonumber(list.COUNT)
if not count or count <= 0 then
return 0 -- List is empty or count is invalid
end
-- Iterate through the list
for position = 1, count do
if list[tostring(position)] == strValue then
list.value = position -- Set the found position in the list
return position
end
end
return 0 -- Value not found in the list
end
--- Get a selected value from a list if one exists
--- @param list iup.list The list to get the selection from
--- @param returnNumeric boolean If true, return the numeric value; otherwise, return the corresponding string
--- @return string|number|"" The selected value from the list or an empty string if no selection
function getSingleValue(list, returnNumeric)
--TESTED: GetSingleValue
-- Check if the list value is non-zero and return the appropriate value based on returnNumeric
-- Otherwise, return an empty string
if list.value ~= 0 then
if returnNumeric then
return list.value
else
return list[tostring(list.value)]
end
else
return ""
end
end
--- GetSelectedValues retrieves selected items from a list.
--- @param list iup.list The list containing items and their selection states
--- @param returnPositions boolean If true, return positions instead of item texts
--- @return string[] Selected items or positions
function getSelectedValues(list, returnPositions)
--TESTED: GetSelectedValues
local selectedValues = {} -- Table to hold selected values
local itemCount = tonumber(list.COUNT) -- Total number of items in the list
if itemCount and itemCount > 0 then
-- list.value is nil until the dialog is mapped, so a caller that runs during construction
-- would otherwise index nil here; treat "not yet mapped" as "nothing selected".
local selectionState = list.value or "" -- String indicating selection states with + and -
for i = 1, itemCount do
if selectionState:sub(i, i) == "+" then -- Check if item is selected
if returnPositions then
table.insert(selectedValues, tostring(i)) -- Add position as string to the table
else
table.insert(selectedValues, list[tostring(i)]) -- Add selected item text to the table
end
end
end
end
return selectedValues -- Return the table of selected items or positions
end
--- Set the selected values in a multi-selection list
--- @param list iup.list The list to set the selected values in
--- @param tblselected string[] An indexed list of strings to select
--- @return nil
function setSelectedValues(list, tblselected)
--TESTED: SetSelectedValues
local tbl = tablex.index_map(tblselected)
local strselection = ""
for i = 1, tonumber(list.COUNT) do
strselection = strselection .. (tbl[list[tostring(i)]] and "+" or "-")
end
list.value = strselection
end
--- Remove selected items from a list and corresponding collection
--- @param list iup.list The UI list control
--- @param collection table[] The collection to remove items from
--- @param updateDisplay function Function to call to update the display
function removeSelectedItems(list, collection, updateDisplay)
if not list then
return
end
local selectedPositions = getSelectedValues(list, true)
if #selectedPositions > 0 then
-- Sort positions in descending order to avoid index shifting
table.sort(selectedPositions, function(a, b)
return tonumber(a) > tonumber(b)
end)
for _, posStr in ipairs(selectedPositions) do
local pos = tonumber(posStr)
if pos and pos <= #collection then
table.remove(collection, pos)
end
end
-- Update the display
if updateDisplay then
updateDisplay()
end
end
end
end
do -- Text Label and Toggle
--- Creates an IUP text element with various options
--- @param options TextOptions Configuration options for the text element. Optionally include help_topic for F1 help.
--- @return iup.text The created text element
function makeText(options)
--TESTED: makeText
-- Checks if the text value is blank and sets it to a default if necessary.
-- @param self (iup.element): The text element itself (passed implicitly).
local function CheckTextNotBlank(self)
if type(self.value) ~= "string" or self.value == "" then
-- On blank, restore to the field's DEFAULT rather than its prior value, so an optional
-- field can be cleared: a default of "" means a blanked field stays blank; a real
-- default (e.g. "ind") resets to it. Falls back to options.value when no default is
-- passed, so direct callers keep today's behaviour. ("" is truthy in Lua, so an empty
-- options.default is honoured.)
self.value = options.default or options.value or ""
end
end
-- Default values for options (if not provided)
local defaults = {
expand = options.multiline == "YES" and "YES" or "HORIZONTAL",
killfocus_cb = function(self)
CheckTextNotBlank(self)
if options.killfocus then
options.killfocus(self)
end -- Only call the provided killfocus function if it exists
end,
}
local t = iup.text(mergeOptions(defaults, options)) -- Create the IUP text element with the merged options
safeSetHandle(options.name or generateRandomDigitString(), t)
if options.tip then
setTipWithHelp(t, options.tip, options.tiptitle, options.help_topic)
end
if options.help_topic and help then
help:attach_help_cb(t, options.help_topic)
end
return t
end
--- Creates an IUP label element with specified options
--- @param options LabelOptions Configuration options for the label. Optionally include help_topic for F1 help.
--- @return iup.label The created label element
function makeLabel(options)
--TESTED: makeLabel
-- Default options
local defaults = { normalizergroup = btnnorm } --done here rather than in theme to allow for long labels that should not be normalized
-- Merge: user options take precedence
local lbl = iup.label(mergeOptions(defaults, options))
if options.tip then
setTipWithHelp(lbl, options.tip, options.tiptitle, options.help_topic)
end
-- IUP label does not support HELP_CB, so F1 help is not attached to labels.
return lbl
end
--- Creates an IUP label element with no normalization
--- @param options LabelOptions Configuration options for the label. Optionally include help_topic for F1 help.
--- @return iup.label The created label element
function makeLongLabel(options)
--TESTED: makeLabel
-- Default options
-- Create label element
local lbl = iup.label(options)
lbl.normalizergroup = nil
if options.tip then
setTipWithHelp(lbl, options.tip, options.tiptitle, options.help_topic)
end
-- IUP label does not support HELP_CB, so F1 help is not attached to labels.
return lbl
end
--- Creates an IUP toggle element with specified options
--- @param options ToggleOptions Configuration options for the toggle. Optionally include help_topic for F1 help.
--- @return iup.toggle The created toggle element
function makeToggle(options)
--TESTED: makeToggle
-- Default options
local defaults = {}
local t = iup.toggle(mergeOptions(defaults, options))
safeSetHandle(options.name or generateRandomDigitString(), t)
if options.tip then
setTipWithHelp(t, options.tip, options.tiptitle, options.help_topic)
end
if options.help_topic and help then
help:attach_help_cb(t, options.help_topic)
end
return t
end
end
do --Container management
--- Constants used when dealing with containers
local dialogs = dialogs
or tablex.index_map({
"dialog",
"messagedlg",
"progesssdlg",
"fontdg",
"filedlg",
"colordlg",
})
local containers = containers
or tablex.index_map({
"frame",
"hbox",
"vbox",
"zbox",
"tabs",
"radio",
"sbox",
"cbox",
"gridbox",
"multibox",
"scrollbox",
"detachbox",
"expander",
"detachbox",
"split",
"backgroundbox",
"spinbox",
})
local datacontrols = datacontrols
or tablex.index_map({
"list",
"text",
"val",
"link",
"multiline",
"toggle",
})
local static = static
or tablex.index_map({
"fill",
"normalizer",
"button",
"label",
"menu",
"submenu",
"item",
"separator",
"imagergb",
"imagergba",
"image",
"matrix",
"cells",
"clipboard",
"timer",
"user",
"link",
}) -- These will be ignored when clearing the dialog
local toohardtohandle = toohardtohandle or tablex.index_map({ "spin", "canvas", "tree" }) -- Only g*d knows
--- Enables or disables all controls in a container except those specified in the exclude table.
--- @param ih iup.elementhandle The container handle
--- @param tblexcludeih table A table of handles to exclude
--- @param strstate string The state to set ("YES" or "NO")
function EnableContainer(ih, tblexcludeih, strstate)
--TESTED: Enable Container
-- Iterate with iup.GetNextChild, NOT integer indexing: ih[i] reflects only the
-- children supplied in the constructor (the Lua-side wrapper is frozen at
-- construction), so it silently misses anything :append()ed later. GetNextChild
-- walks the real, current child list (proven in FH: Add Facts spike S5, July 2026).
local element = iup.GetNextChild(ih)
while element ~= nil do -- Loop through the elements in the parent
if tblexcludeih[element] == nil then
if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
EnableContainer(element, tblexcludeih, strstate) -- Recursively enable/disable containers
elseif datacontrols[iup.GetClassName(element)] ~= nil or iup.GetClassName(element) == "button" then
element.active = strstate -- Enable/disable data controls and buttons
end
end
element = iup.GetNextChild(ih, element)
end
end
--- Clears the values of all data controls in a container except those specified in the exclude table.
--- @param ih iup.elementhandle The container handle to clear controls within
--- @param tblexcludeih table<iup.elementhandle, boolean> A table of handles to exclude from clearing
function ClearContainer(ih, tblexcludeih)
--TESTED: ClearContainer
--- Clears a single data control based on its class
--- @param ih iup.elementhandle The data control handle
local function ClearDataControl(ih)
local cclass = iup.GetClassName(ih)
ih.fgcolor = th.fg --reset text colour to match the theme
if cclass == "list" then
if ih.editbox == "YES" or ih.multiple == "YES" then
ih.value = ""
else
ih.value = "0"
end
elseif cclass == "text" or cclass == "multiline" then
ih.value = ""
elseif cclass == "val" then
ih.value = "0.0"
elseif cclass == "toggle" then
ih.value = "OFF"
end
end
-- Iterate with iup.GetNextChild, NOT integer indexing - see EnableContainer.
local element = iup.GetNextChild(ih)
while element ~= nil do -- Loop through the elements in the parent
if tblexcludeih[element] == nil then
if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
ClearContainer(element, tblexcludeih) -- Recursively clear containers
elseif datacontrols[iup.GetClassName(element)] ~= nil then
ClearDataControl(element) -- Clear data controls
end
end
element = iup.GetNextChild(ih, element)
end
end
local function set_visibility(element, isHidden)
if element.visible ~= nil and element.floating ~= nil then
element.visible = isHidden and "NO" or "YES"
element.floating = isHidden and "YES" or "NO"
end
end
---Shows or hides a control and refreshes the parent dialog
--- @param control iup.control The control
--- @param isHidden boolean indicating whether to hide (true) or show (false) the control
function HideControl(control, isHidden)
--TESTED: HideControl
set_visibility(control, isHidden)
local dialog = iup.GetDialog(control)
if dialog then
iup.Refresh(dialog)
end
end
--- Shows or hides all controls in a container and refreshes the container
--- @param container iup.elementhandle The container handle
--- @param isHidden boolean indicating whether to hide (true) or show (false) the container
function HideContainer(container, isHidden)
--TESTED: HideContainer
-- Loop through the elements in the parent
local e = 1
while container[e] ~= nil do
local element = container[e]
if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then
HideContainer(element, isHidden) -- Recursively hide/show containers
else
set_visibility(element, isHidden) -- Hide/show control
end
e = e + 1
end
iup.Refresh(container)
end
--- Retrieves the value from a control.
--- @param ctl iup.elementhandle The control handle
--- @return iup.element|string # The value of the control
function GetValueFromControl(ctl)
--TESTED: GetValueFromControl
if type(ctl.value) == "string" or type(ctl.value) == "number" then
return ctl.value
else
return ""
end
end
--- Retrieves data from a table of controls.
--- @param tblCtls table The table of controls
--- @return table A table of control values
function GetContainerData(tblCtls)
--TESTED GetContainerData
local is_indexed = (rawget(tblCtls, 1) ~= nil)
local tblVals = {}
if not is_indexed then
for k, v in pairs(tblCtls) do
tblVals[k] = GetValueFromControl(v)
end
else
for k, v in ipairs(tblCtls) do
tblVals[k] = GetValueFromControl(v)
end
end
return tblVals
end
--- Retrieves data elements from a container, adding them to a specified table.
--- @param ih iup.elementhandle The container handle
--- @param tblexcludeih table A table of handles to exclude
--- @param tblElements table A table to store the data elements
--- @param keyname? boolean|nil boolean indicating whether to use control names as keys
--- @return table A table of data controls
function GetDataElements(ih, tblexcludeih, tblElements, keyname)
--TESTED: GetDataElements
if not keyname then
keyname = false
end
local child = iup.GetChild(ih, 0)
while child ~= nil do -- Loop through the elements in the parent and add any data controls to the end of tblElements
if tblexcludeih[child] == nil then
if dialogs[iup.GetClassName(child)] ~= nil or containers[iup.GetClassName(child)] ~= nil then
GetDataElements(child, tblexcludeih, tblElements, keyname) -- Recursively retrieve data elements from containers
elseif datacontrols[iup.GetClassName(child)] ~= nil then
if keyname then
if iup.GetName(child) then
tblElements[iup.GetName(child)] = child
end
else
tblElements[#tblElements + 1] = child
end
end
end
child = iup.GetNextChild(ih, child)
end
return tblElements -- Return a table of data controls
end
--- Rebuilds a container by clearing and repopulating it with new child elements.
--- V1.5 (Stage 4, 5 Jul 2026): optional destroyOld (a detached-only child leaks across
--- repeated rebuilds - template switching rebuilds many times per session), and appended
--- children are now MAPPED when the container is already mapped (content appended to a
--- shown dialog occupies no space and never paints until iup.Map - proven live, Add Facts
--- 0.5.5/0.5.6, which carried its own copy of this dance until this function caught up).
--- @param container iup.elementhandle The container handle to rebuild
--- @param tblNewChildren table An indexed table of new child elements to add
--- @param preserveAttributes? boolean Whether to preserve container attributes (default: true)
--- @param destroyOld? boolean Destroy removed children instead of detaching them (default: false)
--- @param skipRefresh? boolean Omit the trailing full-dialog Refresh; pass true when
--- batching several rebuilds and refreshing once yourself (default: false)
function RebuildContainer(container, tblNewChildren, preserveAttributes, destroyOld, skipRefresh)
if preserveAttributes == nil then
preserveAttributes = true
end
-- Store container attributes if needed
local savedAttrs = {}
if preserveAttributes then
savedAttrs.expand = container.expand
savedAttrs.alignment = container.alignment
savedAttrs.gap = container.gap
savedAttrs.margin = container.margin
savedAttrs.normalizesize = container.normalizesize
end
-- Remove existing children (GetNextChild enumeration - integer indexing only sees
-- constructor-time children, Dialog.lua V1.3 note)
local child = iup.GetChild(container, 0)
while child ~= nil do
local nextChild = iup.GetNextChild(container, child)
iup.Detach(child)
if destroyOld then
pcall(iup.Destroy, child)
end
child = nextChild
end
-- Add new children, mapping them when the container is already mapped (wid set)
local mapped = container.wid ~= nil
for i, newChild in ipairs(tblNewChildren) do
iup.Append(container, newChild)
if mapped then
pcall(iup.Map, newChild)
end
end
-- Restore attributes if preserved
if preserveAttributes then
for attr, val in pairs(savedAttrs) do
if val ~= nil then
container[attr] = val
end
end
end
-- Refresh the parent dialog, unless the caller is batching several rebuilds
if not skipRefresh then
local dialog = iup.GetDialog(container)
if dialog then
iup.Refresh(dialog)
end
end
end
--- Replaces a container with a new one, transferring it into the parent's position.
--- @param oldContainer iup.elementhandle The container handle to replace
--- @param newContainer iup.elementhandle The new container to insert
--- @param destroyOld? boolean Whether to destroy the old container (default: false)
--- @return iup.elementhandle|nil The new container if successful, nil otherwise
function ReplaceContainer(oldContainer, newContainer, destroyOld)
if destroyOld == nil then
destroyOld = false
end
local parent = iup.GetParent(oldContainer)
if parent == nil then
return nil -- Cannot replace a container without a parent
end
-- Find the position of the old container in its parent
local position = nil
local e = 1
while parent[e] ~= nil do
if parent[e] == oldContainer then
position = e
break
end
e = e + 1
end
if position == nil then
return nil -- Could not find old container in parent
end
-- Transfer the name if the old container has one
local oldName = iup.GetName(oldContainer)
-- Detach old container
iup.Detach(oldContainer)
-- Insert new container at the same position
if position == 1 then
iup.Insert(parent, nil, newContainer) -- Insert at beginning
else
local refChild = parent[position - 1]
iup.Insert(parent, refChild, newContainer) -- Insert after reference child
end
-- Transfer name to new container
if oldName then
iup.SetHandle(oldName, newContainer)
end
-- Destroy old container if requested
if destroyOld then
iup.Destroy(oldContainer)
end
-- Refresh the parent dialog
local dialog = iup.GetDialog(parent)
if dialog then
iup.Refresh(dialog)
end
return newContainer
end
end
do -- Additional UI components
--- Creates an expander control
--- @param content iup.element The content to be expanded/collapsed
--- @param title string The title for the expander
--- @param state string The initial state ("OPEN" or "CLOSED")
--- @return iup.expander The created expander element
function makeExpander(content, title, state)
-- The content must be the constructor child (IupExpander has no
-- CONTENT attribute).
local expander = iup.expander({
content,
title = title,
state = state or "CLOSED",
})
return expander
end
--- Creates a gridbox control
--- @param options table Configuration options for the gridbox
--- @return iup.gridbox The created gridbox element
function makeGridbox(options)
options = options or {}
local gridbox = iup.gridbox(options)
return gridbox
end
--- Creates a date field control
--- @param options table Configuration options for the date field
--- @return iup.element The created date field container
function makeDateField(options)
options = options or {}
local title = options.title or "Date"
local tip = options.tip or "Enter date"
local txtDate = makeText({
visiblelines = "1",
expand = "HORIZONTAL",
tip = tip,
name = "date",
})
local labDate = makeLabel({
title = title,
tip = tip,
})
return iup.hbox({ labDate, txtDate, alignment = "ACENTER" })
end
end
end
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/Progress.lua">>
;(function()
-- Progress dialog and controller (IUP)
-- Provides a lightweight progress dialog and a throttled controller for iterative operations
require("iuplua")
-- Progress dialogs are topmost so they stay visible during a long operation - but that also puts
-- them in front of any MessageBox (a native message dialog, which can't be made topmost), hiding it.
-- We track the shown progress dialogs here; MessageBox calls Progress.suspendTopmost() around its
-- popup so the message comes to the front, then restores. Globally applicable: any plugin that uses
-- this Progress controller and shows a message mid-operation gets readable messages for free.
local shownProgressDialogs = {}
--- Drop topmost on every currently-shown progress dialog, returning a function that restores it.
--- Safe to call when none are shown (returns a no-op). Guarded so a mid-teardown dialog can't error.
local function suspendTopmost()
local toRestore = {}
for dlg in pairs(shownProgressDialogs) do
pcall(function()
if dlg.topmost == "YES" then
dlg.topmost = "NO"
toRestore[#toRestore + 1] = dlg
end
end)
end
return function()
for _, dlg in ipairs(toRestore) do
pcall(function() dlg.topmost = "YES" end)
end
end
end
--- Create and manage a lightweight progress dialog
--- @param totalSteps number Total number of iterations to perform
--- @param title? string Optional dialog title
--- @param parentDialog? any Optional parent dialog handle
--- @return table Progress controller with methods: show(), update(step, text), isCancelled(), close()
local function createProgressDialog(totalSteps, title, parentDialog)
local cancelled = false
local lbl = makeLabel({
title = "Starting...",
})
local bar = iup.progressbar({
min = 0,
max = 1,
value = 0,
expand = "HORIZONTAL",
})
local btnCancel = makeButton({
title = "Cancel",
callback = function(self)
cancelled = true
return true
end,
})
local buttons = iup.hbox({ iup.fill({}), btnCancel })
local box = iup.vbox({
lbl,
bar,
buttons,
margin = "10x10",
gap = "8",
expand = "YES",
})
local dlg = makeDialog(box, {
title = title or "Applying...",
size = "QUARTERxEIGHTH",
dialogframe = "YES",
menubox = "NO",
topmost = "YES",
})
local function update(step, text)
local denom = (totalSteps and totalSteps > 0) and totalSteps or 1
bar.value = math.min(1, (step or 0) / denom)
if text and text ~= "" then
lbl.title = text
end
iup.Refresh(dlg)
iup.LoopStep()
end
return {
show = function()
dlg:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
shownProgressDialogs[dlg] = true -- so MessageBox can drop our topmost while a message is up
iup.LoopStep()
end,
update = update,
isCancelled = function()
return cancelled
end,
close = function()
if dlg then
shownProgressDialogs[dlg] = nil
dlg:destroy()
dlg = nil
end
end,
}
end
--- ProgressController handles showing and throttling progress updates based on parameters
--- @class ProgressController
--- @field totalSteps number
--- @field showThreshold number
--- @field updateStepFraction number
--- @field step number
--- @field lastFraction number
--- @field dlg any
--- @field _parentDialog any
local ProgressController = {}
ProgressController.__index = ProgressController
--- Create a new ProgressController
--- @param totalSteps number
--- @param updatePercent? number Percentage step for updates (1-100). Default 5
--- @param showThreshold? number Minimum total steps to show progress. Default 20
--- @param parentDialog? any Optional parent dialog handle for modality
--- @return ProgressController
function ProgressController.new(totalSteps, updatePercent, showThreshold, parentDialog)
local UPDATE_PERCENT = (type(updatePercent) == "number" and updatePercent >= 1 and updatePercent <= 100)
and updatePercent
or 5
local SHOW_THRESHOLD = (type(showThreshold) == "number" and showThreshold >= 0) and showThreshold or 20
local self = setmetatable({}, ProgressController)
self.totalSteps = totalSteps or 0
self.showThreshold = SHOW_THRESHOLD
self.updateStepFraction = math.max(0.01, UPDATE_PERCENT / 100)
self.step = 0
self.lastFraction = -1
self._parentDialog = parentDialog
self.dlg = nil
return self
end
function ProgressController:shouldShow()
return (self.totalSteps or 0) >= (self.showThreshold or 0)
end
function ProgressController:ensureShown()
if not self.dlg and self:shouldShow() and (self.totalSteps or 0) > 0 then
self.dlg = createProgressDialog(self.totalSteps, "Applying...", self._parentDialog)
self.dlg.show()
end
end
function ProgressController:isCancelled()
return self.dlg and self.dlg.isCancelled() or false
end
function ProgressController:update(text)
self.step = self.step + 1
if not self.dlg then
-- Defer showing until needed
self:ensureShown()
end
if not self.dlg then
return -- Not showing progress (below threshold or zero steps)
end
local fraction = (self.totalSteps > 0) and (self.step / self.totalSteps) or 1
local shouldUpdate = (self.totalSteps <= 20)
or (self.step == 1)
or (self.step == self.totalSteps)
or (fraction >= (self.lastFraction + self.updateStepFraction))
if shouldUpdate then
self.dlg.update(self.step, text)
self.lastFraction = fraction
end
end
function ProgressController:finish()
if self.dlg then
self.dlg.update(self.totalSteps, "Finishing...")
self.dlg.close()
self.dlg = nil
end
end
-- Module-style export akin to Config.lua
Progress = {
new = ProgressController.new,
suspendTopmost = suspendTopmost, -- used by MessageBox so messages aren't hidden behind progress
}
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/MenuBar.lua">>
;(function()
--[[
Enhanced MenuBar Helper Function
Creates an IUP menu bar with flexible File menu and standard Help menu.
The File menu accepts any custom items plus automatically adds Cancel/Exit.
Manages visibility of UI elements associated with menu items.
Includes dynamic state management, menu item registries, and title updates.
@Author: Helen Wright (ColeValleyGirl)
@Version: 2.1
@LastUpdated: 24 September 2026
@Description: Enhanced helper function for creating IUP menu bars with dynamic state management
VERSION HISTORY:
- 2.1 (24 September 2026): Added MenuBar.helpers.closeDialog to work around the FH host
swallowing a MENU callback's returned iup.CLOSE on a MainLoop-shown dialog; the automatic
File > Exit item now uses it instead of `return iup.CLOSE` (see USAGE note 7 below).
USAGE EXAMPLES:
1. Basic Menu Creation:
local menuBarData = MenuBar.createMenuBar({
items = {
MenuBar.helpers.createMenuItem("&Options", function(self)
showOptions()
return iup.DEFAULT
end)
}
})
local dialog = makeDialog(content, { menu = menuBarData.menuBar })
2. Dynamic Menu Updates:
-- Update menu item title
menuBarData.updateTitle("myMenuItem", "New Title")
-- Update menu item value (for checkable items)
menuBarData.updateValue("myCheckItem", "ON")
-- Update menu item active state
menuBarData.updateActive("myMenuItem", "NO")
-- Update all items in a registry group
menuBarData.updateRegistryGroup("targets", function(key, menuItem)
menuItem.active = isSupported(key) and "YES" or "NO"
end)
3. Registry System:
local menuBarData = MenuBar.createMenuBar({
registries = {
targets = {}, -- Group for target menu items
modes = {}, -- Group for mode menu items
noteTypes = {} -- Group for note type menu items
},
items = {
MenuBar.helpers.createSubmenu("&Targets", {
MenuBar.helpers.createMenuItem("Select: &Individuals",
function(self) selectTargets("INDI") end,
nil, "indiTarget") -- registryKey
}, "targetsMenu")
}
})
4. Radio Button Groups:
MenuBar.helpers.createRadioMenuItem("&Option 1", currentValue, "option1",
function() return changeValue("option1") end, nil, "radioOption1")
5. File Menu with Custom Items:
Keys are iterated in sorted order, so choose key names that sort into
the menu order you want (e.g. "aSave" before "bExport"):
fileMenu = {
aSave = MenuBar.helpers.createMenuItem("&Save", function(self)
saveData()
return iup.DEFAULT
end, nil, "menuSave"),
bExport = MenuBar.helpers.createMenuItem("&Export", function(self)
exportData()
return iup.DEFAULT
end, nil, "menuExport")
}
6. Keyboard Accelerators (shortcuts that fire from anywhere in the window):
Pass createMenuItem an `accel = { key = <iup key code>, text = "<display>" }` (5th arg):
- `text` adds the shortcut to the menu, right-aligned (e.g. "Save Ctrl+S").
- `key` (e.g. iup.K_cS, iup.K_F5) ALSO binds it window-globally.
- Give `text` only (omit `key`) to DISPLAY a shortcut that stays bound elsewhere
(e.g. Del handled by a list's own k_any - so it doesn't fire while editing a field).
createMenuBar collects the bound ones into menuBarData.accelerators; hand that to makeDialog,
which dispatches them from the dialog's k_any:
local mb = MenuBar.createMenuBar({ items = {
MenuBar.helpers.createSubmenu("&Edit", {
MenuBar.helpers.createMenuItem("&Save", onSave, nil, "save", { key = iup.K_cS, text = "Ctrl+S" }),
MenuBar.helpers.createMenuItem("&Delete", onDelete, nil, nil, { text = "Del" }), -- display only
}),
}})
local dlg = makeDialog(content, { menu = mb.menuBar, accelerators = mb.accelerators })
updateTitle re-appends the accelerator text automatically, so a dynamic title keeps its shortcut.
Caveat: one key auto-binds to one action. If the SAME shortcut appears on two items with different
actions (e.g. a "select" that means different things per view), give both items `text` only and pass
your own view-aware { key, action } table as makeDialog's accelerators (see Add Maps' mainAccelerators).
7. Closing a Dialog from a Menu Action:
Family Historian's host swallows a MENU callback's `return iup.CLOSE` on a dialog shown via
showxy/showTrackedDialog + iup.MainLoop (the title-bar X, Esc and buttons are unaffected -
they go through IUP's own machinery, not a menu action). MenuBar's own automatic File > Exit
item accounts for this already, but ANY of your own menu actions that need to close such a
dialog must do the same - call MenuBar.helpers.closeDialog(self) instead of `return
iup.CLOSE`. This does not apply to a dialog shown with popup() (Options-style dialogs), where
`return iup.CLOSE` from a menu action works as normal - though closeDialog is safe there too:
MenuBar.helpers.createMenuItem("E&xit", function(self)
return MenuBar.helpers.closeDialog(self)
end)
REGISTRY SYSTEM:
- Use registryKey to register menu items for dynamic updates
- Use registryGroup to organize related menu items
- Access registered items via menuBarData.menuItems[key]
- Update groups via menuBarData.updateRegistryGroup(groupKey, updateFunction)
DYNAMIC UPDATES:
- updateTitle(key, newTitle): Change menu item title
- updateValue(key, "ON"/"OFF"): Update checkable item state
- updateActive(key, "YES"/"NO"): Enable/disable menu item
- updateRegistryGroup(groupKey, updateFunction): Batch update group items
HELPER FUNCTIONS:
- MenuBar.helpers.createMenuItem(title, action, capture, registryKey, accel) -- accel = { key?, text? } (see 6 above)
- MenuBar.helpers.createRadioMenuItem(title, currentValue, expectedValue, action, capture, registryKey)
- MenuBar.helpers.createSubmenu(title, items, registryKey)
- MenuBar.helpers.closeDialog(self_or_dialog) -- close a MainLoop-shown dialog from a menu action (see 7 above)
RETURNS (menuBarData): { menuBar, menuItems, registries, accelerators, updateTitle, updateValue,
updateActive, updateRegistryGroup }. Pass `accelerators` to makeDialog's `options.accelerators`.
]]
---@class menuItem
---@field title string Menu item title (include & for mnemonic key e.g. "&File" for Alt+F)
---@field action? function Callback function when menu item is selected (receives self, returns iup.DEFAULT or iup.CLOSE)
---@field active? string "YES" or "NO" - whether the item is enabled
---@field value? string "ON" or "OFF" for checkable items; defaults to "OFF"
---@field ui? iup.element UI element to show when menu item is selected
---@field isPopup? boolean If true, show UI element as popup instead of embedded
---@field submenu? menuItem[] Submenu items if this is a submenu
---@field beforeUI? boolean Execute action before (true) or after (false) showing UI element (defaults to false)
---@field capture? fun(instance:iup.element) Optional callback to capture the created iup.item/submenu instance
---@field registryKey? string Optional key for registering this menu item for dynamic updates
---@field registryGroup? string Optional group key for organizing menu items in registries
---@field titleFunction? function Optional function to compute dynamic title
---@field stateFunction? function Optional function to compute dynamic state (active/value)
---@field accel? {key?: integer, text?: string} Accelerator: `text` shows the shortcut in the menu; `key` (an iup key code) also binds it window-globally (via makeDialog's options.accelerators)
--- Mnemonics: use '&' in titles (e.g., "&File")
---@class menuBarOptions
---@field items? menuItem[] Additional menu items to insert between File and Help menus
---@field fileMenu? table<string, menuItem> Custom File menu items (keys iterated in sorted order - name them to sort into the menu order you want; a "cancel" or "exit" key suppresses the automatic Exit item)
---@field helpMenu? {help?: menuItem, about?: menuItem} Customizations for Help menu items
---@field registries? table<string, table> Optional registries for grouping menu items (e.g., {targets = {}, modes = {}})
---@class MenuBarData
---@field updateTitle fun(key: string, title: string)
---@field updateValue fun(key: string, value: string)
---@field updateActive fun(key: string, active: string)
---@field updateRegistryGroup fun(groupKey: string, updateFunction: function)
---@field menuBar any
---@field menuItems table
---@field registries table
---@field accelerators {key: integer, action: function, item: any}[] Window-global accelerators; pass to makeDialog as options.accelerators
local M = {}
--- Creates a menu bar with flexible File menu and standard Help menu
---@param options menuBarOptions Configuration for the menu bar
---@return table Created menu bar with enhanced functionality
M.createMenuBar = function(options)
-- Local helper function to find an element in a table
local function findInTable(tbl, value)
for _, v in ipairs(tbl) do
if v == value then
return true
end
end
return false
end
options = options or {}
-- Track UI elements for visibility management
local allUIElements = {}
-- Menu item registries for dynamic updates
local menuRegistries = options.registries or {}
local menuItems = {} -- Store all menu items for dynamic updates
-- Window-global accelerators declared via item.accel = { key = <iup key code>, text = "Ctrl+I" }.
-- Each { key, action } is collected here and returned as menuBarData.accelerators for the caller to
-- hand to makeDialog (options.accelerators), which dispatches them from the dialog's k_any so the
-- shortcut fires from anywhere in the window - not only when a particular control has focus. An accel
-- with text but NO key shows the shortcut in the menu without binding it (e.g. Del kept list-scoped).
local accelerators = {}
-- Accel display text per registryKey, so updateTitle can re-append "\tCtrl+I" when a dynamic title changes.
local accelTextByKey = {}
-- Compose a menu title with its accelerator display text, e.g. "&Clear List" + "Ctrl+T" -> "&Clear List\tCtrl+T".
local function titleWithAccel(title, accel)
if accel and accel.text and accel.text ~= "" then
return title .. "\t" .. accel.text
end
return title
end
--- Controls visibility and floating state of UI elements
---@param uiElements table<number,iup.element> List of UI elements to manage
---@param activeElement iup.element|nil Element to make visible, or nil to hide all
local function manageUIVisibility(uiElements, activeElement)
-- Update visibility and floating state of all elements
for _, element in pairs(uiElements) do
if element == activeElement then
element.visible = "YES"
element.floating = "NO" -- Place in normal layout flow
else
element.visible = "NO"
element.floating = "YES" -- Ready for future display
end
end
-- Refresh dialog to update layout
if activeElement then
local dialog = iup.GetDialog(activeElement)
if dialog then
iup.Refresh(dialog)
end
end
end
--- Creates a menu item with UI handling
---@param item menuItem The menu item configuration
---@return table IUP menu item configuration
local function createMenuItem(item)
if not item.title then
return {} -- separator
end
-- Handle submenu
if item.submenu then
local submenuItems = {}
for _, subItem in ipairs(item.submenu) do
table.insert(submenuItems, createMenuItem(subItem))
end
local submenu = iup.submenu({
iup.menu(submenuItems),
title = item.title,
active = item.active,
})
if item.capture then
item.capture(submenu)
end
-- Register submenu if registry key provided
if item.registryKey then
menuItems[item.registryKey] = submenu
end
return submenu
end
-- Create regular menu item. The accelerator display text (if any) is appended to the title so it
-- shows right-aligned in the menu (IUP splits on the tab).
local menuItem = iup.item({
title = titleWithAccel(item.title, item.accel),
active = item.active,
value = item.value,
})
if item.capture then
item.capture(menuItem)
end
-- Register menu item if registry key provided
if item.registryKey then
menuItems[item.registryKey] = menuItem
-- Remember its accel text so updateTitle can re-append it when the title changes dynamically.
if item.accel and item.accel.text then
accelTextByKey[item.registryKey] = item.accel.text
end
-- Also add to specific registry if provided
if item.registryGroup and menuRegistries[item.registryGroup] then
menuRegistries[item.registryGroup][item.registryKey] = menuItem
end
end
-- Handle action and UI
if item.action or item.ui then
menuItem.action = function(self)
local result = iup.DEFAULT
-- Execute pre-UI action if specified
if item.action and item.beforeUI then
result = item.action(self)
if result == iup.CLOSE then
return result
elseif result == iup.IGNORE then
return result
end
end
-- Handle UI element display
if item.ui then
if item.isPopup then
-- Show as popup (modal by default)
item.ui.floating = "YES"
item.ui:popup()
else
-- Show embedded
if not findInTable(allUIElements, item.ui) then
table.insert(allUIElements, item.ui)
end
manageUIVisibility(allUIElements, item.ui)
end
end
-- Execute post-UI action (default behavior)
if item.action and not item.beforeUI then
result = item.action(self)
end
return result
end
end
-- Register a window-global accelerator when the item declares one with a key. It reuses the item's
-- own (wrapped) action, so firing the shortcut behaves exactly like clicking the menu item.
if item.accel and item.accel.key and menuItem.action then
accelerators[#accelerators + 1] = { key = item.accel.key, action = menuItem.action, item = menuItem }
end
return menuItem
end
-- Define flexible File menu with custom items + automatic Cancel/Exit.
-- Keys are iterated in sorted order so the menu is deterministic
-- (pairs() order varies between runs); callers choose key names that
-- sort into the order they want.
local fileMenuItems = {}
if options.fileMenu then
local keys = {}
for key in pairs(options.fileMenu) do
table.insert(keys, key)
end
table.sort(keys)
for _, key in ipairs(keys) do
-- Add separator before close/exit items
if (key == "close" or key == "exit" or key == "cancel") and #fileMenuItems > 0 then
table.insert(fileMenuItems, {}) -- separator
end
table.insert(fileMenuItems, createMenuItem(options.fileMenu[key]))
end
end
-- Always add Cancel/Exit as the last item(s) in File menu
if options.fileMenu and not options.fileMenu.exit and not options.fileMenu.cancel then
-- Only add separator if there are custom items
if #fileMenuItems > 0 then
table.insert(fileMenuItems, {}) -- separator
end
table.insert(
fileMenuItems,
createMenuItem({
title = "E&xit",
action = function(self)
return M.helpers.closeDialog(self)
end,
})
)
end
local fileMenu = iup.submenu({
iup.menu(fileMenuItems),
title = "&File",
})
-- Define the standard Help menu. About is added only when the caller supplies
-- one (e.g. the main window's version box). There is no default About: the
-- old default opened a non-existent "about" help page, which surfaced on
-- the Options dialog (it supplies only Help).
local helpItemDef = options.helpMenu and options.helpMenu.help or {
title = "&Help",
action = function(self)
if Help then
Help.new({}):show("")
end
return iup.DEFAULT
end,
}
local helpMenu
if options.helpMenu and options.helpMenu.about then
-- Help + About: a proper Help submenu holding both items.
local helpItems = {
createMenuItem(helpItemDef),
{}, -- separator
createMenuItem(options.helpMenu.about),
}
helpMenu = iup.submenu({
iup.menu(helpItems),
title = "&Help",
})
else
-- Only Help to show: promote it to a top-level menu-bar item rather than
-- burying a lone "Help" item inside a "Help" submenu. A top-level iup.item
-- fires its action directly on click (and via Alt+H from the mnemonic).
helpMenu = createMenuItem(helpItemDef)
end
-- Build menu items array
local menuItemsArray = { fileMenu }
-- Add custom items between File and Help
if options.items then
for _, item in ipairs(options.items) do
table.insert(menuItemsArray, createMenuItem(item))
end
end
-- Add Help menu
table.insert(menuItemsArray, helpMenu)
-- Create the menu bar
local menuBar = iup.menu(menuItemsArray)
-- Set a handle for the menu bar for global access
iup.SetHandle("mainmenu", menuBar)
-- Return enhanced menu bar with dynamic update capabilities
return {
menuBar = menuBar,
menuItems = menuItems,
registries = menuRegistries,
-- Window-global accelerators ({ key, action, item }); pass to makeDialog as options.accelerators.
accelerators = accelerators,
--- Update menu item title dynamically. Re-appends the item's accelerator display text (if any),
--- so a dynamic title change doesn't drop the "\tCtrl+I" suffix the menu shows.
---@param key string Registry key of the menu item
---@param title string New title (without the accelerator suffix)
updateTitle = function(key, title)
if menuItems[key] then
local accelText = accelTextByKey[key]
menuItems[key].title = accelText and (title .. "\t" .. accelText) or title
end
end,
--- Update menu item active state dynamically
---@param key string Registry key of the menu item
---@param active string "YES" or "NO"
updateActive = function(key, active)
if menuItems[key] then
menuItems[key].active = active
end
end,
--- Update menu item value (for checkable items) dynamically
---@param key string Registry key of the menu item
---@param value string "ON" or "OFF"
updateValue = function(key, value)
if menuItems[key] then
menuItems[key].value = value
end
end,
--- Update all menu items in a registry group
---@param groupKey string Registry group key
---@param updateFunction function Function to call for each menu item in the group
updateRegistryGroup = function(groupKey, updateFunction)
if menuRegistries[groupKey] then
for key, menuItem in pairs(menuRegistries[groupKey]) do
updateFunction(key, menuItem)
end
end
end,
--- Refresh all dynamic menu items (call title/state functions)
refreshDynamicItems = function()
for key, menuItem in pairs(menuItems) do
-- This would need to be enhanced to call titleFunction and stateFunction
-- if they were stored during creation
end
end,
}
end
-- Helper functions for common menu patterns
M.helpers = {}
--- Create a radio button group menu item
---@param title string Menu item title
---@param currentValue any Current value to check against
---@param expectedValue any Expected value for ON state
---@param action function Action function
---@param capture? function Capture function
---@param registryKey? string Registry key for dynamic updates
---@return menuItem Menu item definition
M.helpers.createRadioMenuItem = function(title, currentValue, expectedValue, action, capture, registryKey)
return {
title = title,
action = action,
capture = capture,
value = (currentValue == expectedValue) and "ON" or "OFF",
registryKey = registryKey,
}
end
--- Create a simple menu item
---@param title string Menu item title
---@param action function Action function
---@param capture? function Capture function
---@param registryKey? string Registry key for dynamic updates
---@param accel? {key?: integer, text?: string} Accelerator: `text` shows the shortcut in the menu (e.g. "Ctrl+I"); `key` (an iup key code, e.g. iup.K_cI) also binds it window-globally via the dialog's accelerators. Give `text` only (no `key`) to display a shortcut that stays bound elsewhere (e.g. Del on a list).
---@return menuItem Menu item definition
M.helpers.createMenuItem = function(title, action, capture, registryKey, accel)
return {
title = title,
action = action,
capture = capture,
registryKey = registryKey,
accel = accel,
}
end
--- Create a submenu with items
---@param title string Submenu title
---@param items menuItem[] Array of menu items
---@param registryKey? string Registry key for dynamic updates
---@return menuItem Submenu definition
M.helpers.createSubmenu = function(title, items, registryKey)
return {
title = title,
submenu = items,
registryKey = registryKey,
}
end
--- Close a dialog from a menu item's action. Family Historian's host swallows a MENU callback's
--- returned iup.CLOSE on a dialog shown via showxy/showTrackedDialog + iup.MainLoop (the title-bar
--- X, Esc and buttons are unaffected - see this file's header USAGE notes), so a menu action that
--- needs to close such a dialog must call this instead of `return iup.CLOSE`.
---
--- Accepts either the menu item (`self`, resolved to its owning dialog via iup.GetDialog) or the
--- dialog itself. Fires the dialog's close_cb - exactly once, since hide() does not itself fire
--- close_cb - and honours a veto: if close_cb returns iup.IGNORE (e.g. a plugin refusing to close
--- mid-batch-operation), the dialog is left open. Otherwise the dialog is hidden: hiding the last
--- visible dialog ends iup.MainLoop, and hiding a popup dialog ends its own popup loop, so this is
--- safe for a MainLoop-shown dialog and a popup dialog alike.
---@param self_or_dialog iup.item|iup.dialog Menu item whose action is closing the dialog, or the dialog itself
---@return integer iup.DEFAULT normally; iup.CLOSE only if no dialog could be resolved
M.helpers.closeDialog = function(self_or_dialog)
local dlg = self_or_dialog
if dlg and iup.GetClassName(dlg) ~= "dialog" then
dlg = iup.GetDialog(self_or_dialog)
end
if not dlg then
return iup.CLOSE
end
if dlg.close_cb then
if dlg.close_cb(dlg) == iup.IGNORE then
return iup.DEFAULT
end
end
dlg:hide()
return iup.DEFAULT
end
_G.MenuBar = M
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/Config.lua">>
;(function()
--[[
Configuration Helper for Family Historian Plugins
This file provides tools to save and load settings using .ini files, which are simple text files
that store configuration data in a format like this:
[Section]
key=value
The code creates a class named Config that handles reading and writing these settings,
managing dialog window positions, and creating configuration user interfaces.
@Author: Helen Wright
@Version: 1.3
@V1.3: two beta-retest fixes. (1) showTrackedDialog measured its minimum size AFTER
clearing minsize but BEFORE clearing the dialog's own SIZE attribute, so IUP's
reported natural size - which is max(content natural, SIZE) - inflated the floor
to the SIZE hint (e.g. a HALFxHALF main dialog got a minimum of half the SCREEN
on a large display, unnoticeable on a small one). computePlacement gains a fifth,
optional openSize parameter so a dialog still opens at its SIZE hint (capped) when
there is no saved size, while minsize and the natural-size floor use content-only
measurements. (2) showConfigDialog's Options dialog popped up with
iup.CENTERPARENT, which centres on the parent rectangle but does not clamp to the
screen - with the main window near the top of the display and Options taller than
it, the computed top could sit above the screen, leaving the title bar unreachable
until a resize re-clamped it. New pure placeOverParent helper computes an
explicit, screen-clamped popup position instead.
@V1.2: window geometry (tracked-dialog x/y/rastersize, and the Options
dialog's size) now lives in a per-machine store instead of the
plugin's own config ini, which for some plugins is per-project and so
synced a saved position between machines - a beta showstopper when an
ultra-wide-monitor position opened off-screen on a laptop. The saved
position is also now validated against each monitor's full bounds,
not just its origin, and clamped fully on-screen instead of being
accepted or rejected outright. The Options dialog is now capped at
the screen on first build (its sections already scroll), remembers
its size between sessions, and centres reliably over the plugin's
main window instead of wherever the keyboard focus happened to be.
@V1.1: showTrackedDialog restores a saved size clamped to [natural size,
screen work area] and floors minsize there (it previously measured
the natural size and discarded it, so stale saved sizes clipped
trailing controls and every consumer carried its own fix).
@Date: 2024
]]
do
local M = {} -- Module table
---@class Config
---@field filePath string
---@field defaultConfig table
---@field cache table<string, table<string, any>>
---@field callbacks table<string, table<string, fun(value:any, oldValue:any)>>
local Config = {}
Config.__index = Config
--==================== CONFIG-PLACEMENT-PURE ====================--
-- Dialog placement/sizing maths, pulled out pure (no iup/fh calls) so config_placement_spec.lua
-- can exercise it under plain lua.exe. Mirrored verbatim there - keep the two in step.
-- Vertical allowance left below a dialog's height cap for the title bar and taskbar (there is
-- no equivalent horizontal allowance - width caps use the monitor/screen width as-is).
local DECOR_ALLOWANCE = 80
--- Parse an IUP MONITORSINFO string into an array of monitor rects. Tolerates a nil/malformed
--- string (returns {}); coordinates may be negative (a monitor positioned left of/above the
--- primary display).
---@param miString string? -- multi-line "x y w h" per monitor
---@return {x:number, y:number, w:number, h:number}[]
local function parseMonitorsInfo(miString)
local monitors = {}
if type(miString) ~= "string" then
return monitors
end
for line in miString:gmatch("[^\r\n]+") do
local x, y, w, h = line:match("^%s*(%-?%d+)%s+(%-?%d+)%s+(%-?%d+)%s+(%-?%d+)")
if x then
monitors[#monitors + 1] = { x = tonumber(x), y = tonumber(y), w = tonumber(w), h = tonumber(h) }
end
end
return monitors
end
--- Work out where and how big to (re)open a tracked dialog. Pure: takes parsed inputs, returns
--- a placement, does no measuring or IUP calls itself.
---@param saved {x:number?, y:number?, w:number?, h:number?} -- from the geometry store
---@param natural {w:number?, h:number?} -- the dialog's measured, content-only natural size
---@param monitors {x:number, y:number, w:number, h:number}[] -- parsed MONITORSINFO
---@param screen {w:number?, h:number?} -- primary SCREENSIZE
---@param openSize {w:number?, h:number?}? -- the dialog's SIZE-attribute user size (e.g. "HALFxHALF"),
--- used only when there is no saved size; see the base-size comment at step 3
---@return {x:number?, y:number?, w:number?, h:number?, minW:number?, minH:number?} -- nil x/y means centre on parent
local function computePlacement(saved, natural, monitors, screen, openSize)
saved = saved or {}
natural = natural or {}
monitors = monitors or {}
screen = screen or {}
openSize = openSize or {}
-- 1. Which monitor (if any) does the saved position claim to be on? The margins (100
-- horizontal, 80 vertical) keep enough of the title bar on-screen to grab or close the
-- dialog even when the saved position sits right at a monitor's edge. Exactly (0,0) is the
-- existing "no saved position" sentinel, not a real top-left placement.
local target
if saved.x and saved.y and not (saved.x == 0 and saved.y == 0) then
for _, m in ipairs(monitors) do
if saved.x >= m.x and saved.x <= m.x + m.w - 100 and saved.y >= m.y and saved.y <= m.y + m.h - 80 then
target = m
break
end
end
end
-- 2. Cap dimensions against the target monitor, or the primary screen when centring.
local capW, capH
if target then
capW, capH = target.w, target.h - DECOR_ALLOWANCE
elseif screen.w and screen.h then
capW, capH = screen.w, screen.h - DECOR_ALLOWANCE
end
-- 3. Size: saved wins over openSize wins over natural (a stale saved size from an older,
-- narrower layout must not clip trailing controls, so the result is still floored at
-- natural below regardless of which of the three the base size came from). openSize is
-- the dialog's own SIZE-attribute hint (e.g. "HALFxHALF"): with no saved size the dialog
-- opens at that hint, capped like everything else - only the floor/minsize below are
-- content-only. When the dialog has no SIZE attribute, openSize == natural and this step
-- behaves exactly as before.
local w = saved.w or openSize.w or natural.w
local h = saved.h or openSize.h or natural.h
if natural.w and w then
w = math.max(w, natural.w)
end
if natural.h and h then
h = math.max(h, natural.h)
end
if capW and w then
w = math.min(w, capW)
end
if capH and h then
h = math.min(h, capH)
end
-- 4. Floor future user resizes at the (capped) natural size.
local minW = natural.w and (capW and math.min(natural.w, capW) or natural.w) or nil
local minH = natural.h and (capH and math.min(natural.h, capH) or natural.h) or nil
-- 5. Position: only meaningful with a target monitor (otherwise centre on parent). Clamp
-- using the final w/h so the dialog rect stays fully on the monitor; when w/h are unknown,
-- the already-contained saved position is kept as-is.
local x, y
if target then
x, y = saved.x, saved.y
if w then
x = math.max(target.x, math.min(x, target.x + target.w - w))
end
if h then
y = math.max(target.y, math.min(y, target.y + target.h - h))
end
end
return { x = x, y = y, w = w, h = h, minW = minW, minH = minH }
end
--- Cap an initial dialog size at the primary screen, with a 300x200 sanity floor - but NO
--- natural-size floor (unlike computePlacement above): a deliberately-reduced saved size must
--- survive between sessions. Used for the Options dialog, whose sections live in scrollboxes,
--- so a capped size never clips a field - it just scrolls.
---@param saved {w:number?, h:number?}?
---@param natural {w:number?, h:number?}?
---@param screen {w:number?, h:number?}?
---@return {w:number?, h:number?}
local function capSize(saved, natural, screen)
saved = saved or {}
natural = natural or {}
screen = screen or {}
local w = saved.w or natural.w
local h = saved.h or natural.h
if screen.w and w then
w = math.min(w, screen.w)
end
if screen.h and h then
h = math.min(h, screen.h - DECOR_ALLOWANCE)
end
if w then
w = math.max(w, 300)
end
if h then
h = math.max(h, 200)
end
return { w = w, h = h }
end
--- Work out where to pop up a dialog centred on its parent, clamped fully onto whichever
--- monitor the parent sits on (or the primary screen if none matches). iup.CENTERPARENT alone
--- centres on the parent rectangle but does NOT clamp to the screen: when the parent sits near
--- the top of the display and the popup is taller than it, CENTERPARENT can compute a top
--- above the screen, leaving the title bar unreachable until the next resize re-clamps it (a
--- beta report against the Options dialog).
---@param parent {x:number, y:number, w:number, h:number}? -- the parent dialog's screen rect
---@param size {w:number?, h:number?}? -- the popup dialog's own current size
---@param monitors {x:number, y:number, w:number, h:number}[] -- parsed MONITORSINFO
---@param screen {w:number?, h:number?} -- primary SCREENSIZE
---@return {x:number?, y:number?} -- empty table means "fall back to CENTERPARENT" (no usable parent/size)
local function placeOverParent(parent, size, monitors, screen)
monitors = monitors or {}
screen = screen or {}
size = size or {}
if not (parent and parent.x and parent.y and parent.w and parent.h and size.w and size.h) then
return {}
end
-- Centre on the parent rect.
local x = math.floor(parent.x + (parent.w - size.w) / 2)
local y = math.floor(parent.y + (parent.h - size.h) / 2)
-- Pick the monitor containing the parent's centre point - plain rect containment, no edge
-- margins needed (unlike computePlacement's saved-position check above, which is testing a
-- corner that must leave room to grab a title bar; here we are only choosing which monitor
-- rect to clamp into).
local cx = parent.x + parent.w / 2
local cy = parent.y + parent.h / 2
local clamp
for _, m in ipairs(monitors) do
if cx >= m.x and cx <= m.x + m.w and cy >= m.y and cy <= m.y + m.h then
clamp = m
break
end
end
if not clamp and screen.w and screen.h then
clamp = { x = 0, y = 0, w = screen.w, h = screen.h }
end
if not clamp then
return { x = x, y = y }
end
-- Clamp fully onto the chosen monitor; the lower bound is applied LAST so the title bar
-- (top-left) always wins when the popup is bigger than the monitor.
x = math.min(x, clamp.x + clamp.w - size.w)
x = math.max(clamp.x, x)
y = math.min(y, clamp.y + clamp.h - size.h)
y = math.max(clamp.y, y)
return { x = x, y = y }
end
--==================== END CONFIG-PLACEMENT-PURE ====================--
-- Machine-scope geometry store for ALL dialog geometry (tracked-dialog x/y/rastersize, and the
-- Options dialog's persisted size, further down). Window geometry is per-machine data: it must
-- never travel with a synced project the way the rest of a plugin's config can (Add Trees'
-- config ini is per-project, which is exactly how a saved ultra-wide-monitor position ended up
-- opening off-screen on a laptop - the frozen-looking-FH beta bug this store exists to prevent).
-- Path is resolved lazily, once, and pcall-guarded; on failure geometry persistence just
-- silently degrades to defaults / a centred dialog. No migration from old project-ini positions
-- - those are exactly the cross-machine values this store exists to stop trusting.
local geometryFilePath, geometryFilePathTried
local function getGeometryFilePath()
if not geometryFilePathTried then
geometryFilePathTried = true
local ok, path = pcall(function()
return fhGetPluginDataFileName("LOCAL_MACHINE", true) .. "\\"
.. fhGetContextInfo("CI_PLUGIN_NAME") .. " Window Layout.ini"
end)
if ok then
geometryFilePath = path
end
end
return geometryFilePath
end
local function getGeometryValue(key, fhType, default)
local path = getGeometryFilePath()
if not path then
return default
end
local ok, result = pcall(fhGetIniFileValue, path, "Dialogs", key, fhType, default)
if ok then
return result
end
return default
end
local function setGeometryValue(key, fhType, value)
local path = getGeometryFilePath()
if not path then
return
end
pcall(fhSetIniFileValue, path, "Dialogs", key, fhType, value)
end
--- Constructor for Config
---@param defaultConfig table
---@param scope? string
---@param filename? string
---@return Config
function M.new(defaultConfig, scope, filename)
local self = setmetatable({}, Config)
local pluginName = fhGetContextInfo("CI_PLUGIN_NAME")
scope = scope or "LOCAL_MACHINE"
filename = filename or (pluginName .. ".ini")
self.filePath = fhGetPluginDataFileName(scope, true) .. "\\" .. filename
self.defaultConfig = defaultConfig
self.cache = {}
self.callbacks = {}
local fhfu = require("fhFileUtils")
local fileExists = fhfu.fileExists(self.filePath)
-- Check if file exists and has content
local fileHasContent = false
if fileExists then
local success, fileContent = pcall(function()
return fhLoadTextFile(self.filePath, "UTF-16LE")
end)
if success and fileContent then
fileHasContent = #fileContent:gsub("%s+", "") > 0
end
end
-- Create empty file if it doesn't exist
if not fileExists then
local success = pcall(function()
fhSaveTextFile(self.filePath, "", "UTF-16LE")
end)
-- If creating the file fails, we'll continue with defaults
end
for _, section in ipairs(self.defaultConfig.sections or {}) do
self.cache[section.title] = {}
self.callbacks[section.title] = {}
for _, field in ipairs(section.fields) do
local valueType = type(field.default)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
local value
-- Only try to read from file if it exists and has content
if fileExists and fileHasContent then
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section.title, field.key, fhType, field.default)
end)
if success then
value = result
else
-- If reading fails, use default value
value = field.default
end
else
-- Use default value and write it to the file
value = field.default
local success = pcall(function()
fhSetIniFileValue(self.filePath, section.title, field.key, fhType, value)
end)
-- If writing fails, just continue - this ensures the method doesn't crash
if not success then
-- Log error using fhMessageBox
fhMessageBox(
"Failed to write configuration value to file: "
.. self.filePath
.. "\nSection: "
.. section.title
.. "\nKey: "
.. field.key,
"MB_OK",
"MB_ICONERROR"
)
end
end
self.cache[section.title][field.key] = value
if field.onChange then
self.callbacks[section.title][field.key] = field.onChange
end
end
end
return self
end
function Config:initializeDefaults()
for _, section in ipairs(self.defaultConfig.sections or {}) do
for _, field in ipairs(section.fields) do
local valueType = type(field.default)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
-- Use pcall to handle potential errors when writing to file
local success = pcall(function()
fhSetIniFileValue(self.filePath, section.title, field.key, fhType, field.default)
end)
-- If writing fails, just continue - this ensures the method doesn't crash
if not success then
-- Log error using fhMessageBox
fhMessageBox(
"Failed to write configuration value to file: "
.. self.filePath
.. "\nSection: "
.. section.title
.. "\nKey: "
.. field.key,
"MB_OK",
"MB_ICONERROR"
)
end
end
end
end
function Config:getString(section, key, default)
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, "text", default or "")
end)
return success and result or (default or "")
end
function Config:getNumber(section, key, default)
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, "integer", default or 0)
end)
return success and result or (default or 0)
end
function Config:getBool(section, key, default)
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, "bool", default or false)
end)
return success and result or (default or false)
end
-- Hex-only color handling
--- Opens a color picker dialog, stores the result if confirmed, and returns chosen hex
---@param section string
---@param key string
---@param startColor string|nil -- e.g. "#RRGGBB"
---@return string|nil -- hex or nil if cancelled
function Config:chooseColor(section, key, startColor)
local baseHex = self:getHexColor(section, key, startColor or "#000000")
local dlg = iup.colordlg({})
pcall(function()
dlg.valuehex = baseHex
dlg.showhex = "YES"
end)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local hex = tostring(dlg.valuehex or baseHex)
self:setHexColor(section, key, hex)
return hex
end
return nil
end
--- Opens a color picker with alpha; stores and returns hex (#RRGGBBAA)
---@param section string
---@param key string
---@param startColor string|nil -- e.g. "#RRGGBBAA"
---@return string|nil
function Config:chooseColorRGBA(section, key, startColor)
local current = self:getHexColorRGBA(section, key, startColor or "#000000FF")
local baseHex = current:sub(1, 7)
local dlg = iup.colordlg({})
pcall(function()
dlg.valuehex = baseHex
dlg.showhex = "YES"
dlg.showalpha = "YES"
-- keep existing alpha in the field if any
dlg.alpha = tostring(tonumber(current:sub(8, 9), 16) or 255)
end)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local hex = tostring(dlg.valuehex or baseHex)
local aa = tonumber(dlg.alpha) or 255
aa = math.max(0, math.min(255, aa))
hex = string.format("%s%02X", hex, aa)
self:setHexColorRGBA(section, key, hex)
return hex
end
return nil
end
--- Retrieves a font string from config (IUP font syntax, e.g. "Segoe UI, 10")
---@param section string
---@param key string
---@param default string|nil
---@return string
function Config:getFont(section, key, default)
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, "text", default or "")
end)
local value = success and result or (default or "")
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = value
return value
end
-- Hex-first color API (simple string get/set). Preferred for plugins using hex everywhere.
--- Gets a hex color string (#RRGGBB) from config
---@param section string
---@param key string
---@param default string|nil -- e.g. "#000000"
---@return string
function Config:getHexColor(section, key, default)
return self:getString(section, key, default or "#000000")
end
--- Sets a hex color string (#RRGGBB) in config
---@param section string
---@param key string
---@param hex string
function Config:setHexColor(section, key, hex)
pcall(function()
fhSetIniFileValue(self.filePath, section, key, "text", tostring(hex or "#000000"))
end)
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = tostring(hex or "#000000")
end
--- Gets a hex color string with alpha (#RRGGBBAA) from config
---@param section string
---@param key string
---@param default string|nil -- e.g. "#000000FF"
---@return string
function Config:getHexColorRGBA(section, key, default)
return self:getString(section, key, default or "#000000FF")
end
--- Sets a hex color string with alpha (#RRGGBBAA) in config
---@param section string
---@param key string
---@param hex string
function Config:setHexColorRGBA(section, key, hex)
pcall(function()
fhSetIniFileValue(self.filePath, section, key, "text", tostring(hex or "#000000FF"))
end)
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = tostring(hex or "#000000FF")
end
--- Stores a font string in config (IUP font syntax)
---@param section string
---@param key string
---@param font string
function Config:setFont(section, key, font)
pcall(function()
fhSetIniFileValue(self.filePath, section, key, "text", font)
end)
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = font
end
--- Opens a font dialog, stores the result if confirmed, and returns the chosen font string
---@param section string
---@param key string
---@param startFont string|nil
---@return string|nil -- returns nil if cancelled
function Config:chooseFont(section, key, startFont)
local current = self:getFont(section, key, startFont or "")
local dlg = iup.fontdlg({ value = current })
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" and dlg.value and dlg.value ~= "" then
self:setFont(section, key, dlg.value)
return dlg.value
end
return nil
end
--- Shows a dialog with position and size tracking capabilities
--- This function enhances a dialog by:
--- 1. Loading and restoring the dialog's previous position and size from configuration
--- 2. Saving the dialog's position and size when it's closed
--- 3. Ensuring the dialog appears on a valid monitor
--- 4. Preserving any existing dialog callbacks
---@param dialog iup.dialog The dialog to show with tracking
---@param dialogId string Unique identifier for this dialog (used for config storage)
function Config:showTrackedDialog(dialog, dialogId)
local config = self
-- Store the original close callback to preserve existing functionality
local originalClose = dialog.close_cb
-- Load the previously saved position and size from the per-machine geometry store (NOT
-- self - see the geometry-store comment above; a project-scoped position is exactly what
-- produced the off-screen-on-a-laptop beta bug). Missing keys default to 0/"" - the same
-- defaults fhGetIniFileValue would give a first run - so (0,0) still reads as "unset" below.
local savedX = getGeometryValue("Dialog_" .. dialogId .. ".x", "integer", 0)
local savedY = getGeometryValue("Dialog_" .. dialogId .. ".y", "integer", 0)
local savedRaster = getGeometryValue("Dialog_" .. dialogId .. ".rastersize", "text", "")
-- Override the close callback to save position and size when dialog closes
dialog.close_cb = function(dlg)
-- Only save position if dialog is not maximized or minimized
if dialog.maximized == "NO" and dialog.minimized == "NO" then
local pos = dialog.screenposition
if pos then
-- Extract x,y coordinates from the position string (format: "x,y")
local closeX, closeY = pos:match("^(%-?%d+),(%-?%d+)$")
if closeX and closeY then
-- Save the current position and size to the geometry store
setGeometryValue("Dialog_" .. dialogId .. ".x", "integer", tonumber(closeX))
setGeometryValue("Dialog_" .. dialogId .. ".y", "integer", tonumber(closeY))
setGeometryValue("Dialog_" .. dialogId .. ".rastersize", "text", dlg.rastersize)
end
end
end
-- Call the original close callback if it exists
if originalClose then
return originalClose(dlg)
end
return iup.CLOSE
end
-- Measure natural size twice. First with the dialog's own SIZE attribute (if any) still in
-- place: IUP's reported natural size is max(content natural, SIZE), so a dialog built with
-- e.g. size = "HALFxHALF" reports a "natural" size of half the SCREEN on a large display,
-- unnoticeable on a small one - a beta report against Add Trees' main dialog. Then again
-- with SIZE cleared, for the content-only natural size that the floor/minsize below must
-- use (SIZE is an opening-size hint, not a floor). When the dialog has no SIZE attribute
-- the two measurements are identical and behaviour is exactly as before.
dialog.minsize = iup.NULL
iup.Refresh(dialog)
local naturalWithUser = dialog.naturalsize -- includes any SIZE= user size (e.g. HALFxHALF)
dialog.size = iup.NULL -- clear the user size: an opening-size hint, not a floor
iup.Refresh(dialog)
local true_natural = dialog.naturalsize -- content-only natural
-- Work out size/position via the pure placement function (Stage 4, 5 Jul 2026 moved the
-- floor/cap logic here from the consumer plugins; the monitor-bounds validation it relied
-- on only ever checked a monitor's origin, so any large positive coordinate passed and the
-- dialog could open off-screen - computePlacement checks the full monitor rect instead).
local openW, openH = tostring(naturalWithUser or ""):match("^(%d+)x(%d+)$")
local natW, natH = tostring(true_natural or ""):match("^(%d+)x(%d+)$")
local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
local monitors = parseMonitorsInfo(iup.GetGlobal("MONITORSINFO"))
local savedW, savedH
if savedRaster ~= "" then
local w, h = savedRaster:match("^(%d+)x(%d+)$")
savedW, savedH = tonumber(w), tonumber(h)
end
local placement = computePlacement(
{ x = savedX, y = savedY, w = savedW, h = savedH },
{ w = natW and tonumber(natW) or nil, h = natH and tonumber(natH) or nil },
monitors,
{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil },
{ w = openW and tonumber(openW) or nil, h = openH and tonumber(openH) or nil }
)
if placement.w and placement.h then
dialog.rastersize = placement.w .. "x" .. placement.h
end
-- Floor future user resizes at the (screen-capped) natural size.
if placement.minW and placement.minH then
dialog.minsize = placement.minW .. "x" .. placement.minH
end
-- Override the show callback to restore minimum size after dialog is shown
local originalShow = dialog.show_cb
dialog.show_cb = function(self, state)
-- Call the original show callback if it exists
if originalShow then
originalShow(self, state)
end
return iup.DEFAULT
end
-- Record the first dialog tracked as the plugin's main window (showConfigDialog uses this
-- to centre the Options dialog reliably - see the PARENTDIALOG comment there).
if not config._trackedMainDialog then
config._trackedMainDialog = dialog
end
-- Position the dialog based on saved coordinates or center it
if placement.x and placement.y then
dialog:showxy(placement.x, placement.y)
else
dialog:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
end
end
function Config:getValue(section, key, default, validator)
local value = self.cache[section] and self.cache[section][key]
if value == nil then
local valueType = type(default)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
-- Use pcall to handle potential errors when reading from file
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, key, fhType, default)
end)
if success then
value = result
else
-- If reading fails, use the default value
value = default
end
self.cache[section] = self.cache[section] or {}
self.cache[section][key] = value
end
if validator and not validator(value) then
return default
end
return value
end
function Config:setValues(section, prefix, valueTable)
self.cache[section] = self.cache[section] or {}
for key, value in pairs(valueTable) do
local fullKey = prefix and (prefix .. "." .. key) or key
local valueType = type(value)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
local oldValue = self.cache[section][fullKey]
-- Use pcall to handle potential errors when writing to file
local success = pcall(function()
fhSetIniFileValue(self.filePath, section, fullKey, fhType, value)
end)
if success then
self.cache[section][fullKey] = value
if oldValue ~= value and self.callbacks[section] and self.callbacks[section][fullKey] then
self.callbacks[section][fullKey](value, oldValue)
end
else
-- If writing fails, still update the cache but log the error
-- This ensures the application continues to work even if file writing fails
self.cache[section][fullKey] = value
end
end
end
function Config:getValues(section, prefix, defaultTable)
local results = {}
for key, default in pairs(defaultTable) do
local fullKey = prefix and (prefix .. "." .. key) or key
local valueType = type(default)
local fhType = valueType == "string" and "text"
or valueType == "number" and "integer"
or valueType == "boolean" and "bool"
or valueType
-- Use pcall to handle potential errors when reading from file
local success, result = pcall(function()
return fhGetIniFileValue(self.filePath, section, fullKey, fhType, default)
end)
if success then
results[key] = result
else
-- If reading fails, use the default value
results[key] = default
end
end
return results
end
--- Create a control for a config field. When sectionTitle is passed (createControl(sectionTitle, field, value)),
--- it is available for controls that need section context. Values are saved only when the user chooses Save.
---@param a string|table Section title (3-arg) or field definition (2-arg)
---@param b table|any Field definition (3-arg) or value (2-arg)
---@param c any|nil Current value (3-arg only)
---@return iup.element control The control whose value holds the field value.
---@return iup.element? display Optional composite container to place instead of the control (pickers).
---@return fun()? onValueSet Optional hook to re-sync the control's display after a programmatic value change (colour swatches).
function Config:createControl(a, b, c)
local sectionTitle, field, value
if c ~= nil then
sectionTitle, field, value = a, b, c
else
field, value = a, b
sectionTitle = nil
end
if field.type == "text" or field.type == "string" then
local opts = {
value = value ~= nil and tostring(value) or "",
-- Restore-on-blank target for Dialog.makeText: the field's default. Lets an optional text
-- field be cleared (default "" -> stays blank) instead of snapping back to the old value.
default = field.default,
}
if field.mask == "TOKEN" then
-- Tokens are matched against template text case-sensitively, so
-- keep them upper-case. FILTER converts typed input live (the
-- native Windows edit style), unlike the old "[A-Z0-9.]*" MASK
-- which rejected lower-case keys and kept only the first letter.
opts.value = (opts.value or ""):upper()
opts.filter = "UPPERCASE"
elseif field.mask then
opts.mask = field.mask
end
-- Optional per-field validation when focus leaves the box (e.g. checking a privacy
-- flag exists). Receives the control's current value.
if field.onBlur then
opts.killfocus = function(self)
field.onBlur(self.value)
end
end
return makeText(opts)
elseif field.type == "number" then
return makeText({
value = tostring(value),
mask = iup.MASK_FLOAT, -- FH's iuplua registers iup.MASK_FLOAT; IUP_MASK_FLOAT is nil
})
elseif field.type == "boolean" then
return makeToggle({
title = "",
value = value and "ON" or "OFF",
})
elseif field.type == "list" then
local selectedIndex = 1
for i, opt in ipairs(field.options) do
if opt == value then
selectedIndex = i
break
end
end
-- sort=NO is essential: the value is mapped to its POSITION in
-- field.options, so the list must keep insertion order (the IUPLIST
-- theme default sorts alphabetically, which desynchronises the
-- index from the option). Set the value after the items are
-- populated so the selection actually takes.
local list = makeList({
dropdown = "YES",
sort = "NO",
values = field.options or {},
})
list.value = tostring(selectedIndex)
return list
elseif field.type == "font" then
local btn = iup.button({ title = tostring(value or "Choose..."), expand = "HORIZONTAL" })
btn.__value = tostring(value or "")
if btn.__value ~= "" then
btn.font = btn.__value
end
function btn:action()
local start = self.__value or ""
local dlg = iup.fontdlg({ value = start })
pcall(function()
dlg.parentdialog = iup.GetDialog(self)
end)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" and dlg.value and dlg.value ~= "" then
self.__value = dlg.value
self.title = dlg.value
self.font = dlg.value
end
return iup.DEFAULT
end
return btn
elseif field.type == "color" or field.type == "colorRGBA" then
local startHex = tostring(value or (field.type == "colorRGBA" and "#000000FF" or "#000000"))
if field.type == "colorRGBA" then
-- Create a flat button for RGBA with integrated color display
local btn = iup.flatbutton({ title = startHex, expand = "HORIZONTAL" })
btn.__value = startHex
btn.border = "YES"
btn.borderwidth = "1"
btn.bordercolor = "128 128 128"
local function applyStyle()
local hex = btn.__value
local base = hex:sub(1, 7) -- Get #RRGGBB part
-- Parse alpha if present
local alpha = 255
if #hex == 9 then -- #RRGGBBAA
alpha = tonumber(hex:sub(8, 9), 16) or 255
end
-- Blend color with dialog background (assume white background)
local r = tonumber(hex:sub(2, 3), 16) or 0
local g = tonumber(hex:sub(4, 5), 16) or 0
local b = tonumber(hex:sub(6, 7), 16) or 0
-- Alpha blend with white background (255, 255, 255)
local alphaRatio = alpha / 255
local blendedR = math.floor(r * alphaRatio + 255 * (1 - alphaRatio))
local blendedG = math.floor(g * alphaRatio + 255 * (1 - alphaRatio))
local blendedB = math.floor(b * alphaRatio + 255 * (1 - alphaRatio))
local blendedHex = string.format("#%02X%02X%02X", blendedR, blendedG, blendedB)
btn.bgcolor = blendedHex -- Use alpha-blended background color
btn.fgcolor = base -- Keep text color as the original color
end
applyStyle()
function btn:map_cb()
applyStyle()
return iup.DEFAULT
end
function btn:flat_action()
local current = self.__value or "#000000FF"
local baseHex = current:sub(1, 7)
local alpha = 255
if #current == 9 then -- #RRGGBBAA
alpha = tonumber(current:sub(8, 9), 16) or 255
end
local dlg = iup.colordlg({})
dlg.valuehex = baseHex
dlg.showhex = "YES"
dlg.showalpha = "YES"
dlg.alpha = tostring(alpha)
dlg.parentdialog = iup.GetDialog(self)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local hex = tostring(dlg.valuehex or baseHex)
local aa = tonumber(dlg.alpha) or alpha
aa = math.max(0, math.min(255, aa))
hex = string.format("%s%02X", hex, aa)
self.__value = hex
self.title = hex
applyStyle()
end
return iup.DEFAULT
end
return btn, nil, applyStyle
else
-- Regular color button for non-RGBA
local btn = iup.button({ title = startHex, expand = "HORIZONTAL" })
btn.__value = startHex
local function applyStyle()
local base = btn.__value:sub(1, 7) -- Get #RRGGBB part
btn.fgcolor = base
end
applyStyle()
function btn:map_cb()
applyStyle()
return iup.DEFAULT
end
function btn:action()
local current = self.__value or "#000000"
local dlg = iup.colordlg({})
dlg.valuehex = current
dlg.showhex = "YES"
dlg.parentdialog = iup.GetDialog(self)
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local hex = tostring(dlg.valuehex or current)
self.__value = hex
self.title = hex
applyStyle()
end
return iup.DEFAULT
end
return btn, nil, applyStyle
end
elseif field.type == "choice" then
-- Simple dropdown using provided field.choices list
local combo = iup.list({
dropdown = "YES",
expand = "HORIZONTAL",
})
-- Populate options
local choices = field.choices or {}
local selectedIndex = 1
for i, opt in ipairs(choices) do
combo[tostring(i)] = tostring(opt)
if value == opt then
selectedIndex = i
end
end
-- Fallbacks for values not in choices
if value ~= nil and value ~= "" then
if #choices == 0 then
-- No predefined choices: treat current value as the sole option
combo["1"] = tostring(value)
selectedIndex = 1
else
-- choices present: insert custom value at index 0 if not found
local found = false
for _, opt in ipairs(choices) do
if opt == value then
found = true
break
end
end
if not found then
-- Insert at position 0 visually; leave underlying field.choices untouched
combo["0"] = tostring(value)
selectedIndex = 0
end
end
end
combo.value = tostring(selectedIndex)
return combo
elseif field.type == "colour" then
-- A hex colour whose own field background shows the colour (an iup.label's BGCOLOR
-- does not paint on Windows, but a text field's edit-area background does), plus a
-- native picker. The value lives on the text control's .value (a hex string), so it
-- rides the default value paths (savedValueFor/applyValueToControl/controlValue). The
-- returned refresh hook (onValueSet) repaints the swatch after a programmatic change
-- (a scheme/theme load, or Reset), which does not fire valuechanged_cb.
local text = makeText({
value = tostring(value or "#ffffff"),
mask = "#[0-9a-fA-F]+",
})
local function refreshSwatch()
local r, g, b = tostring(text.value or ""):match("^#(%x%x)(%x%x)(%x%x)$")
if r then
local rn, gn, bn = tonumber(r, 16), tonumber(g, 16), tonumber(b, 16)
text.bgcolor = string.format("%d %d %d", rn, gn, bn)
-- Contrasting text so the hex stays readable on its colour.
local luminance = 0.299 * rn + 0.587 * gn + 0.114 * bn
text.fgcolor = (luminance > 140) and "0 0 0" or "255 255 255"
end
end
refreshSwatch()
text.valuechanged_cb = function()
refreshSwatch()
return iup.DEFAULT
end
local pick = makeAssistantButton({
tip = "Choose a colour",
callback = function()
local dlg = iup.colordlg({ title = field.label:gsub(":%s*$", "") })
local r, g, b = tostring(text.value or ""):match("^#(%x%x)(%x%x)(%x%x)$")
if r then
dlg.value = string.format("%d %d %d", tonumber(r, 16), tonumber(g, 16), tonumber(b, 16))
end
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if dlg.status == "1" then
local rr, gg, bb = tostring(dlg.value):match("^(%d+)%s+(%d+)%s+(%d+)")
if rr then
text.value = string.format("#%02x%02x%02x", tonumber(rr), tonumber(gg), tonumber(bb))
refreshSwatch()
end
end
dlg:destroy()
end,
})
return text, iup.hbox({ text, pick, alignment = "ACENTER" }), refreshSwatch
elseif field.type == "folder" or field.type == "file" then
-- A read-only path display plus a native browse dialog. The user
-- may choose ANY folder/file; field.root (a string, or a function
-- returning one) is only a convenient starting directory, not a
-- restriction. The value lives on the text control's own .value
-- (no smuggling on a container).
local fhfu = require("fhFileUtils")
local function pickerRoot()
local root = field.root
if type(root) == "function" then
root = root()
end
if type(root) ~= "string" or root == "" then
root = field.default
end
if type(root) ~= "string" or root == "" then
root = fhGetContextInfo("CI_PROJECT_PUBLIC_FOLDER") or ""
end
return tostring(root or "")
end
local text = makeText({
value = tostring(value or ""),
readonly = "YES",
expand = "HORIZONTAL",
})
local browse = makeAssistantButton({
tip = (field.type == "folder") and "Browse for a folder" or "Browse for a file",
callback = function()
local start = text.value
if start == "" or not (fhfu.folderExists(start) or fhfu.fileExists(start)) then
start = pickerRoot()
end
local dlg = iup.filedlg({
dialogtype = (field.type == "folder") and "DIR" or "OPEN",
title = (field.label or ""):gsub(":%s*$", ""),
directory = (start ~= "") and start or nil,
parentdialog = identifyActiveWindow(),
})
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
-- STATUS is "-1" on cancel; "0"/"1" select an existing/new path.
if tostring(dlg.status) ~= "-1" and dlg.value and dlg.value ~= "" then
text.value = (dlg.value):gsub("[/\\]+$", "")
end
dlg:destroy()
end,
})
return text, iup.hbox({ text, browse, alignment = "ACENTER" })
else
error("Unsupported field type: " .. field.type)
end
end
---@class ConfigDialogApi
---@field setValue fun(sectionTitle: string, key: string, value: any) Push a value into a field's control.
---@field setListOptions fun(sectionTitle: string, key: string, newOptions: string[], selected?: string) Replace a list field's options in place.
---@class ConfigExtraAction
---@field title string Button label.
---@field tip? string Tooltip.
---@field section? string If set, render the button inside that section's tab only, instead of on the bottom row shown beneath every tab.
---@field action fun(values: table<string, table<string, any>>, api: ConfigDialogApi) Receives the current (unsaved) values keyed by section then field key, plus the live-dialog API.
---Show the options dialog.
---@param options? table Configuration structure; defaults to the one passed to new().
---@param help_topic? string Help topic for F1/Help menu.
---@param extraActions? ConfigExtraAction[] Buttons that read the live, unsaved control values (e.g. Preview).
function Config:showConfigDialog(options, help_topic, extraActions)
options = options or self.defaultConfig
help_topic = help_topic or "options"
-- Pop the Options dialog up centred on the plugin's main window, clamped fully onto
-- whichever monitor that window is on - plain iup.CENTERPARENT does not clamp to the
-- screen, so with the main window near the top of the display and Options taller than it,
-- the title bar could open above the screen until the next resize re-clamped it (beta
-- report). Falls back to CENTERPARENT if the parent's geometry can't be read (e.g. no
-- tracked main dialog yet, or a screenposition/rastersize parse failure). Used on every
-- open - both the reuse fast path below and the first-build popup at the end - since the
-- main window can have moved between opens.
local function popupPlaced(dlg)
local parent
local main = self._trackedMainDialog
if main then
local pos, raster = main.screenposition, main.rastersize
if pos and raster then
local px, py = pos:match("^(%-?%d+),(%-?%d+)$")
local pw, ph = raster:match("^(%d+)x(%d+)$")
if px and py and pw and ph then
parent = { x = tonumber(px), y = tonumber(py), w = tonumber(pw), h = tonumber(ph) }
end
end
end
local dw, dh
local dr = dlg.rastersize
if dr then
local w, h = dr:match("^(%d+)x(%d+)$")
dw, dh = tonumber(w), tonumber(h)
end
local monitors = parseMonitorsInfo(iup.GetGlobal("MONITORSINFO"))
local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
local r = placeOverParent(
parent,
{ w = dw, h = dh },
monitors,
{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil }
)
if r.x and r.y then
dlg:popup(r.x, r.y)
else
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
end
end
-- Build the options dialog ONCE, then reuse it. Rebuilding it on every
-- open - a fresh dialog, a fresh menu, and the controls re-added to the
-- global normalisers - corrupted native state and silently killed FH
-- on the second open (release builds only; not seen in the plugin
-- debugger). On reopen we just reload the saved values into the
-- existing controls and show the same dialog again.
if self._optionsDialog then
if self._optionsReload then
self._optionsReload()
end
popupPlaced(self._optionsDialog)
return
end
local controls = {}
local mainContent
local allSections = {}
local sectionMenuItems = {}
-- Auto-assign an Alt-key access key to each section-selector menu item. The section items sit in the
-- menu bar beside File and Help, so their mnemonics must not collide with those (F, H), with each
-- other, or with the dialog's action buttons (which the menu bar would otherwise shadow). We insert
-- '&' before the first ASCII letter of the title that is still free; if a title already has an '&'
-- mnemonic we respect it, and if nothing is free we leave the title plain (no mnemonic).
local usedMnemonics = { F = true, H = true } -- File / Help are always present
for _, extra in ipairs(extraActions or {}) do -- reserve each action button's access key
local m = type(extra.title) == "string" and extra.title:match("&([A-Za-z])")
if m then usedMnemonics[m:upper()] = true end
end
local function sectionTitleWithMnemonic(title)
local existing = title:match("&([A-Za-z])")
if existing then
usedMnemonics[existing:upper()] = true
return title
end
for i = 1, #title do
local ch = title:sub(i, i)
if ch:match("[A-Za-z]") and not usedMnemonics[ch:upper()] then
usedMnemonics[ch:upper()] = true
return title:sub(1, i - 1) .. "&" .. title:sub(i)
end
end
return title -- no free letter; leave it without a mnemonic
end
-- Forward declaration: section-targeted action buttons (built in the
-- section loop) call this, but it is defined after the loop.
local collectValues
-- Set one control to one value, per field type. Shared by Reset (with
-- the field default) and by reload-on-reopen (with the saved value).
local function applyValueToControl(field, control, value)
if field.type == "boolean" then
control.value = value and "ON" or "OFF"
elseif field.type == "list" then
for i, opt in ipairs(field.options or {}) do
if opt == value then
control.value = tostring(i)
return
end
end
elseif field.type == "font" then
control.__value = tostring(value or "")
control.title = (control.__value ~= "") and control.__value or "Choose..."
if control.__value ~= "" then
pcall(function() control.font = control.__value end)
end
elseif field.type == "color" or field.type == "colorRGBA" then
local fallback = field.type == "colorRGBA" and "#000000FF" or "#000000"
control.__value = tostring(value or fallback)
control.title = control.__value
-- The swatch is repainted by the field's onValueSet (applyStyle),
-- fired by the caller after this.
elseif field.type == "choice" then
for i, opt in ipairs(field.choices or {}) do
if opt == value then
control.value = tostring(i)
return
end
end
else
control.value = tostring(value)
end
end
-- The saved value of a field, read the same way createControl reads it.
local function savedValueFor(field, sectionTitle, key)
if
field.type == "font"
or field.type == "color"
or field.type == "colorRGBA"
or field.type == "colour"
or field.type == "folder"
or field.type == "file"
then
return self:getString(sectionTitle, key, field.default or "")
end
return self:getValue(sectionTitle, key, field.default)
end
-- Pull a control's current (unsaved) value, coerced to the field type.
local function controlValue(field, control)
if field.type == "number" then
local v = tonumber(control.value)
if v == nil then
v = field.default
end
return v
elseif field.type == "boolean" then
return control.value == "ON"
elseif field.type == "list" then
if control.value == "0" then
return field.default
end
return field.options[tonumber(control.value)]
elseif field.type == "choice" then
local idx = tonumber(control.value)
if idx == 0 then
-- Custom value not in the choices list, held at combo["0"].
return (control["0"] and control["0"] ~= "") and control["0"] or field.default
elseif field.choices and idx and field.choices[idx] then
return field.choices[idx]
end
return field.default
elseif field.type == "font" or field.type == "color" or field.type == "colorRGBA" then
return tostring(control.__value or "")
else
-- text / string / folder / file
local v = control.value
if field.mask == "TOKEN" then
v = (v or ""):upper()
end
return v
end
end
-- The sections live in a zbox, which sizes itself to the LARGEST child
-- and shows one at a time. That gives a stable dialog size that fits
-- every tab - no resizing as you switch, and no controls clipped (the
-- previous visible/floating + Refresh approach resized the dialog to
-- each tab in turn, which shrank it below some tabs' width). The visible
-- child is selected by its 0-based position (VALUEPOS), a plain
-- attribute. (Selecting by handle would also work - iuplua implements
-- IupSetAttributeHandle via attribute assignment, e.g. zbox.value = child,
-- spike S6 - but the position is what we track here anyway.)
local sectionZbox
local function showSection(sectionToShow)
if not sectionZbox then
return
end
for i, section in ipairs(allSections) do
if section == sectionToShow then
sectionZbox.valuepos = tostring(i - 1)
return
end
end
end
-- A small API handed to field liveChange callbacks and extra-action
-- buttons, for updating other live controls in the open dialog.
---@type ConfigDialogApi
local dialogApi = {}
function dialogApi.setValue(sectionTitle, key, value)
local info = controls[sectionTitle] and controls[sectionTitle][key]
if info then
applyValueToControl(info.field, info.control, value)
if info.onValueSet then
info.onValueSet()
end
end
end
function dialogApi.setListOptions(sectionTitle, key, newOptions, selected)
local info = controls[sectionTitle] and controls[sectionTitle][key]
if info and info.field.type == "list" then
info.field.options = newOptions
populateList(info.control, newOptions)
if selected ~= nil then
applyValueToControl(info.field, info.control, selected)
end
end
end
-- Field labels live in their OWN normaliser so they line up with each
-- other inside the Options dialog WITHOUT joining the global btnnorm -
-- a long option label there would otherwise stretch every label and
-- button in every other dialog the plugin opens (the makeLabel /
-- DoNormalize trap). Callers can pass options.labelNormalizer to share
-- this normaliser with other parts of their UI; default is a fresh,
-- dialog-local one. We fire it ourselves below, after DoNormalize.
local labelNorm = options.labelNormalizer or iup.normalizer({})
sectionZbox = iup.zbox({})
mainContent = sectionZbox
for _, section in ipairs(options.sections or {}) do
local sectionContent = iup.vbox({})
controls[section.title] = {}
-- Section action buttons (extra.section) - shown FIRST in the tab and left-aligned (under
-- the label column), so a prominent action like a picker leads the section rather than
-- trailing the fields.
for _, extra in ipairs(extraActions or {}) do
if extra.section == section.title then
sectionContent:append(iup.hbox({
makeButton({
title = extra.title,
tip = extra.tip,
callback = function()
extra.action(collectValues(), dialogApi)
end,
}),
iup.fill({}),
-- Indent to line the button up under the field labels, which carry the theme's
-- 20px horizontal label padding (so the button doesn't sit proud to their left).
margin = "20x0",
}))
end
end
for _, field in ipairs(section.fields) do
local value
if field.type == "font" then
value = self:getString(section.title, field.key, field.default or "")
elseif field.type == "color" then
value = self:getString(section.title, field.key, field.default or "#000000")
elseif field.type == "colorRGBA" then
value = self:getString(section.title, field.key, field.default or "#000000FF")
elseif field.type == "colour" then
value = self:getString(section.title, field.key, field.default or "#ffffff")
elseif field.type == "folder" or field.type == "file" then
-- Read from file so the display matches the saved value (cache
-- may predate the file write); keep cache in step.
value = self:getString(section.title, field.key, field.default or "")
self.cache[section.title] = self.cache[section.title] or {}
self.cache[section.title][field.key] = value
else
value = self:getValue(section.title, field.key, field.default)
end
local label = makeLabel({ title = field.label, normalizergroup = labelNorm })
local control, display, onValueSet = self:createControl(section.title, field, value)
-- The tooltip lives on the CONTROL, not the label - consistent with the plugins' main
-- UIs, which tip the field you interact with (so the tip never jumps side-to-side as you
-- move down the dialog). field.tip lets a plugin override the wording (e.g. a placeholder
-- hint); otherwise the field's description. Routed through setTipWithHelp so long tips wrap.
-- `control` is always the value-bearing widget (for folder/file that's the text box, not
-- the hbox), so this lands on the right place for every field type.
local fieldTip = field.tip or field.description
if control and fieldTip and fieldTip ~= "" then
setTipWithHelp(control, fieldTip)
end
controls[section.title][field.key] = {
control = control,
field = field,
onValueSet = onValueSet,
}
-- Wire list-field live-change callbacks (e.g. a colour-scheme
-- list loading its palette into the colour fields). DIALOG-only;
-- must NOT be named onChange (a save-time (value, oldValue)
-- callback) - firing that here would pass a string where the
-- dialog API is expected.
if field.liveChange and field.type == "list" then
control.action = function(_, text, _, state)
if state == 1 then
field.liveChange(text, dialogApi)
end
return iup.DEFAULT
end
end
-- Validate-on-leave: if a field defines validate(value) -> ok[, message], check it when
-- the control loses focus and warn at once. The Save action re-checks as a backstop. The
-- guard stops the warning's own focus change from re-entering the callback.
if field.validate then
local validating = false
control.killfocus_cb = function(ctrl)
if validating then return end
local ok, message = field.validate(controlValue(field, ctrl))
if not ok then
validating = true
fhMessageBox((field.label or field.key):gsub("%s*:%s*$", "") .. " - "
.. (message or "invalid value"), "MB_OK", "MB_ICONWARNING")
validating = false
end
end
end
sectionContent:append(iup.hbox({
label,
display or control,
alignment = "ACENTER",
}))
end
local sectionBox = iup.scrollbox({ sectionContent, expand = "YES" })
table.insert(allSections, sectionBox)
iup.Append(sectionZbox, sectionBox)
table.insert(sectionMenuItems, {
title = sectionTitleWithMnemonic(section.title),
action = function()
showSection(sectionBox)
return iup.DEFAULT
end,
})
end
-- Read every control's current (unsaved) value, coerced to its type.
-- (Assigns the forward-declared local so section-targeted buttons can
-- reference it.)
---@return table<string, table<string, any>>
collectValues = function()
local all = {}
for section, fields in pairs(controls) do
local values = {}
for key, info in pairs(fields) do
values[key] = controlValue(info.field, info.control)
end
all[section] = values
end
return all
end
-- Save all control values back to config.
local function saveAll()
for section, values in pairs(collectValues()) do
self:setValues(section, nil, values)
end
end
local function resetAllToDefaultsAndRefresh()
local response = iup.Alarm(
"Confirm Reset All",
"Are you sure you want to reset ALL settings in ALL sections to their defaults?",
"Yes",
"No"
)
if response == 1 then
self:resetToDefaults()
for _, sectionControls in pairs(controls) do
for _, info in pairs(sectionControls) do
applyValueToControl(info.field, info.control, info.field.default)
if info.onValueSet then
info.onValueSet()
end
end
end
end
return iup.DEFAULT
end
-- Reload saved values into the existing controls; used when the dialog
-- is reopened (it is built only once). Reading from saved config means
-- closing without Save discards unsaved edits.
local function reload()
for sectionTitle, sectionControls in pairs(controls) do
for key, info in pairs(sectionControls) do
applyValueToControl(info.field, info.control, savedValueFor(info.field, sectionTitle, key))
if info.onValueSet then
info.onValueSet()
end
end
end
if #allSections > 0 then
showSection(allSections[1])
end
end
-- Extra actions are either pinned to a section (extra.section -> a
-- button inside that tab, built above) or shown along the bottom row.
local buttonActions = {}
for _, extra in ipairs(extraActions or {}) do
if not extra.section then
buttonActions[#buttonActions + 1] = extra
end
end
-- Hoisted so the Save/Cancel/Esc closures below (built as part of menuBarData, before
-- makeDialog returns) can read the dialog's live size once it exists - they share this
-- upvalue, which is assigned further down.
local dialog
-- Persist the Options dialog's current size to the per-machine geometry store, so it's
-- restored next open. Skipped while maximized/minimized (rastersize is unreliable in
-- either state - the tracked-dialog close_cb applies the same guard). There is deliberately
-- no close_cb on this dialog (see the comment on makeDialog below), so the title-bar X
-- can't reach this - only Save, Cancel and Esc do.
local function saveOptionsSize()
if dialog and dialog.maximized ~= "YES" and dialog.minimized ~= "YES" then
setGeometryValue("Dialog_Options.rastersize", "text", dialog.rastersize)
end
end
---@type MenuBarData
local menuBarData = MenuBar.createMenuBar({
items = sectionMenuItems,
-- Key names sort into menu order (Save, Reset All, Cancel); the
-- "cancel" key also tells MenuBar not to add an automatic Exit.
fileMenu = {
aSave = {
title = "&Save",
action = function()
-- Per-field validation: a field may define validate(value) -> ok[, message].
-- Any failure blocks the Save and keeps the dialog open so the user can fix it.
local problems = {}
for _, sectionControls in pairs(controls) do
for key, info in pairs(sectionControls) do
if info.field.validate then
local ok, message = info.field.validate(controlValue(info.field, info.control))
if not ok then
local labelText = (info.field.label or key):gsub("%s*:%s*$", "")
problems[#problems + 1] = labelText .. " - " .. (message or "invalid value")
end
end
end
end
if #problems > 0 then
fhMessageBox("Please fix the following before saving:\n\n- "
.. table.concat(problems, "\n- "), "MB_OK", "MB_ICONWARNING")
return iup.DEFAULT
end
saveAll()
saveOptionsSize()
return iup.CLOSE
end,
},
bResetAll = {
title = "&Reset All",
action = function()
return resetAllToDefaultsAndRefresh()
end,
},
cancel = {
title = "&Cancel",
action = function()
saveOptionsSize()
return iup.CLOSE
end,
},
},
helpMenu = {
help = {
title = "&Help",
action = function()
help:show(help_topic)
return iup.DEFAULT
end,
},
},
})
-- Optional action buttons (e.g. Preview) working on the live values,
-- shown on a shared bottom row beneath every tab.
local dialogContent = { mainContent }
if #buttonActions > 0 then
local buttons = { iup.fill({}) }
for _, extra in ipairs(buttonActions) do
buttons[#buttons + 1] = makeButton({
title = extra.title,
tip = extra.tip,
callback = function()
extra.action(collectValues(), dialogApi)
end,
})
end
dialogContent[#dialogContent + 1] = iup.hbox(buttons)
end
dialogContent.margin = "10x10"
dialog = makeDialog(
iup.vbox(dialogContent),
-- Deliberately NO close_cb: for a dialog shown with popup, the
-- title-bar X must take IUP's default close action. Calling
-- hide() in a close_cb hung the teardown, and returning
-- iup.CLOSE from one exits an EXTRA message loop on top of the
-- close itself, ending the plugin's main loop too. Buttons and
-- menu items are different: there, returning iup.CLOSE is the
-- only thing that ends the popup.
{
title = options.title or self.defaultConfig.title,
resize = "YES",
menubox = "YES",
menu = menuBarData.menuBar,
help_topic = help_topic,
-- Esc mirrors Cancel: close and discard unsaved edits. Returning iup.CLOSE from this key
-- handler is safe (it acts like the Cancel menu item, not a close_cb - see the note above).
-- No Enter default: Save is Alt+S / the File menu, and Enter belongs to the focused field.
on_escape = function()
saveOptionsSize()
return iup.CLOSE
end,
}
)
self._optionsReload = reload
self._optionsDialog = dialog
-- makeDialog has already normalised this dialog independently (its own button/text normalisers,
-- not the global DoNormalize that used to re-size every other open dialog). Field labels keep the
-- Options-local labelNorm, fired here.
labelNorm.normalize = "HORIZONTAL"
if #allSections > 0 then
showSection(allSections[1])
end
-- First-build sizing: cap the initial size at the screen (minus the taskbar/title-bar
-- allowance) and restore any saved size. No natural-size floor here, unlike
-- showTrackedDialog's computePlacement: on a small screen the natural size can exceed the
-- cap, and a user's deliberately-reduced height must survive across sessions - each
-- section already scrolls (iup.scrollbox, above), so a capped/reduced size never clips a
-- field, it just hides it behind a scrollbar. capSize's 300x200 sanity floor guards against
-- a corrupt or zeroed saved value producing an unusable dialog.
iup.Refresh(dialog)
local natW, natH = tostring(dialog.naturalsize or ""):match("^(%d+)x(%d+)$")
local savedRaster = getGeometryValue("Dialog_Options.rastersize", "text", "")
local savedW, savedH
if savedRaster ~= "" then
local w, h = savedRaster:match("^(%d+)x(%d+)$")
savedW, savedH = tonumber(w), tonumber(h)
end
local scrW, scrH = tostring(iup.GetGlobal("SCREENSIZE") or ""):match("^(%d+)x(%d+)$")
local size = capSize(
{ w = savedW, h = savedH },
{ w = natW and tonumber(natW) or nil, h = natH and tonumber(natH) or nil },
{ w = scrW and tonumber(scrW) or nil, h = scrH and tonumber(scrH) or nil }
)
if size.w and size.h then
dialog.rastersize = size.w .. "x" .. size.h
end
-- Deterministic parent: makeDialog otherwise picks a parent from whatever control has
-- keyboard focus at build time (Dialog.lua's getParentDialogInfo) - built once, here, on
-- first open, that can be an unrelated control rather than the plugin's main window, so
-- CENTERPARENT centres on the wrong thing. Override it with the first dialog
-- showTrackedDialog tracked, if there is one. (This binding's iuplua has no
-- iup.SetAttributeHandle function - IupSetAttributeHandle is reached via plain attribute
-- assignment instead, the same idiom Dialog.lua uses for DEFAULTENTER/DEFAULTESC.)
if self._trackedMainDialog then
dialog.PARENTDIALOG = self._trackedMainDialog
end
-- Never destroyed (FH frees plugin dialogs at script end); reused on
-- the next open via the fast path at the top of this function. Positioning is explicit
-- (popupPlaced, defined above) rather than plain CENTERPARENT - see its comment.
popupPlaced(dialog)
end
function Config:resetToDefaults(section)
if section then
local sectionConfig = nil
for _, sec in ipairs(self.defaultConfig.sections or {}) do
if sec.title == section then
sectionConfig = sec
break
end
end
if sectionConfig then
local values = {}
for _, field in ipairs(sectionConfig.fields) do
values[field.key] = field.default
end
self:setValues(section, nil, values)
end
else
for _, sec in ipairs(self.defaultConfig.sections or {}) do
local values = {}
for _, field in ipairs(sec.fields) do
values[field.key] = field.default
end
self:setValues(sec.title, nil, values)
end
end
end
_G.Config = M
end
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/Results.lua">>
;(function()
--[[
Results.lua - shared Results class for an FH plugin's end-of-run outcome
@Author: Helen Wright
@Version: 1.1
@LastUpdated: 23 July 2026
@V1.1: NoResults's message is now suppressible - NoResults(nil) (or "") makes Display()
show nothing on zero rows instead of an "informational" fhMessageBox. A consumer
that never calls NoResults, or that sets a string as before, is unaffected: the
default message is unchanged and the box only disappears when a plugin opts in
with a nil/empty message. Family-wide UX policy (23 Jul 2026 beta discussion): an
idle close (no work done) should be silent - the old "No X were Y"-style farewell
boxes are noise now that the run's outcome lands in FH's Result Set window anyway.
]]
---Results class for managing the results display within an FH plugin
---@param intTableCount integer Number of columns in the results table
---@return table Results object with methods for managing result display
function Results(intTableCount)
---Local shallow copy helper - creates a copy of a table without deep copying nested structures
---This is used to safely copy configuration arrays without creating references
---@param tbl table Table to copy
---@return table Shallow copy of the input table
local function shallow_copy(tbl)
local t = {}
for k, v in pairs(tbl) do
t[k] = v
end
return t
end
--public methods and associated private state variables
local iRes = 0 -- index used to track results - counts how many result rows we have
local strTitle = "" -- stores the title for the results window
local strNoResults = "" -- stores the message to display when there are no results
local tblResults = {} --table of results tables - stores all the data for each column
local tblVisibility = {} -- controls which columns are visible in the results
local tblSort = {} -- defines the sort order for each column
local tblResultHeadings = {} -- stores the column headers
local tblResultType = {} -- defines the data type for each column (text, integer, item, etc.)
local tblResultWidth = {} -- defines the width for each column
-- Initialize the results tables - each table will hold one column of data
-- This creates separate arrays for each column to store the data efficiently
for i = 1, intTableCount do
tblResults[i] = {}
end
---Update function: adds a new row of results to the display
---tblNewResults should contain one value for each column
---This is the main method for adding data to the results
---@param tblNewResults table Array of values for the new row
local Update = function(tblNewResults)
iRes = iRes + 1 -- increment the result counter
for i, v in ipairs(tblNewResults) do
tblResults[i][iRes] = v -- store each value in its appropriate column
end
end
---Title function: sets the title for the results window
---This will be displayed at the top of the results window
---@param str string Title for the results window
local Title = function(str)
strTitle = str
end
---NoResults function: sets the message to display when there are no results
---This provides user feedback when no links are found
---@param str string Message to display when there are no results
local NoResults = function(str)
strNoResults = str
end
---Types function: defines the data type for each column
---Types can be: "text", "integer", "item", "date", etc.
---This affects how FH displays and sorts the data
---@param types table Array of data types for each column
local Types = function(types)
tblResultType = shallow_copy(types)
end
---Headings function: sets the column headers
---These are the labels that appear at the top of each column
---@param headings table Array of column header strings
local Headings = function(headings)
tblResultHeadings = shallow_copy(headings)
end
---Visibility function: controls which columns are shown
---Values can be "show" or "hide"
---"buddy" makes a column invisible but keeps it for sorting purposes
---@param visibility table Array of visibility settings for each column
local Visibility = function(visibility)
tblVisibility = shallow_copy(visibility)
end
---Sort function: defines the sort order for each column
---Lower numbers = higher priority in sorting
---This determines the default sort order when results are displayed
---@param sort table Array of sort priorities for each column
local Sort = function(sort)
tblSort = shallow_copy(sort)
end
---Width function: defines the width for each column
---@param width table Array of widths for each column
local Width = function(width)
tblResultWidth = shallow_copy(width)
end
---Display function: outputs all collected results to Family Historian's result window
---This is the final step that shows all the collected data to the user
local Display = function()
if iRes > 0 then -- there are results to display
-- Set the window title
fhOutputResultSetTitles(strTitle)
-- Output each column with its configuration
-- This creates the actual result set in FH's display
for i, _ in ipairs(tblResults) do
fhOutputResultSetColumn(
tblResultHeadings[i], -- Column header
tblResultType[i], -- Data type
tblResults[i], -- The data for this column
iRes, -- Number of rows
tblResultWidth[i] or 80, -- Column width or 80 if not set
"align_left", -- Text alignment
tblSort[i] or 0, -- Sort priority (0 = not part of the initial sort; nil crashes fhOutputResultSetColumn)
true, -- Sortable
"default", -- Sort direction
tblVisibility[i] -- Visibility setting
)
end
-- Update the display to show the results
fhUpdateDisplay()
elseif strNoResults ~= nil and strNoResults ~= "" then
-- No results found - show informational message (suppressed if NoResults(nil)/(""))
fhMessageBox(strNoResults, "MB_OK", "MB_ICONINFORMATION")
end
-- else: zero rows and no message set - silent idle close (NoResults(nil) opt-in)
end
--expose public methods - return an object with all the public functions
-- This creates the public interface for the Results class
return {
Title = Title,
Headings = Headings,
Visibility = Visibility,
Types = Types,
Update = Update,
Display = Display,
Sort = Sort,
NoResults = NoResults,
Width = Width,
}
end
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/Help.lua">>
;(function()
--[[
@class Help
@desc Provides online HTML help for Family Historian plugins, with context-sensitive support.
@prerequisites
Online Help:
- A valid help_root URL pointing to the online help location for your plugin.
@usage
-- Paste the Help class definition at the top of your script.
local help = Help.new{version="1.0"}
-- Add to menu: help:menu_item("topic")
-- Show help: help:show("topic")
-- Wire up IUP HELP_CB: help:attach_help_cb(control, "topic")
]]
do
local M = {}
local iup = require("iuplua")
---@class Help
---@field plugin_name string The name of the plugin.
---@field help_root string The root URL for online help.
local Help = {}
Help.__index = Help
---Create a new Help object
---@param opts {help_root?: string}
---@return Help
function M.new(opts)
local self = setmetatable({}, Help)
self.plugin_name = fhGetContextInfo("CI_PLUGIN_NAME")
self.help_root = opts and opts.help_root
or ("http://pluginstore.family-historian.co.uk/page/help/" .. self.plugin_name)
return self
end
---Open help for a topic (online only)
---@param topic string The help topic: can be a page ("options"), full page path ("guides/options.html"), or an anchor on index ("#options")
function Help:show(topic)
topic = topic or ""
-- Supports either a standalone page at help_root/topic or an anchor on index (when topic starts with '#').
local sep = self.help_root:sub(-1) == "/" and "" or "/"
local url
if topic:sub(1, 1) == "#" then
-- Anchor on index page: append fragment directly (no extra slash)
url = self.help_root .. topic
else
-- Treat as a page or path relative to help_root
url = self.help_root .. sep .. topic
end
-- basic normalization
url = url:gsub("%%20", "-")
url = url:gsub(" ", "-")
fhShellExecute(url)
end
---Create a menu item for help
---@param topic string The help topic to show when the menu item is clicked
---@param label string The label for the menu item
---@return iup.menuitem #The IUP menu item object
function Help:menu_item(topic, label)
label = label or "Help"
return iup.item({
title = label,
action = function()
self:show(topic)
end,
})
end
---Attach context help to an IUP control via help_cb
---@param control iup.control The IUP control to attach help to
---@param topic string The help topic to show when help is requested
function Help:attach_help_cb(control, topic)
control.help_cb = function()
self:show(topic)
return iup.IGNORE
end
end
_G.Help = M
end
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/Facts.lua">>
;(function()
--[[
Facts.lua - fact-type enumeration + multi-select picker (shared boilerplate)
@Author: Helen Wright
@Version: 2.0.1
@LastUpdated: 4 July 2026
A rebuild of the old FH6 Facts.lua. Scope here is the PICKER side only - enumerate the
project's fact types and let the user choose a set. Fact AUTHORING (the old Details/Create
machinery) is out of scope and largely superseded by fhUtils.createUpdateFact.
WHY THE REBUILD
- FH has no API that lists all fact types, so we still read the Fact Type definition files
(.fhf), but the layout differs from the old V6 one: Standard + Custom live under CI_APP_DATA_FOLDER\
Fact Types (per-machine OR per-user, depending how FH was installed - CI_APP_DATA_FOLDER
tracks that), and projects now have their OWN facts under CI_PROJECT_DATA_FOLDER\Fact Types.
- Each .fhf is clean INI: a [.index] section (Count + Item1..ItemN) then [FCT-<item>] blocks.
We parse it as INI (section-by-section) rather than the old order-dependent single regex, so
it is robust to field reordering. Item identifiers encode everything we need:
BIRT-IE standard: <TAG>-<I|F><E|A>
EVEN-TEST_PM2-FE custom event: EVEN-<customId>-<rec><type>
_ATTR-SHARED_DNA_WITH_ROOT-IA custom attribute: _ATTR-<customId>-<rec><type>
- We do NOT read the per-fact "Field Place/Date/..." capability flags. The picker only chooses
WHICH fact types to use; whether a given fact actually has a place is read from the data at
the point of use, so capability flags would only add fragility for a cosmetic list trim.
The PURE block (parsers) is mirrored verbatim into tests/facts_spec.lua - edit one, mirror the
other - so the parsing is unit-tested outside FH against real .fhf content. It is kept at top
level (column 0) precisely so the two copies stay byte-identical.
]]
--==================== FACTS-PURE (mirror into tests/facts_spec.lua) ====================--
-- Parse FH .fhf / .fhdata INI text into ordered sections. Returns a table keyed by section
-- name (each a table of key->value), plus an `order` array of section names in file order.
-- ASCII-explicit patterns (FH runs Lua in a cp1252 locale where %w/%a are unreliable).
local function parseIni(text)
local sections, order = {}, {}
local current
if type(text) ~= "string" then return sections, order end
text = text:gsub("^\239\187\191", "") -- strip a UTF-8 BOM if present
for rawline in (text .. "\n"):gmatch("(.-)\n") do
local line = rawline:gsub("\r$", "")
local name = line:match("^%[(.+)%]%s*$")
if name then
current = {}
sections[name] = current
order[#order + 1] = name
elseif current then
local key, value = line:match("^([^=]-)%s*=%s*(.-)%s*$")
if key and key ~= "" then current[key] = value end
end
end
return sections, order
end
-- Parse a fact-type identifier into its parts, or nil if it is not a fact identifier.
-- The trailing 2 chars are <rec=I|F><type=E|A>; the leading token (to the first '-') is the
-- GEDCOM tag; anything between is the custom id (nil for standard facts).
local function parseFactId(id)
if type(id) ~= "string" then return nil end
local prefix, rec, typ = id:match("^(.-)%-([IF])([EA])$")
if not prefix or prefix == "" then return nil end
local tag = prefix:match("^([^%-]+)")
local customId = prefix:match("^[^%-]+%-(.+)$") -- nil when the prefix is just the tag
return {
id = id,
gedcomTag = tag,
customId = customId,
isCustom = customId ~= nil,
recType = (rec == "I") and "INDI" or "FAM",
isAttribute = (typ == "A"),
}
end
-- Build fact-type records from one parsed .fhf, in [.index] order. Each record carries the
-- identifier parts plus Name/Label/Abbr/Hidden read from its [FCT-<item>] block.
local function factsFromIni(text)
local sections = parseIni(text)
local index = sections[".index"]
local out = {}
if not index then return out end
local count = tonumber(index.Count or "0") or 0
for i = 1, count do
local item = index["Item" .. i]
local rec = item and parseFactId(item)
if rec then
local blk = sections["FCT-" .. item] or {}
rec.name = (blk.Name ~= nil and blk.Name ~= "") and blk.Name or item
rec.label = (blk.Label ~= nil and blk.Label ~= "") and blk.Label or rec.name
rec.abbr = blk.Abbr or ""
rec.hidden = (blk.Hidden == "Y")
out[#out + 1] = rec
end
end
return out
end
-- Parse a GroupIndex.fhdata [groups] section into an ascending-priority list of {name,index}.
-- Lower index = higher priority (a lower-index set's definition of a shared id wins).
local function parseGroupIndex(text)
local sections = parseIni(text)
local groups = sections.groups or {}
local list = {}
for name, idx in pairs(groups) do
list[#list + 1] = { name = name, index = tonumber(idx) or 0 }
end
table.sort(list, function(a, b)
if a.index ~= b.index then return a.index < b.index end
return a.name < b.name
end)
return list
end
-- Merge per-source fact lists (given in priority order, highest first) into one sorted list.
-- The FIRST (highest-priority) definition of an id wins OUTRIGHT - including its Hidden flag - so a
-- hide/override in a higher-priority set can never be resurrected by a lower-priority visible copy.
-- Hidden winners are then dropped (unless opts.includeHidden); opts.recType ("INDI"|"FAM") filters.
local function mergeFactLists(sources, opts)
opts = opts or {}
local winner, order = {}, {}
for _, list in ipairs(sources) do
for _, f in ipairs(list) do
if winner[f.id] == nil then
winner[f.id] = f
order[#order + 1] = f.id
end
end
end
local out = {}
for _, id in ipairs(order) do
local f = winner[id]
if (opts.includeHidden or not f.hidden) and (not opts.recType or f.recType == opts.recType) then
out[#out + 1] = f
end
end
table.sort(out, function(a, b)
if a.label:lower() ~= b.label:lower() then return a.label:lower() < b.label:lower() end
return a.id < b.id
end)
return out
end
--==================== END FACTS-PURE ====================--
do
local M = {}
-- Read a UTF-16LE FH data file, or nil if it is missing / unreadable.
local function readFhFile(path)
local fhfu = require("fhFileUtils")
if fhfu and fhfu.fileExists and not fhfu.fileExists(path) then return nil end
local ok, text = pcall(fhLoadTextFile, path, "UTF-16LE")
if ok and type(text) == "string" then return text end
return nil
end
-- Resolve the .fhf files to read, in priority order (project first, then app sets by ascending
-- GroupIndex). Each entry is { path = <absolute path>, source = <human label of the set> };
-- missing files are skipped on read.
local function factFilePaths()
local sep = "\\"
local entries = {}
local function add(path, source) entries[#entries + 1] = { path = path, source = source } end
-- Project facts: <CI_PROJECT_DATA_FOLDER>\Fact Types\<group>.fhf (highest priority).
local projRoot = fhGetContextInfo("CI_PROJECT_DATA_FOLDER")
if projRoot and projRoot ~= "" then
local projFT = projRoot .. sep .. "Fact Types"
local gi = readFhFile(projFT .. sep .. "GroupIndex.fhdata")
if gi then
for _, g in ipairs(parseGroupIndex(gi)) do
add(projFT .. sep .. g.name .. ".fhf", "Project: " .. g.name)
end
end
end
-- App facts: Standard group -> Standard\Standard.fhf; every other group -> Custom\<group>.fhf.
local appRoot = fhGetContextInfo("CI_APP_DATA_FOLDER")
if appRoot and appRoot ~= "" then
local appFT = appRoot .. sep .. "Fact Types"
local gi = readFhFile(appFT .. sep .. "Standard" .. sep .. "GroupIndex.fhdata")
if gi then
for _, g in ipairs(parseGroupIndex(gi)) do
if g.name == "Standard" then
add(appFT .. sep .. "Standard" .. sep .. "Standard.fhf", "Standard")
else
add(appFT .. sep .. "Custom" .. sep .. g.name .. ".fhf", g.name)
end
end
end
end
return entries
end
-- Enumerate the project's fact types. Returns an array of records:
-- { id, gedcomTag, customId, isCustom, recType="INDI"|"FAM", isAttribute, name, label, abbr,
-- hidden, source } (source = the winning set, for diagnostics)
-- deduped by id (first source wins, i.e. project > lower-index app set > higher), hidden facts
-- dropped. opts.recType ("INDI"|"FAM") filters; opts.includeHidden keeps hidden facts.
function M.enumerate(opts)
local sources = {}
for _, e in ipairs(factFilePaths()) do
local text = readFhFile(e.path)
if text then
local facts = factsFromIni(text)
for _, f in ipairs(facts) do f.source = e.source end
sources[#sources + 1] = facts
end
end
return mergeFactLists(sources, opts)
end
-- "X of Y selected" summary, for a button caption / status label.
function M.summary(selected, facts)
selected = selected or {}
local n = 0
for _ in pairs(selected) do n = n + 1 end
return n .. " of " .. #(facts or {}) .. " fact types"
end
-- Disambiguated display labels for a facts list: identical labels (e.g. two "Military Service")
-- get their FACT SET appended - a name users recognise - rather than the cryptic id.
local function buildLabels(facts)
local labels = {}
for i, f in ipairs(facts) do
labels[i] = f.label
for j, g in ipairs(facts) do
if j ~= i and g.label == f.label then
labels[i] = f.label .. " (" .. tostring(f.source or f.customId or f.id) .. ")"
break
end
end
end
return labels
end
-- The picker dialog is built ONCE and REUSED (never destroyed). Repeatedly destroying+recreating an
-- IUP dialog corrupts FH's native state and crashes it intermittently - the exact reason the Config
-- dialog also builds once and reuses. Each open just repopulates the list and reseeds the ticks.
-- `pickerUI` holds the dialog plus a mutable `state` table the button callbacks read, so they always
-- act on the CURRENT call's facts/selection. Buttons sit in a private normaliser (consistent sizes,
-- isolated from the global btnnorm); the list stays out of the global textnorm.
local pickerUI
-- extrasSpec (from the first choose() call) is a stable list of { key, label } checkboxes shown
-- under the list - extra, caller-defined options that belong with the selection (e.g. scope toggles).
local function buildPickerUI(extrasSpec)
local state = { facts = {}, defaultSel = nil, result = nil }
local list = makeList({ dropdown = "NO", visiblelines = 22, visiblecolumns = 34, expand = "YES",
tip = "Tick the fact types to include. Ctrl/Shift-click to select runs; Select All / None below." })
list.MULTIPLE = "YES"
local function setTicks(pred)
local v = {}
for i, f in ipairs(state.facts) do v[i] = pred(f) and "+" or "-" end
list.value = table.concat(v)
end
-- Caller-supplied extra checkboxes (e.g. scope toggles), keyed for set/collect.
local extraToggles = {}
local extraRows = {}
for _, e in ipairs(extrasSpec or {}) do
local t = makeToggle({ title = e.label, value = "OFF" })
extraToggles[e.key] = t
extraRows[#extraRows + 1] = t
end
local lblPrompt = makeLabel({ title = "Tick the fact types to include:" })
local btnAll = makeButton({ title = "Select &All", callback = function() setTicks(function() return true end); return iup.DEFAULT end })
local btnNone = makeButton({ title = "&None", callback = function() setTicks(function() return false end); return iup.DEFAULT end })
local btnDefault = makeButton({ title = "&Default", tip = "Reset to the default fact-type selection",
callback = function()
local d = state.defaultSel or {}
setTicks(function(f) return d[f.id] == true end)
return iup.DEFAULT
end })
-- Save collects the ticked fact ids + extra-toggle values into state.result, then closes the dialog.
local function saveResult()
local v = tostring(list.value or "")
local ids = {}
for i, f in ipairs(state.facts) do if v:sub(i, i) == "+" then ids[f.id] = true end end
local extras = {}
for key, t in pairs(extraToggles) do extras[key] = (t.value == "ON") end
state.result = { ids = ids, extras = extras }
return iup.CLOSE
end
local content = { lblPrompt, iup.frame({ list, expand = "YES" }) }
-- List actions sit directly under the list (they act ON the list), left-aligned.
content[#content + 1] = iup.hbox({ btnAll, btnNone, btnDefault, iup.fill({}), gap = "6" })
if #extraRows > 0 then
content[#content + 1] = makeLabel({ title = "Also include:" })
for _, t in ipairs(extraRows) do content[#content + 1] = t end
end
content.margin = "8x8"
content.gap = "8"
content.expand = "YES"
-- A File > Save / Cancel menu, matching the Config dialogs (the user's choice over OK/Cancel buttons).
-- Save records the result then returns iup.CLOSE; Cancel just closes (state.result stays nil). The
-- "cancel" key suppresses MenuBar's automatic Exit; createMenuBar also adds the standard Help menu
-- (opens the plugin help index). Menu actions fire within the popup and close it - no popup-in-popup.
local menuBarData = MenuBar.createMenuBar({
fileMenu = {
aSave = { title = "&Save", action = saveResult },
cancel = { title = "&Cancel", action = function() return iup.CLOSE end },
},
})
-- Esc mirrors Cancel: close without recording a result (state.result stays nil). Save is Alt+S / the
-- File menu; no Enter default, as the list swallows Enter for tick toggling.
local dlg = makeDialog(iup.vbox(content),
{ title = "Choose fact types", minbox = "NO", maxbox = "NO", resize = "YES", menu = menuBarData.menuBar,
on_escape = function() return iup.CLOSE end })
-- makeDialog normalised this independently; on reuse we re-apply via renormalizeDialog (this
-- dialog is built once and reused, never destroyed, and its list is repopulated each open).
return { dlg = dlg, list = list, state = state, setTicks = setTicks,
lblPrompt = lblPrompt, btnDefault = btnDefault, extraToggles = extraToggles }
end
-- Show the picker over `facts` (an enumerate() list). opts.selected is a set of ids to pre-tick;
-- opts.defaultSelected (a set) enables the Default reset button; opts.extras is a list of
-- { key, label, value=bool } checkboxes shown under the list. Returns { ids = <set of selected ids>,
-- extras = { key = bool, ... } }, or nil if cancelled. Needs the Dialog boilerplate.
function M.choose(opts)
opts = opts or {}
local facts = opts.facts or M.enumerate()
if not pickerUI then pickerUI = buildPickerUI(opts.extras) end
local ui = pickerUI
populateList(ui.list, buildLabels(facts)) -- list position i <-> facts[i]
ui.state.facts = facts
ui.state.defaultSel = opts.defaultSelected
ui.state.result = nil
ui.lblPrompt.title = opts.prompt or "Tick the fact types to include:"
ui.dlg.title = opts.title or "Choose fact types"
ui.btnDefault.floating = opts.defaultSelected and "NO" or "YES" -- hide Default if no default given
ui.btnDefault.visible = opts.defaultSelected and "YES" or "NO"
for _, e in ipairs(opts.extras or {}) do -- reflect this call's extra-toggle values
local t = ui.extraToggles[e.key]
if t then t.value = e.value and "ON" or "OFF" end
end
local selected = opts.selected or {}
ui.setTicks(function(f) return selected[f.id] == true end)
renormalizeDialog(ui.dlg) -- re-size for this call's content
iup.Refresh(ui.dlg)
-- The picker is built once and REUSED, so the dialog handle remembers whatever size it was last
-- left at - including a user-shrunk size that clipped the buttons on reopen. Measure the content's
-- natural size now (after renormalise + Refresh, so it reflects THIS call's content) and use it as
-- both the floor (minsize, so the buttons can never be clipped again) and the opening size (so it
-- always opens tidily at natural size, never a stale dragged-small one). Mirrors the main dialog's
-- naturalsize -> minsize pattern in Add Maps.fh_lua.
local nat = tostring(ui.dlg.naturalsize or "")
if nat:match("^%d+x%d+$") then
ui.dlg.minsize = nat
ui.dlg.rastersize = nat
end
ui.dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT) -- reused, never destroyed
return ui.state.result -- nil unless OK was pressed
end
-- The resolved .fhf paths, in priority order (for diagnostics / debugging).
M.sources = factFilePaths
-- Expose the pure parsers for testing / advanced callers.
M._parseIni = parseIni
M._parseFactId = parseFactId
M._factsFromIni = factsFromIni
M._parseGroupIndex = parseGroupIndex
M._mergeFactLists = mergeFactLists
Facts = M -- global, in keeping with the other boilerplate modules (Config, Dialog, ...)
end
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE "../../2 Boilerplate/HtmlInject.lua" HtmlInject>>
HtmlInject = (function()
--[[
@Title: HtmlInject
@Author: Helen Wright
@Description:
Shared boilerplate (pure): finds a target <div> in an HTML string by class
token and inserts or replaces a marker-wrapped block inside it, returning
the new HTML string. The block is just a string - it has carried an SVG
(Add Trees) and an <iframe> (Add Maps); the module is content-agnostic. No
file I/O here - a thin wrapper in each plugin does the reading and writing.
Injection is idempotent: the block is wrapped in
<!-- <marker>:start ind<id> --> ... <!-- <marker>:end ind<id> -->
and a re-run replaces the existing block rather than accumulating copies.
TWO INDEPENDENT TOKENS, both defaulting to "FhSeeAlso" for back-compatibility
but distinct concepts:
* classToken - WHICH div to inject into (matched against the div's class
list; a multi-token value like "fhsection fhsecdata" must
match all of them, the way a CSS ".a.b" selector would).
* markerToken - the LABEL in the idempotency comment. Set this PER PLUGIN
(e.g. Add Maps passes "AddMaps") so two plugins can embed
into the same page without overwriting each other's block:
the marker is otherwise keyed only by person id, so a
shared label would let one plugin's re-run clobber the
other's block (notably with top/bottom placement, which
finds a block by marker).
There is no HTML parser in the plugin environment, so the opening div tag
is located by a small scanner (skipping quoted attribute values that might
contain '>') and its attributes are then examined for a class list
containing the class token. This is robust to attribute order, quote
style, extra whitespace and tag-name case.
]]
local M = {}
---The default class token that marks the target div in FH-generated pages, and
---the default marker label for the idempotency comments. The two are separate
---options (see the header) that merely share this back-compatible default.
local CLASS_TOKEN = "FhSeeAlso"
local MARKER_TOKEN = "FhSeeAlso"
---@class FsEmbedOptions Embedding options (all optional).
---@field placement? "replace"|"top"|"bottom" Where the block goes in the div (default "replace").
---@field classToken? string Target div class token (default "FhSeeAlso").
---@field markerToken? string Label for the idempotency marker comments (default "FhSeeAlso"); set per plugin so two plugins can embed into the same page without clobbering each other.
---@field align? "left"|"centre"|"right" Horizontal alignment of the chart in the div (default "centre").
---@field caption? string Optional heading rendered above the chart.
---@field captionClass? string CSS class for the caption div (default "fs-embed-caption"); all caption styling comes from the site's CSS for this class.
---@field fit? boolean True when the SVG itself is width="100%" (no horizontal scroll wrapper).
---@field containerClass? string CSS class on the outer wrapper div (default "fs-embed-chart"); a styling hook for the site's CSS.
---@field hideable? boolean Wrap the chart in a no-JavaScript <details> element (collapsible). Starts expanded; the caption (or "Family chart") is the summary label.
--------------------------------------------------------------
-- LOCATING THE TARGET DIV
--------------------------------------------------------------
---Scan a div's opening tag from just after "<div" to its closing ">", skipping
---quoted attribute values (which may legitimately contain '>'). Plain Lua, so
---the plugin needs no lpeg library.
---@param html string
---@param pos integer Index of the first character after "<div".
---@return string|nil attributes Raw text between "<div" and ">", or nil if the tag never closes.
---@return integer|nil afterTag Index just after the closing ">".
local function scanDivOpen(html, pos)
local i, n = pos, #html
while i <= n do
local c = html:sub(i, i)
if c == '"' or c == "'" then
-- Skip a quoted value whole, including any '>' inside it.
local close = html:find(c, i + 1, true)
if not close then
return nil -- unterminated quote: malformed tag
end
i = close + 1
elseif c == ">" then
return html:sub(pos, i - 1), i + 1
else
i = i + 1
end
end
return nil -- no closing ">"
end
---Does an attribute string carry CLASS_TOKEN as a whole class token?
---Accepts double-quoted, single-quoted and unquoted attribute values.
---@param attributes string The raw text between "<div" and ">".
---@param classToken string One or more class tokens (space-separated); the div must carry ALL of them.
---@return boolean
local function hasSeeAlsoClass(attributes, classToken)
-- A multi-token target (e.g. "fhsection fhsecdata") matches a div that
-- carries every one of those classes - the way a CSS selector ".a.b"
-- would - so Family Historian's own containers can be addressed.
local required = {}
for token in classToken:gmatch("%S+") do
required[#required + 1] = token
end
if #required == 0 then
return false
end
local function tokensContainTarget(value)
local present = {}
for token in value:gmatch("%S+") do
present[token] = true
end
for _, token in ipairs(required) do
if not present[token] then
return false
end
end
return true
end
for name, quote, value in attributes:gmatch([[([A-Za-z0-9_:-]+)%s*=%s*(["'])(.-)%2]]) do
if name:lower() == "class" and tokensContainTarget(value) then
return true
end
end
for name, value in attributes:gmatch([[([A-Za-z0-9_:-]+)%s*=%s*([^%s"'][^%s]*)]]) do
if name:lower() == "class" and tokensContainTarget(value) then
return true
end
end
return false
end
---Locate the first target div: the position of its "<" and the position just
---after its opening ">".
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return integer|nil tagStart, integer|nil afterOpenTag
local function locateDiv(html, classToken)
classToken = classToken or CLASS_TOKEN
local pos = 1
while true do
local tagStart = html:find("<[dD][iI][vV][%s/>]", pos)
if not tagStart then
return nil
end
-- Parse from just after "<div"; divOpen yields the attribute text and
-- the position following ">".
local attributes, afterTag = scanDivOpen(html, tagStart + 4)
if attributes and hasSeeAlsoClass(attributes, classToken) then
return tagStart, afterTag
end
pos = tagStart + 4
end
end
---Find the first target div's opening tag.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return integer|nil afterOpenTag Position just after the opening tag's ">", or nil if none found.
local function findTargetDiv(html, classToken)
local _, afterTag = locateDiv(html, classToken)
return afterTag
end
---The position of the </div> that closes the div opened just before
---afterOpen, accounting for nested divs.
---@param html string
---@param afterOpen integer Position just after the target div's opening ">".
---@return integer|nil closeStart Position of the matching "</div>"'s "<", or nil if unbalanced.
local function matchingDivClose(html, afterOpen)
local depth = 1
local pos = afterOpen
while true do
local openStart = html:find("<[dD][iI][vV][%s/>]", pos)
local closeStart = html:find("</[dD][iI][vV]%s*>", pos)
if not closeStart then
return nil -- no matching close
end
if openStart and openStart < closeStart then
-- A nested opening div: skip past its tag and go deeper.
local _, afterNested = scanDivOpen(html, openStart + 4)
depth = depth + 1
pos = afterNested or (openStart + 4)
else
depth = depth - 1
if depth == 0 then
return closeStart
end
pos = closeStart + 1
end
end
end
---Whether a page contains a target div at all. inject() reports the same
---condition via its error return; this read-only probe exists for tests and
---diagnostics.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return boolean
function M.hasTargetDiv(html, classToken)
return findTargetDiv(html, classToken) ~= nil
end
---Remove the first div carrying classToken, in full (its opening tag, content
---and matching close). Independent of injection: a site can drop Family
---Historian's own "See also" box (default "FhSeeAlso") and place the chart in
---a different div instead.
---@param html string
---@param classToken? string Class token to match (default "FhSeeAlso").
---@return string newHtml The page with the div removed (unchanged if none found or unbalanced).
---@return boolean removed True when a div was removed.
function M.removeDiv(html, classToken)
local tagStart, afterTag = locateDiv(html, classToken)
if not tagStart then
return html, false
end
local closeStart = matchingDivClose(html, afterTag)
if not closeStart then
-- Unbalanced markup: leave the page intact rather than guess.
return html, false
end
local _, closeEnd = html:find("</[dD][iI][vV]%s*>", closeStart)
closeEnd = closeEnd or closeStart
return html:sub(1, tagStart - 1) .. html:sub(closeEnd + 1), true
end
--------------------------------------------------------------
-- BLOCK CONSTRUCTION AND INJECTION
--------------------------------------------------------------
---XML-escape the five reserved characters (for the optional caption text).
---@param text string
---@return string
local function escapeText(text)
return (text:gsub('[&<>"\']', {
["&"] = "&",
["<"] = "<",
[">"] = ">",
['"'] = """,
["'"] = "'",
}))
end
---Wrap an inline SVG in the container used both for injection and the
---options-dialog preview, applying alignment, an optional caption, the fit
---mode and (optionally) a no-JavaScript collapsible. The inline styles avoid
---depending on the site's stylesheets; the outer div also carries a CSS class
---(default "fs-embed-chart") as a styling hook.
--- * default (scroll): natural-size SVG, the container scrolls horizontally;
--- * fit: the SVG is width="100%" and simply fills the container;
--- * hideable: the caption + chart sit in a <details> element (starts open),
--- so a reader can collapse the chart with no script. The caption becomes
--- the <summary> label (or "Family chart" when there is no caption).
---@param svg string An embedded-mode SVG.
---@param opts? FsEmbedOptions
---@return string
function M.wrapSvg(svg, opts)
opts = opts or {}
local alignMap = { left = "left", centre = "center", center = "center", right = "right" }
local textAlign = alignMap[opts.align or "centre"] or "center"
local outer = opts.fit and ("max-width:100%;text-align:" .. textAlign)
or ("overflow:auto;max-width:100%;text-align:" .. textAlign)
local containerClass = (opts.containerClass and opts.containerClass:match("%S")) and opts.containerClass
or "fs-embed-chart"
local captionText = (opts.caption and opts.caption:match("%S")) and opts.caption or nil
local inner
if opts.hideable then
-- No-JavaScript collapsible: <details> starts open and the caption (or a
-- default) labels the <summary>. Styling is delegated to the site's CSS
-- (e.g. ".fs-embed-chart summary { ... }").
local summary = captionText or "Family chart"
inner = string.format("<details open><summary>%s</summary>%s</details>", escapeText(summary), svg)
else
local caption = ""
if captionText then
-- All caption styling (font, size, colour, alignment) is delegated to
-- the site's CSS for this class; the plugin only emits the class.
local captionClass = (opts.captionClass and opts.captionClass:match("%S")) and opts.captionClass
or "fs-embed-caption"
caption = string.format('<div class="%s">%s</div>', escapeText(captionClass), escapeText(captionText))
end
inner = caption .. svg
end
return string.format('<div class="%s" style="%s">%s</div>', escapeText(containerClass), outer, inner)
end
---The complete marker-wrapped block for one person.
---@param svg string An embedded-mode SVG.
---@param personId integer Raw FH record id.
---@param opts? FsEmbedOptions
---@return string
function M.makeBlock(svg, personId, opts)
opts = opts or {}
local marker = (opts.markerToken and opts.markerToken:match("%S")) and opts.markerToken or MARKER_TOKEN
return string.format(
"<!-- %s:start ind%d -->\n%s\n<!-- %s:end ind%d -->",
marker,
personId,
M.wrapSvg(svg, opts),
marker,
personId
)
end
---Lua-pattern-escape a literal string.
---@param text string
---@return string
local function escapePattern(text)
return (text:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%0"))
end
---Find an existing marker block for a person.
---@param html string
---@param personId integer
---@param markerToken? string Marker label to match (default "FhSeeAlso"); must match makeBlock's.
---@return integer|nil blockStart, integer|nil blockEnd Inclusive bounds of the whole block.
local function findMarkerBlock(html, personId, markerToken)
local marker = (markerToken and markerToken:match("%S")) and markerToken or MARKER_TOKEN
local id = "ind" .. personId
local startPattern = "<!%-%-%s*" .. escapePattern(marker) .. ":start%s+" .. escapePattern(id) .. "%s*%-%->"
local endPattern = "<!%-%-%s*" .. escapePattern(marker) .. ":end%s+" .. escapePattern(id) .. "%s*%-%->"
local blockStart = html:find(startPattern)
if not blockStart then
return nil
end
local _, blockEnd = html:find(endPattern, blockStart)
if not blockEnd then
-- A start marker without its end marker: treat as absent rather than
-- guessing how much of the page to replace.
return nil
end
return blockStart, blockEnd
end
---Inject (or re-inject) a person's chart into an HTML page.
---
---Placement (opts.placement):
--- * "replace" (default) - the target div's entire content becomes the
--- chart block: the chart appears instead of, not as well as, whatever
--- was there (re-running replaces it again - idempotent);
--- * "top" / "bottom" - the block is inserted at the start / end of the
--- div, keeping any other content; a re-run replaces the existing marker
--- block in place rather than adding another.
---@param html string The page HTML.
---@param svg string An embedded-mode SVG (natural size, no XML declaration).
---@param personId integer Raw FH record id.
---@param opts? FsEmbedOptions
---@return string|nil newHtml The updated page, or nil on failure.
---@return string|nil err Why injection was not possible.
function M.inject(html, svg, personId, opts)
opts = opts or {}
local placement = opts.placement or "replace"
local classToken = opts.classToken or CLASS_TOKEN
local block = M.makeBlock(svg, personId, opts)
local afterTag = findTargetDiv(html, classToken)
if not afterTag then
return nil, 'no <div class="' .. classToken .. '"> found in the page'
end
local closeStart = matchingDivClose(html, afterTag)
if placement == "replace" and closeStart then
-- The div content becomes exactly the block; nothing accumulates.
return html:sub(1, afterTag - 1) .. "\n" .. block .. "\n" .. html:sub(closeStart)
end
-- top/bottom (and replace's fallback when the div is unbalanced): keep
-- other content; a re-run replaces this person's existing block in place.
-- Match on this plugin's own marker so a re-run never touches another
-- plugin's block in the same page.
local blockStart, blockEnd = findMarkerBlock(html, personId, opts.markerToken)
if blockStart then
return html:sub(1, blockStart - 1) .. block .. html:sub(blockEnd + 1)
end
if placement == "bottom" and closeStart then
return html:sub(1, closeStart - 1) .. block .. "\n" .. html:sub(closeStart)
end
-- "top" and the unbalanced fallback: just inside the opening tag.
return html:sub(1, afterTag - 1) .. "\n" .. block .. html:sub(afterTag)
end
return M
end)()
--<<FS_SPLICE_END>>
--------------------------------------------------------------
-- CONFIGURATION (persisted to <plugin>.ini)
--------------------------------------------------------------
-- Two settings stores (the Config helper is built to be instantiated more than once per plugin, each
-- at its own scope). GEOCODING settings are global to the user - your geocoder accounts/keys and
-- geocoding behaviour don't change between family trees. MAPPING settings are per-project - output
-- folder, marker styling, which events to map and (later) embedding all naturally differ per project
-- (e.g. a memorial site vs your own family history). Region bias stays global as a default, but is
-- overridable per session on the Geocoding pane; the output folder is a per-project default overridable
-- (and re-saved) on the Mapping pane.
-- Record-flag privacy helpers (mirror Add Trees; see fh7-record-flags-api). Used by the published-map
-- privacy filter and the Config flag-name validator.
local function flagExists(flagName)
local ok, tag = pcall(fhGetFlagTag, flagName, false)
return ok and type(tag) == "string" and tag ~= ""
end
local function hasFlag(indiPtr, flagName)
if not (indiPtr and indiPtr.IsNotNull and indiPtr:IsNotNull()) then return false end
local ok, res = pcall(fhCallBuiltInFunction, "HasFlag", indiPtr, flagName)
return ok and res == true
end
-- True if this individual must be kept off a PUBLISHED map: omit-Private, or the basic-details flag
-- (a map is event/location data, which "basic details = names + relationships only" excludes). `privacy`
-- is nil for local maps, so no filtering happens there.
local function privacyBlocks(indiPtr, privacy)
if not privacy then return false end
if privacy.omit and hasFlag(indiPtr, "Private") then return true end
if privacy.basicFlag and privacy.basicFlag ~= "" and hasFlag(indiPtr, privacy.basicFlag) then return true end
return false
end
local tblGeoConfig = {
title = cstrPluginName .. " - Geocoding Options",
sections = {
{
title = "Geocoding",
fields = {
{
key = "provider", label = "Geocoder:", type = "list",
options = { "Geoapify", "OpenCage", "LocationIQ" }, default = "Geoapify",
description = "Online service used to look up coordinates (better than FH's built-in).",
},
{
key = "geoapifyKey", label = "Geoapify API key:", type = "text", default = "",
description = "Your personal Geoapify API key (open-data geocoder; results may be stored).",
},
{
key = "openCageKey", label = "OpenCage API key:", type = "text", default = "",
description = "Your personal OpenCage API key. The free tier is for testing only; a one-time licence (about GBP 20) is needed for live use.",
},
{
key = "locationIqKey", label = "LocationIQ API key:", type = "text", default = "",
description = "Your personal LocationIQ API key (OpenStreetMap-based; results may be stored).",
},
{
key = "regionBias", label = "Region bias (country code):", type = "text", default = "gb",
description = "Default two-letter ISO country code to bias ambiguous searches, e.g. gb for the UK ('uk' is also accepted). You can change it for a single session on the Geocoding pane without altering this default.",
validate = function(v) return regionFieldValidate(v) end,
},
{
key = "markTentative", label = "Mark auto-geocoded places 'tentative':", type = "boolean", default = true,
description = "Mark auto-geocoded PLACES as 'tentative'. FH addresses have no status, so this never applies to them.",
},
{
key = "recodeExisting", label = "Re-geocode existing coordinates:", type = "boolean", default = false,
description = "When on, 'Geocode All' and 'Geocode Selected' also re-geocode places/addresses that already have a coordinate (overwriting) - handy for comparing geocoders.",
},
},
},
},
}
local tblMapConfig = {
title = cstrPluginName .. " - Mapping Options",
sections = {
{
title = "Web maps",
fields = {
{
key = "outputMode", label = "Make:", type = "list",
options = { "Automatic", "A page per individual", "One combined map" }, default = "Automatic",
description = "Automatic: one combined map for several people, a single page for one. Or force a page each / one combined map.",
},
{
key = "mapFolder", label = "Output folder:", type = "folder", default = "",
description = "Where the maps go. For standalone maps, the folder they're written to. When 'Embed maps in web pages' is on, this is the folder holding your website's individual pages (<prefix><id>.html - the site needn't be Family Historian's, as long as pages are named by record id): each map is written into a subfolder of it and injected into the matching page.",
},
{
key = "tempMaps", label = "Temporary maps (deleted when the plugin closes):", type = "boolean", default = false,
description = "Build the map into a temporary folder and delete it automatically when you close Add Maps, instead of leaving files in your Output folder. Choose this for a quick look at where someone's events fall when you don't want to keep or tidy up the files - you don't even need to set an Output folder. The map still opens in your browser; keep that tab open while you look at it, because the file is removed when the plugin closes. Ignored when 'Embed maps in web pages' is on (embedded maps must be kept). Any leftovers from a previous session are cleared each time Add Maps starts.",
},
{
key = "defaultZoom", label = "Default zoom:", type = "number", default = 12,
description = "Initial Leaflet zoom level when a map has a single location (multi-location maps fit all markers).",
},
{
key = "mapGoogleLinks", label = "Google Maps / Street View links:", type = "boolean", default = true,
description = "Add per-marker 'Open in Google Maps' and 'Street View' links (links only - never Google map tiles).",
},
{
key = "pathDefault", label = "Show journey by default:", type = "boolean", default = false,
description = "Initial state of the in-map 'Show journey' toggle: on = numbered pins + life polyline visible; off = unnumbered pins, polyline hidden (less cluttered). User can flip it on each map.",
},
{
key = "timeSlider", label = "Time slider:", type = "boolean", default = false,
description = "Add a year range slider to the map: drag the two handles to show only events within a period, or Play to glide a fixed-width window across time. Shown only when the mapped events span two or more years.",
},
{
key = "historicMaps", label = "Historic map layers:", type = "boolean", default = true,
description = "Offer historic Ordnance Survey / NLS base maps in the map's layer control, alongside Street and Satellite. Keyless NLS layers cover Great Britain (and Victorian Scotland); add a MapTiler key below to unlock the full MapTiler NLS set (GB Victorian six-inch, London five-foot, Ireland).",
},
{
key = "maptilerKey", label = "MapTiler key:", type = "text", default = "",
description = "Your everyday MapTiler Cloud API key (cloud.maptiler.com) for LOCAL maps. When set, the MapTiler NLS historic maps appear as extra base layers. Stored only in your local settings, never in the plugin. Leave this key unrestricted so local map files (opened from disk) work. For maps you PUBLISH, set a separate domain-restricted key under Embedding (advanced) - this everyday key is never written into published maps.",
},
{
key = "markerSize", label = "Marker size:", type = "list",
options = { "Small", "Medium", "Large" }, default = "Medium",
description = "Size of the map markers - the numbered pins, the plain dots and the distance at which nearby markers cluster all scale together. Applies to every map.",
},
{
key = "singleColour", label = "Single-map marker colour:", type = "color", default = "",
description = "Marker colour for a map of ONE individual (leave blank to use the standard palette - red). A map of several people always uses the per-person palette, so this is ignored there.",
},
},
},
{
title = "Website embedding",
fields = {
{
key = "embedInPages", label = "Embed maps in web pages:", type = "boolean", default = false,
description = "Inject each individual's map into the matching website page (<prefix><id>.html), not only standalone files. Needs 'A page per individual', and the Output folder (Web maps) set to the folder holding those pages. Build your website first, then embed.",
},
{
key = "frameHeight", label = "Map height (pixels):", type = "number", default = 430,
description = "Height of the embedded map frame. The width always fills the page column; an embedded map needs a fixed height because it is a self-contained frame.",
},
{
key = "embedCaption", label = "Caption:", type = "text", default = "",
description = "Optional heading above the embedded map. Use [NAME] for the person's name, e.g. 'Where [NAME] lived and travelled'. Leave blank for none.",
},
{
key = "embedHideable", label = "Collapsible map:", type = "boolean", default = false,
description = "Wrap the embedded map in a no-JavaScript expander (HTML <details>). It starts open; readers can collapse it. The Caption becomes the expander's label.",
},
{
key = "embedAlign", label = "Alignment:", type = "list", options = { "Centre", "Left", "Right" }, default = "Centre",
description = "How the map sits within the page container.",
},
},
},
{
title = "Embedding (advanced)",
fields = {
{
key = "embedKeepKeyed", label = "Keep keyed historic layers:", type = "boolean", default = false,
description = "By default a published map omits the MapTiler historic layers, because a published page would expose the key. Turn this on ONLY if you set a domain-restricted 'published' key below - a key locked to your website's domain, so an exposed copy cannot be misused elsewhere.",
},
{
key = "maptilerPublishKey", label = "MapTiler key for published maps:", type = "text", default = "",
description = "A SEPARATE MapTiler key, domain-restricted to your published website, used only in embedded/published maps when 'Keep keyed historic layers' is on. Keep it distinct from the everyday key under Web maps: a domain-restricted key will not work in local map files (opened from disk), so use the unrestricted Web-maps key for local maps and this one only for the web. MapTiler allows several keys at no cost.",
},
{
key = "pagePrefix", label = "Individual page prefix:", type = "text", default = "ind",
description = "The filename prefix your website gives each individual's page. A standard Family Historian website uses 'ind' (ind123.html); some site generators use a different prefix. Used to find each person's page and to link back from the map.",
},
{
key = "embedMapsSubfolder", label = "Maps subfolder:", type = "text", default = "maps",
description = "When embedding, the maps are written into this subfolder of the Output folder (so they sit alongside, not amongst, your website's pages) and the page's <iframe> points there. Default 'maps'. Leave it as is unless you have a reason to change it.",
},
{
key = "embedDivClass", label = "Target div class:", type = "text", default = "fhsection fhsecdata",
description = "The class of the page container the map is injected into. The default places the map just under the person's name and dates; change it only if your website template uses a different one.",
},
{
key = "embedPlacement", label = "Placement:", type = "list",
options = { "Replace div contents", "At top of div", "At bottom of div" }, default = "At top of div",
description = "Put the map at the top or bottom of the target div (keeping its other content), or replace the div's contents entirely.",
},
{
key = "embedRemoveDiv", label = "Remove a section:", type = "boolean", default = false,
description = "Before embedding, delete a section from the page (e.g. Family Historian's own 'See also' box) so the map can sit in a different div. Set Target div class to that other div.",
},
{
key = "embedRemoveDivClass", label = "Section to remove:", type = "text", default = "",
description = "The class of the section deleted when 'Remove a section' is on (e.g. FhSeeAlso for Family Historian's own 'See also' box). Leave blank unless you need it. Ignored if it matches the Target div class.",
},
{
key = "embedContainerClass", label = "Map CSS class:", type = "text", default = "fs-embed-map",
description = "CSS class put on the map's wrapper div, so you can style it (borders, spacing, background) in your website's stylesheet. Alignment is still set by the Alignment option.",
},
{
key = "embedCaptionClass", label = "Caption CSS class:", type = "text", default = "fs-embed-caption",
description = "The CSS class given to the caption. Style it in your website's stylesheet; the plugin only applies the class.",
},
},
},
{
title = "Privacy",
fields = {
{
key = "omitPrivate", label = "Omit individuals flagged 'Private':", type = "boolean", default = true,
description = "For PUBLISHED maps (embedded in your website): never embed a map for anyone whose record carries Family Historian's 'Private' flag, and drop their timeline/witness events from other people's maps. Local maps you build for yourself are unaffected (you are warned if a local build includes them). Mirrors FH's own website privacy.",
},
{
key = "basicDetailsEnabled", label = "No published map for 'basic details only' individuals:", type = "boolean", default = true,
description = "For PUBLISHED maps: treat individuals carrying the flag below like FH's 'basic details only' (names and relationships only). A map shows event locations - more than basic details - so no map is embedded for them, and their events are dropped from other people's published maps.",
},
{
key = "basicDetailsFlag", label = "...for this flag:", type = "text", default = "Living",
description = "The exact name of a record flag in your project (e.g. Living). People with this flag get no published map. You are warned at once if you type a flag that does not exist in this project.",
onBlur = function(value)
value = tostring(value or "")
if value ~= "" and not flagExists(value) then
MessageBox("warning", "There is no record flag called '" .. value .. "' in this project, so basic-details privacy will not apply to anyone. Check the name (Edit > Record Flags).")
end
end,
},
},
},
-- What to map (fact types + the witness / timeline scope toggles) is all chosen in one place,
-- the Options > Events to Map picker (see Facts.lua + chooseFactTypes), not as Config fields here.
},
}
--------------------------------------------------------------
-- LOCATION DATA LAYER (native _PLAC places + _ADDR addresses; verified: work in DECIMAL)
-- read : ~.LATLONG:LAT_NUMERIC / :LONG_NUMERIC + ~.STAT (Title-cased display value)
-- write : fhCreateItem("LATLONG", record) + fhSetValueAsText(item, "<lat>, <lng>")
-- (~.LATLONG only *displays* DMS; storage is full-precision decimal.)
-- _ADDR addresses carry their OWN MAP/LATLONG, same as _PLAC places.
-- STAT (status) is edited via the panel's status dropdown and written on Save.
--------------------------------------------------------------
-- Normalise a raw ~.STAT display value ("Tentative" / "Not Found" / "Blocked" / "No Auto-Geocode")
-- to a canonical lowercase key. Empty / unrecognised -> "". FH's "Blocked" status means
-- do-not-auto-geocode; we key it as "no auto" and match either spelling defensively.
local function statKey(raw)
local s = (raw or ""):lower()
if s:find("tentative") then return "tentative" end
if s:find("not%s*found") then return "not found" end
if s:find("auto") or s:find("block") then return "no auto" end
return ""
end
-- Build the geocoding query for a record: its own STAN if present (the standardised form), and for
-- an address also its parent-place context. A module-level helper so buildEntry builds it and Save
-- can rebuild it after a manual STAN edit (so a later geocode uses the new value).
local function buildGeoQuery(name, stan, geoCtx, isAddr)
if isAddr then
return (stan ~= "" and stan or name) .. ((geoCtx and geoCtx ~= "") and (", " .. geoCtx) or "")
end
return (stan ~= "" and stan) or name
end
-- Build a model entry for one place/address record, given its live record pointer. The pointer is
-- kept in the entry and used directly for later reads/writes - FH pointers are safe (deleting a
-- record nulls its pointer, never invalidates it), so we no longer walk the record list by index.
-- Geocoding uses the record's own Standardised value (STAN) when present - the modern equivalent of
-- a historic name that won't itself geocode (places AND addresses can carry one). Addresses also
-- pick up their parent place (its STAN preferred) for display and as the place context in the query.
local function buildEntry(ptr, recType)
local isAddr = (recType == "_ADDR")
local name = (fhGetItemText(ptr, "~") or ""):gsub("^[%s,]+", "") -- strip leading empty hierarchy commas
local lat = fhGetItemText(ptr, "~.LATLONG:LAT_NUMERIC")
local lng = fhGetItemText(ptr, "~.LATLONG:LONG_NUMERIC")
local stat = fhGetItemText(ptr, "~.STAT") -- raw, Title-cased display value
local stan = fhGetItemText(ptr, "~.STAN") or "" -- standardised value: places AND addresses can have one
local parentPlace, geoCtx, parentLat, parentLng = "", "", "", ""
if isAddr then
-- follow the address's _PLAC link to its parent place (for display + geocoding context, and as
-- a sensible map centre when the address itself has no coordinate yet)
local placItem = fhGetItemPtr(ptr, "~._PLAC")
if placItem and placItem:IsNotNull() then
local placRec = fhGetValueAsLink(placItem)
if placRec and placRec:IsNotNull() then
parentPlace = (fhGetItemText(placRec, "~") or ""):gsub("^[%s,]+", "")
local pStan = fhGetItemText(placRec, "~.STAN") or ""
geoCtx = (pStan ~= "" and pStan) or parentPlace
parentLat = fhGetItemText(placRec, "~.LATLONG:LAT_NUMERIC")
parentLng = fhGetItemText(placRec, "~.LATLONG:LONG_NUMERIC")
end
end
end
return {
ptr = ptr, -- live record pointer; reads/writes use it directly
name = name,
lat = lat,
lng = lng,
stat = stat,
statKey = statKey(stat),
hasCoord = (lat ~= "" and lng ~= ""),
stan = stan,
parentPlace = parentPlace, -- "" for places
geoCtx = geoCtx, -- parent-place context for addresses ("" for places); used to rebuild geoQuery on Save
parentLat = parentLat, -- address's parent-place coordinate ("" if none); map-centre fallback
parentLng = parentLng,
geoQuery = buildGeoQuery(name, stan, geoCtx, isAddr),
}
end
-- Write decimal lat/lng (strings) to a record. Empty lat/lng clears the coordinate.
-- Deliberately does NOT touch STAT - status is edited separately (the status dropdown / setStat),
-- so coordinate and status stay independent (unlike a coupled coordinate/status model).
local function writeCoordinate(recPtr, latDec, lngDec)
local ll = fhGetItemPtr(recPtr, "~.LATLONG")
if ll and ll:IsNotNull() then fhDeleteItem(ll) end
if latDec ~= "" and lngDec ~= "" then
local newll = fhCreateItem("LATLONG", recPtr)
fhSetValueAsText(newll, latDec .. ", " .. lngDec)
end
end
-- A scannable status marker for the list row.
local function statusMarker(e)
if not e.hasCoord then
if e.statKey == "not found" then return "[x] "
elseif e.statKey == "no auto" then return "[-] "
else return "[ ] " end
end
if e.statKey == "tentative" then return "[t] " end
return "[*] "
end
--------------------------------------------------------------
-- GEOCODING (Phase 3)
-- The pure helpers (JSON decode / URL build / response parse / coord format) are mirrored in
-- tests/geocode_spec.lua and unit-tested under standalone Lua; the WinHttp + FH-write glue
-- below wraps them. Geoapify, OpenCage and LocationIQ are all wired via gcBuildUrl and gcParse.
--------------------------------------------------------------
-- Active geocoder + its key, read from Config (drives the use-time key check).
local cProviderKeyField = {
["Geoapify"] = "geoapifyKey",
["OpenCage"] = "openCageKey",
["LocationIQ"] = "locationIqKey",
}
-- Minimum gap between batch requests, per provider (free-tier rate limits differ a lot).
local cProviderDelayMs = {
["Geoapify"] = 250,
["OpenCage"] = 1100,
["LocationIQ"] = 1100,
}
local function activeGeocoder()
local provider = myGeoConfig:getValue("Geocoding", "provider", "Geoapify")
-- Google was withdrawn as a geocoder (its ToS forbids storing coordinates) and its request/parse code
-- removed; coerce any previously-saved "Google Maps" setting back to the default as a safety net.
if provider == "Google Maps" then provider = "Geoapify" end
local keyField = cProviderKeyField[provider]
local key = keyField and myGeoConfig:getString("Geocoding", keyField, "") or ""
return provider, key
end
-- True if the selected geocoder has a key; otherwise warns (pointing at Options) and returns false.
local function geocoderReady()
local provider, key = activeGeocoder()
if key == "" then
MessageBox("warning", "No API key set for " .. provider
.. ".\n\nAdd one in Geocoding Options, or choose a different geocoder.", "OK")
return false
end
return true
end
--==================== GEOCODE-PURE (mirror of tests/geocode_spec.lua) ====================--
local P, S, R, V = lpeg.P, lpeg.S, lpeg.R, lpeg.V
local C, Cs, Ct, Cg, Cc, Cf = lpeg.C, lpeg.Cs, lpeg.Ct, lpeg.Cg, lpeg.Cc, lpeg.Cf
-- Minimal, correct JSON decoder (lpeg). Returns a Lua value, or nil + message.
local jsonNull = setmetatable({}, { __tostring = function() return "null" end })
local function cpToUtf8(hex)
local cp = tonumber(hex, 16)
if cp < 0x80 then
return string.char(cp)
elseif cp < 0x800 then
return string.char(0xC0 + cp // 0x40, 0x80 + cp % 0x40)
else
return string.char(0xE0 + cp // 0x1000, 0x80 + (cp // 0x40) % 0x40, 0x80 + cp % 0x40)
end
end
local jsonGrammar
do
local space = S(" \t\n\r") ^ 0
local digit = R("09")
local int = P("-") ^ -1 * (P("0") + R("19") * digit ^ 0)
local number = C(int * (P(".") * digit ^ 1) ^ -1 * (S("eE") * S("+-") ^ -1 * digit ^ 1) ^ -1) / tonumber
local escMap = { ['"'] = '"', ["\\"] = "\\", ["/"] = "/", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t" }
local hex = R("09", "af", "AF")
local escape = (P("\\") * C(S('"\\/bfnrt') + P("u") * hex * hex * hex * hex)) / function(cap)
if cap:byte(1) == 117 then return cpToUtf8(cap:sub(2)) end -- 117 == 'u'
return escMap[cap]
end
local jstring = P('"') * Cs((escape + (P(1) - S('"\\'))) ^ 0) * P('"')
jsonGrammar = P({
"doc",
doc = space * V("value") * space * P(-1),
value = space * (V("object") + V("array") + jstring + number
+ (P("true") * Cc(true)) + (P("false") * Cc(false)) + (P("null") * Cc(jsonNull))) * space,
object = P("{") * Cf(Ct("") * (V("member") * (P(",") * V("member")) ^ 0) ^ -1, rawset) * space * P("}"),
member = Cg(space * jstring * space * P(":") * V("value")),
array = P("[") * Ct((V("value") * (P(",") * V("value")) ^ 0) ^ -1) * space * P("]"),
})
end
local function jsonDecode(s)
if type(s) ~= "string" then return nil, "not a string" end
local ok, res = pcall(lpeg.match, jsonGrammar, s)
if not ok then return nil, "JSON parse error: " .. tostring(res) end
if res == nil then return nil, "invalid JSON" end
return res
end
-- Percent-encode a string for a URL query (RFC 3986 unreserved kept; everything else, incl. UTF-8
-- bytes, encoded). Explicit A-Za-z0-9 range, NOT %w: %w is locale-dependent and can match high bytes
-- in a non-C locale, leaving UTF-8 unencoded - which the geocoder rejects with "Bad request".
local function urlEncode(s)
return (tostring(s):gsub("[^A-Za-z0-9_%.~%-]", function(c)
return string.format("%%%02X", c:byte())
end))
end
-- Pull a human-readable message out of a geocoder error body, if it is JSON.
-- Covers OpenCage (status.message), Geoapify (message), LocationIQ (error), Google (error_message).
local function errorMessage(body)
local d = jsonDecode(body or "")
if type(d) ~= "table" then return nil end
if type(d.status) == "table" and d.status.message then return d.status.message end
return d.message or d.error or d.error_message
end
local function httpError(httpStatus, body)
local m = errorMessage(body)
return "HTTP " .. tostring(httpStatus) .. (m and (": " .. m) or "")
end
-- Per-provider response parsers. Each returns a canonical result:
-- { status = "ok"|"not_found"|"denied"|"quota"|"invalid"|"error",
-- lat=, lng=, confidence=(0-1 or nil), type=, message= }
local function parseGeoapify(httpStatus, body)
if httpStatus == 200 then
local data = jsonDecode(body)
if not data then return { status = "error", message = "Bad response from Geoapify" } end
local feats = data.features
if type(feats) ~= "table" or feats[1] == nil then return { status = "not_found" } end
local props = feats[1].properties or {}
if type(props.lat) ~= "number" or type(props.lon) ~= "number" then return { status = "not_found" } end
local conf = (type(props.rank) == "table") and props.rank.confidence or nil
return { status = "ok", lat = props.lat, lng = props.lon, confidence = conf, type = props.result_type }
elseif httpStatus == 401 or httpStatus == 403 then
return { status = "denied", message = errorMessage(body) }
elseif httpStatus == 429 then
return { status = "quota", message = errorMessage(body) }
elseif httpStatus == 400 then
return { status = "invalid", message = errorMessage(body) }
end
return { status = "error", message = httpError(httpStatus, body) }
end
local function parseOpenCage(httpStatus, body)
if httpStatus == 200 then
local data = jsonDecode(body)
if not data then return { status = "error", message = "Bad response from OpenCage" } end
local results = data.results
if type(results) ~= "table" or results[1] == nil then return { status = "not_found" } end
local g = results[1].geometry or {}
if type(g.lat) ~= "number" or type(g.lng) ~= "number" then return { status = "not_found" } end
local conf = (type(results[1].confidence) == "number") and (results[1].confidence / 10) or nil
local typ = (type(results[1].components) == "table") and results[1].components._type or nil
return { status = "ok", lat = g.lat, lng = g.lng, confidence = conf, type = typ }
elseif httpStatus == 401 or httpStatus == 403 then
return { status = "denied", message = errorMessage(body) }
elseif httpStatus == 402 or httpStatus == 429 then
return { status = "quota", message = errorMessage(body) }
elseif httpStatus == 400 then
return { status = "invalid", message = errorMessage(body) }
end
return { status = "error", message = httpError(httpStatus, body) }
end
local function parseLocationIQ(httpStatus, body)
if httpStatus == 200 then
local data = jsonDecode(body)
if type(data) ~= "table" or data[1] == nil then return { status = "not_found" } end
local r = data[1]
local lat, lng = tonumber(r.lat), tonumber(r.lon)
if not lat or not lng then return { status = "not_found" } end
return { status = "ok", lat = lat, lng = lng, confidence = nil, type = r.type or r.class }
elseif httpStatus == 404 then
return { status = "not_found" } -- LocationIQ returns 404 when nothing matches
elseif httpStatus == 401 or httpStatus == 403 then
return { status = "denied", message = errorMessage(body) }
elseif httpStatus == 429 then
return { status = "quota", message = errorMessage(body) }
elseif httpStatus == 400 then
return { status = "invalid", message = errorMessage(body) }
end
return { status = "error", message = httpError(httpStatus, body) }
end
-- Country-code normalisation: the geocoders want an ISO 3166-1 alpha-2 code. The UK is the common
-- gotcha - people type the ccTLD "uk", but the ISO code is "gb".
local cIsoFromAlias = { uk = "gb" }
-- Build the request URL for a provider. Returns url, or nil + message.
local function gcBuildUrl(provider, key, text, region)
local q = urlEncode((tostring(text)):match("^%s*(.-)%s*$")) -- trim surrounding spaces
local raw = (region and region ~= "") and region:lower() or nil
local iso = raw and urlEncode(cIsoFromAlias[raw] or raw) or nil -- ISO alpha-2 (uk -> gb)
if provider == "Geoapify" then
local u = "https://api.geoapify.com/v1/geocode/search?text=" .. q .. "&limit=1&format=geojson"
if iso then u = u .. "&bias=countrycode:" .. iso end
return u .. "&apiKey=" .. urlEncode(key)
elseif provider == "OpenCage" then
local u = "https://api.opencagedata.com/geocode/v1/json?q=" .. q .. "&limit=1&no_annotations=1"
if iso then u = u .. "&countrycode=" .. iso end
return u .. "&key=" .. urlEncode(key)
elseif provider == "LocationIQ" then
local u = "https://us1.locationiq.com/v1/search?q=" .. q .. "&format=json&limit=1"
if iso then u = u .. "&countrycodes=" .. iso end
return u .. "&key=" .. urlEncode(key)
end
return nil, "Geocoder not supported: " .. tostring(provider)
end
-- Parse a provider's HTTP response into the canonical result (dispatches per provider).
local function gcParse(provider, httpStatus, body)
if provider == "Geoapify" then return parseGeoapify(httpStatus, body) end
if provider == "OpenCage" then return parseOpenCage(httpStatus, body) end
if provider == "LocationIQ" then return parseLocationIQ(httpStatus, body) end
return { status = "error", message = "Geocoder not supported: " .. tostring(provider) }
end
-- Format a numeric coordinate as a tidy decimal string (<=7 dp, no trailing zeros).
local function fmtCoord(n)
local s = string.format("%.7f", n)
if s:find("%.") then s = s:gsub("0+$", ""):gsub("%.$", "") end
return s
end
-- ISO 3166-1 alpha-2 country codes (plus 'uk', the everyday alias for GB that the geocoders accept),
-- used to sanity-check a region-bias code before geocoding. Pure data, so it lives in this mirrored
-- block and is unit-tested alongside the URL builders.
local cCountryCodes = {}
for code in ("af ax al dz as ad ao ai aq ag ar am aw au at az bs bh bd bb by be bz bj bm bt bo bq ba bw bv br io bn bg bf bi cv kh cm ca ky cf td cl cn cx cc co km cg cd ck cr ci hr cu cw cy cz dk dj dm do ec eg sv gq er ee sz et fk fo fj fi fr gf pf tf ga gm ge de gh gi gr gl gd gp gu gt gg gn gw gy ht hm va hn hk hu is in id ir iq ie im il it jm jp je jo kz ke ki kp kr kw kg la lv lb ls lr ly li lt lu mo mg mw my mv ml mt mh mq mr mu yt mx fm md mc mn me ms ma mz mm na nr np nl nc nz ni ne ng nu nf mk mp no om pk pw ps pa pg py pe ph pn pl pt pr qa re ro ru rw bl sh kn lc mf pm vc ws sm st sa sn rs sc sl sg sx sk si sb so za gs ss es lk sd sr sj se ch sy tw tj tz th tl tg tk to tt tn tr tm tc tv ug ua ae gb us um uy uz vu ve vn vg vi wf eh ye zm zw uk"):gmatch("%S+") do
cCountryCodes[code] = true
end
-- True if a region-bias code is acceptable: blank (no bias), or a known two-letter code (any case).
local function gcValidRegion(code)
if type(code) ~= "string" then return false end
code = code:gsub("%s+", ""):lower()
if code == "" then return true end
return cCountryCodes[code] == true
end
-- Representative calendar year for the time slider, from a GEDCOM date VALUE (e.g. "25 MAY 1912",
-- "ABT 1850", "BET 1860 AND 1870", "1641/2"). Returns the first 3-or-4-digit run as a number, so a
-- 1-2 digit day is skipped and a range / approximate / dual date collapses to its first (representative)
-- year. nil when there is no year (undated -> shown at the slider's end). Pure: digits only, no locale.
local function factYear(s)
if type(s) ~= "string" then return nil end
for num in s:gmatch("%d+") do
if #num >= 3 and #num <= 4 then return tonumber(num) end
end
return nil
end
-- GEDCOM month abbreviation -> number, for factYMD. Keys upper-case (GEDCOM months are upper-case).
local cGedMonths = { JAN = 1, FEB = 2, MAR = 3, APR = 4, MAY = 5, JUN = 6, JUL = 7, AUG = 8, SEP = 9, OCT = 10, NOV = 11, DEC = 12 }
-- Representative year, month, day from a GEDCOM date VALUE, for building a Date (DateAt) to feed AgeAt.
-- GEDCOM order is [modifier] [day] [month] [year]: year = factYear; month = the first recognised 3-letter
-- month token; day = the first 1-2 digit number (only meaningful once a month is present). month and day
-- are nil when absent (year-only, or an approximate / range date). Returns nil when there is no year.
-- Pure; ASCII-explicit letter match (FH's cp1252 locale makes %a unreliable).
local function factYMD(s)
local y = factYear(s)
if not y then return nil end
local m
for tok in s:gmatch("[A-Za-z]+") do
local n = cGedMonths[tok:upper()]
if n then m = n; break end
end
local d
if m then
for num in s:gmatch("%d+") do
if #num <= 2 then d = tonumber(num); break end
end
end
return y, m, d
end
--================== END GEOCODE-PURE ==================--
-- WinHttp HTTP layer (COM; the request object is created once) --------------
local winHttp
-- Shared validity message for a region-bias code, used by the Geocoding Options Save (via the field's
-- validate hook) and by the live hint on the Geocoding pane. Blank is allowed (no bias).
regionFieldValidate = function(v)
if gcValidRegion(v) then return true end
return false, "use a two-letter ISO code (e.g. gb, us, ca, fr) or leave it blank"
end
-- Pre-flight for the session region bias: blank or a known country code passes; anything else warns
-- (with examples) and blocks the geocode so a typo like "uk." or "england" can't silently lose the bias.
local function regionBiasOK()
if gcValidRegion(gRegionBias) then return true end
MessageBox("warning", "'" .. tostring(gRegionBias) .. "' isn't a recognised country code.\n\n"
.. "Use a two-letter ISO code - e.g. gb (UK), us, ca, fr, au - or leave Region bias blank for no bias.\n\n"
.. "Set it on the Geocoding pane (or the default in Geocoding Options).", "OK")
return false
end
local function ensureHttp()
if winHttp then return winHttp end
local ok, obj = pcall(luacom.CreateObject, "winhttp.winhttprequest.5.1")
if ok and obj then
luacom.config.abort_on_error = false
luacom.config.abort_on_API_error = false
winHttp = obj
end
return winHttp
end
-- GET a URL. Returns httpStatus, body; or nil, nil, errText on a transport-level failure.
local function httpGet(url)
local h = ensureHttp()
if not h then return nil, nil, "Could not create the WinHttp component (winhttp.winhttprequest.5.1)." end
luacom.config.last_error = ""
pcall(function() h:SetTimeouts(10000, 10000, 10000, 20000) end)
h:Open("GET", url, 0) -- 0 = synchronous
h:Send()
local lastErr = luacom.config.last_error
if lastErr and lastErr ~= "" then
return nil, nil, lastErr .. "\n\nIf you use a VPN, try pausing it and geocoding again."
end
return tonumber(h.Status) or 0, h.ResponseText or ""
end
-- Native place STAT write. Controlled vocab "tentative" / "not found" / "no auto", or "" to clear
-- the status (delete the STAT node). Never touches the coordinate. Kept defensive (pcall) so a
-- failure never loses data.
local function setStat(recPtr, value)
return pcall(function()
local st = fhGetItemPtr(recPtr, "~.STAT")
if value == "" then
if st and st:IsNotNull() then fhDeleteItem(st) end
elseif st and st:IsNotNull() then
fhSetValueAsText(st, value)
else
fhSetValueAsText(fhCreateItem("STAT", recPtr), value)
end
end)
end
-- Native place/address STAN write (standardised value). Same shape as setStat: "" clears (deletes
-- the STAN node), otherwise set/create it. Defensive pcall so a failure never loses data.
local function setStan(recPtr, value)
return pcall(function()
local st = fhGetItemPtr(recPtr, "~.STAN")
if value == "" then
if st and st:IsNotNull() then fhDeleteItem(st) end
elseif st and st:IsNotNull() then
fhSetValueAsText(st, value)
else
fhSetValueAsText(fhCreateItem("STAN", recPtr), value)
end
end)
end
-- Geocode one model entry by its place/address name; returns the canonical gcParse result.
local function geocodeRecord(e)
local provider, key = activeGeocoder()
local q = (e.geoQuery and e.geoQuery ~= "") and e.geoQuery or e.name -- STAN / place-augmented query
local name = fh.stripCommas and fh.stripCommas(q) or q -- tidy stray/duplicate commas (fhUtils)
local url, uerr = gcBuildUrl(provider, key, name, gRegionBias)
if not url then return { status = "error", message = uerr } end
local httpStatus, body, herr = httpGet(url)
if not httpStatus then return { status = "error", message = herr } end
return gcParse(provider, httpStatus, body)
end
-- Result-row labels + quality text for the FH results table.
local cResultLabel = {
ok = "Geocoded", not_found = "No match", denied = "Key rejected",
quota = "Quota / rate limit", invalid = "Bad request", error = "Error",
}
local function resultLabel(status) return cResultLabel[status] or tostring(status) end
local function qualityText(res)
if res.confidence then return math.floor(res.confidence * 100 + 0.5) .. "%" end
return res.type or ""
end
-- FHUG privacy standard: geocoding uploads each place/address name to an external API, so we
-- must get specific consent (with a privacy-policy link) before sending any data. Per provider,
-- once per session.
local cProviderSite = {
["Geoapify"] = { name = "Geoapify", privacy = "https://www.geoapify.com/privacy-policy/" },
["OpenCage"] = { name = "OpenCage", privacy = "https://opencagedata.com/privacy" },
["LocationIQ"] = { name = "LocationIQ", privacy = "https://locationiq.com/privacy" },
}
local consentGiven = {}
local function geocodeConsent(provider)
if consentGiven[provider] then return true end
local info = cProviderSite[provider] or { name = tostring(provider), privacy = "" }
local msg = "Add Maps needs your consent to do two things:\n\n"
.. "1. Send each place or address name over the internet to " .. info.name
.. ", an external geocoding service.\n"
.. "2. Write the coordinates it returns into your project.\n\n"
.. "You can undo the changes to your project with Edit > Undo Plugin Updates,\n"
.. "but only until you close Family Historian.\n\n"
.. info.name .. " privacy policy:\n" .. info.privacy .. "\n\n"
.. "Click OK to consent to both, or Cancel to stop."
if MessageBox("question", msg, "OKCANCEL") ~= "OK" then return false end
consentGiven[provider] = true
return true
end
--------------------------------------------------------------
-- CORRECTION MAP (Phase 5: nudge one place/address by hand)
-- "Show on Map" writes a single-marker Leaflet page (Esri World Imagery for placing the pin, plus
-- Esri street tiles), opens it in the default browser, and offers Street View / Google Maps links
-- that track the pin (keyless Maps-URLs scheme - links only, never Google tiles). Dragging the
-- marker or clicking the map copies "AM:<lat>,<lng>" to the clipboard via the execCommand
-- hidden-textarea trick (works from a file:// page). "Get from Map" then reads the clipboard ONCE
-- (no polling - a clipboard-watch timer hard-crashed FH) and fills the coordinate, pending Save.
--------------------------------------------------------------
local cClipSentinel = "AM:" -- prefix so we only react to coordinates copied by our own map page
-- Encode a Lua string as a JavaScript double-quoted string literal (escapes quotes/backslashes and
-- "<" so a name can never break out of the <script> block).
local function jsStr(s)
s = tostring(s or ""):gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\r", ""):gsub("\n", "\\n"):gsub("<", "\\x3C")
return '"' .. s .. '"'
end
-- Full path of the correction page (plugin data folder, on LOCAL_MACHINE - not OneDrive, so no sync
-- conflict). One reused file: re-showing overwrites it (the snapshot model).
local function correctionHtmlPath()
local dir = cstrPluginDir or ""
if dir ~= "" and not dir:match("[/\\]$") then dir = dir .. "\\" end
return dir .. "Add Maps - correct location.html"
end
-- "Temporary maps" (Web maps > Temporary maps): standalone maps built into a throwaway folder under
-- the plugin data folder (LOCAL_MACHINE, not OneDrive - no sync conflict) and deleted when the plugin
-- closes, so someone who only wants a quick look never has to find and delete the files afterwards.
-- One shared folder, wiped whole (deleteFolder is recursive) both on the way out AND at start-up, so a
-- crash or force-close can't leave orphans. Never used for published/embedded maps, which must persist
-- to be injected (see buildMaps).
local function tempMapsDir()
local dir = cstrPluginDir or ""
if dir ~= "" and not dir:match("[/\\]$") then dir = dir .. "\\" end
return dir .. "Temporary maps\\"
end
-- Remove the temporary-maps folder and everything in it. Best-effort: a file a browser still holds
-- open just survives to the next sweep - never an error the user sees.
local function cleanupTempMaps()
local dir = tempMapsDir():gsub("[/\\]+$", "")
if dir ~= "" and fhfu.folderExists(dir) then
pcall(fhfu.deleteFolder, dir, true)
end
end
-- Build the correction page for one location. lat/lng/zoom are strings/numbers already validated by
-- the caller (lat/lng may be a sensible default when the record has no coordinate yet).
local cCorrectionTemplate = [[<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>Add Maps - correct location</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
body{font-family:Segoe UI,sans-serif;margin:10px}
#map{height:70vh;min-height:340px;margin-top:8px;border:1px solid #ccc}
#out{font-weight:bold;color:#063}
button{font-size:15px;padding:7px 12px;cursor:pointer}
a{font-size:15px}
.leaflet-control-layers-list{max-height:60vh;overflow-y:auto}
</style></head><body>
<h3>Correct the location of: <span id="place"></span></h3>
<p>Drag the blue marker (or click the map) to the right spot, then click
<b>Copy this coordinate</b>, switch back to Family Historian and click <b>Get from Map</b>.</p>
<p><button id="btn">Copy this coordinate</button> <span id="out">(nothing copied yet)</span></p>
<p><a id="sv" href="#" target="_blank" rel="noopener">Open Street View here</a>
| <a id="gm" href="#" target="_blank" rel="noopener">Open in Google Maps</a></p>
<div id="map"></div>
<script>
var NAME=__NAME__, LAT=__LAT__, LNG=__LNG__, ZOOM=__ZOOM__, SENT=__SENT__, HIST=__HIST__;
document.getElementById('place').textContent = NAME;
document.title = 'Correct location: ' + NAME; // so each open tab is labelled with the place
// Esri street tiles (not OSM): this page is ALWAYS opened from disk (file://), and OSM's tile policy
// now blocks referer-less file:// requests. Esri serves them fine - the same source as the satellite
// layer below, which has always worked locally.
var street = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',{maxZoom:19,attribution:'Tiles © Esri'});
var esri = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{maxZoom:19,attribution:'Imagery © Esri'});
var map = L.map('map',{center:[LAT,LNG],zoom:ZOOM,layers:[street]}); // street by default; satellite/historic optional
// Base layers incl. the historic OS/NLS maps - especially useful HERE: a historical address often isn't
// on a modern map, so an old map under the marker is the only way to place it. maxNativeZoom upscales
// past the source's real max instead of going blank.
var baseLayers={'Street (Esri)':street,'Satellite (Esri)':esri};
HIST.forEach(function(h){ baseLayers[h.name]=L.tileLayer(h.url,{maxZoom:19,maxNativeZoom:(h.mnz||16),attribution:h.attr}); });
L.control.layers(baseLayers).addTo(map);
var m = L.marker([LAT,LNG],{draggable:true}).addTo(map);
function fmt(n){return (+n).toFixed(5);}
function links(lat,lng){
// cbll snaps Street View to the nearest available panorama (viewpoint alone often opens blank)
document.getElementById('sv').href='https://maps.google.com/maps?q=&layer=c&cbll='+fmt(lat)+','+fmt(lng);
document.getElementById('gm').href='https://www.google.com/maps/search/?api=1&query='+fmt(lat)+','+fmt(lng);
}
function copyToClip(t){
var ta=document.createElement('textarea'); ta.value=t;
ta.style.position='fixed'; ta.style.top='-1000px';
document.body.appendChild(ta); ta.focus(); ta.select();
var ok=false; try{ ok=document.execCommand('copy'); }catch(e){}
document.body.removeChild(ta);
document.getElementById('out').textContent = t.replace(SENT,'') + (ok?' [copied]':' [copy failed - select and copy by hand]');
}
function send(lat,lng){ links(lat,lng); copyToClip(SENT+fmt(lat)+','+fmt(lng)); }
m.on('dragend',function(e){ var p=e.target.getLatLng(); send(p.lat,p.lng); });
map.on('click',function(e){ m.setLatLng(e.latlng); send(e.latlng.lat,e.latlng.lng); });
document.getElementById('btn').onclick=function(){ var p=m.getLatLng(); send(p.lat,p.lng); };
links(LAT,LNG);
</script></body></html>]]
local function buildCorrectionHtml(name, lat, lng, zoom, histLayers)
local histParts = {}
for _, h in ipairs(histLayers or {}) do
histParts[#histParts + 1] = "{name:" .. jsStr(h.name) .. ",url:" .. jsStr(h.url)
.. ",attr:" .. jsStr(h.attr) .. ",mnz:" .. tostring(h.mnz or 16) .. "}"
end
local subs = { NAME = jsStr(name), LAT = tostring(lat), LNG = tostring(lng), ZOOM = tostring(zoom),
SENT = jsStr(cClipSentinel), HIST = "[" .. table.concat(histParts, ",") .. "]" }
return (cCorrectionTemplate:gsub("__(%u+)__", function(k) return subs[k] end))
end
--------------------------------------------------------------
-- WEB MAPS - DATA LAYER (Phase 4a)
-- For each selected individual, collect every located event (Address-preferred coordinate),
-- honouring the "Map events" fact-type toggles. Coordinates already live on the _PLAC/_ADDR
-- records; we reach them with FH's own facility - fhGetValueAsLink follows the event's place /
-- address / migration-second-place field to its record, then we read the native ~.LATLONG.
--------------------------------------------------------------
-- Event tag -> the "Map events" Config key controlling whether it is mapped. Tags not listed fall to
-- the "all other located events" toggle.
-- Fact-type selection drives "what to map" (replaces the old fixed event-group toggles + catch-all).
-- The user picks fact types via Mapping Options; the choice is stored in myMapConfig as a "|"-joined
-- list of Facts ids. We enumerate once (Facts.enumerate) and build a map-time lookup keyed by what we
-- can derive from a fact instance: standard facts by tag+recType; custom facts (EVEN/_ATTR) by their
-- GEDCOM TYPE value (the fact's Name) + recType. See Facts.lua (2 Boilerplate) for the id scheme.
local gFactList -- cached Facts.enumerate() result (set at startup by loadFactSelection)
local gSelectedFactIds = {} -- set: Facts id -> true
local gFactKeyToId = {} -- map-time lookup: match-key -> Facts id
-- Scope toggles, chosen alongside the fact types in the same "Events to Map" picker (own facts always;
-- these add witnessed events and relatives' timeline events). Persisted in myMapConfig "Map events".
local gWitness, gTimeline = false, false
-- Tags the old six on-by-default toggles covered, used to seed the selection on first run so mapping
-- behaviour is unchanged until the user opens the picker.
local cDefaultFactTags = {
BIRT = true, CHR = true, BAPM = true, BLES = true, CHRA = true,
MARR = true, ENGA = true, MARB = true, MARC = true, MARL = true, MARS = true,
RESI = true, CENS = true, IMMI = true, EMIG = true, DEAT = true, BURI = true, CREM = true,
}
local function factMatchKey(gedcomTag, customName, recType)
if gedcomTag == "EVEN" or gedcomTag == "_ATTR" then
return "C\0" .. gedcomTag .. "\0" .. (customName or "") .. "\0" .. recType
end
return "S\0" .. gedcomTag .. "\0" .. recType
end
local function saveFactSelection()
local ids = {}
for id in pairs(gSelectedFactIds) do ids[#ids + 1] = id end
table.sort(ids)
myMapConfig:setValues("Map events", nil, {
factIds = table.concat(ids, "|"),
evtWitness = gWitness,
tlMaster = gTimeline,
})
end
-- The default selection: every enumerated fact whose tag is in cDefaultFactTags. Used both to seed
-- the first run and to back the picker's "Default" reset button.
local function defaultFactIdSet()
local set = {}
for _, f in ipairs(gFactList or {}) do
if cDefaultFactTags[f.gedcomTag] then set[f.id] = true end
end
return set
end
-- Enumerate the project's fact types and load the stored selection, seeding today's default set the
-- first time (when the key has never been written - distinguished from "user cleared all" by a sentinel).
local function loadFactSelection()
gFactList = Facts.enumerate()
gFactKeyToId = {}
for _, f in ipairs(gFactList) do
gFactKeyToId[factMatchKey(f.gedcomTag, f.isCustom and f.name or nil, f.recType)] = f.id
end
gWitness = myMapConfig:getBool("Map events", "evtWitness", false)
gTimeline = myMapConfig:getBool("Map events", "tlMaster", false)
local SENTINEL = "\1unset"
local stored = myMapConfig:getString("Map events", "factIds", SENTINEL)
if stored == SENTINEL then
gSelectedFactIds = defaultFactIdSet()
saveFactSelection()
else
gSelectedFactIds = {}
for id in stored:gmatch("[^|]+") do gSelectedFactIds[id] = true end
end
end
-- Open the "Events to Map" picker - fact types PLUS the witness / timeline scope toggles, all in one
-- place - persisting any change.
local function chooseFactTypes()
local result = Facts.choose({
facts = gFactList, selected = gSelectedFactIds, defaultSelected = defaultFactIdSet(),
title = "Events to map", prompt = "Tick the fact types to map (events and attributes):",
extras = {
{ key = "witness", label = "Events the person witnessed", value = gWitness },
{ key = "timeline", label = "Relatives' timeline events that overlap their life", value = gTimeline },
},
})
if result then
gSelectedFactIds = result.ids
gWitness = result.extras.witness == true
gTimeline = result.extras.timeline == true
saveFactSelection()
end
end
-- A friendly verb for the marker popup; unlisted tags fall back to FH's own fact name, then the tag.
local cEventVerb = {
BIRT = "Born", CHR = "Christened", BAPM = "Baptised", BLES = "Blessed", CHRA = "Christened",
MARR = "Married", ENGA = "Engaged", MARB = "Banns", MARC = "Marriage contract", MARL = "Marriage licence", MARS = "Marriage settlement",
RESI = "Lived", CENS = "Census", IMMI = "Immigrated", EMIG = "Emigrated",
DEAT = "Died", BURI = "Buried", CREM = "Cremated",
}
-- Distinct, reasonably colour-blind-friendly marker colours, cycled across the people on a map.
local cPersonPalette = { "#e6194B", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#469990", "#f032e6", "#9A6324", "#808000", "#000075" }
local function personColour(i) return cPersonPalette[((i - 1) % #cPersonPalette) + 1] end
-- True if a fact instance's type is in the user's selected set. Standard facts match by tag+recType;
-- custom facts (EVEN/_ATTR) by their GEDCOM TYPE value (the fact's Name) + recType.
local function mapFactWanted(fp, tag, recTag)
local recType = (recTag == "FAM") and "FAM" or "INDI"
local customName
if tag == "EVEN" or tag == "_ATTR" then customName = fhGetItemText(fp, "~.TYPE") or "" end
local id = gFactKeyToId[factMatchKey(tag, customName, recType)]
return id ~= nil and gSelectedFactIds[id] == true
end
-- The marker label for an event, from FH itself: the fact LABEL (Birth / Residence / Death, and a
-- renamed type's own name such as "Travelled"). We use the label, not the abbreviation, because some
-- abbreviations are cryptic ("Resid"). Fall back to a friendly verb, then the tag, only if the call fails.
local function eventLabel(fp, tag)
for _, fn in ipairs({ "FactLabel", "FactName" }) do
local ok, n = pcall(fhCallBuiltInFunction, fn, fp)
if ok and type(n) == "string" and n ~= "" then return n end
end
return cEventVerb[tag] or tag
end
-- Read a record's coordinate (returns lat,lng strings or nil).
local function recordCoord(rec)
if rec and rec.IsNotNull and rec:IsNotNull() then
local lat = fhGetItemText(rec, "~.LATLONG:LAT_NUMERIC") or ""
local lng = fhGetItemText(rec, "~.LATLONG:LONG_NUMERIC") or ""
if lat ~= "" and lng ~= "" then return lat, lng end
end
return nil
end
-- Follow a fact's PLAC / ADDR field to its linked _PLAC / _ADDR record (nil if not a link / absent).
local function linkedRecord(fp, ref)
local f = fhGetItemPtr(fp, ref)
if f and f:IsNotNull() then
local ok, rec = pcall(fhGetValueAsLink, f)
if ok and rec then return rec end
end
return nil
end
-- Resolve a coordinate for one of a fact's place/address fields, purely via FH's own link facility:
-- follow the field (ref = "~.PLAC" / "~._PLAC" / "~.ADDR") to its _PLAC/_ADDR record and read its
-- coordinate. (A diagnostic over real data confirmed fhGetValueAsLink resolves 100% of event places
-- and addresses to their records, so no name-matching fallback is needed.)
local function coordFor(fp, ref)
return recordCoord(linkedRecord(fp, ref))
end
-- Migration facts carry TWO places (FH extension): the standard PLAC plus a subordinate _PLAC. For
-- EMIG, PLAC is the departure and _PLAC the arrival; IMMI sits at the arrival, so PLAC=arrival,
-- _PLAC=origin. We map both as separate, labelled markers.
local cMigration = { EMIG = true, IMMI = true }
-- Tidy a place/address string for display. fhUtils.stripCommas does the comma work (strip spaces
-- around commas, drop leading/trailing/repeated commas, single space after each); we add the
-- internal-whitespace collapse and edge trim it doesn't cover.
local function tidyText(s)
s = tostring(s or ""):gsub("%s+", " ") -- collapse runs of whitespace (e.g. "Gosford Street")
if fh.stripCommas then s = fh.stripCommas(s) end -- FH's own comma tidy
return (s:gsub("^%s+", ""):gsub("%s+$", "")) -- trim leading/trailing whitespace
end
-- Every located event-point for one individual (honouring the fact-type + witness + timeline-facts
-- Config). A normal event yields one point (Address-preferred); a migration yields two (from + to).
-- Also returns a count of points whose place/address isn't geocoded yet. groupSet is the set of
-- INDI record IDs being mapped together - when "Include timeline events" is on, timeline events
-- whose owner is in groupSet are skipped (that fact is already covered by the owner's own copy).
-- Representative year for an event, for the time slider. Prefer the fact's Sort Date (FH's _SDATE,
-- which also positions otherwise-undated facts and is what FH orders by), else its DATE; read as the
-- GEDCOM VALUE (not the localised display) and reduced to a single year by factYear. Each reference is
-- tried defensively (pcall) and the first that yields a year wins; nil = undated. The exact _SDATE
-- reference path is belt-and-braces (both the fact-level and DATE-level forms) so a wrong guess just
-- falls through to ~.DATE rather than erroring.
local function eventYear(dp)
for _, ref in ipairs({ "~._SDATE", "~.DATE._SDATE", "~.DATE" }) do
local ok, v = pcall(fhGetItemText, dp, ref)
if ok and type(v) == "string" and v ~= "" then
local y = factYear(v)
if y then return y end
end
end
return nil
end
-- The map subject's age (whole years) at an event's date, via FH's own AgeAt(individual, date) built-in
-- (returns Numeric years). indiPtr is always the SUBJECT, so this works for own, timeline and witness
-- events alike - their age when a relative's event happened ("aged 5" when a sibling was born). Returns
-- nil when it can't be computed (no birth date, undated event) or would be negative (event before they
-- were born - common on timelines), so the popup never shows nonsense. pcall-guarded; the AgeAt date-arg
-- form (a date value from fhGetValueAsDate) is confirmed in FH at build.
-- The record-owner's age at a fact's date, via FH's :AGE_AT date scheme (data-reference approach). On a
-- fact, ~.DATE:AGE_AT yields the owner's age at that date - so for the subject's OWN facts (owner ==
-- subject) it's their age, exactly as FH computes it. Read as text and parse the leading integer. nil
-- when blank or negative. NOT used for timeline / witness facts: those live on a relative's / principal's
-- record, so :AGE_AT would give THAT person's age, not the subject's - the caller gates it to own facts.
local function eventAge(dp)
local function num(v)
if type(v) == "number" then return v end
if type(v) == "string" then return tonumber((v:match("^%s*(%-?%d+)"))) end
return nil
end
for _, read in ipairs({
function() return fhGetItemText(dp, "~.DATE:AGE_AT") end,
function() return fhGetDisplayText(dp, "~.DATE:AGE_AT", "min") end,
}) do
local ok, v = pcall(read)
local n = ok and num(v) or nil
if n and n >= 0 then return math.floor(n) end
end
return nil
end
-- The SUBJECT's age at a fact's date, for TIMELINE / WITNESS facts (which live on a relative's record,
-- so :AGE_AT would give the wrong person). Builds the event's date from its parsed year/month/day with
-- FH's DateAt(year[,month[,day]]) and calls AgeAt(subject, thatDate) - equivalent to the formula
-- =AgeAt(%INDI%, DateAt(1901,1,1)). nil when undated or the age is negative (event before birth).
local function eventAgeAt(indiPtr, dp)
local okT, gd = pcall(fhGetItemText, dp, "~.DATE")
if not okT or type(gd) ~= "string" or gd == "" then return nil end
local y, m, d = factYMD(gd)
if not y then return nil end
local okV, dv = pcall(function()
if d then return fhCallBuiltInFunction("DateAt", y, m, d) end
if m then return fhCallBuiltInFunction("DateAt", y, m) end
return fhCallBuiltInFunction("DateAt", y)
end)
if not okV or dv == nil then return nil end
local okA, age = pcall(fhCallBuiltInFunction, "AgeAt", indiPtr, dv)
if okA and type(age) == "number" and age >= 0 then return math.floor(age) end
return nil
end
local function eventsForIndividual(indiPtr, groupSet, privacy)
groupSet = groupSet or {}
local out, notGeo = {}, 0
local incWitness = gWitness -- chosen in the Events-to-Map picker, alongside the fact types
local tlMaster = gTimeline
local indiId = fhGetRecordId(indiPtr)
-- The subject's lifespan. Relatives' timeline / witness events OUTSIDE it - an elder sibling's birth
-- before the subject existed, a grandchild's wedding decades after their death - aren't part of this
-- person's life (and produced absurd / no ages), so we drop them.
local okBB, birthGd = pcall(fhGetItemText, indiPtr, "~.BIRT.DATE")
local birthYear = okBB and factYear(birthGd or "") or nil
local okDD, deathGd = pcall(fhGetItemText, indiPtr, "~.DEAT.DATE")
local deathYear = okDD and factYear(deathGd or "") or nil
-- (ptr, incWitness, incTimeline, timelinePrefsOnly, narrStyle=false, resolveExclusions=false,
-- excludePrivateFacts=true, [excludeRejectedFacts default true]). prefsOnly=true so FH's own
-- Preferences > General > Timeline Facts setting filters the timeline subset. Private facts
-- excluded - private is private, don't map them. Rejected facts excluded by default already.
-- Returned list is in FH's CHRONOLOGICAL ORDER (per the docs), honouring Sort Date (_SDATE)
-- and Time Frame placement for undated facts - so we don't need any sort of our own.
local ok, facts = pcall(fhIndGetFactList, indiPtr, incWitness, tlMaster, true, false, false, true)
if not ok or type(facts) ~= "table" then return out, notGeo end
-- Single pass: classify each fact, honour the Map events toggles + group dedup, decide whether
-- it sits on the journey path (has a date OR a non-Life Time Frame slot), and add it.
--
-- Own vs timeline = record identity. An INDI fact is OWN iff its containing record IS indiPtr;
-- a FAM fact is OWN iff indiPtr is HUSB or WIFE on that FAM. Anything else is a timeline fact.
-- (Tried FactOwner / TimelineFactText in v0.28 / v0.28.1 - both unreliable; FactOwner can
-- return indiPtr for facts shown on their timeline, and TimelineFactText returns text for own
-- facts in some setups. Record identity sidesteps both.) TimelineFactText is still used, but
-- only for the popup wording AFTER detection.
-- Read a FAM's HUSB / WIFE link as a numeric INDI record ID. Used to collect dedup-candidate
-- IDs for timeline FAM facts (so a parents' MARR dedups whichever parent is in the group).
local function famSpouseId(famPtr, role)
local fld = fhGetItemPtr(famPtr, "~." .. role)
if fld and fld:IsNotNull() then
local okL, sp = pcall(fhGetValueAsLink, fld)
if okL and sp and sp:IsNotNull() then return fhGetRecordId(sp) end
end
return nil
end
-- The individual pointers of a FAM's spouses. Used by the published-map privacy filter to test the
-- owning individuals of a timeline/witness fact that lives on a relative's marriage.
local function famSpousePtrs(famPtr)
local ptrs = {}
for _, role in ipairs({ "HUSB", "WIFE" }) do
local fld = fhGetItemPtr(famPtr, "~." .. role)
if fld and fld:IsNotNull() then
local okL, sp = pcall(fhGetValueAsLink, fld)
if okL and sp and sp:IsNotNull() then ptrs[#ptrs + 1] = sp end
end
end
return ptrs
end
-- The OTHER spouse on a FAM (the one that isn't selfId) - their formatted name, or nil if
-- the FAM has only one named spouse. Used to enrich own FAM-fact labels ("Marriage to X").
local function otherSpouseName(famPtr, selfId)
for _, role in ipairs({ "HUSB", "WIFE" }) do
local fld = fhGetItemPtr(famPtr, "~." .. role)
if fld and fld:IsNotNull() then
local okL, sp = pcall(fhGetValueAsLink, fld)
if okL and sp and sp:IsNotNull() and fhGetRecordId(sp) ~= selfId then
local okN, name = pcall(fhIndGetName, sp)
if okN and type(name) == "string" and name ~= "" then return name end
end
end
end
return nil
end
-- Both named spouses on a FAM as "X and Y" (or whichever exists), nil if none. Used to name the
-- principals of a WITNESSED family fact ("Witness at Marriage of John SMITH and Jane DOE").
local function famSpousesName(famPtr)
local names = {}
for _, role in ipairs({ "HUSB", "WIFE" }) do
local fld = fhGetItemPtr(famPtr, "~." .. role)
if fld and fld:IsNotNull() then
local okL, sp = pcall(fhGetValueAsLink, fld)
if okL and sp and sp:IsNotNull() then
local okN, name = pcall(fhIndGetName, sp)
if okN and type(name) == "string" and name ~= "" then names[#names + 1] = name end
end
end
end
if #names == 0 then return nil end
return table.concat(names, " and ")
end
-- Preposition for joining an own FAM-fact label to the other spouse's name. "to" reads
-- naturally for marriage / engagement, "from" for divorce; the rest of FH's FAM fact-types
-- (marriage banns / contract / licence / settlement) sit awkwardly with either, so "with".
local cFamPreposition = {
MARR = "to", ENGA = "to",
DIV = "from",
MARB = "with", MARC = "with", MARL = "with", MARS = "with",
}
-- All of this person's witness roles on one fact, joined "A / B". A person can hold MORE than one
-- role on the same fact (e.g. Godparent AND Priest = two _SHAR children) - but fhIndGetFactList
-- only returns ONE _SHAR pointer for the fact, so to show every role we scan the resolved fact
-- node's _SHAR children for this person. "" if none carry a role. (MoveNext takes no tag arg in
-- this build, so we walk all children and filter by tag.)
local function witnessRolesFor(factPtr, wantId)
local c = fhNewItemPtr()
c:MoveToFirstChildItem(factPtr)
local parts = {}
while c:IsNotNull() do
if fhGetTag(c) == "_SHAR" then
local okL, who = pcall(fhGetValueAsLink, c)
if okL and who and who:IsNotNull() and fhGetRecordId(who) == wantId then
local role = fhGetItemText(c, "~.ROLE") or ""
if role ~= "" then parts[#parts + 1] = role end
end
end
c:MoveNext()
end
return table.concat(parts, " / ")
end
-- Classification of each entry fhIndGetFactList returns:
-- WITNESS - a fact this person witnessed. FH returns these (bIncWitness) NOT as the fact node
-- but as the _SHAR sharing-link pointer that sits UNDER the principal's fact and
-- carries the ROLE. So a _SHAR tag IS the witness signal; the real fact is its
-- PARENT (dp below), and dp is what we read date / place / label / coords from.
-- OWN - a normal fact node whose containing record IS indiPtr (INDI) / where indiPtr is a
-- spouse (FAM).
-- TIMELINE - a normal fact node on a relative's record (record identity), gated by tlMaster.
-- Detection sidesteps TimelineFactText / FactOwner (both unreliable here - see Timeline
-- Diagnostic); TimelineFactText is still used AFTER detection for the timeline popup wording.
local recPtr, parentPtr = fhNewItemPtr(), fhNewItemPtr()
for _, fp in ipairs(facts) do
-- Witness facts arrive as a _SHAR pointer; resolve to the real fact node (its parent). Take
-- every role this person holds on that fact (witnessRolesFor), falling back to this single
-- _SHAR's ROLE. Normal facts: dp = fp.
local isWitness, witRole, dp = false, nil, fp
if fhGetTag(fp) == "_SHAR" then
isWitness = true
parentPtr:MoveToParentItem(fp)
dp = parentPtr
witRole = witnessRolesFor(dp, indiId)
if witRole == "" then witRole = fhGetItemText(fp, "~.ROLE") or "" end
end
local tag = fhGetTag(dp)
recPtr:MoveToRecordItem(dp)
local recTag = fhGetTag(recPtr)
local recId = fhGetRecordId(recPtr)
local h, w
if recTag == "FAM" then h, w = famSpouseId(recPtr, "HUSB"), famSpouseId(recPtr, "WIFE") end
local isTimeline, dedupIds, tlText = false, nil, nil
if not isWitness then
local own
if recTag == "INDI" then own = (recId == indiId)
elseif recTag == "FAM" then own = (h == indiId) or (w == indiId)
else own = true end -- unknown container (shouldn't happen for a fact): treat as own
if not own and tlMaster then
isTimeline = true
if recTag == "FAM" then
dedupIds = {}
if h then dedupIds[#dedupIds + 1] = h end
if w then dedupIds[#dedupIds + 1] = w end
else
dedupIds = { recId }
end
local okT, t = pcall(fhCallBuiltInFunction, "TimelineFactText", dp, indiPtr)
if okT and type(t) == "string" and t ~= "" then tlText = t end
end
end
local year = eventYear(dp) -- numeric year (time slider + the lifespan check; nil = undated)
-- A relative's timeline / witness event OUTSIDE the subject's lifespan (before their birth or
-- after their death) isn't part of their life - drop it (removes clutter + absurd "aged 100" ages).
-- Undated, or no birth/death year on record, -> kept (can't tell).
local outsideLife = (isWitness or isTimeline) and year
and ((birthYear and year < birthYear) or (deathYear and year > deathYear))
local mapThis = false
if outsideLife then
mapThis = false -- relative event outside the subject's lifespan: skip
elseif isWitness then
-- Witness events honour the SAME fact-type selection as own events, but are NOT deduped
-- against the group: a witness marker is meaningful even when the principal is mapped too.
if mapFactWanted(dp, tag, recTag) then mapThis = true end
elseif isTimeline then
-- Timeline facts honour the SAME fact-type selection as own facts (a type you've excluded
-- shouldn't reappear via a relative's timeline), plus the group-dedup check.
local dedup = false
for _, id in ipairs(dedupIds or {}) do
if groupSet[id] then dedup = true; break end
end
if not dedup and mapFactWanted(dp, tag, recTag) then mapThis = true end
elseif mapFactWanted(dp, tag, recTag) then
mapThis = true
end
-- Published-map privacy: a timeline/witness event belongs to a RELATIVE / PRINCIPAL, not the
-- subject, so drop it if that owning individual is Private-omitted or basic-details - otherwise a
-- living relative's event and place could leak onto the subject's public page. (Own facts belong to
-- the subject, who has already passed the same test to get a map at all. `privacy` is nil = local.)
if mapThis and privacy and (isWitness or isTimeline) then
if recTag == "INDI" then
if privacyBlocks(recPtr, privacy) then mapThis = false end
elseif recTag == "FAM" then
for _, sp in ipairs(famSpousePtrs(recPtr)) do
if privacyBlocks(sp, privacy) then mapThis = false; break end
end
end
end
if mapThis then
-- Use FH's display formatting (honours the user's date-format / place-display prefs),
-- with the "min" option so we get the VALUE only - "std" prepends "Date:" / "Place:".
local date = fhGetDisplayText(dp, "~.DATE", "min") or ""
-- year computed above (also gated the post-death skip)
-- Subject's age at the event. OWN facts use FH's :AGE_AT scheme (record-relative = the
-- subject, using the real stored date). TIMELINE / WITNESS facts live on a relative's record,
-- so :AGE_AT would give the wrong person - there we build the date (DateAt) and ask
-- AgeAt(subject, date) instead, which yields the subject's age at someone else's event.
local age
if isWitness or isTimeline then age = eventAgeAt(indiPtr, dp) else age = eventAge(dp) end
-- Suppress "aged 0" on the subject's OWN birth only - a relative's birth (timeline) is where
-- the subject's age IS meaningful ("aged 26 when son John was born"), so keep that.
if tag == "BIRT" and not (isTimeline or isWitness) then age = nil end
local verb = eventLabel(dp, tag)
-- Witness popup wording, built from the BARE fact label + the principal(s): "Father at
-- Birth of John SMITH" / "Witness at Marriage of John SMITH and Jane DOE". Built before
-- the own-FAM augmentation below so the "to <spouse>" suffix never lands on a witness label.
local witText = nil
if isWitness then
local principal
if recTag == "INDI" then
local okN, nm = pcall(fhIndGetName, recPtr)
if okN and type(nm) == "string" and nm ~= "" then principal = nm end
elseif recTag == "FAM" then
principal = famSpousesName(recPtr)
end
local roleTxt = (witRole and witRole ~= "") and witRole or "Witness"
witText = roleTxt .. " at " .. verb .. (principal and (" of " .. principal) or "")
end
-- Own FAM facts (MARR / DIV / ...): name the other spouse so the popup reads
-- "Marriage to Peggy JONES" not just "Marriage". Not for witnessed FAM facts (the person
-- isn't a spouse there - witText above already names the couple).
if recTag == "FAM" and not isTimeline and not isWitness then
local other = otherSpouseName(recPtr, indiId)
if other then
verb = verb .. " " .. (cFamPreposition[tag] or "with") .. " " .. other
end
end
local place = tidyText(fhGetDisplayText(dp, "~.PLAC", "min"))
local addr = tidyText(fhGetDisplayText(dp, "~.ADDR", "min"))
local place2 = tidyText(fhGetDisplayText(dp, "~._PLAC", "min")) -- FH second place
local function add(label, lat, lng, placeStr)
if lat then
out[#out + 1] = { tag = tag, label = label, date = date, year = year, age = age,
isTimeline = isTimeline, tlText = tlText,
isWitness = isWitness, witText = witText,
place = placeStr, address = "", lat = lat, lng = lng }
else
notGeo = notGeo + 1
end
end
-- The label carries a colon (and, for a two-place migration, "from"/"to") so the popup reads
-- "<label>: <place>" - e.g. "Residence: ..." or "Emigration: from ...". The points are
-- inserted in chronological from -> to order so the journey path draws origin to
-- destination for both EMIG (PLAC=origin, _PLAC=destination) and IMMI (PLAC=destination,
-- _PLAC=origin) - the two points share the fact's chronological position, so insertion
-- order decides which the polyline visits first.
if cMigration[tag] and place2 ~= "" then
local fromRef, fromPlace, toRef, toPlace
if tag == "EMIG" then
fromRef, fromPlace = "~.PLAC", place
toRef, toPlace = "~._PLAC", place2
else -- IMMI: PLAC=arrival (destination), _PLAC=origin
fromRef, fromPlace = "~._PLAC", place2
toRef, toPlace = "~.PLAC", place
end
if fromPlace ~= "" then
local la, lo = coordFor(dp, fromRef)
add(verb .. ": from", la, lo, fromPlace)
end
if toPlace ~= "" then
local la2, lo2 = coordFor(dp, toRef)
add(verb .. ": to", la2, lo2, toPlace)
end
elseif addr ~= "" then
local la, lo = coordFor(dp, "~.ADDR")
if not la then la, lo = coordFor(dp, "~.PLAC") end
add(verb .. ":", la, lo, tidyText((place ~= "" and (addr .. ", " .. place)) or addr))
elseif place ~= "" then
local la, lo = coordFor(dp, "~.PLAC")
add(verb .. ":", la, lo, place)
end
end
end
return out, notGeo
end
--------------------------------------------------------------
-- WEB MAPS - PAGE GENERATOR (Phase 4a; N-person, single individual is N=1)
--------------------------------------------------------------
local cMapTemplate = [==[<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<title>__TITLE__</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<style>
html,body{height:100%;margin:0;font-family:Segoe UI,sans-serif}
#map{height:100%}
#ttl{position:absolute;z-index:1000;top:8px;left:54px;margin:0;font-size:16px;background:rgba(255,255,255,.88);padding:4px 10px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3)}
.leaflet-popup-content{font-size:14px;margin:8px 10px}
.pn{font-weight:bold;margin-bottom:2px}
.ev{margin:3px 0}
.evd{color:#555}
.legend{background:rgba(255,255,255,.88);padding:6px 9px;border-radius:4px;font:13px Segoe UI,sans-serif;line-height:1.6;box-shadow:0 1px 4px rgba(0,0,0,.3)}
/* Only the per-person list scrolls; the controls (toggles + slider) above it stay visible even with
many people on the map. */
.legend .plist{max-height:32vh;overflow:auto}
.legend label{display:block;cursor:pointer;user-select:none}
.legend input{vertical-align:-1px;margin-right:5px}
.legend hr{margin:6px 0;border:0;border-top:1px solid #ccc}
.sw{display:inline-block;width:12px;height:12px;border-radius:50%;margin-right:6px;vertical-align:-1px;border:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.25)}
.leaflet-bar a.tool{font-size:18px;font-weight:bold;text-align:center}
/* Cap the base-layer list so the historic maps (up to a dozen entries) scroll rather than overflow. */
.leaflet-control-layers-list{max-height:60vh;overflow-y:auto}
/* Pin-shaped marker for dated events (numbered in life order). The bottom of the pin sits on the
location (iconAnchor 11,28). Visually distinct from MarkerCluster's round numbered icons. */
.pin svg{display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}
.nu{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.4)}
/* Year range slider, embedded in the legend (so it lines up with the toggles + person list rather than
being a separate control). Two overlaid range inputs = a dual-handle window; the track is shared and
each input is transparent with pointer-events only on its thumb, so both thumbs stay grabbable. */
.tsl{margin:6px 0 2px;padding-top:6px;border-top:1px solid #ccc;min-width:228px}
.tsl .trow{display:flex;align-items:center;gap:8px;margin-bottom:3px}
.tsl a.tplay{flex:0 0 auto;width:22px;height:22px;line-height:20px;text-align:center;text-decoration:none;color:#333;border:1px solid #bbb;border-radius:3px;background:#f6f6f6}
.tsl .tval{font-weight:bold;min-width:38px;text-align:center}
.tsl .tdash{color:#888}
.tsl .tfill{flex:1 1 auto}
.tsl .trng{position:relative;height:18px}
.tsl .trng input[type=range]{position:absolute;left:0;top:0;width:100%;height:18px;margin:0;background:none;pointer-events:none;-webkit-appearance:none;appearance:none}
.tsl .trng input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:#ccc}
.tsl .trng input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:#ccc}
.tsl .trng input[type=range]::-webkit-slider-thumb{pointer-events:auto;-webkit-appearance:none;appearance:none;margin-top:-5px;width:14px;height:14px;border-radius:50%;background:#3367d6;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.4);cursor:pointer}
.tsl .trng input[type=range]::-moz-range-thumb{pointer-events:auto;width:14px;height:14px;border-radius:50%;background:#3367d6;border:2px solid #fff;cursor:pointer}
</style></head><body>
<div id="ttl"></div><div id="map"></div>
<script>
var TITLE=__TITLE__, PEOPLE=__PEOPLE__, GLINKS=__GLINKS__, ZOOM=__ZOOM__, PATH_INIT=__PATH__, SCALE=__SIZE__, SLIDER=__SLIDER__, HIST=__HIST__, PUBLISHED=__PUBLISHED__, PREFIX=__PREFIX__;
document.title=TITLE; document.getElementById('ttl').textContent=TITLE;
function esc(s){return (s||'').replace(/[&<>]/g,function(c){return {'&':'&','<':'<','>':'>'}[c];});}
// Esri street is the default base layer. OSM's tile policy now blocks referer-less file:// requests,
// so a map opened from disk can't use OSM; Esri serves file:// fine. OSM is offered as an extra layer
// only on PUBLISHED maps (served from a real website, which sends a referer) - see baseLayers below.
var street=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',{maxZoom:19,attribution:'Tiles © Esri'});
var esri=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{maxZoom:19,attribution:'Imagery © Esri'});
var map=L.map('map',{layers:[street]});
// One cluster group holds every person's markers, so coincident markers from different people stack
// cleanly (spiderfy on click). Per-person filtering = cluster.addLayers / removeLayers on toggle.
var cluster=L.markerClusterGroup({showCoverageOnHover:false,maxClusterRadius:Math.round(30*SCALE),spiderfyOnMaxZoom:true});
map.addLayer(cluster);
var pts=[];
function evHtml(p,e){
var s='<div class="pn"><span class="sw" style="background:'+p.colour+'"></span>'+esc(p.name)+'</div>';
// The subject's age at the event (FH AgeAt), shown as a muted suffix when computable. Same person for
// own / timeline / witness events, so it reads as "their age then" in every case.
var aged=(e.age!=null)?' · aged '+e.age:'';
// Timeline events use FH's TimelineFactText ("Sister Mary was born in..."); own events build
// a "<label> <place>" line and add the date (+ age) in muted text.
if(e.witText){
// Witness event: "<Role> at <Fact> of <Principal>" + place + muted date + age.
s+='<div class="ev">'+esc(e.witText)+(e.place?'<br>'+esc(e.place):'')+(e.date?'<br><span class="evd">'+esc(e.date)+aged+'</span>':'')+'</div>';
}else if(e.tlText){
// Timeline event: FH's wording (which omits the date), then the date + age in muted text - so a
// relative's event reads "Death of sister Mary / 1890 · aged 5".
s+='<div class="ev">'+esc(e.tlText)+(e.date?'<br><span class="evd">'+esc(e.date)+aged+'</span>':(e.age!=null?'<br><span class="evd">aged '+e.age+'</span>':''))+'</div>';
}else{
s+='<div class="ev">'+esc(e.label)+'<br>'+esc(e.place)+(e.date?'<br><span class="evd">'+esc(e.date)+aged+'</span>':'')+'</div>';
}
if(GLINKS){
s+='<div class="ev"><a href="https://www.google.com/maps/search/?api=1&query='+e.lat+','+e.lng+'" target="_blank" rel="noopener">Google Maps</a> · <a href="https://maps.google.com/maps?q=&layer=c&cbll='+e.lat+','+e.lng+'" target="_blank" rel="noopener">Street View</a></div>';
}
return s;
}
// Tear-drop pin SVG for a dated event in life sequence (seq = 1..N), coloured per person. The
// hollow flag (set for timeline AND witness events) inverts the fill: white interior, coloured
// outline + numeral - "contextual to this person's life, not their own move". The witness flag
// adds a small coloured centre dot below the numeral, so witness events read as a distinct third
// category without hiding the journey number (own = filled, timeline = hollow, witness = hollow+dot).
function pinIcon(colour,seq,hollow,witness){
var fill = hollow ? '#fff' : colour;
var stroke = hollow ? colour : '#fff';
var textC = hollow ? colour : '#fff';
var sw = hollow ? '2' : '1.5';
// viewBox is fixed at the design size (22x28), so the path + numeral scale with width/height.
var w=Math.round(22*SCALE), h=Math.round(28*SCALE);
var dot = witness ? '<circle cx="11" cy="20.5" r="2" fill="'+colour+'"/>' : '';
var svg='<svg width="'+w+'" height="'+h+'" viewBox="0 0 22 28" xmlns="http://www.w3.org/2000/svg">'
+'<path d="M11 0C4.92 0 0 4.92 0 11C0 18 11 28 11 28S22 18 22 11C22 4.92 17.08 0 11 0Z" fill="'+fill+'" stroke="'+stroke+'" stroke-width="'+sw+'"/>'
+'<text x="11" y="14.5" text-anchor="middle" fill="'+textC+'" font-family="Segoe UI,sans-serif" font-size="10" font-weight="bold">'+seq+'</text>'
+dot
+'</svg>';
return L.divIcon({className:'pin',html:svg,iconSize:[w,h],iconAnchor:[Math.round(w/2),h]});
}
// Small unnumbered coloured circle. Used only as the initial marker icon before applyIcons() runs;
// markers are always shown as pins now (a dot reads as a cluster marker), so this is no longer a
// final state. Kept for the brief pre-applyIcons placeholder.
function dotIcon(colour){
// Inline width/height override the .nu base (14px) so the dot scales with the marker size.
var s=Math.round(14*SCALE);
return L.divIcon({className:'',html:'<div class="nu" style="width:'+s+'px;height:'+s+'px;background:'+colour+'"></div>',iconSize:[s,s],iconAnchor:[Math.round(s/2),Math.round(s/2)]});
}
// Per-person marker lists and polylines for the toggles.
// pMarkers[i] = every marker for person i, in journey order (cluster ops + numbering)
// pTimelineMarkers[i] = subset: timeline events only, for the timeline toggle
// pWitnessMarkers[i] = subset: witness events only, for the witness toggle
// Each marker carries m._isTimeline / m._isWitness / m._colour so applyIcons() can recompute its
// icon (and journey number) live from the current pathOn / tlOn / witOn state - no cached variants.
// The polyline coords are derived on demand (journeyCoords) from whichever markers are currently on
// the journey, so the two independent toggles don't need pre-baked coord arrays for every combo.
var pMarkers=[], pTimelineMarkers=[], pWitnessMarkers=[], pPaths=[];
var pVisible=[]; // per-person legend state (initially everyone visible)
var pathOn=PATH_INIT; // initial path / numbering state (from the "Show journey by default" Option)
var tlOn=true; // initial timeline state (always shown when present)
var witOn=true; // initial witness state (always shown when present)
var hasTimeline=false; // detected from PEOPLE; gates the in-map "Show timeline events" toggle
var hasWitness=false; // detected from PEOPLE; gates the in-map "Show witness events" toggle
// Year-window state for the time slider. YMIN/YMAX are the data extent (dated events only); winLo/winHi
// are the current window; hasSlider gates the whole feature (config off, or fewer than two distinct years).
var YMIN=Infinity, YMAX=-Infinity, winLo, winHi, hasSlider=false;
// Category membership = the JOURNEY SET: own always, timeline only when tlOn, witness only when witOn.
// This drives the NUMBERING, so toggling a category renumbers 1..N. The year window is deliberately NOT
// part of it - the window hides pins without renumbering, so the visible ones keep their lifetime number.
function categoryOn(m){
if(m._isTimeline && !tlOn) return false;
if(m._isWitness && !witOn) return false;
return true;
}
// Year-window gate (visibility only). Everything passes when there's no slider; a dated marker must fall
// in [winLo,winHi]; an undated marker (null year) shows only when the upper handle is at the maximum.
function windowOn(m){
if(!hasSlider) return true;
if(m._year==null) return winHi>=YMAX;
return m._year>=winLo && m._year<=winHi;
}
// Polyline coords for person i: the markers currently visible on the journey, in life order (markers are
// built in FH's chronological order, so no sort needed).
function journeyCoords(i){
var c=[];
pMarkers[i].forEach(function(m){ if(categoryOn(m) && windowOn(m)) c.push(m.getLatLng()); });
return c;
}
// (Re)assign person i's icons and LIFETIME journey numbers. seq counts the category-on markers only - it
// IGNORES the year window - so a windowed-out pin keeps its number and the visible pins never renumber as
// the window slides. The journey toggle controls only whether the number shows. Own = filled pin,
// timeline + witness = hollow pin, witness additionally gets a centre dot.
function applyIcons(i){
var seq=0;
pMarkers[i].forEach(function(m){
if(!categoryOn(m)) return;
seq++;
m.setIcon(pinIcon(m._colour, pathOn ? seq : '', (m._isTimeline||m._isWitness), m._isWitness));
});
}
// Bring person i's cluster membership + polyline into line with the current state (person visibility,
// category toggles, year window). Each marker's _inC flag tracks whether it is currently in the cluster,
// so only the delta is added/removed (no double-add). Numbering is refreshed first, so a marker the
// window reveals already carries the right number.
function refreshPerson(i){
applyIcons(i);
var add=[], rem=[];
pMarkers[i].forEach(function(m){
var show = pVisible[i] && categoryOn(m) && windowOn(m);
if(show && !m._inC){ m._inC=true; add.push(m); }
else if(!show && m._inC){ m._inC=false; rem.push(m); }
});
if(rem.length) cluster.removeLayers(rem);
if(add.length) cluster.addLayers(add);
if(pVisible[i] && pathOn){
pPaths[i].setLatLngs(journeyCoords(i));
if(!map.hasLayer(pPaths[i])) map.addLayer(pPaths[i]);
}else if(map.hasLayer(pPaths[i])){
map.removeLayer(pPaths[i]);
}
}
function refreshAll(){ PEOPLE.forEach(function(_,i){ refreshPerson(i); }); }
PEOPLE.forEach(function(p,i){
// Markers built in FH's journey order; numbered by applyIcons() via refreshPerson() below.
var mine=[], tlMine=[], witMine=[];
p.events.forEach(function(e){
var m=L.marker([e.lat,e.lng],{icon:dotIcon(p.colour)}).bindPopup(evHtml(p,e));
m._isTimeline=!!e.isTimeline; m._isWitness=!!e.isWitness; m._colour=p.colour;
m._year=(typeof e.year==='number')?e.year:null; m._inC=false; // year (null = undated); in-cluster flag
if(m._year!=null){ if(m._year<YMIN)YMIN=m._year; if(m._year>YMAX)YMAX=m._year; }
mine.push(m);
if(e.isTimeline){ tlMine.push(m); hasTimeline=true; }
if(e.isWitness){ witMine.push(m); hasWitness=true; }
pts.push([e.lat,e.lng]);
});
pMarkers[i]=mine; pTimelineMarkers[i]=tlMine; pWitnessMarkers[i]=witMine;
pVisible[i]=true;
// Polyline lives off-map until refreshPerson adds it; keep the reference for the toggles.
pPaths[i]=L.polyline([],{color:p.colour,weight:3,opacity:0.65});
});
// Enable the slider only when the config asked for it AND the data spans two or more distinct years.
hasSlider = SLIDER && isFinite(YMIN) && isFinite(YMAX) && YMAX>YMIN;
winLo=YMIN; winHi=YMAX;
refreshAll(); // initial cluster fill + polylines (identical to the old build when there's no window)
// Toggle one person on / off (legend checkbox).
function setPersonVisible(i,on){ pVisible[i]=on; refreshPerson(i); }
// Journey path + numbered pins for everyone (off = polylines hidden + pins lose their numbers).
function setPathOn(on){ pathOn=on; refreshAll(); }
// Timeline / witness categories for everyone - these RENUMBER 1..N (categoryOn drives applyIcons).
function setTimelineOn(on){ tlOn=on; refreshAll(); }
function setWitnessOn(on){ witOn=on; refreshAll(); }
function fit(){ if(pts.length>1){map.fitBounds(pts,{padding:[34,34]});} else if(pts.length===1){map.setView(pts[0],ZOOM);} else {map.setView([20,0],2);} }
// Base layers: Street (Esri) + Satellite always, plus any historic maps (NLS keyless + MapTiler when
// keyed). maxNativeZoom = the source's real max so Leaflet upscales past it rather than showing blank.
var baseLayers={'Street (Esri)':street,'Satellite (Esri)':esri};
// OSM as an extra street option, but ONLY on published maps: a file:// preview can't send the referer
// OSM's tile policy now requires, so offering it there would just show OSM's "blocked" tiles.
if(PUBLISHED){ baseLayers['Street (OpenStreetMap)']=L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OpenStreetMap',referrerPolicy:'no-referrer-when-downgrade'}); }
HIST.forEach(function(h){
baseLayers[h.name]=L.tileLayer(h.url,{maxZoom:19,maxNativeZoom:(h.mnz||16),attribution:h.attr});
});
L.control.layers(baseLayers).addTo(map);
// Reset-view + Full-screen tools (top-left, under the zoom control).
var tools=L.control({position:'topleft'});
tools.onAdd=function(){
var d=L.DomUtil.create('div','leaflet-bar');
function mk(glyph,title,fn){var a=L.DomUtil.create('a','tool',d);a.href='#';a.title=title;a.innerHTML=glyph;
L.DomEvent.on(a,'click',L.DomEvent.stop).on(a,'click',fn);return a;}
mk('↺','Reset view',function(){fit();});
mk('⛶','Full screen',function(){var el=document.documentElement;
if(document.fullscreenElement){document.exitFullscreen();}else if(el.requestFullscreen){el.requestFullscreen();}});
return d;
};
tools.addTo(map);
// One bottom-right panel holds everything, so the controls line up and stay together. Order: the
// journey / timeline / witness toggles first, then the time slider, then (when more than one person) a
// divider and the per-person list. Only the person list scrolls (.plist), so the toggles + slider are
// never pushed below the fold on a map with many people.
var lg=L.control({position:'bottomright'});
lg.onAdd=function(){
var d=L.DomUtil.create('div','legend');
L.DomEvent.disableClickPropagation(d); L.DomEvent.disableScrollPropagation(d);
var pathLbl=document.createElement('label');
pathLbl.innerHTML='<input type="checkbox" '+(pathOn?'checked':'')+' data-path="1">Show journey';
d.appendChild(pathLbl);
if(hasTimeline){
var tlLbl=document.createElement('label');
tlLbl.innerHTML='<input type="checkbox" '+(tlOn?'checked':'')+' data-tl="1">Show timeline events';
d.appendChild(tlLbl);
}
if(hasWitness){
var witLbl=document.createElement('label');
witLbl.innerHTML='<input type="checkbox" '+(witOn?'checked':'')+' data-wit="1">Show witness events';
d.appendChild(witLbl);
}
if(hasSlider){
var ts=document.createElement('div'); ts.className='tsl';
ts.innerHTML=
'<div class="trow"><a href="#" class="tplay" title="Play across time">▶</a>'
+'<span class="tval" id="tlo"></span><span class="tdash">to</span>'
+'<span class="tval" id="thi"></span><span class="tfill"></span></div>'
+'<div class="trng">'
+'<input type="range" id="rlo" min="'+YMIN+'" max="'+YMAX+'" step="1" value="'+winLo+'">'
+'<input type="range" id="rhi" min="'+YMIN+'" max="'+YMAX+'" step="1" value="'+winHi+'"></div>';
d.appendChild(ts);
}
if(PEOPLE.length>1){
d.appendChild(document.createElement('hr'));
var pl=document.createElement('div'); pl.className='plist';
PEOPLE.forEach(function(p,i){
var lbl=document.createElement('label');
// On a published map the person's name links to their website page (target=_top so a click
// escapes an embedding iframe). The <a> is interactive content inside the <label>, so per the
// HTML spec clicking it does NOT toggle the visibility checkbox - it just navigates. Standalone
// maps (PUBLISHED false) keep the plain name.
var nm=(PUBLISHED && p.id!=null)
? '<a href="'+PREFIX+p.id+'.html" target="_top">'+esc(p.name)+'</a>'
: esc(p.name);
lbl.innerHTML='<input type="checkbox" checked data-p="'+i+'"><span class="sw" style="background:'+p.colour+'"></span>'+nm;
pl.appendChild(lbl);
});
d.appendChild(pl);
}
d.addEventListener('change',function(ev){
var t=ev.target;
if(!t||t.type!=='checkbox') return;
if(t.getAttribute('data-path')) { setPathOn(t.checked); }
else if(t.getAttribute('data-tl')) { setTimelineOn(t.checked); }
else if(t.getAttribute('data-wit')) { setWitnessOn(t.checked); }
else { setPersonVisible(+t.getAttribute('data-p'), t.checked); }
});
return d;
};
lg.addTo(map);
// Wire the time slider built inside the legend above. Two overlaid range inputs form the dual-handle
// window [winLo,winHi]; Play glides a fixed-width window - the current handle gap, or a fallback of
// round(span/5) floored at 10 years when the window is full - from start to end.
if(hasSlider){
var playTimer=null;
var rlo=document.getElementById('rlo'), rhi=document.getElementById('rhi');
var tlo=document.getElementById('tlo'), thi=document.getElementById('thi');
var play=document.querySelector('.tsl a.tplay');
function paint(){
tlo.textContent=winLo; thi.textContent=winHi;
// Lift whichever thumb sits in the right-hand half above the other, so a fully-closed window (both
// handles together) still exposes both thumbs to the cursor.
rlo.style.zIndex=(winLo>(YMIN+YMAX)/2)?5:3; rhi.style.zIndex=4;
}
function applyWindow(){ paint(); refreshAll(); }
rlo.addEventListener('input',function(){ var v=+rlo.value; if(v>winHi){v=winHi; rlo.value=v;} winLo=v; applyWindow(); });
rhi.addEventListener('input',function(){ var v=+rhi.value; if(v<winLo){v=winLo; rhi.value=v;} winHi=v; applyWindow(); });
function stopPlay(){ if(playTimer){ clearInterval(playTimer); playTimer=null; play.innerHTML='▶'; } }
function startPlay(){
var span=YMAX-YMIN, w=winHi-winLo;
if(w<=0 || w>=span) w=Math.max(10, Math.round(span/5)); // full (or closed) window -> fallback width
if(w>span) w=span;
winLo=YMIN; winHi=YMIN+w; rlo.value=winLo; rhi.value=winHi; applyWindow();
play.innerHTML='❙❙'; // pause glyph
playTimer=setInterval(function(){
if(winHi>=YMAX){ stopPlay(); return; }
winLo+=1; winHi+=1;
if(winHi>YMAX){ winHi=YMAX; winLo=YMAX-w; }
rlo.value=winLo; rhi.value=winHi; applyWindow();
},250);
}
L.DomEvent.on(play,'click',L.DomEvent.stop).on(play,'click',function(){ if(playTimer) stopPlay(); else startPlay(); });
paint();
}
fit();
</script></body></html>]==]
-- Build a map page for a list of people: each = { name, colour, events = { {label, date,
-- isTimeline, tlText, isWitness, witText, place, address, lat, lng}, ... } }. Events come in FH
-- chronological order from fhIndGetFactList (date / Sort Date / Time Frame placement / user's manual
-- positioning, all handled by FH) and are emitted as a single ordered list - every event is on the
-- journey path at the position FH gave us, numbered 1..N. Markers live in a single MarkerCluster
-- group so coincident markers spiderfy cleanly; per-person legend toggles add/remove a person's
-- markers from the cluster and show/hide their polyline; the timeline and witness toggles hide their
-- hollow-pin markers and reflow polylines through whatever stays on the journey.
-- Marker-size scale factors (Medium = the design size). Applied in the template to the pin SVG,
-- the plain dot and the cluster radius (see SCALE there).
local cMarkerScale = { Small = 0.75, Medium = 1.0, Large = 1.4 }
-- Historic base maps (Maps Design.md s6.1.1). KEYLESS NLS S3 tilesets - verified live + keyless, and
-- served XYZ despite the OSM wiki's "TMS" label (GB data sits at the XYZ y). Each: name + url + max
-- NATIVE zoom (the source's real max; Leaflet upscales beyond it instead of going blank).
local cHistAttrNLS = 'Historic maps © <a href="https://maps.nls.uk/">National Library of Scotland</a>'
local cHistKeyless = {
{ name = "Historic: OS 6-inch GB (1940s–60s)", url = "https://mapseries-tilesets.s3.amazonaws.com/os/britain10knatgrid/{z}/{x}/{y}.png", mnz = 16 },
{ name = "Historic: OS 1:25k GB (1945–65)", url = "https://mapseries-tilesets.s3.amazonaws.com/os/25000_outline/{z}/{x}/{y}.png", mnz = 16 },
{ name = "Historic: OS 6-inch Scotland (1843–82)", url = "https://mapseries-tilesets.s3.amazonaws.com/os/6inchfirst/{z}/{x}/{y}.png", mnz = 16 },
{ name = "Historic: OS 25-inch Scotland (1892–1905)", url = "https://mapseries-tilesets.s3.amazonaws.com/25_inch/scotland_1/{z}/{x}/{y}.png", mnz = 18 },
}
-- MapTiler NLS set (https://www.maptiler.com/nls/) - KEYED raster tilesets, plain Leaflet, no MapLibre.
-- Shown only when a MapTiler key is set; flagged needsKey so the future website-embed path (4f) can drop
-- them by default (the key would otherwise be exposed in a published page - Maps Design.md s6.1.1 #6).
local cHistAttrMapTiler = 'Historic maps © <a href="https://maps.nls.uk/">NLS</a> / <a href="https://www.maptiler.com/">MapTiler</a>'
-- mnz (maxNativeZoom) values are the tilesets' real max zooms, read off the MapTiler dashboard. NB
-- uk-osgb1888 is the MULTI-SCALE "1900s" composite (1:1M when zoomed out -> six-inch when zoomed in),
-- distinct from uk-osgb10k1888 which is the pure six-inch - they are NOT duplicates.
local cHistMapTiler = {
{ id = "uk-osgb1888", name = "Historic: OS GB 1900s (1:1M–six-inch)", mnz = 17 },
{ id = "uk-osgb10k1888", name = "Historic: OS 6-inch GB (1888–1913)", mnz = 17 },
{ id = "uk-oslondon1k1893", name = "Historic: OS 5-foot London (1893–96)", mnz = 20 },
{ id = "uk-osgb63k1885", name = "Historic: OS 1-inch GB 'Hills' (1885–1903)", mnz = 16 },
{ id = "uk-osgb25k1937", name = "Historic: OS 1:25k GB 'Provisional' (1937–61)", mnz = 16 },
{ id = "uk-osgb63k1955", name = "Historic: OS 1-inch GB 7th Series (1955–61)", mnz = 15 },
{ id = "uk-osgb1919", name = "Historic: OS UK (1919–47)", mnz = 14 },
{ id = "uk-baire250k1940", name = "Historic: Bartholomew Ireland (1940s)", mnz = 12 },
}
-- Build the historic base-map layer list for a map page: keyless NLS always; the MapTiler NLS set when a
-- key is set. Returns { { name, url, attr, mnz, needsKey }, ... } (empty if the feature is off).
---@param maptilerKey string The MapTiler key to embed in the keyed layer URLs; "" omits those layers.
local function buildHistoricLayers(maptilerKey)
if not myMapConfig:getBool("Web maps", "historicMaps", true) then return {} end
local out = {}
for _, h in ipairs(cHistKeyless) do
out[#out + 1] = { name = h.name, url = h.url, attr = cHistAttrNLS, mnz = h.mnz, needsKey = false }
end
-- The keyed MapTiler NLS layers carry a key in their tile URL. The caller passes the key suited to
-- where the maps go (the unrestricted key locally, a domain-restricted key when publishing, or "" to
-- drop them) - see buildMaps. A published page would otherwise expose the key (Maps Design.md s6.1.1
-- #6 / s11.3).
local key = (maptilerKey or ""):gsub("%s+", "")
if key ~= "" then
for _, h in ipairs(cHistMapTiler) do
out[#out + 1] = { name = h.name, attr = cHistAttrMapTiler, mnz = h.mnz, needsKey = true,
url = "https://api.maptiler.com/tiles/" .. h.id .. "/{z}/{x}/{y}.png?key=" .. key }
end
end
return out
end
-- published: emit the publish-safe variant - the legend links each person to their website page
-- (pagePrefix .. id .. ".html"). pagePrefix defaults to "ind" (FH's own naming) but is configurable
-- for sites generated by other tools. Standalone maps (published = false) leave the legend as plain text.
local function buildPeopleMapHtml(title, people, googleLinks, zoom, pathOn, size, singleColour, sliderOn, histLayers, published, pagePrefix)
-- A map of ONE person may use a chosen single-marker colour instead of the palette; a map of
-- several people always keeps the per-person palette (so the people stay distinguishable).
local oneColour = (#people == 1 and singleColour and singleColour ~= "") and singleColour or nil
local function evJson(e)
local placeStr = (e.address ~= "" and (e.address .. ", " .. e.place)) or e.place
local parts = {
"lat:" .. e.lat, "lng:" .. e.lng,
"label:" .. jsStr(e.label), "date:" .. jsStr(e.date), "place:" .. jsStr(placeStr),
}
-- isTimeline/tlText and isWitness/witText only emitted for those categories - the JS side
-- defaults to a filled pin + own-popup format when all are absent (keeps own-event JSON terse).
if e.isTimeline then parts[#parts + 1] = "isTimeline:true" end
if e.tlText then parts[#parts + 1] = "tlText:" .. jsStr(e.tlText) end
if e.isWitness then parts[#parts + 1] = "isWitness:true" end
if e.witText then parts[#parts + 1] = "witText:" .. jsStr(e.witText) end
if e.year then parts[#parts + 1] = "year:" .. tostring(e.year) end -- omitted when undated
if e.age then parts[#parts + 1] = "age:" .. tostring(e.age) end -- subject's age; omitted when not computable
return "{" .. table.concat(parts, ",") .. "}"
end
local peopleParts = {}
for _, person in ipairs(people) do
-- Events come straight from fhIndGetFactList in FH's chronological order (date / Sort Date
-- / Time Frame, plus any manual positioning the user has done in FH). No sort or filter of
-- our own - every event is on the journey path at the position FH gave us.
local parts = {}
for _, e in ipairs(person.events) do parts[#parts + 1] = evJson(e) end
-- id drives the legend's website link on published maps; omitted when absent.
local idPart = person.id and (",id:" .. tostring(person.id)) or ""
peopleParts[#peopleParts + 1] = "{name:" .. jsStr(person.name)
.. ",colour:" .. jsStr(oneColour or person.colour)
.. idPart
.. ",events:[" .. table.concat(parts, ",") .. "]}"
end
-- Historic base-map layers (each becomes an L.tileLayer base layer in the page's layer control).
local histParts = {}
for _, h in ipairs(histLayers or {}) do
histParts[#histParts + 1] = "{name:" .. jsStr(h.name) .. ",url:" .. jsStr(h.url)
.. ",attr:" .. jsStr(h.attr) .. ",mnz:" .. tostring(h.mnz or 16) .. "}"
end
local subs = {
TITLE = jsStr(title),
PEOPLE = "[" .. table.concat(peopleParts, ",") .. "]",
GLINKS = googleLinks and "true" or "false",
ZOOM = tostring(zoom or 12),
PATH = pathOn and "true" or "false",
SIZE = tostring(cMarkerScale[size] or 1.0),
SLIDER = sliderOn and "true" or "false",
HIST = "[" .. table.concat(histParts, ",") .. "]",
PUBLISHED = published and "true" or "false",
PREFIX = jsStr(pagePrefix or "ind"),
}
return (cMapTemplate:gsub("__(%u+)__", function(k) return subs[k] end))
end
--------------------------------------------------------------
-- WEBSITE EMBEDDING (build step 4f): inject each per-individual map into ind<id>.html via the shared
-- HtmlInject boilerplate. The map is an <iframe> to a publish-safe Map<id>.html in a "maps" subfolder
-- of the website folder, so all of Leaflet's CSS/JS stays sealed in the frame. See Maps Design.md s11.
--------------------------------------------------------------
-- Config placement / alignment choices -> HtmlInject's option vocabulary.
local cEmbedPlacement = { ["Replace div contents"] = "replace", ["At top of div"] = "top", ["At bottom of div"] = "bottom" }
local cEmbedAlign = { Centre = "centre", Left = "left", Right = "right" }
-- Separator-agnostic, trailing-slash-trimmed path (mirrors Add Trees' normalizePath).
local function normalizePath(p)
if not p or p == "" then return "" end
return (p:gsub("\\", "/"):gsub("/+$", ""))
end
-- Is path inside (or equal to) root? Case-insensitive, separator-agnostic. Guards the page write so a
-- stray prefix/id can never resolve outside the chosen website folder.
local function isPathUnder(root, path)
local r = normalizePath(root):lower()
local p = normalizePath(path):lower()
if r == "" then return false end
return p:sub(1, #r) == r and (#p == #r or p:sub(#r + 1, #r + 1) == "/")
end
-- mkdir -p: create every missing folder from the shallowest down (FH's createFolder is non-recursive).
local function createFolderTree(path)
local missing, current = {}, path
while current and current ~= "" and not fhfu.folderExists(current) do
missing[#missing + 1] = current
local parent = fhfu.getParent(current)
if not parent or parent == current then break end
current = parent
end
for i = #missing, 1, -1 do
local ok, err = fhfu.createFolder(missing[i])
if not ok then return false, err end
end
return true
end
-- HTML-escape text for a double-quoted attribute value (the iframe title).
local function htmlAttrEsc(s)
return (tostring(s or ""):gsub('[&<>"]', { ["&"] = "&", ["<"] = "<", [">"] = ">", ['"'] = """ }))
end
-- The <iframe> block injected into the page. fit = true on the HtmlInject side (the iframe is
-- width:100%), so the height is the only dimension we set; the frame needs an explicit pixel height.
local function buildMapIframe(src, name, height)
local h = math.max(120, math.floor(tonumber(height) or 430))
return string.format(
'<iframe src="%s" title="Map of %s\'s life events" style="width:100%%;height:%dpx;border:0" loading="lazy"></iframe>',
src, htmlAttrEsc(name), h)
end
-- Inject one person's map iframe into their website page. Returns ok, note.
local function embedMapIntoPage(websiteFolder, id, name, iframeSrc, opts)
local pagePath = websiteFolder .. "\\" .. opts.pagePrefix .. id .. ".html"
if not isPathUnder(websiteFolder, pagePath) then
return false, "page path is not under the website folder"
end
if not fhfu.fileExists(pagePath) then
return false, "page not found (" .. opts.pagePrefix .. id .. ".html)"
end
local html = fhfu.readTextFile(pagePath, true, 8)
if type(html) ~= "string" then
return false, "could not read " .. opts.pagePrefix .. id .. ".html"
end
-- Optionally drop a section first (e.g. FH's own "See also" box). Never remove the div we inject into.
if opts.removeDiv and opts.removeDivClass ~= "" and opts.removeDivClass ~= opts.divClass then
html = HtmlInject.removeDiv(html, opts.removeDivClass)
end
-- [NAME] -> the person's name (function replacement, so a '%' in the name can't corrupt the result).
local caption = (opts.caption ~= "") and (opts.caption:gsub("%[NAME%]", function() return name end)) or nil
local iframe = buildMapIframe(iframeSrc, name, opts.frameHeight)
local newHtml, err = HtmlInject.inject(html, iframe, id, {
markerToken = "AddMaps", -- our own marker label, so a re-run never touches Add Trees' block
classToken = opts.divClass,
placement = opts.placement,
align = opts.align,
caption = caption,
captionClass = opts.captionClass,
containerClass = opts.containerClass,
hideable = opts.hideable,
fit = true,
})
if not newHtml then
return false, err or "injection failed"
end
local ok, werr = fhfu.createTextFile(pagePath, true, true, newHtml, 8)
if not ok then
return false, "could not write the page: " .. tostring(werr)
end
return true, ""
end
--------------------------------------------------------------
-- MAIN DIALOG
--------------------------------------------------------------
-- Build one geocoding tab for a record type.
-- recType : "_PLAC" or "_ADDR"
-- nouns : lower-case plural for labels, e.g. "places" / "addresses"
-- Nouns : capitalised plural, e.g. "Places" / "Addresses"
-- Noun : capitalised singular for buttons, e.g. "Place" / "Address"
local function buildGeocodeTab(recType, nouns, Nouns, Noun)
-- Target-list model (Add Trees "Target Records" pattern). The list holds the records the user has
-- chosen via FH's OWN record picker - a subset, a saved query, a smart folder, or Select All - so
-- the filtering/searching power is FH's, far beyond a text box we could offer. Each entry carries
-- its live record pointer, so reads/writes use it directly (no walking the record list by index).
-- Sorted A-Z; the display row maps 1:1 to the model index (no filtering indirection).
local model = {}
local selected = 0 -- MODEL index of the row shown in the edit panel (0 = none)
-- Enables/disables the Coordinate panel's controls for the current selection (Blocked = status-only;
-- multi-tick = single-item editing off). Forward-declared; defined once every widget exists.
local refreshPanelMode
local isPlace = (recType == "_PLAC")
-- visiblelines is deliberately small: it sets only the list's NATURAL height. expand=YES makes the
-- list fill the available height (and scroll when there are more rows), so the list shrinks
-- vertically and the window's minimum height is driven by the Coordinate form + the summary/Geocode
-- row beneath - not by the list.
local lstRecs = makeList({ dropdown = "NO", sort = "NO", expand = "YES", visiblelines = 6, visiblecolumns = 36,
tip = "The " .. nouns .. " you've chosen to work on. Use the Geocoding menu to add or remove.\n\nShortcuts:\n- Ctrl+I: Select " .. nouns .. "\n- Ctrl+T: Clear list\n- Ctrl+A: Select all\n- Del: Remove selected\n\nCtrl- or Shift-click to tick several for Geocode Selected." })
lstRecs.MULTIPLE = "YES" -- tick several rows for "Geocode Selected" (set before the dialog is mapped)
local txtLat = makeText({ tip = "Latitude in decimal degrees, e.g. 53.763201" })
local txtLng = makeText({ tip = "Longitude in decimal degrees, e.g. -2.70309" })
local txtStan = makeText({ tip = "Standardised name used for geocoding when the name itself won't (e.g. a historic name); blank = geocode the name. Click Save to apply." })
local lblName = makeLongLabel({ title = "(select a " .. Noun:lower() .. ")", size = "240x", ellipsis = "YES", tip = "Selected " .. Noun:lower() })
local lblParent = makeLongLabel({ title = "", size = "240x", ellipsis = "YES", tip = "Parent place (addresses only)" })
local lblMap = makeLongLabel({ title = "", size = "240x", ellipsis = "YES" }) -- in-pane feedback for Get from Map
local lblMode = makeLongLabel({ title = "", size = "240x40", wordwrap = "YES" }) -- Blocked / multi-select notice (wraps; height reserves ~2 lines so the panel doesn't jump)
local lblSummary = makeLongLabel({ title = "" })
-- Status control: an editable dropdown for places (FH addresses have no status). Labels match
-- FH's own map-window legend; "(none)" clears the status flag. Setting a status never touches the
-- coordinate (they are kept independent). The dropdown is a PENDING edit
-- - nothing is written when it changes; Save commits it.
local cStatusOptions = { "(none)", "Tentative", "Not found", "Blocked" }
local cStatusValueByItem = { "", "tentative", "not found", "no auto" } -- dropdown item index -> stored STAT
local cStatusItem = { [""] = 1, tentative = 2, ["not found"] = 3, ["no auto"] = 4 } -- statKey -> item index
local lblStat, lstStat
if isPlace then
lstStat = makeList({ dropdown = "YES", sort = "NO", values = cStatusOptions, visibleitems = 4, active = "NO",
tip = "This place's status (FH's own terms). 'Blocked' excludes it from auto-geocoding; '(none)' clears the flag. Click Save to apply." })
else
lblStat = makeLabel({ title = "", tip = "Addresses have no status" })
end
-- Reflect entry e's status in the status control (nil clears + disables the place dropdown).
local function showStatus(e)
if isPlace then
lstStat.value = tostring(cStatusItem[(e and e.statKey) or ""] or 1)
lstStat.active = e and "YES" or "NO"
else
lblStat.title = (e and e.stat ~= "" and e.stat) or "(none)"
end
end
local function refreshSummary()
if #model == 0 then
lblSummary.title = "(no " .. nouns .. " selected - use the Geocoding menu, or Ctrl+I)"
return
end
local n, withCoord, tentative, noAuto, todo = #model, 0, 0, 0, 0
for _, e in ipairs(model) do
if e.hasCoord then withCoord = withCoord + 1 end
if e.statKey == "tentative" then tentative = tentative + 1 end
if e.statKey == "no auto" then noAuto = noAuto + 1
elseif not e.hasCoord then todo = todo + 1 end
end
lblSummary.title = string.format(
"%d %s | %d with coordinates | %d tentative | %d blocked | %d to do",
n, nouns, withCoord, tentative, noAuto, todo)
end
local function rowText(e)
local t = statusMarker(e) .. e.name
if e.parentPlace and e.parentPlace ~= "" then t = t .. " - " .. e.parentPlace end
return t
end
local function showSelected()
local e = model[selected]
if not e then return end
lblName.title = e.name
lblParent.title = (e.parentPlace and e.parentPlace ~= "") and ("in " .. e.parentPlace) or ""
lblMap.title = ""
txtLat.value = e.lat
txtLng.value = e.lng
txtStan.value = e.stan or ""
showStatus(e)
if refreshPanelMode then refreshPanelMode() end
end
-- Reset the edit panel to "nothing selected".
local function clearPanel()
selected = 0
lblName.title = "(select a " .. Noun:lower() .. ")"
lblParent.title = ""
lblMap.title = ""
txtLat.value = ""
txtLng.value = ""
txtStan.value = ""
showStatus(nil)
if refreshPanelMode then refreshPanelMode() end
end
-- Rebuild the visible list from the model (row index == model index; no filtering).
local function rebuildList()
lstRecs.REMOVEITEM = "ALL"
for i, e in ipairs(model) do
lstRecs[tostring(i)] = rowText(e)
end
end
-- Refresh one model entry's row text in place.
local function setRow(mi)
lstRecs[tostring(mi)] = rowText(model[mi])
end
local function sortModel()
table.sort(model, function(a, b) return a.name:lower() < b.name:lower() end)
end
function lstRecs:action(text, item, state)
if state == 1 then
selected = tonumber(item) or 0 -- row index IS the model index (no filtering)
showSelected() -- calls refreshPanelMode
elseif refreshPanelMode then
refreshPanelMode() -- untick: the tick-count may have crossed the single/multi boundary
end
return iup.DEFAULT
end
-- Is this record pointer already in the list? (identity, not name - two places can share a name.)
local function isListed(ptr)
for _, e in ipairs(model) do
if e.ptr and ptr and e.ptr:IsSame(ptr) then return true end
end
return false
end
-- Add record pointers to the target list (keep only this tab's type; dedupe by identity; A-Z),
-- then refresh the view. Returns how many were actually added.
local function addPtrs(ptrs)
local added = 0
for _, p in ipairs(ptrs or {}) do
if p and p:IsNotNull() and fhGetTag(p) == recType and not isListed(p) then
model[#model + 1] = buildEntry(p, recType)
added = added + 1
end
end
if added > 0 then
sortModel()
rebuildList()
refreshSummary()
clearPanel()
end
return added
end
-- Seed the list on launch from the current FH selection of this record type, or the property box.
local function seedFromSelection()
local sel = fhGetCurrentRecordSel(recType)
if sel and #sel > 0 then
addPtrs(sel)
else
local pb = fhGetCurrentPropertyBoxRecord()
if pb and pb:IsNotNull() and fhGetTag(pb) == recType then addPtrs({ pb }) end
end
end
-- The status dropdown is a PENDING edit (no write here). But "Blocked"/"Not found" are UNGEOCODED
-- states in FH, so reflect that in the coordinate fields at once - blank them for those, and put
-- the saved coordinate back when switching to a geocoded status. Save commits everything.
if isPlace then
function lstStat:action(text, item, state)
if state ~= 1 then return iup.DEFAULT end
local e = model[selected]
if not e then return iup.DEFAULT end
local v = cStatusValueByItem[tonumber(item)]
if v == "no auto" or v == "not found" then
txtLat.value, txtLng.value = "", ""
elseif txtLat.value == "" and txtLng.value == "" then
txtLat.value, txtLng.value = e.lat, e.lng
end
if refreshPanelMode then refreshPanelMode() end -- e.g. picking/clearing Blocked locks/unlocks the fields
return iup.DEFAULT
end
end
-- Validate a decimal coordinate; "" means clear; nil means invalid.
local function parseCoord(s, lo, hi)
s = (s or ""):gsub("%s+", "")
if s == "" then return "" end
local nval = tonumber(s)
if not nval or nval < lo or nval > hi then return nil end
return tostring(nval)
end
-- Log a manual Save to the on-exit results table as a clickable record row. The Result column
-- reflects the status set, so a block shows "Blocked" (not just "Saved").
local function logEdit(ptr, e)
local result = (e.statKey == "no auto" and "Blocked")
or (e.statKey == "not found" and "Not found")
or (e.statKey == "tentative" and "Tentative")
or (e.hasCoord and "Saved")
or (e.stan ~= "" and "Standardised") or "Cleared"
addGeocodeRow(ptr, isPlace and "Place" or "Address", "(manual)", result, e.lat, e.lng, "", e.stan)
end
local btnSave = makeButton({ title = "&Save", callback = function()
if selected == 0 or not model[selected] then
MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK")
return
end
local e = model[selected]
local newStatKey = isPlace and (cStatusValueByItem[tonumber(lstStat.value)] or "") or e.statKey
-- FH couples status to whether the item is geocoded: "Blocked"/"Not found" are UNGEOCODED
-- states (no coordinate); "Tentative" requires one. Enforce that so the status actually takes.
-- Only "Blocked" (no auto) is unconditionally ungeocoded - it never keeps a coordinate. Every
-- other status may carry one, so we read and validate the fields.
local lat, lng
if newStatKey == "no auto" then
lat, lng = "", ""
else
lat = parseCoord(txtLat.value, -90, 90)
lng = parseCoord(txtLng.value, -180, 180)
if lat == nil then
MessageBox("warning", "Latitude must be a number between -90 and 90.", "OK")
return
end
if lng == nil then
MessageBox("warning", "Longitude must be a number between -180 and 180.", "OK")
return
end
if (lat == "") ~= (lng == "") then
MessageBox("warning", "Enter both latitude and longitude, or clear both.", "OK")
return
end
if newStatKey == "tentative" and lat == "" then
MessageBox("warning", "'Tentative' is for a geocoded place. Add a coordinate, or choose a different status.", "OK")
return
end
-- A coordinate means the place WAS found, so "Not found" + a coordinate is contradictory:
-- keep the coordinate the user typed and clear the status to "(none)", rather than silently
-- discarding the coordinate (which is what treating "Not found" as ungeocoded used to do).
if newStatKey == "not found" and lat ~= "" then newStatKey = "" end
end
local ptr = e.ptr
if not (ptr and ptr:IsNotNull()) then
MessageBox("error", "That record no longer exists - remove it from the list.", "OK")
return
end
local statErr, stanErr
if isPlace and newStatKey ~= e.statKey then
local ok, err = setStat(ptr, newStatKey)
if ok then e.statKey = newStatKey else statErr = err end
end
-- Standardised value (both types): write it and rebuild the geocode query so a later geocode
-- uses the edit straight away.
local newStan = (txtStan.value or ""):match("^%s*(.-)%s*$")
if newStan ~= e.stan then
local ok, err = setStan(ptr, newStan)
if ok then
e.stan = newStan
e.geoQuery = buildGeoQuery(e.name, newStan, e.geoCtx, not isPlace)
else
stanErr = err
end
end
writeCoordinate(ptr, lat, lng)
e.lat, e.lng = lat, lng
e.hasCoord = (lat ~= "" and lng ~= "")
txtLat.value, txtLng.value = lat, lng -- reflect any coordinate cleared by a block
showStatus(e) -- re-sync the dropdown to the saved status (e.g. a "Not found" that flipped to "(none)")
setRow(selected)
refreshSummary()
logEdit(ptr, e)
if refreshPanelMode then refreshPanelMode() end -- a saved status change may lock/unlock the fields
if statErr or stanErr then
showStatus(e)
local msg = "The coordinate was saved, but:"
if statErr then msg = msg .. "\n- the status could not be changed (" .. tostring(statErr) .. ")" end
if stanErr then msg = msg .. "\n- the standardised value could not be saved (" .. tostring(stanErr) .. ")" end
MessageBox("warning", msg, "OK")
end
end })
local btnClear = makeButton({ title = "&Clear", tip = "Blank the coordinate and status here, then Save to apply (nothing is written until you Save)", callback = function()
if selected == 0 or not model[selected] then
MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK")
return
end
txtLat.value, txtLng.value = "", ""
if isPlace then lstStat.value = "1" end -- "(none)", pending until Save
end })
-- List-management actions (target list). Reached from the per-type menu (Places / Addresses) and
-- by keyboard shortcuts on the list (Add Trees / Add Notes pattern). Nothing here changes the
-- project - they only manage which records are in the working list.
local function actSelect() -- Ctrl+I / menu: add via FH's own record picker
local sel = fhPromptUserForRecordSel(recType, -1, getParentWindowHandle())
if sel and #sel > 0 then
if addPtrs(sel) == 0 then
MessageBox("info", "Those " .. nouns .. " are already in the list.", "OK")
end
end
end
local function actRemove() -- Del / menu: remove the ticked rows
if #getSelectedValues(lstRecs, true) == 0 then
MessageBox("info", "Tick one or more " .. nouns .. " to remove.\n\n(Ctrl-click or Shift-click to select several.)", "OK")
return
end
removeSelectedItems(lstRecs, model, function() rebuildList(); refreshSummary(); clearPanel() end)
end
local function actClear() -- Ctrl+T / menu: empty the list
if #model == 0 then return end
model = {}
rebuildList()
refreshSummary()
clearPanel()
end
local function actSelectAll() -- Ctrl+A: tick every row
local count = tonumber(lstRecs.COUNT) or 0
if count > 0 then lstRecs.value = string.rep("+", count) end
end
-- Ctrl+I / Ctrl+T are now window-global (dispatched from the dialog's accelerators), so they fire
-- from anywhere - not only when this list has focus. Ctrl+A (Select All, which has no menu item) and Del
-- (kept list-scoped so it never clobbers Delete while editing a field) stay bound to the list here.
function lstRecs:k_any(c)
if c == iup.K_cA then actSelectAll(); return iup.IGNORE
elseif c == iup.K_DEL then actRemove(); return iup.IGNORE
end
return iup.CONTINUE
end
-- Geocode buttons: use-time key check now; actual geocoding lands in Phase 3.
-- Re-entrancy guard for the batch loop (fhSleep pumps the UI, so the button could fire again).
local batchRunning = false
-- Geocode ONE entry: look it up and, on success, write the coordinate and (places only) set
-- STAT="tentative", updating the model entry. Returns the gcParse result (with .statErr set if
-- the STAT write failed). No dialogs - callers handle messaging.
local function geocodeEntry(e)
local ptr = e.ptr
local res = geocodeRecord(e)
if res.status == "ok" then
if not (ptr and ptr:IsNotNull()) then
res = { status = "error", message = "That record no longer exists - remove it from the list." }
else
local latS, lngS = fmtCoord(res.lat), fmtCoord(res.lng)
writeCoordinate(ptr, latS, lngS)
-- FH's geocode status (tentative) is a PLACE-only field; addresses have none.
local doTentative = recType == "_PLAC" and myGeoConfig:getBool("Geocoding", "markTentative", true)
local statOk, statErr = true, nil
if doTentative then statOk, statErr = setStat(ptr, "tentative") end
e.lat, e.lng, e.hasCoord = latS, lngS, true
if doTentative and statOk then e.stat, e.statKey = "Tentative", "tentative" end
res.statErr = (doTentative and not statOk) and statErr or nil
end
end
addGeocodeRow(ptr, (recType == "_PLAC") and "Place" or "Address", (activeGeocoder()),
resultLabel(res.status), (res.status == "ok") and e.lat or "",
(res.status == "ok") and e.lng or "", qualityText(res), e.stan)
return res
end
-- Run the geocode loop over a list of model indices (already filtered + confirmed by the caller):
-- progress, per-row + summary updates, stop-on-quota/denied, cancel, and the closing message.
local function runGeocodeBatch(indices, provider)
batchRunning = true
pluginBusy = true -- block closing the plugin while the loop pumps the UI (see pluginBusy)
local progress = Progress.new(#indices, 1, 20, dlgMain)
local nOk, nNotFound, nError, stopped, cancelled = 0, 0, 0, nil, false
for n, i in ipairs(indices) do
local e = model[i]
progress:update("Geocoding " .. n .. " of " .. #indices)
if progress:isCancelled() then
cancelled = true
break
end
local res = geocodeEntry(e)
if res.status == "ok" then
nOk = nOk + 1
setRow(i)
if i == selected then showSelected() end
elseif res.status == "not_found" then
nNotFound = nNotFound + 1
elseif res.status == "denied" or res.status == "quota" then
stopped = res
break
else
nError = nError + 1
end
refreshSummary()
fhSleep(cProviderDelayMs[provider] or 500, 80) -- per-provider rate limit (free tiers differ)
end
progress:finish()
batchRunning = false
pluginBusy = false
local summary = string.format("Geocoded: %d\nNo match: %d", nOk, nNotFound)
if nError > 0 then summary = summary .. string.format("\nErrors: %d", nError) end
if stopped then
local why = (stopped.status == "quota")
and (provider .. " hit its quota or rate limit, so I stopped early.")
or ("The geocoder rejected a request (check your " .. provider .. " API key), so I stopped early.")
MessageBox("warning", why .. (stopped.message and ("\n" .. stopped.message) or "") .. "\n\n" .. summary, "OK")
elseif cancelled then
MessageBox("info", "Stopped at your request.\n\n" .. summary, "OK")
else
MessageBox("info", "Finished geocoding " .. nouns .. ".\n\n" .. summary, "OK")
end
end
local btnGeoAll = makeButton({ title = "Geocode &All", callback = function()
if batchRunning then return iup.DEFAULT end
if not geocoderReady() or not regionBiasOK() then return iup.DEFAULT end
local provider = activeGeocoder()
if not geocodeConsent(provider) then return iup.DEFAULT end
-- Build the work list: entries needing a coordinate, plus (if "Re-geocode existing" is
-- on in Options) ones that already have one. "No auto" entries are always skipped.
local recode = myGeoConfig:getBool("Geocoding", "recodeExisting", false)
local toDo, nRedo, nNoAuto = {}, 0, 0
for i, e in ipairs(model) do
if e.statKey == "no auto" then
nNoAuto = nNoAuto + 1
elseif not e.hasCoord then
toDo[#toDo + 1] = i
elseif recode then
toDo[#toDo + 1] = i
nRedo = nRedo + 1
end
end
if #toDo == 0 then
local hint = (not recode)
and "\n\nTo redo ones that already have a coordinate, turn on 'Re-geocode existing coordinates' in Geocoding Options."
or ""
MessageBox("info", "Nothing to geocode here." .. hint, "OK")
return iup.DEFAULT
end
local msg = "Geocode " .. #toDo .. " " .. nouns .. " via " .. provider .. "?"
if nRedo > 0 then msg = msg .. "\n(" .. nRedo .. " already have a coordinate and will be re-geocoded.)" end
if nNoAuto > 0 then msg = msg .. "\n(" .. nNoAuto .. " set to 'Blocked' will be skipped.)" end
msg = msg .. "\n\nThis writes the results to your project."
if MessageBox("question", msg, "YESNO") ~= "Yes" then return iup.DEFAULT end
runGeocodeBatch(toDo, provider)
return iup.DEFAULT
end })
local btnGeoThis = makeButton({ title = "Geocode &This", callback = function()
if not geocoderReady() or not regionBiasOK() then return iup.DEFAULT end
if selected == 0 or not model[selected] then
MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK")
return iup.DEFAULT
end
local e = model[selected]
if e.statKey == "no auto" then
MessageBox("info", "This " .. Noun:lower() .. " is set to 'Blocked' (do not auto-geocode).\n\nChange its status above to geocode it.", "OK")
return iup.DEFAULT
end
-- Geocode This is a deliberate single-item action, so (unlike the bulk actions) it isn't gated by
-- "Re-geocode existing" - but it must never overwrite a coordinate silently, so confirm first.
if e.hasCoord then
if MessageBox("question", e.name .. "\n\nThis " .. Noun:lower() .. " already has a coordinate ("
.. e.lat .. ", " .. e.lng .. ").\n\nReplace it by geocoding again?", "YESNO") ~= "Yes" then
return iup.DEFAULT
end
end
local provider = activeGeocoder()
if not geocodeConsent(provider) then return iup.DEFAULT end
local res = geocodeEntry(e)
if res.status == "ok" then
txtLat.value, txtLng.value = e.lat, e.lng
showStatus(e)
setRow(selected)
refreshSummary()
local quality = res.confidence and (" (confidence " .. math.floor(res.confidence * 100 + 0.5) .. "%)") or (res.type and (" (" .. tostring(res.type) .. ")")) or ""
local note = res.statErr and ("\n\nNote: the coordinate was saved, but the status could not be set to 'tentative':\n" .. tostring(res.statErr)) or ""
MessageBox("info", e.name .. "\n\nGeocoded via " .. provider .. quality .. ".\nSaved: " .. e.lat .. ", " .. e.lng .. note, "OK")
elseif res.status == "not_found" then
MessageBox("info", "No match was found for:\n\n" .. e.name .. "\n\nTry standardising or simplifying the " .. Noun:lower() .. ".", "OK")
elseif res.status == "denied" then
MessageBox("warning", "The geocoder rejected the request - check your " .. provider .. " API key in Geocoding Options." .. (res.message and ("\n\n" .. res.message) or ""), "OK")
elseif res.status == "quota" then
MessageBox("warning", provider .. " is over its quota or rate limit. Please try again later." .. (res.message and ("\n\n" .. res.message) or ""), "OK")
elseif res.status == "invalid" then
MessageBox("warning", "The geocoder could not understand the request for:\n\n" .. e.name .. (res.message and ("\n\n" .. res.message) or ""), "OK")
else
MessageBox("error", "Geocoding failed." .. (res.message and ("\n\n" .. res.message) or ""), "OK")
end
return iup.DEFAULT
end })
local btnGeoSelected = makeButton({ title = "Geocode Se&lected", tip = "Geocode the " .. nouns .. " ticked in the list (Ctrl- or Shift-click to select several)", callback = function()
if batchRunning then return iup.DEFAULT end
if not geocoderReady() or not regionBiasOK() then return iup.DEFAULT end
local positions = getSelectedValues(lstRecs, true) -- numeric positions of ticked rows, as strings
if #positions == 0 then
MessageBox("info", "Tick one or more " .. nouns .. " in the list first.\n\n(Ctrl-click or Shift-click to select several.)", "OK")
return iup.DEFAULT
end
-- Same rule as Geocode All: skip already-geocoded rows UNLESS "Re-geocode existing" is on, and
-- always skip 'no auto'. (Selected used to ignore the option and overwrite silently - the two
-- bulk actions now behave identically.)
local recode = myGeoConfig:getBool("Geocoding", "recodeExisting", false)
local toDo, nNoAuto, nRedo, nSkip = {}, 0, 0, 0
for _, posStr in ipairs(positions) do
local i = tonumber(posStr) -- row index IS the model index (no filtering)
local e = i and model[i]
if e then
if e.statKey == "no auto" then
nNoAuto = nNoAuto + 1
elseif not e.hasCoord then
toDo[#toDo + 1] = i
elseif recode then
toDo[#toDo + 1] = i
nRedo = nRedo + 1
else
nSkip = nSkip + 1
end
end
end
if #toDo == 0 then
local why = (nSkip > 0)
and "The selected " .. nouns .. " already have coordinates.\n\nTo redo them, turn on 'Re-geocode existing coordinates' in Geocoding Options."
or "Nothing to geocode - the selected " .. nouns .. " are all set to 'Blocked'."
MessageBox("info", why, "OK")
return iup.DEFAULT
end
local provider = activeGeocoder()
if not geocodeConsent(provider) then return iup.DEFAULT end
local msg = "Geocode " .. #toDo .. " selected " .. nouns .. " via " .. provider .. "?"
if nRedo > 0 then msg = msg .. "\n(" .. nRedo .. " already have a coordinate and will be overwritten.)" end
if nNoAuto > 0 then msg = msg .. "\n(" .. nNoAuto .. " set to 'Blocked' will be skipped.)" end
msg = msg .. "\n\nThis writes the results to your project."
if MessageBox("question", msg, "YESNO") ~= "Yes" then return iup.DEFAULT end
runGeocodeBatch(toDo, provider)
return iup.DEFAULT
end })
-- Correction map (Phase 5): show the selected record on a browser map to nudge it by hand, then
-- read the nudged coordinate back from the clipboard. Separate from the per-individual web maps.
-- Where to centre the map for a record with NO coordinate of its own: its parent place (addresses),
-- else the centroid of the records already geocoded in this list, else a whole-world view (NOT a
-- GB/region-specific default - users research all over the world) for the user to pan from.
local function fallbackCentre(e)
if e.parentLat and e.parentLat ~= "" and e.parentLng ~= "" then
return e.parentLat, e.parentLng, 11 -- near the address's parent place
end
local sumLat, sumLng, n = 0, 0, 0
for _, x in ipairs(model) do
if x.hasCoord then
local a, o = tonumber(x.lat), tonumber(x.lng)
if a and o then sumLat, sumLng, n = sumLat + a, sumLng + o, n + 1 end
end
end
if n > 0 then return tostring(sumLat / n), tostring(sumLng / n), 8 end -- among the located records
return "20", "0", 2 -- nothing to anchor on: whole-world view
end
local function showOnMap()
if selected == 0 or not model[selected] then
MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK")
return
end
local e = model[selected]
local lat, lng, zoom
if e.lat ~= "" and e.lng ~= "" then
lat, lng, zoom = e.lat, e.lng, myMapConfig:getNumber("Web maps", "defaultZoom", 12)
else
lat, lng, zoom = fallbackCentre(e)
end
local label = (e.parentPlace ~= "") and (e.name .. ", " .. e.parentPlace) or e.name
local path = correctionHtmlPath()
-- The correction page is always a LOCAL file (never published), so it carries the everyday
-- unrestricted MapTiler key - same exposure as local event maps (Maps Design.md s6.1.1 #6).
local ok, err = fhfu.createTextFile(path, true, true, buildCorrectionHtml(label, lat, lng, zoom, buildHistoricLayers(myMapConfig:getString("Web maps", "maptilerKey", ""))), 8)
if not ok then
MessageBox("error", "Could not write the map page:\n\n" .. tostring(err), "OK")
return
end
if not pcall(fhShellExecute, path) then
MessageBox("warning", "The map page was written to:\n\n" .. path
.. "\n\nbut could not be opened automatically. Open it in a browser by hand.", "OK")
return
end
lblMap.title = "Map opened - nudge the pin, Copy, then Get from Map." -- in-pane hint (no extra click)
end
-- Read one coordinate the map copied to the clipboard (one-shot on click - never poll; a clipboard
-- watch timer hard-crashed FH). Fills the panel pending Save; nothing is written until you Save.
-- Confirms in-pane (lblMap) rather than a modal, to keep the round-trip to as few clicks as possible.
local function getFromMap()
if selected == 0 or not model[selected] then
MessageBox("info", "Select a " .. Noun:lower() .. " first.", "OK")
return
end
local clip = iup.clipboard{}
local txt = (clip and clip.text) or ""
pcall(function() iup.Destroy(clip) end)
local la, lo = tostring(txt):match("^%s*" .. cClipSentinel .. "%s*(%-?[%d%.]+)%s*,%s*(%-?[%d%.]+)")
if not la then
MessageBox("info", "No map coordinate was found on the clipboard.\n\nIn the map page, drag the"
.. " marker (or click the map), click 'Copy this coordinate', then come back and click Get from Map.", "OK")
return
end
local latV, lngV = parseCoord(la, -90, 90), parseCoord(lo, -180, 180)
if not latV or not lngV or latV == "" or lngV == "" then
MessageBox("warning", "The coordinate on the clipboard wasn't valid:\n\n" .. tostring(txt), "OK")
return
end
txtLat.value, txtLng.value = latV, lngV
lblMap.title = "From map: " .. latV .. ", " .. lngV .. " - click Save to store it."
end
local btnShow = makeButton({ title = "Sho&w on Map", tip = "Open the selected " .. Noun:lower() .. " on a browser map to nudge its position by hand", callback = function() showOnMap(); return iup.DEFAULT end })
local btnGet = makeButton({ title = "G&et from Map", tip = "Read the coordinate you copied from the map into the panel (then Save)", callback = function() getFromMap(); return iup.DEFAULT end })
-- Enable/disable the Coordinate panel's controls for the current selection. Two lock reasons:
-- * Blocked (no auto): status stays editable (to unblock), but coordinate/standardised/actions off.
-- * More than one row ticked: the panel edits a single row, so single-item editing is off entirely
-- - use Geocode Selected, or click one row. This is what stops "Geocode This" acting on whichever
-- row happened to be highlighted last (a confusing "random item" behaviour otherwise).
refreshPanelMode = function()
-- Count ticked rows from the list's own +/- string. lstRecs.value is nil until the dialog is
-- mapped, and this runs once during construction (via the initial clearPanel), so guard it -
-- going via getSelectedValues here would index that nil and crash Add Maps on open.
local selStr = lstRecs.value
local nTicked = (type(selStr) == "string") and select(2, selStr:gsub("%+", "")) or 0
local e = model[selected]
local multi = nTicked > 1
-- Lock reflects the PENDING status shown in the dropdown, not the saved statKey, so choosing or
-- clearing "Blocked" locks/unlocks the fields at once (before Save).
local pendingStat = isPlace and (cStatusValueByItem[tonumber(lstStat.value)] or "") or (e and e.statKey) or ""
local blocked = (e ~= nil) and (pendingStat == "no auto")
local YN = function(on) return on and "YES" or "NO" end
if isPlace then lstStat.active = YN(e ~= nil and not multi) end -- editable even when blocked (to unblock)
local editOn = (e ~= nil) and not multi and not blocked
txtLat.active, txtLng.active, txtStan.active = YN(editOn), YN(editOn), YN(editOn)
btnShow.active, btnGet.active, btnGeoThis.active, btnClear.active = YN(editOn), YN(editOn), YN(editOn), YN(editOn)
btnSave.active = YN(e ~= nil and not multi) -- Save a status-only change (e.g. unblock) on a single row
if multi then
lblMode.title = nTicked .. " selected - editing off. Use Geocode Selected, or click one row."
elseif blocked then
lblMode.title = "Blocked - change the status to unblock; coordinate editing is off."
else
lblMode.title = ""
end
end
local statusRow = isPlace
and iup.hbox({ makeLabel({ title = "Status:" }), lstStat, alignment = "ACENTER" })
or iup.hbox({ makeLabel({ title = "Status:" }), lblStat, alignment = "ACENTER" })
local detail = iup.vbox({
lblName,
lblParent,
makeLongLabel({ title = "Edit the coordinate and status, then Save. Geocode This saves its result straight away." }),
iup.hbox({ makeLabel({ title = "Latitude:" }), txtLat, alignment = "ACENTER" }),
iup.hbox({ makeLabel({ title = "Longitude:" }), txtLng, alignment = "ACENTER" }),
statusRow,
iup.hbox({ makeLabel({ title = "Standardised:" }), txtStan, alignment = "ACENTER" }),
-- Geocode This lives HERE, next to the item it acts on (the row named at the top of this panel),
-- not with the bulk actions below - so it's always clear which record it will geocode.
iup.hbox({ btnGeoThis, btnShow, btnGet, btnSave, btnClear, gap = "6" }),
lblMap,
lblMode,
gap = "6",
})
-- Seed from the current FH selection / property box, then initialise the view (even when empty).
seedFromSelection()
rebuildList()
refreshSummary()
clearPanel()
-- Session region-bias override (transient: edits drive gRegionBias, used by the geocoder, but are
-- never written back to Options). Built on both geocode panes; biasSync re-seeds it from gRegionBias
-- when the pane is shown, so the two panes always agree on the one session value.
local txtBias = makeText({ value = gRegionBias, default = "", visiblecolumns = 4,
tip = "Country-code bias for this session only (e.g. gb, fr). Seeded from Geocoding Options; changing it here is NOT saved." })
local lblBias = makeLabel({ title = "" }) -- data-entry hint: flags an unrecognised code on focus-out
local function showBiasValid() lblBias.title = gcValidRegion(gRegionBias) and "" or "(unknown code)" end
-- Keep gRegionBias current as you type (invisible), but only show the validity hint on focus-out.
txtBias.valuechanged_cb = function(self) gRegionBias = (self.value or ""):gsub("^%s+", ""):gsub("%s+$", "") end
txtBias.killfocus_cb = function() showBiasValid() end
local function biasSync() txtBias.value = gRegionBias; showBiasValid() end
showBiasValid()
local tabBox = iup.vbox({
-- Two short lines so it doesn't overflow the width (the dialog minsize floors the window at the
-- layout's natural width, so neither line is ever clipped). Fuller help is in the list tooltip.
makeLongLabel({ title = "Choose " .. nouns .. " from the Geocoding menu (Ctrl+I) - a subset, a query, a smart folder, or all.\nClick a row to edit or geocode it; Ctrl/Shift-click to tick several for Geocode Selected." }),
iup.hbox({
-- The list frame takes all the horizontal stretch (and shrink); the Coordinate frame keeps
-- its natural width (grows only vertically) so resizing/narrowing the window flexes the list,
-- never collapses the form on the right.
iup.frame({ lstRecs, title = " " .. Nouns .. " ", expand = "YES" }),
iup.frame({ detail, title = " Coordinate ", expand = "VERTICAL" }),
gap = "8", expand = "YES",
}),
lblSummary,
-- Bulk actions only (Geocode This moved into the Coordinate panel, beside its target row).
iup.hbox({ btnGeoSelected, btnGeoAll,
iup.fill({}), makeLabel({ title = "Region bias:" }), txtBias, lblBias,
alignment = "ACENTER", gap = "6" }),
margin = "4x4", gap = "8", expand = "YES",
})
-- Expose the list-management actions so the main menu can drive this tab (the menu items mirror
-- the keyboard shortcuts wired on the list above), plus biasSync so the view switch keeps the
-- session region-bias field in step across the Places / Addresses panes.
return { vbox = tabBox, biasSync = biasSync,
actions = { select = actSelect, remove = actRemove, clear = actClear } }
end
-- Mapping pane: an individual target list (FH-native picker, like the Geocode panes) + Build Map(s).
-- Returns { vbox, actions } so the Mapping menu can drive Select / Remove / Clear.
local function buildWebMapsTab()
local model = {} -- selected individuals: { ptr, id, name }
local lstIndis = makeList({ dropdown = "NO", sort = "NO", expand = "YES", visiblelines = 6, visiblecolumns = 40,
tip = "The individuals whose maps will be built. Use the Mapping menu to add or remove.\n\nShortcuts:\n- Ctrl+I: Select individuals\n- Ctrl+T: Clear list\n- Ctrl+A: Select all\n- Del: Remove selected" })
lstIndis.MULTIPLE = "YES"
local lblSummary = makeLongLabel({ title = "" })
-- "Make" choice on the pane (at generation time), initialised from the Options default.
local cMakeOptions = { "Automatic", "A page per individual", "One combined map" }
local lstMake = makeList({ dropdown = "YES", sort = "NO", values = cMakeOptions, visibleitems = 3,
tip = "What to build: Automatic (one combined map for several people, a single page for one), a page each, or one combined map." })
do
local cfg = myMapConfig:getString("Web maps", "outputMode", "Automatic")
local idx = 1
for i, v in ipairs(cMakeOptions) do if v == cfg then idx = i end end
lstMake.value = tostring(idx)
end
local function indiName(ptr)
local ok, n = pcall(fhIndGetName, ptr, true) -- include life dates
if ok and type(n) == "string" and n ~= "" then return n end
return fhGetDisplayText(ptr) or "?"
end
-- The person's name WITHOUT life dates, for the embedded map's caption ([NAME]) and iframe title -
-- the website page already shows their dates, so repeating them in the caption is noise.
local function indiNamePlain(ptr)
local ok, n = pcall(fhIndGetName, ptr, false) -- no life dates
if ok and type(n) == "string" and n ~= "" then return n end
return (indiName(ptr):gsub("%s*%b()%s*$", "")) -- fallback: strip a trailing "(dates)"
end
local function rebuildList()
lstIndis.REMOVEITEM = "ALL"
for i, e in ipairs(model) do lstIndis[tostring(i)] = e.name end
end
local function refreshSummary()
lblSummary.title = (#model == 0)
and "(no individuals selected - use the Mapping menu, or Ctrl+I)"
or string.format("%d individual%s selected", #model, #model == 1 and "" or "s")
end
local function sortModel() table.sort(model, function(a, b) return a.name:lower() < b.name:lower() end) end
local function isListed(ptr)
for _, e in ipairs(model) do
if e.ptr and ptr and e.ptr:IsSame(ptr) then return true end
end
return false
end
local function addPtrs(ptrs)
local added = 0
for _, p in ipairs(ptrs or {}) do
if p and p:IsNotNull() and fhGetTag(p) == "INDI" and not isListed(p) then
model[#model + 1] = { ptr = p, id = fhGetRecordId(p), name = indiName(p) }
added = added + 1
end
end
if added > 0 then sortModel(); rebuildList(); refreshSummary() end
return added
end
local function seedFromSelection()
-- Use the untyped current selection and keep the individuals (more reliable across FH contexts
-- than asking for "INDI" specifically); fall back to the property-box individual.
local sel = fhGetCurrentRecordSel()
local indis = {}
if sel then
for _, p in ipairs(sel) do
if p and p:IsNotNull() and fhGetTag(p) == "INDI" then indis[#indis + 1] = p end
end
end
if #indis > 0 then
addPtrs(indis)
else
local pb = fhGetCurrentPropertyBoxRecord()
if pb and pb:IsNotNull() and fhGetTag(pb) == "INDI" then addPtrs({ pb }) end
end
end
local function actSelect()
local sel = fhPromptUserForRecordSel("INDI", -1, getParentWindowHandle())
if sel and #sel > 0 then
if addPtrs(sel) == 0 then MessageBox("info", "Those individuals are already in the list.", "OK") end
end
end
local function actRemove()
if #getSelectedValues(lstIndis, true) == 0 then
MessageBox("info", "Tick one or more individuals to remove.\n\n(Ctrl-click or Shift-click to select several.)", "OK")
return
end
removeSelectedItems(lstIndis, model, function() rebuildList(); refreshSummary() end)
end
local function actClear()
if #model == 0 then return end
model = {}
rebuildList(); refreshSummary()
end
local function actSelectAll()
local count = tonumber(lstIndis.COUNT) or 0
if count > 0 then lstIndis.value = string.rep("+", count) end
end
-- See lstRecs:k_any - Ctrl+I/Ctrl+T are window-global now; Ctrl+A and Del stay list-scoped.
function lstIndis:k_any(c)
if c == iup.K_cA then actSelectAll(); return iup.IGNORE
elseif c == iup.K_DEL then actRemove(); return iup.IGNORE
end
return iup.CONTINUE
end
local txtMapFolder -- the Mapping-pane folder field (assigned below); chooseMapFolder keeps it in step
local function outputFolder()
local dir = myMapConfig:getString("Web maps", "mapFolder", "")
-- "" when unset: callers force a choice rather than silently writing to the plugin data folder.
if dir ~= "" and not dir:match("[/\\]$") then dir = dir .. "\\" end
return dir
end
-- Pop the folder picker, persist the choice as this project's default, reflect it in the pane field,
-- and return the chosen folder (raw, no trailing slash) or nil if cancelled. Shared by the Folder…
-- button and by Build (which forces a choice when none is set yet).
local function chooseMapFolder()
local start = myMapConfig:getString("Web maps", "mapFolder", "")
if start == "" or not (fhfu.folderExists and fhfu.folderExists(start)) then
-- FH reports the project Public folder even before it has been created on disk, so use it as
-- the picker's start only when it actually exists; otherwise fall back to the plugin data
-- folder (always present). Mirrors Add Trees - never assume Public exists.
local pub = fhGetContextInfo("CI_PROJECT_PUBLIC_FOLDER") or ""
if pub ~= "" and fhfu.folderExists and fhfu.folderExists(pub) then
start = pub
else
start = cstrPluginDir or ""
end
end
local dlg = iup.filedlg({ dialogtype = "DIR", title = "Output folder (maps, or your website's pages when embedding)", directory = start })
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
local chosen
if tonumber(dlg.status) ~= -1 then
local v = dlg.value or ""
if v ~= "" then
chosen = v
myMapConfig:setValues("Web maps", nil, { mapFolder = v }) -- persist as the project default
if txtMapFolder then txtMapFolder.value = v end
end
end
dlg:destroy()
return chosen
end
-- Suggested name for a combined map, pre-filled into the build-time prompt. If every mapped
-- person shares one surname -> "<Surname> family"; otherwise fall back to the old "N individuals".
-- Surname comes from FH's own name parse (~.NAME:SURNAME), not string-splitting the display name.
local function suggestGroupName(ptrs, n)
local surname
for _, ptr in ipairs(ptrs) do
local s = (fhGetItemText(ptr, "~.NAME:SURNAME") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if s == "" then return n .. " individuals" end -- a missing surname -> no shared-name claim
if surname == nil then
surname = s
elseif surname:lower() ~= s:lower() then
return n .. " individuals"
end
end
return (surname and surname ~= "") and (surname .. " family") or (n .. " individuals")
end
-- Filesystem-safe output file name derived from the map's title, so several named group maps can
-- coexist (the old build wrote one fixed MapGroup.html). ASCII-only ranges (FH runs Lua in a cp1252
-- locale where %w is unreliable on UTF-8); accents/punctuation in the title drop from the file name
-- only - the visible page title keeps them. Empty slug -> the historic MapGroup.html.
local function groupFileName(title)
local slug = (title or ""):gsub("[^A-Za-z0-9 _%-]", ""):gsub("%s+", "-")
:gsub("%-+", "-"):gsub("^%-+", ""):gsub("%-+$", "")
if slug == "" then return "MapGroup.html" end
return "Map-" .. slug .. ".html"
end
-- Build the map(s) for the listed individuals, honouring the "Make" option.
local function buildMaps()
if #model == 0 then
MessageBox("info", "Choose one or more individuals first (Mapping menu, or Ctrl+I).", "OK")
return
end
-- Resolve the output mode and whether we're embedding up front. Published (embed feature on) maps
-- are publish-safe: keyed historic layers dropped unless the advanced opt-in keeps them, and the
-- legend links each person to their website page. The page-name prefix is configurable (default
-- "ind") for sites generated by other tools.
local mode = cMakeOptions[tonumber(lstMake.value)] or "Automatic" -- the pane's Make choice
if mode == "Automatic" then mode = (#model > 1) and "One combined map" or "A page per individual" end
local published = myMapConfig:getBool("Website embedding", "embedInPages", false)
local keepKeyed = myMapConfig:getBool("Embedding (advanced)", "embedKeepKeyed", false) -- advanced opt-in
local pagePrefix = myMapConfig:getString("Embedding (advanced)", "pagePrefix", "ind")
local embedActive = published and mode == "A page per individual"
-- "Temporary maps" (Web maps): build into a throwaway folder wiped when the plugin closes, so a
-- quick look leaves nothing to tidy up. Local maps only - published maps must persist to be
-- injected, so embedding always wins.
local useTemp = myMapConfig:getBool("Web maps", "tempMaps", false) and not published
-- Record-flag privacy applies to PUBLISHED maps (embedded in the website). Local/standalone maps are
-- the user's own file: honour the selection, but warn if it includes people a published map would hold
-- back. privacy = nil for local builds, which disables all filtering in eventsForIndividual.
local privOpts = {
omit = myMapConfig:getBool("Privacy", "omitPrivate", true),
basicFlag = myMapConfig:getBool("Privacy", "basicDetailsEnabled", true)
and myMapConfig:getString("Privacy", "basicDetailsFlag", "Living") or nil,
}
local privacy = published and privOpts or nil
if not published then
local nSensitive = 0
for _, e in ipairs(model) do if privacyBlocks(e.ptr, privOpts) then nSensitive = nSensitive + 1 end end
if nSensitive > 0 and MessageBox("question", string.format(
"This is a local map (not embedded in your website), so privacy filtering is NOT applied.\n\n"
.. "%d of the listed individual(s) are flagged Private or '%s'; the map file will show their "
.. "locations.\n\nBuild it anyway?", nSensitive, tostring(privOpts.basicFlag or "Living")),
"OKCANCEL") ~= "OK" then
return
end
end
-- Where the maps are written. Temporary maps go to a throwaway folder under the plugin data folder
-- (no Output folder needed - that's the point). Otherwise the single Output folder serves both roles:
-- where standalone/combined maps are written, and (when embedding) the folder holding the website's
-- individual pages - required either way, so prompt if unset.
local dir
if useTemp then
dir = tempMapsDir()
local okDir, derr = createFolderTree(dir:gsub("[/\\]+$", ""))
if not okDir then
MessageBox("error", "Could not create the temporary maps folder:\n" .. dir .. (derr and ("\n\n" .. derr) or ""), "OK")
return
end
else
dir = outputFolder()
if dir == "" then
if not chooseMapFolder() then
MessageBox("info", "No output folder chosen - nothing was built.\n\nChoose a folder, then Build.", "OK")
return
end
dir = outputFolder()
end
end
local googleLinks = myMapConfig:getBool("Web maps", "mapGoogleLinks", true)
local zoom = myMapConfig:getNumber("Web maps", "defaultZoom", 12)
local pathOn = myMapConfig:getBool("Web maps", "pathDefault", false)
local size = myMapConfig:getString("Web maps", "markerSize", "Medium")
local sliderOn = myMapConfig:getBool("Web maps", "timeSlider", false)
-- Pick the MapTiler key the historic layers carry, by where the maps will go:
-- * local / standalone -> the unrestricted key (Web maps > MapTiler key);
-- * published + opt-in -> the domain-restricted PUBLISHED key (never the unrestricted one,
-- which a published page would leak); empty -> keyless + a heads-up;
-- * published, no opt-in -> keyless.
local localKey = myMapConfig:getString("Web maps", "maptilerKey", "")
local publishKey = myMapConfig:getString("Embedding (advanced)", "maptilerPublishKey", "")
local histKey
if not published then
histKey = localKey
elseif keepKeyed then
histKey = publishKey
local pk = (publishKey or ""):gsub("%s+", "")
if pk == "" then
if MessageBox("question", "'Keep keyed historic layers' is on, but no published MapTiler key "
.. "is set (Mapping Options > Embedding (advanced)).\n\nThe MapTiler historic layers will be "
.. "omitted from the published maps.\n\nBuild anyway? (Cancel to set a domain-restricted "
.. "key first.)", "OKCANCEL") ~= "OK" then
return
end
elseif pk == (localKey or ""):gsub("%s+", "") then
-- Identical keys defeat the purpose - and worse, you're about to publish with that key.
if MessageBox("question", "Your published MapTiler key is the same as your everyday (Web maps) "
.. "key.\n\nA single key can't safely do both jobs: if it is restricted to your website's "
.. "domain it will not work in your local maps; if it is unrestricted, publishing these maps "
.. "exposes it to everyone on the internet who views your site.\n\nUse two separate keys - an "
.. "unrestricted one for local maps and a domain-restricted one for the web.\n\nPublish "
.. "anyway?", "OKCANCEL") ~= "OK" then
return
end
end
else
histKey = ""
end
local histLayers = buildHistoricLayers(histKey)
-- Single-map marker colour: honoured only when a map ends up with one person (per-individual
-- pages are always one; the combined map honours it only if exactly one person has events).
local singleColour = myMapConfig:getString("Web maps", "singleColour", "")
-- Group dedup for timeline events: applies ONLY to a combined map, where everyone shares
-- one set of markers, so a relative's event would otherwise appear twice (once as their
-- own, once as a timeline event on the other person). For a page-PER-individual each map
-- is standalone, so NO dedup - father and son each show the other as a timeline event.
-- e.id is the FH numeric record ID; groupSet stays empty for the per-individual case.
local groupSet = {}
if mode == "One combined map" then
for _, e in ipairs(model) do groupSet[e.id] = true end
end
local people, totalEvents, totalNotGeo, withEvents, privacyHeld = {}, 0, 0, 0, 0
for i, e in ipairs(model) do
local events, notGeo = eventsForIndividual(e.ptr, groupSet, privacy)
local blocked = (privacy and privacyBlocks(e.ptr, privacy)) or false
if blocked then
if #events > 0 then privacyHeld = privacyHeld + 1 end
else
totalEvents, totalNotGeo = totalEvents + #events, totalNotGeo + notGeo
if #events > 0 then withEvents = withEvents + 1 end
end
people[i] = { id = e.id, name = e.name, ptr = e.ptr, colour = personColour(i), events = events, notGeo = notGeo, file = "", embed = blocked and "Held for privacy" or "", blocked = blocked }
end
if totalEvents == 0 then
MessageBox("info", (privacyHeld > 0
and (privacyHeld .. " individual(s) held back for privacy; no other listed individual has a geocoded event to map.")
or "None of the listed individuals has a geocoded event to map.")
.. (totalNotGeo > 0 and ("\n\n" .. totalNotGeo .. " event(s) have a place or address that isn't geocoded yet - geocode them on the Geocoding tab first.") or ""), "OK")
return
end
-- Website-embedding setup. Only per-individual pages can be embedded (a combined map has no
-- single host page; embedActive was resolved above) - a combined build with embedding on still
-- produces a publish-safe file, just not injected anywhere.
local embedOpts, mapsDir, websiteFolder
if published and not embedActive then
if MessageBox("question", "Embedding is on, but you're making one combined map, which can't be "
.. "injected into a single page.\n\nThe combined map will still be built (publish-safe) but "
.. "not embedded.\n\nBuild it? (Cancel and set 'Make' to 'A page per individual' to embed "
.. "each person.)", "OKCANCEL") ~= "OK" then
return
end
end
local mapsSubfolder
if embedActive then
-- The Output folder IS the website folder when embedding (it holds the <prefix><id>.html pages).
websiteFolder = dir:gsub("[/\\]+$", "")
if websiteFolder == "" or not fhfu.folderExists(websiteFolder) then
MessageBox("error", "The Output folder does not exist:\n" .. websiteFolder
.. "\n\nWhen embedding, set the Output folder (Web maps) to the folder holding your website's individual pages.", "OK")
return
end
mapsSubfolder = (myMapConfig:getString("Embedding (advanced)", "embedMapsSubfolder", "maps") or ""):gsub("[/\\]+", "")
if mapsSubfolder == "" then mapsSubfolder = "maps" end
-- Guard the classic mistake: pointing the Output folder at the maps SUBFOLDER, not the website's
-- page folder - which double-nests the maps AND finds no pages to inject into. If its leaf is the
-- maps-subfolder name, offer the parent.
do
local leaf = websiteFolder:match("[^/\\]+$") or ""
if leaf:lower() == mapsSubfolder:lower() then
local parent = websiteFolder:gsub("[/\\]*[^/\\]+$", "")
if parent ~= "" and fhfu.folderExists(parent) and MessageBox("question",
"The Output folder ends in '" .. mapsSubfolder .. "', which is the maps subfolder, not your "
.. "website's page folder, so the maps would be double-nested and no pages would be found."
.. "\n\nUse the parent folder instead?\n" .. parent, "OKCANCEL") == "OK" then
websiteFolder = parent
end
end
end
-- The Output folder must be the website PAGE folder: at least one selected person's page
-- (<prefix><id>.html) must exist there, or embedding has nothing to inject into.
local anyPage = false
for _, p in ipairs(people) do
if #p.events > 0 and not p.blocked
and fhfu.fileExists(websiteFolder .. "\\" .. pagePrefix .. p.id .. ".html") then
anyPage = true; break
end
end
if not anyPage then
MessageBox("error", "This folder has no " .. pagePrefix .. "*.html pages. Embedding will fail. "
.. "Point the Output folder at your website's page folder.", "OK")
return
end
mapsDir = websiteFolder .. "\\" .. mapsSubfolder
local okDir, derr = createFolderTree(mapsDir)
if not okDir then
MessageBox("error", "Could not create the maps subfolder:\n" .. mapsDir .. (derr and ("\n\n" .. derr) or ""), "OK")
return
end
embedOpts = {
pagePrefix = pagePrefix,
divClass = myMapConfig:getString("Embedding (advanced)", "embedDivClass", "fhsection fhsecdata"),
placement = cEmbedPlacement[myMapConfig:getString("Embedding (advanced)", "embedPlacement", "At top of div")] or "top",
align = cEmbedAlign[myMapConfig:getString("Website embedding", "embedAlign", "Centre")] or "centre",
caption = myMapConfig:getString("Website embedding", "embedCaption", ""),
captionClass = myMapConfig:getString("Embedding (advanced)", "embedCaptionClass", "fs-embed-caption"),
containerClass = myMapConfig:getString("Embedding (advanced)", "embedContainerClass", "fs-embed-map"),
hideable = myMapConfig:getBool("Website embedding", "embedHideable", false),
removeDiv = myMapConfig:getBool("Embedding (advanced)", "embedRemoveDiv", false),
removeDivClass = myMapConfig:getString("Embedding (advanced)", "embedRemoveDivClass", ""),
frameHeight = myMapConfig:getNumber("Website embedding", "frameHeight", 430),
}
-- Confirm before changing the user's website files (the plugin keeps no backups of its own).
local pageWord = (withEvents == 1) and "page" or "pages"
-- Show the RESOLVED paths (pages folder + an example page + where the maps go) so a wrong folder
-- is obvious before anything is written.
local egPage = ""
for _, p in ipairs(people) do
if #p.events > 0 and not p.blocked then egPage = websiteFolder .. "\\" .. pagePrefix .. p.id .. ".html"; break end
end
local msg = string.format(
"Embedding is on.\n\nPages folder (%d %s updated):\n%s\ne.g. %s\n\nMaps written to:\n%s\n",
withEvents, pageWord, websiteFolder, egPage, mapsDir)
if embedOpts.removeDiv and embedOpts.removeDivClass ~= "" and embedOpts.removeDivClass ~= embedOpts.divClass then
msg = msg .. string.format('\n"Remove a section" is on: the section with class "%s" will be removed from each page first.\n', embedOpts.removeDivClass)
end
msg = msg .. "\nBack up your website first if you have not already. Continue?"
if MessageBox("question", msg, "OKCANCEL") ~= "OK" then
return
end
end
local written, firstPath, writeErr = {}, nil, nil
local embedded, firstEmbeddedPage, cancelled = 0, nil, false
if mode == "A page per individual" then
-- The people who actually get a map written; drives the progress bar's total.
local toBuild = {}
for _, p in ipairs(people) do if #p.events > 0 and not p.blocked then toBuild[#toBuild + 1] = p end end
local progress = Progress.new(#toBuild, 1, 20, dlgMain) -- shows only once there are >= 20 maps
for _, p in ipairs(toBuild) do
progress:update((embedActive and "Embedding " or "Building ") .. p.name)
if progress:isCancelled() then cancelled = true; break end
local fileName = "Map" .. p.id .. ".html"
local html = buildPeopleMapHtml(p.name, { { name = p.name, colour = p.colour, id = p.id, events = p.events } }, googleLinks, zoom, pathOn, size, singleColour, sliderOn, histLayers, published, pagePrefix)
-- Embedding: the publish-safe map goes into the website's "maps" subfolder (so the iframe
-- src resolves) and is injected into ind<id>.html; otherwise it's a standalone file in the
-- map output folder.
local path = embedActive and (mapsDir .. "\\" .. fileName) or (dir .. fileName)
local ok, err = fhfu.createTextFile(path, true, true, html, 8)
if ok then
written[#written + 1] = path; firstPath = firstPath or path
p.file = embedActive and (mapsSubfolder .. "\\" .. fileName) or fileName
if embedActive then
-- The caption's [NAME] and the iframe title use the DATELESS name (the page already
-- shows the person's dates); the standalone map title keeps them.
local emb, note = embedMapIntoPage(websiteFolder, p.id, indiNamePlain(p.ptr), mapsSubfolder .. "/" .. fileName, embedOpts)
if emb then
embedded = embedded + 1; p.embed = "Yes"
firstEmbeddedPage = firstEmbeddedPage or (websiteFolder .. "\\" .. pagePrefix .. p.id .. ".html")
else
p.embed = "No - " .. note
end
end
else
writeErr = err
end
end
progress:finish()
else
local mapPeople, mapPtrs = {}, {}
for _, p in ipairs(people) do
if #p.events > 0 and not p.blocked then
mapPeople[#mapPeople + 1] = { name = p.name, colour = p.colour, id = p.id, events = p.events }
mapPtrs[#mapPtrs + 1] = p.ptr
end
end
-- Name the combined map. One person -> just their name (no prompt). Several -> ask, pre-filled
-- with the auto-derived suggestion; the chosen name is the page title AND drives the file name.
local title
if #mapPeople == 1 then
title = mapPeople[1].name
else
local suggestion = suggestGroupName(mapPtrs, #mapPeople)
local okName, entered = GetText({
strPrompt = "Name this combined map (page title and file name):",
strDefault = suggestion,
})
if not okName then return end -- Cancel abandons the whole build
title = (entered or ""):gsub("^%s+", ""):gsub("%s+$", "")
if title == "" then title = suggestion end
end
local fileName = groupFileName(title)
local ok, err = fhfu.createTextFile(dir .. fileName, true, true, buildPeopleMapHtml(title, mapPeople, googleLinks, zoom, pathOn, size, singleColour, sliderOn, histLayers, published, pagePrefix), 8)
if ok then
written[#written + 1] = dir .. fileName; firstPath = dir .. fileName
for _, p in ipairs(people) do if #p.events > 0 then p.file = fileName end end
else
writeErr = err
end
end
if #written == 0 then
MessageBox("error", "Could not write the map page(s):\n\n" .. tostring(writeErr), "OK")
return
end
-- Where the maps actually landed (the website "maps" subfolder when embedding, else the map folder).
local reportDir = embedActive and mapsDir or dir
-- Log each individual's outcome to the map result window (shown, clickable, on close).
for _, p in ipairs(people) do
addMapRow(p.ptr, tostring(#p.events), tostring(p.notGeo),
(p.file ~= "" and p.file) or (#p.events == 0 and "(no located events)" or "(not written)"),
p.embed or "", reportDir)
end
-- What to open when done. A single map opens in the browser (the injected page when embedding, so
-- the map is seen in situ); several maps open the folder the map files were written to - the "maps"
-- subfolder when embedding (reportDir), not its parent - rather than one arbitrary person's page.
local openTarget
if embedActive then
openTarget = (#written == 1 and firstEmbeddedPage) or mapsDir
else
openTarget = (#written == 1 and firstPath) or dir
end
pcall(fhShellExecute, openTarget)
local msg = (#written == 1 and "Built 1 map." or ("Built " .. #written .. " maps."))
.. "\n\n" .. totalEvents .. " event(s) mapped across " .. withEvents .. " individual(s)."
if embedActive then
local notEmbedded = #written - embedded
msg = msg .. "\n" .. embedded .. " of " .. #written .. " embedded into website pages"
.. (notEmbedded > 0 and (" (" .. notEmbedded .. " not embedded - see the Embedded column in the results table).") or ".")
end
if privacyHeld > 0 then
msg = msg .. "\n" .. privacyHeld .. " individual(s) held back for privacy (Private / basic-details) - no map embedded."
end
if cancelled then
msg = msg .. "\n\nStopped early - you cancelled before all the maps were built."
end
if totalNotGeo > 0 then
msg = msg .. "\n" .. totalNotGeo .. " event(s) aren't geocoded yet - geocode their places/addresses on the Geocoding tab to include them."
end
if useTemp then
msg = msg .. "\n\nThese are temporary maps: they will be deleted when you close Add Maps. "
.. "Keep the browser tab open while you look at them."
end
MessageBox("info", msg .. "\n\nWritten to:\n" .. reportDir, "OK")
end
local btnBuild = makeButton({ title = "&Build Map(s)", tip = "Build the map(s) for the listed individuals (per the 'Make' choice)", callback = function() buildMaps(); return iup.DEFAULT end })
local btnOpen = makeButton({ title = "Ope&n Folder", tip = "Open the map output folder", callback = function()
local dir = outputFolder()
if dir == "" or not (fhfu.folderExists and fhfu.folderExists(dir)) then
MessageBox("info", "No map folder yet - choose one with Folder…, or set it in Mapping Options.", "OK")
else
pcall(fhShellExecute, dir)
end
return iup.DEFAULT
end })
-- Output folder, shown and editable here at build time. It is this project's saved default (Mapping
-- Options), so changing it with Folder… both uses it now AND persists it as the project's default.
txtMapFolder = makeText({ value = myMapConfig:getString("Web maps", "mapFolder", ""), readonly = "YES",
expand = "HORIZONTAL", tip = "Where the maps are written (saved per project). If you want to embed "
.. "maps into your website, point this at the folder holding your website's individual pages - the "
.. "maps then go into a subfolder of it and are injected into each person's page." })
local btnFolder = makeButton({ title = "Fol&der…", tip = "Choose the output folder (your website's page folder, if embedding)",
callback = function() chooseMapFolder(); return iup.DEFAULT end })
seedFromSelection()
rebuildList()
refreshSummary()
local box = iup.vbox({
makeLongLabel({ title = "Use the Mapping menu (or Ctrl+I) to choose individuals - FH's own record picker.\nBuild Leaflet maps of their located events (Address-preferred). Choose which events with Options ▸ Events to Map." }),
iup.frame({ lstIndis, title = " Individuals ", expand = "YES" }),
lblSummary,
iup.hbox({ makeLabel({ title = "Output folder:" }), txtMapFolder, btnFolder, alignment = "ACENTER", gap = "6" }),
iup.hbox({ makeLabel({ title = "Make:" }), lstMake, btnBuild, btnOpen, alignment = "ACENTER", gap = "6" }),
margin = "4x4", gap = "8", expand = "YES",
})
-- folderSync re-reads the saved project folder into the pane field, so a change made in Mapping
-- Options shows here too (the pane field and the Options field edit the one stored value).
local function folderSync() txtMapFolder.value = myMapConfig:getString("Web maps", "mapFolder", "") end
return { vbox = box, folderSync = folderSync,
actions = { select = actSelect, remove = actRemove, clear = actClear } }
end
-- True (and warns) if a batch geocode is running, so the close paths can refuse. Closing mid-batch
-- leaves an orphaned window that blocks FH from exiting (the loop pumps the UI), so we hold the close.
local function closeHeldByGeocoding()
if not pluginBusy then return false end
MessageBox("warning", "Geocoding is still running.\n\nPlease wait for it to finish - or click Cancel on the"
.. " progress window - before closing.", "OK")
return true
end
-- Title-bar suffix for each mode, so the window always says which mode you're in.
local cViewTitle = {
places = "Geocoding - Places",
addresses = "Geocoding - Addresses",
mapping = "Mapping - Event maps",
}
-- Menus rather than tabs. So there is no tab control: the three panes (geocode Places, geocode
-- Addresses, the web maps) are stacked, and only the one for the chosen menu mode is shown. The
-- top-level menus are Geocoding (with a Places/Addresses mode, plus the selection-list actions) and
-- Mapping; the current mode also shows in the title bar.
local function makeMainDialog()
local placesTab = buildGeocodeTab("_PLAC", "places", "Places", "Place")
local addrTab = buildGeocodeTab("_ADDR", "addresses", "Addresses", "Address")
local mappingTab = buildWebMapsTab()
local panes = { places = placesTab.vbox, addresses = addrTab.vbox, mapping = mappingTab.vbox }
local paneOrder = { panes.places, panes.addresses, panes.mapping }
local geoActionsByMode = { places = placesTab.actions, addresses = addrTab.actions }
local dlg -- set once makeDialog has built it (used for the title + refresh)
local menuData -- set once createMenuBar has run (radio ticks + Select relabel)
local currentView = "places" -- which pane is shown: places | addresses | mapping
local currentGeoMode = "places" -- last geocoding sub-mode chosen (places | addresses)
-- Show one pane (hide the others, the way MenuBar.manageUIVisibility does), and bring the title
-- bar, the menu radio ticks and the Select label into line with it.
local function setView(name)
currentView = name
if name == "places" or name == "addresses" then currentGeoMode = name end
-- Keep the region-bias field on the pane we're showing in step with the one session value.
if name == "places" and placesTab.biasSync then placesTab.biasSync()
elseif name == "addresses" and addrTab.biasSync then addrTab.biasSync()
elseif name == "mapping" and mappingTab.folderSync then mappingTab.folderSync() end
for _, p in ipairs(paneOrder) do
local on = (panes[name] == p)
p.visible = on and "YES" or "NO"
p.floating = on and "NO" or "YES"
end
if menuData then
-- The Places/Addresses ticks track the geocoding mode and persist even while the Mapping
-- view is shown (clearer for users; the mode and its target list are retained); Event Maps
-- is ticked only while the maps pane is actually showing.
menuData.updateValue("viewPlaces", currentGeoMode == "places" and "ON" or "OFF")
menuData.updateValue("viewAddresses", currentGeoMode == "addresses" and "ON" or "OFF")
menuData.updateValue("viewMapping", currentView == "mapping" and "ON" or "OFF")
-- updateTitle re-appends the "\tCtrl+I" accelerator text registered for this item.
menuData.updateTitle("geoSelect",
"&Select: " .. ((currentGeoMode == "addresses") and "Addresses" or "Places"))
end
if dlg then
dlg.title = cstrPluginName .. " " .. cstrPluginVersion .. " - " .. (cViewTitle[name] or "")
iup.Refresh(dlg)
end
end
-- A Geocoding-menu list action acts on the current geocoding sub-mode; if we're in the Mapping
-- view, switch back to that sub-mode's pane first so the action is visibly on the right list.
local function geoAction(key)
return function()
if currentView == "mapping" then setView(currentGeoMode) end
geoActionsByMode[currentGeoMode][key]()
return iup.DEFAULT
end
end
-- A Mapping-menu list action switches to the Mapping view (if not there) and runs on its list.
local function mapAction(key)
return function()
if currentView ~= "mapping" then setView("mapping") end
mappingTab.actions[key]()
return iup.DEFAULT
end
end
-- Window-global keyboard shortcuts for the list actions, dispatched from the dialog's k_any so they
-- fire from anywhere in the window (previously they only worked when a list had focus). Unlike the menu
-- items - geoAction/mapAction deliberately switch INTO their section - a shortcut acts on whatever view
-- is current. Del and Ctrl+A stay on each list's own k_any: Del would clobber text editing elsewhere,
-- and Select-All has no menu item. The menu items carry the shortcut TEXT only (no key), so the menus
-- still advertise them without double-binding.
local function currentActions()
if currentView == "mapping" then return mappingTab.actions end
return geoActionsByMode[currentGeoMode]
end
local mainAccelerators = {
{ key = iup.K_cI, action = function() currentActions().select() end },
{ key = iup.K_cT, action = function() currentActions().clear() end },
}
local menuBarData = MenuBar.createMenuBar({
-- Our own Exit (suppresses the automatic one) so it can refuse while a batch is geocoding. It
-- routes through the dialog's close_cb (which showTrackedDialog wrapped to save size/position) -
-- returning iup.CLOSE directly would skip that, so Exit wouldn't remember the window like X does.
fileMenu = {
exit = MenuBar.helpers.createMenuItem("E&xit", function()
if closeHeldByGeocoding() then return iup.DEFAULT end
if dlgMain and dlgMain.close_cb then return dlgMain.close_cb(dlgMain) end
return iup.CLOSE
end),
},
-- Menus, not tabs: Geocoding (Places/Addresses mode + Add Trees-style selection actions) and
-- Mapping, then Options, before Help.
items = {
MenuBar.helpers.createSubmenu("&Geocoding", {
MenuBar.helpers.createRadioMenuItem("&Places", "places", "places",
function() setView("places"); return iup.DEFAULT end, nil, "viewPlaces"),
MenuBar.helpers.createRadioMenuItem("&Addresses", "places", "addresses",
function() setView("addresses"); return iup.DEFAULT end, nil, "viewAddresses"),
{}, -- separator
MenuBar.helpers.createMenuItem("&Select: Places", geoAction("select"), nil, "geoSelect", { text = "Ctrl+I" }),
MenuBar.helpers.createMenuItem("&Remove Selected", geoAction("remove"), nil, nil, { text = "Del" }),
MenuBar.helpers.createMenuItem("&Clear List", geoAction("clear"), nil, nil, { text = "Ctrl+T" }),
}, "menuGeocoding"),
MenuBar.helpers.createSubmenu("&Mapping", {
MenuBar.helpers.createRadioMenuItem("&Event Maps", "places", "mapping",
function() setView("mapping"); return iup.DEFAULT end, nil, "viewMapping"),
{}, -- separator
MenuBar.helpers.createMenuItem("&Select: Individuals", mapAction("select"), nil, nil, { text = "Ctrl+I" }),
MenuBar.helpers.createMenuItem("&Remove Selected", mapAction("remove"), nil, nil, { text = "Del" }),
MenuBar.helpers.createMenuItem("&Clear List", mapAction("clear"), nil, nil, { text = "Ctrl+T" }),
}, "menuMapping"),
-- One Options menu instead of scattered top-level items. Each is a menu item, so the fact-
-- type picker fires at MainLoop level (MainLoop -> popup) - never popup-in-popup.
MenuBar.helpers.createSubmenu("&Options", {
MenuBar.helpers.createMenuItem("&Geocoding Options…", function()
myGeoConfig:showConfigDialog(nil, "add-maps-reference#geocoding-options")
return iup.DEFAULT
end, nil, "menuGeoOptions"),
MenuBar.helpers.createMenuItem("&Mapping Options…", function()
myMapConfig:showConfigDialog(nil, "add-maps-reference#webmaps-options")
return iup.DEFAULT
end, nil, "menuMapOptions"),
{}, -- separator
MenuBar.helpers.createMenuItem("&Events to Map…", function()
chooseFactTypes(); return iup.DEFAULT
end, nil, "menuFactTypes"),
}, "menuOptions"),
},
helpMenu = {
help = MenuBar.helpers.createMenuItem("&Help", function()
if myHelp then myHelp:show("") end
return iup.DEFAULT
end),
about = MenuBar.helpers.createMenuItem("&About", function()
MessageBox("info", cstrPluginName .. " v" .. cstrPluginVersion
.. "\n\nGeocodes the places and addresses in your project and builds interactive maps of where life events happened, which you can embed into the matching pages of a Family Historian generated website.", "OK")
return iup.DEFAULT
end),
},
})
menuData = menuBarData
local content = iup.vbox({ panes.places, panes.addresses, panes.mapping, margin = "6x6", gap = "8", expand = "YES" })
-- No explicit size here: EXECUTE measures the true content-minimum first (with no user size set,
-- so naturalsize reflects the content, not HALFxHALF), then sets the half-screen opening size.
dlg = makeDialog(content, {
title = cstrPluginName .. " " .. cstrPluginVersion,
expand = "YES",
resize = "YES",
menubox = "YES",
menu = menuBarData.menuBar,
name = "AddMapsMain",
accelerators = mainAccelerators, -- Ctrl+I / Ctrl+T fire from anywhere in the window
-- Esc closes the window, mirroring File > Exit exactly: it refuses while a batch is geocoding and
-- otherwise routes through close_cb (which showTrackedDialog wrapped to save size/position). No
-- Enter default - the primary action differs per mode (Geocode vs Build) and the lists own Enter.
on_escape = function()
if closeHeldByGeocoding() then return iup.IGNORE end
if dlgMain and dlgMain.close_cb then return dlgMain.close_cb(dlgMain) end
return iup.CLOSE
end,
})
setView("places") -- initial mode: show Places, set the title and the radio ticks
return dlg
end
--------------------------------------------------------------
-- EXECUTE
--------------------------------------------------------------
myHelp = Help.new({})
help = myHelp -- global referenced by the Dialog / Config helpers
-- Global settings (geocoder keys/behaviour + window geometry) live per-user; mapping settings live
-- per-project (CURRENT_PROJECT). The project scope needs project mode - Add Maps always runs inside a
-- project (it reads INDI records), but guard anyway and fall back to per-user so it can never error.
myGeoConfig = Config.new(tblGeoConfig, "CURRENT_USER", cstrPluginName .. ".ini")
local mapScope = ((fhGetContextInfo("CI_PROJECT_FILE") or "") ~= "") and "CURRENT_PROJECT" or "CURRENT_USER"
myMapConfig = Config.new(tblMapConfig, mapScope, cstrPluginName .. " - Maps.ini")
-- Seed the session region-bias override from the saved global default (editable on the Geocoding pane).
gRegionBias = myGeoConfig:getString("Geocoding", "regionBias", "gb")
-- Enumerate the project's fact types and load the user's selection (seeding today's default first run).
loadFactSelection()
-- FH supports only one result set per plugin run, so geocoding and mapping outcomes share this single
-- object: "Record" carries the geocoded record or mapped individual, "Activity" says which kind of row
-- it is ("Geocoded" / "Map"), and the geocode-only / map-only columns are blank on rows that don't apply
-- (see addGeocodeRow / addMapRow above, which own the column order).
myResults = Results(14)
myResults.Title(cstrPluginName .. " - results")
myResults.Headings({ "Record", "Activity", "Kind", "Geocoder", "Result", "Latitude", "Longitude", "Quality",
"Standardised", "Events mapped", "Not geocoded", "Map file", "Embedded", "Folder" })
myResults.Types({ "item", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text", "text" })
myResults.Width({ 160, 60, 48, 72, 96, 72, 72, 60, 140, 60, 60, 96, 150, 200 }) -- 1/4-character units (80 ~ 20 chars)
myResults.Sort({ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }) -- 0 = no default sort (keep run order); columns stay clickable
myResults.Visibility({ "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show", "show" })
-- Converges on the shared suppress mechanism: the didGeocode/didMap guard at the foot of the
-- file already makes an idle close silent (Display() is never called when nothing was
-- attempted); NoResults(nil) additionally keeps a session that DID attempt geocoding or
-- mapping silent even if it produced no rows (family UX policy, 23 Jul 2026).
myResults.NoResults(nil)
dlgMain = makeMainDialog()
-- Refuse the title-bar X (and Alt+F4) while a batch is geocoding; set before showTrackedDialog so it
-- becomes the "original" close the tracker chains to. iup.IGNORE vetoes the close; iup.CLOSE allows it.
dlgMain.close_cb = function()
if closeHeldByGeocoding() then return iup.IGNORE end
return iup.CLOSE
end
DoNormalize()
-- Measure the true content-minimum now, while no user size is set, so naturalsize reflects the
-- content (the Coordinate form + the summary/Geocode row - the list is short and scrolls), not a
-- HALFxHALF or saved size. This becomes the window's minimum, so it can't be dragged small enough to
-- clip the form, but it doesn't grow when a larger size is saved.
iup.Refresh(dlgMain)
local contentMin = dlgMain.naturalsize -- "WxH" px, content-driven (the list is short); the window minimum
-- Opening size: at least half the screen, but never smaller than the content needs in EITHER
-- dimension - otherwise it opens too short and snaps taller the moment you resize. (This content needs
-- a little more than half the screen height.) showTrackedDialog uses a saved size instead, when present.
do
local sw, sh = tostring(iup.GetGlobal("SCREENSIZE")):match("(%d+)x(%d+)")
local cw, ch = tostring(contentMin):match("(%d+)x(%d+)")
sw, sh, cw, ch = tonumber(sw), tonumber(sh), tonumber(cw), tonumber(ch)
if sw and sh and cw and ch then
dlgMain.rastersize = math.max(sw // 2, cw) .. "x" .. math.max(sh // 2, ch)
else
dlgMain.size = "HALFxHALF"
end
end
-- One-off: discard a saved window size left over from the v0.21.4 build (implausibly narrow), so it
-- opens at the proper size rather than that collapsed width.
do
local saved = myGeoConfig:getString("Dialogs", "Dialog_Main.rastersize", "")
local w = tonumber(tostring(saved):match("^(%d+)"))
if w and w < 500 then myGeoConfig:setValues("Dialogs", "Dialog_Main", { rastersize = "" }) end
end
myGeoConfig:showTrackedDialog(dlgMain, "Main")
if contentMin and contentMin ~= "" then dlgMain.minsize = contentMin end
cleanupTempMaps() -- clear any temporary maps a previous session left behind (crash / force-close)
iup.MainLoop()
cleanupTempMaps() -- delete this session's temporary maps on the way out
destroyAllDialogs()
-- Show the result window for whatever was actually done this session (geocoding and/or mapping share
-- the one Results object - FH supports only one result set per plugin run).
if (didGeocode or didMap) and myResults and myResults.Display then myResults.Display() end
Source: Add-Maps.fh_lua