Split Addresses from Places.fh_lua
--[[
@Title: Split Addresses from Places
@Type: Standard
@Author: Helen Wright
@Contributors:
@Version: 1.0.1
@Keywords: address, place, geocode, split
@LastUpdated: 9 October 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: Splits street-level detail out of place names into Address records, re-pointing every reference without losing any data.
]]
--[[ChangeLog:
Version 1.0.1: Improvements from beta testing.
- The Result Set has an Old Place Id column, and the Action column says when an old place is kept (for example, because it is in a named list)
- An old place that Family Historian will not delete is reported as kept, not as deleted
- A geocode, note or media link that cannot be removed from the old place is left there and reported, never copied twice
Version 1.0: First release. For anyone who came from Family Historian 7, which had no Address records: splits the street-level
detail held in place names into Family Historian 8 Address records and a shorter place, without losing any data.
- Choose the places from a list, starting from your current selection; Previous Selection is remembered for each project
- You say how many columns, counting from the left, make up the address; blank columns are kept, and no blank address or place is ever created
- Preview and Exit shows what will happen in the Result Set; Apply and Exit does it, and Edit > Undo Plugin Updates reverses it
- Facts, sources, source-template fields and links in notes are re-pointed; fields that cannot hold an address get the place plus an 'Address:' note
- Places that become the same name are merged, as are identical addresses; the geocode, standardised value, notes and media go where you choose in Options
- A place that cannot be tidied away (for example, one in a named list) is kept, and the Result Set says why
]]
--------------------------------------------------------------
--INITIALISE (FH8 minimum + prompt to save so we act on current data)
--------------------------------------------------------------
-- fhInitialise must be the FIRST FH call. Address records exist only in Family Historian 8, so
-- the minimum version is 8. "save_recommended" offers to save unsaved changes first (Yes/No/Cancel;
-- Cancel exits) so the plugin operates on up-to-date data - which matters here: this plugin
-- batch-edits records.
fhInitialise(8, 0, 0, "save_recommended")
--------------------------------------------------------------
--EXTERNAL LIBRARIES
--------------------------------------------------------------
do
require("iuplua") -- UI
fh = require("fhUtils") -- FH helpers (global by house convention: the spliced boilerplate expects `fh`)
end
--------------------------------------------------------------
--ENVIRONMENT CONSTANTS
--------------------------------------------------------------
local cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME")
local cstrPluginVersion = "1.0.1" -- MUST match @Version in the header
local myConfig, myResults
--------------------------------------------------------------
--SHARED BOILERPLATE (spliced in at build by build/assemble.lua)
--------------------------------------------------------------
-- Order matters: Theme before Dialog; MenuBar/Config/Results/Help/Progress use Dialog's make* helpers.
-- These are all "effect" modules (bare directive, no global name - they set their own global).
--<<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/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/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
-- makeLongLabel (not makeLabel): makeLabel joins the shared button normaliser, which pins the
-- label at button width and clips long text such as a person's full name.
local lbl = makeLongLabel({
title = "Starting...",
expand = "HORIZONTAL",
})
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 = "QUARTERx", -- width only: height stays natural so the Cancel button is never clipped
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
-- Change the label text without moving the bar (for phases that aren't counted steps)
local function status(text)
if text and text ~= "" then
lbl.title = text
iup.Refresh(dlg)
iup.LoopStep()
end
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,
status = status,
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
--- @field _title string|nil
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
--- @param title? string Optional dialog title. Default "Applying..."
--- @return ProgressController
function ProgressController.new(totalSteps, updatePercent, showThreshold, parentDialog, title)
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._title = title
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, self._title or "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
--- Show a status message without advancing the step count (no-op if the dialog isn't shown)
--- @param text string
function ProgressController:status(text)
if self.dlg then
self.dlg.status(text)
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>>
-- Resolve Help once to a real instance so consumers can assume it exists. Global by design:
-- Dialog.lua's helpers reference `help` for F1/context help wiring.
help = Help.new({})
--------------------------------------------------------------
--PURE LOGIC (spliced src modules, spec-tested standalone in tests/)
--------------------------------------------------------------
--<<FS_SPLICE src/split.lua Split>>
Split = (function()
--[[
split.lua - pure text-splitting decisions for Split Addresses from Places.
Everything here is decision logic with NO fh* or iup calls, so it is unit-testable
standalone (spec: "tests/split_spec.lua") and spliced into
"Split Addresses from Places.fh_lua" as `Split` (assign mode).
The idea: an FH7-era place such as
"158, Cole Valley Road, , Hall Green, Birmingham, England"
is really an ADDRESS plus a PLACE. The first N comma-separated columns are the address,
the rest the place. Blank columns are kept VERBATIM inside the address and the place (users
keep them for alignment), with one exception: BLANK COLUMNS AT THE END OF THE ADDRESS are
trimmed. With N = 3 the address is "158, Cole Valley Road" (the text before the 3rd comma,
trailing blanks/commas removed; leading blank columns stay, so ", Upper Ann Street, " becomes
", Upper Ann Street") and the place is "Hall Green, Birmingham, England" (the text after the
3rd comma, leading whitespace removed). A blank address and a blank place are never produced.
cp1252 host locale: no %w/%a/%s pattern classes anywhere (they are locale-dependent and
the FH host runs Lua in cp1252); blank tests use the explicit set [ \t\r\n].
]]
local M = {}
-- Characters treated as "blank" when deciding whether a stretch of text holds anything:
-- whitespace and the column separator itself.
local BLANK_ONLY = "^[ \t\r\n,]*$"
---Trim whitespace from both ends (explicit set, locale-proof).
---@param s string
---@return string
local function trim(s)
return (s:match("^[ \t\r\n]*(.-)[ \t\r\n]*$"))
end
---Case-fold for comparing place/address texts. FH treats Place (and, to be safe, Address) texts as
---equal when they differ only by case. Lua's own lower() follows the host's cp1252 locale and so
---corrupts UTF-8 bytes; this folds explicitly: ASCII A-Z, plus the Latin-1 capitals encoded in
---UTF-8 as 0xC3 0x80-0x9E (except the multiplication sign 0xC3 0x97).
---@param s string|nil
---@return string
function M.fold(s)
s = s or ""
s = s:gsub("[A-Z]", function(c)
return string.char(c:byte() + 32)
end)
s = s:gsub(string.char(195) .. "([" .. string.char(128) .. "-" .. string.char(158) .. "])", function(c)
if c:byte() == 151 then
return nil -- the multiplication sign has no lower-case form
end
return string.char(195) .. string.char(c:byte() + 32)
end)
return s
end
---True when `s` holds nothing but spaces, tabs, newlines and commas (i.e. only blank columns).
---@param s string|nil
---@return boolean
function M.isBlank(s)
return s == nil or s:match(BLANK_ONLY) ~= nil
end
---Split free text on commas into its columns, verbatim (no trimming).
---@param text string
---@return string[] columns
local function columnsOf(text)
local cols = {}
local from = 1
while true do
local c = text:find(",", from, true)
if not c then
cols[#cols + 1] = text:sub(from)
break
end
cols[#cols + 1] = text:sub(from, c - 1)
from = c + 1
end
return cols
end
---The number of comma-separated columns in a text ("a, b, c" = 3; "" = 1).
---@param text string
---@return integer
function M.columnCount(text)
return #columnsOf(text or "")
end
---Split a place text into address and place.
---@param text string the old place's full text
---@param n integer number of address columns (clamped to >= 1)
---@return table result { kind = "split"|"tidy"|"skip", addr = string|nil, place = string|nil,
--- columns = integer, country = string, reason = string|nil }
--- split: address non-blank and place non-blank
--- tidy: address columns all blank, place non-blank (no address; the place is just renamed)
--- skip: fewer than n+1 columns, or the tail after the address is blank (reason says which)
function M.split(text, n)
text = text or ""
n = math.floor(tonumber(n) or 3)
if n < 1 then
n = 1
end
local cols = columnsOf(text)
local result = {
columns = #cols,
-- Country = the last column, trimmed. The Preview shows it so mixed-country selections
-- (which need a different N) are easy to spot and sort.
country = trim(cols[#cols]),
kind = "skip",
}
if #cols < n + 1 then
result.reason = string.format("%d column%s; needs more than %d", #cols, #cols == 1 and "" or "s", n)
return result
end
-- Position of the Nth comma: everything before it is the address, everything after the place.
local pos = 0
for _ = 1, n do
pos = text:find(",", pos + 1, true)
end
local rawAddr = text:sub(1, pos - 1)
local addr = rawAddr:gsub("[ \t,]+$", "") -- trailing blank columns are dropped
local place = text:sub(pos + 1):gsub("^[ \t\r\n]+", "")
if M.isBlank(place) then
result.reason = "nothing left for the place after the address columns"
return result
end
if place == text then
-- Cannot happen for n >= 1 (the place is always shorter than the text); kept as a guard.
result.reason = "place would be unchanged"
return result
end
result.place = place
if M.isBlank(rawAddr) then
result.kind = "tidy"
result.addr = nil
else
result.kind = "split"
result.addr = addr
end
return result
end
---An address with its trailing blank columns / commas / spaces removed - used for human-readable
---notes. ("Morlais Chapel, , " -> "Morlais Chapel"). Leading text is left exactly as it is.
---@param addr string
---@return string
function M.addressCore(addr)
return (addr or ""):match("^(.-)[ \t\r\n,]*$")
end
---The text of the note added when an address has nowhere to live (source "events recorded"
---places, the second place on emigration/immigration facts).
---@param addr string
---@return string
function M.noteText(addr)
return "Address: " .. M.addressCore(addr)
end
---New text for an EXISTING address record when the split-off address is prefixed to it:
---`addr .. ", " .. existing` ("158, Cole Valley Road" .. ", " .. "Back bedroom" =
---"158, Cole Valley Road, Back bedroom"). Returns nil when the existing text already contains the
---address (caseless plain find) - the existing address is then left alone. A blank `existing` just
---becomes the address.
---@param addr string the split-off address (non-blank, already trimmed)
---@param existing string|nil the existing address record's text
---@return string|nil newText
function M.prefixAddress(addr, existing)
local core = M.addressCore(addr)
if core == "" then
return nil
end
existing = existing or ""
if M.isBlank(existing) then
return addr
end
if M.fold(existing):find(M.fold(core), 1, true) then
return nil
end
return addr .. ", " .. existing
end
---The most common column count among the selected places. Ties go to the LARGER count
---(a deliberate, documented choice: with a split tie the longer layout is the more specific one,
---so the shorter ones are flagged as the oddities).
---@param counts integer[] one column count per place
---@return integer|nil modal count (nil for an empty list)
function M.majorityColumns(counts)
local freq = {}
for _, c in ipairs(counts) do
freq[c] = (freq[c] or 0) + 1
end
local best, bestFreq
for c, f in pairs(freq) do
if not bestFreq or f > bestFreq or (f == bestFreq and c > best) then
best, bestFreq = c, f
end
end
return best
end
---The "irregular place" warning shown in the Preview.
---@param columns integer this place's column count
---@param majority integer|nil the selection's modal column count
---@return string|nil
function M.checkWarning(columns, majority)
if majority and columns ~= majority then
return string.format("Check split: %d columns; most selected places have %d", columns, majority)
end
return nil
end
-- Processing order: TIDY places first (they become the canonical town records that the SPLIT places
-- then merge into, so the canonical record keeps its identity and geocode), then SPLIT, then SKIP.
local KIND_RANK = { tidy = 1, split = 2, skip = 3 }
---Return a NEW array of plan entries ordered tidy -> split -> skip. Stable: entries of the same
---kind keep their original relative order. Each entry needs a `.kind` field.
---@param plan table[]
---@return table[]
function M.orderPlan(plan)
local decorated = {}
for i, entry in ipairs(plan) do
decorated[i] = { entry = entry, rank = KIND_RANK[entry.kind] or 4, idx = i }
end
table.sort(decorated, function(a, b)
if a.rank ~= b.rank then
return a.rank < b.rank
end
return a.idx < b.idx
end)
local out = {}
for i, d in ipairs(decorated) do
out[i] = d.entry
end
return out
end
return M
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE src/richtext_links.lua RichLinks>>
RichLinks = (function()
--[[
richtext_links.lua - pure editing of FTF record-link commands for Split Addresses from Places.
NO fh* or iup calls: unit-testable standalone (spec: "tests/richtext_links_spec.lua") and
spliced into "Split Addresses from Places.fh_lua" as `RichLinks` (assign mode).
A rich-text field (a note) stores a record link as a command in its text:
<rec=INDEX,DISPLAY,FLAG>
INDEX points into a record-link table held beside the text; DISPLAY is the fallback / fixed
text; FLAG is `auto` (the link shows the target record's CURRENT name) or `text` (it always
shows DISPLAY). A parameter must be double-quoted when it contains a space, comma, `>`,
backslash or double quote (inside the quotes, `\"` and `\\` are escapes); otherwise the
quotes are optional. A literal `<` in ordinary text is written `\<`, and any character may be
escaped by a backslash - so a `\<rec=1,...>` in the text is NOT a link.
The plugin cannot retarget a link by setting it (FH refuses), so it rewrites the TEXT and the
link table instead; these helpers do the text half. They use a small hand-written scanner,
not a gsub, so escapes and quoted parameters are handled exactly.
]]
local M = {}
---Backslash-escape a display string and wrap it in double quotes, ready to sit in a <rec=...>
---parameter slot. (Always quoting is safe: quotes are optional only when no special
---characters are present, never forbidden.)
---@param s string
---@return string
function M.encodeParam(s)
s = tostring(s or "")
s = s:gsub("\\", "\\\\")
s = s:gsub('"', '\\"')
return '"' .. s .. '"'
end
---Parse one `<rec=...>` command starting at position `i` (which must point at the `<`).
---Returns nil when what follows is not a well-formed record link (the scanner then treats the
---`<` as ordinary text).
---@param txt string
---@param i integer
---@return table|nil link { stop = last char position, idxStart, idxEnd, index = integer, params = string[] }
local function parseRec(txt, i)
if txt:sub(i, i + 4) ~= "<rec=" then
return nil
end
local idxStart = i + 5
local digits = txt:match("^[0-9]+", idxStart)
if not digits then
return nil
end
local idxEnd = idxStart + #digits - 1
local pos = idxEnd + 1
local params = {}
while true do
local ch = txt:sub(pos, pos)
if ch == ">" then
return { stop = pos, idxStart = idxStart, idxEnd = idxEnd, index = tonumber(digits), params = params }
elseif ch == "," then
pos = pos + 1
local first = txt:sub(pos, pos)
if first == '"' then
-- Quoted parameter: read to the closing quote, honouring backslash escapes.
local buf = {}
pos = pos + 1
while true do
local c = txt:sub(pos, pos)
if c == "" then
return nil -- unterminated quote: not a link
elseif c == "\\" then
buf[#buf + 1] = txt:sub(pos + 1, pos + 1)
pos = pos + 2
elseif c == '"' then
pos = pos + 1
break
else
buf[#buf + 1] = c
pos = pos + 1
end
end
params[#params + 1] = table.concat(buf)
else
-- Unquoted parameter: runs to the next comma or the closing >.
local raw = txt:match("^[^,>]*", pos)
params[#params + 1] = raw
pos = pos + #raw
end
else
return nil
end
end
end
---Walk `txt`, calling `fn(link)` for every genuine record link; when fn returns a string it
---replaces the link's whole command, otherwise the original text is kept verbatim. Escaped
---characters (including an escaped `\<rec=`) are copied through untouched.
---@param txt string
---@param fn fun(link: table): string|nil
---@return string
local function rewrite(txt, fn)
local out = {}
local i, n = 1, #txt
local copyFrom = 1 -- start of the pending run of unchanged text
while i <= n do
local ch = txt:sub(i, i)
if ch == "\\" then
i = i + 2 -- an escape: skip the backslash and the character it protects
elseif ch == "<" then
local link = parseRec(txt, i)
if link then
local replacement = fn(link)
if replacement then
out[#out + 1] = txt:sub(copyFrom, i - 1)
out[#out + 1] = replacement
copyFrom = link.stop + 1
end
i = link.stop + 1
else
i = i + 1
end
else
i = i + 1
end
end
out[#out + 1] = txt:sub(copyFrom)
return table.concat(out)
end
---Convert every AUTO link to record `idx` into a fixed-text link showing `displayText`:
---`<rec=idx,<anything>,auto>` -> `<rec=idx,"<encoded displayText>",text>`. Links already fixed
---(`text`) and links to other indices are left exactly as they are.
---@param txt string
---@param idx integer
---@param displayText string
---@return string
function M.fixAutoText(txt, idx, displayText)
return rewrite(txt, function(link)
if link.index == idx and link.params[2] == "auto" then
return "<rec=" .. idx .. "," .. M.encodeParam(displayText) .. ",text>"
end
return nil
end)
end
---Renumber the record links for "old record folded into an existing entry": every link to
---`idxOld` is pointed at `idxNew`, and every index above `idxOld` drops by one because the
---caller then removes `recs[idxOld]` from the link table. `idxNew` is given in the OLD numbering
---(its position in the table before the removal), so it is itself decremented when it lies
---above `idxOld`. Works whether idxNew is below or above idxOld.
---@param txt string
---@param idxOld integer
---@param idxNew integer
---@return string
function M.renumber(txt, idxOld, idxNew)
local target = (idxNew > idxOld) and (idxNew - 1) or idxNew
return rewrite(txt, function(link)
local newIdx
if link.index == idxOld then
newIdx = target
elseif link.index > idxOld then
newIdx = link.index - 1
end
if newIdx and newIdx ~= link.index then
-- Only the number changes; the rest of the command is kept byte-for-byte.
return "<rec=" .. newIdx .. txt:sub(link.idxEnd + 1, link.stop)
end
return nil
end)
end
---Does the text contain a genuine (unescaped) record link to `idx`?
---@param txt string
---@param idx integer
---@return boolean
function M.hasLink(txt, idx)
local found = false
rewrite(txt, function(link)
if link.index == idx then
found = true
end
return nil
end)
return found
end
return M
end)()
--<<FS_SPLICE_END>>
--<<FS_SPLICE src/options.lua SplitOptions>>
SplitOptions = (function()
--[[
options.lua - pure mapping between the option WORDING shown to the user and the internal values
the engine uses, for Split Addresses from Places. No fh* or iup calls, so it is unit-testable
standalone (spec: "tests/options_spec.lua") and spliced into "Split Addresses from Places.fh_lua"
as `SplitOptions` (assign mode).
The saved preferences (Options dialog / ini file) hold the dropdown WORDING ("Move to address",
"Leave on place", "5" ...). The engine works with internal values: `columns` = integer 1-9, the
other four = "address" | "place". Version 0.2 stored the internal words ("address"/"place") and a
number, so `normalise` upgrades those to the current wording and anything unrecognised (a
hand-edited ini) to the default - a user's saved settings survive the change.
The wording and the internal value live side by side in this one table; nothing else in the
plugin mentions either spelling.
]]
local M = {}
M.COLUMN_MIN = 1
M.COLUMN_MAX = 9
local COLUMN_CHOICES = {}
for i = M.COLUMN_MIN, M.COLUMN_MAX do
COLUMN_CHOICES[#COLUMN_CHOICES + 1] = tostring(i)
end
-- One entry per option, in dialog order. `choices` is the dropdown wording, `values` the parallel
-- internal values (nil for columns, whose internal value is the number itself).
local SPEC = {
columns = { choices = COLUMN_CHOICES, default = 3 },
geocode = { choices = { "Move to address", "Leave on place" }, values = { "address", "place" }, default = 1 },
stan = { choices = { "Move to address", "Leave on place" }, values = { "address", "place" }, default = 1 },
notes = { choices = { "Move to address", "Move to place" }, values = { "address", "place" }, default = 1 },
media = { choices = { "Move to address", "Move to place" }, values = { "address", "place" }, default = 1 },
}
---The option keys in dialog order.
M.keys = { "columns", "geocode", "stan", "notes", "media" }
---A copy of an option's dropdown wording, in display order.
---@param key string
---@return string[]
function M.choicesFor(key)
local out = {}
for i, c in ipairs(SPEC[key].choices) do
out[i] = c
end
return out
end
---The default wording of an option (a string: the Config field default).
---@param key string
---@return string
function M.defaultFor(key)
return SPEC[key].choices[SPEC[key].default]
end
---Lower-case for ASCII only (the host locale is cp1252, so the built-in lower() is not trusted).
local function asciiLower(s)
return (s:gsub("[A-Z]", function(c)
return string.char(c:byte() + 32)
end))
end
---Position of `wording` in an option's choices (exact match), or nil.
---@param key string
---@param wording string|nil
---@return integer|nil
function M.indexOf(key, wording)
for i, c in ipairs(SPEC[key].choices) do
if c == wording then
return i
end
end
return nil
end
---Turn whatever is stored for an option - current wording, a version 0.2 internal word or number, or
---junk - into valid CURRENT wording.
---@param key string
---@param stored any
---@return string wording
function M.normalise(key, stored)
local spec = SPEC[key]
if key == "columns" then
local n = tonumber(stored)
if n == nil then
return M.defaultFor(key)
end
n = math.floor(n)
if n < M.COLUMN_MIN then
n = M.COLUMN_MIN
elseif n > M.COLUMN_MAX then
n = M.COLUMN_MAX
end
return tostring(n)
end
if type(stored) ~= "string" then
return M.defaultFor(key)
end
if M.indexOf(key, stored) then
return stored
end
-- Version 0.2 wrote the internal words; match them (and the wording) without regard to case/space.
local word = asciiLower(stored:match("^[ \t]*(.-)[ \t]*$"))
for i, v in ipairs(spec.values) do
if word == v or word == asciiLower(spec.choices[i]) then
return spec.choices[i]
end
end
return M.defaultFor(key)
end
---The internal value for a stored/selected wording (normalised first).
---@param key string
---@param stored any
---@return integer|string value integer 1-9 for `columns`, otherwise "address" | "place"
function M.valueOf(key, stored)
local wording = M.normalise(key, stored)
if key == "columns" then
return tonumber(wording)
end
return SPEC[key].values[M.indexOf(key, wording)]
end
---The internal value for a dropdown position (1-based; anything invalid gives the default).
---@param key string
---@param index any
---@return integer|string
function M.valueAtIndex(key, index)
local spec = SPEC[key]
local i = math.floor(tonumber(index) or 0)
local wording = spec.choices[i] or M.defaultFor(key)
return M.valueOf(key, wording)
end
---Build the engine's options table from a function that supplies each key's stored wording.
---@param getStored fun(key: string): any
---@return table opts { columns = integer, geocode, stan, notes, media = "address"|"place" }
function M.fromStored(getStored)
local opts = {}
for _, key in ipairs(M.keys) do
opts[key] = M.valueOf(key, getStored(key))
end
return opts
end
---Build the engine's options table from a function that supplies each key's dropdown position.
---@param getIndex fun(key: string): any
---@return table opts same shape as fromStored
function M.fromIndexes(getIndex)
local opts = {}
for _, key in ipairs(M.keys) do
opts[key] = M.valueAtIndex(key, getIndex(key))
end
return opts
end
return M
end)()
--<<FS_SPLICE_END>>
--------------------------------------------------------------
--REUSABLE RECORD LIST (shared boilerplate, assign mode; needs Dialog + Progress above)
--------------------------------------------------------------
--<<FS_SPLICE "../../2 Boilerplate/RecordList.lua" RecordList>>
RecordList = (function()
--[[
RecordList V1
@Author: Helen Wright
@Version: 1.0
@LastUpdated: 7 October 2026
@Description: A reusable "list of records" selector for plugins that act on a user-chosen set of
records (Add Notes / Add Trees / Add Maps style). Parameterised by record tag and noun: it holds
the entries, builds the multi-select list control, and provides the actions Select (FH's own
record picker, with a Progress dialog for big selections), Remove ticked rows (with confirmation),
Clear, Select All, and Previous Selection (the record ids of the last run, saved per project and
reloaded later, skipping records that no longer exist). The pure parts (entry keys, duplicate
check, id-list save format and parsing, wording) have no fh*/iup dependency and are spec-tested
(tests/recordlist_spec.lua); the rest needs the live host and the Dialog/Progress boilerplate.
An entry is { recordPointer, displayText, recordType } plus `key` ("TAG:id") and `id`.
Spliced in assign mode, bound to the global `RecordList`. Written in prose here (not as a live
marker): the directive is the quoted path "../../2 Boilerplate/RecordList.lua" followed by the
global name RecordList.
USAGE
recList = RecordList.new({
tag = "_PLAC", noun = "place", nouns = "places",
parentDialog = function() return dlgmain end, -- for Progress dialogs (optional)
onChange = function(count) ... end, -- list changed (optional)
fileName = "Previous Selection.txt", -- per-project file (optional)
})
recList.loadInitial() -- start from FH's current selection (else empty)
content = iup.vbox({ recList.list, ... })
makeDialog(content, { accelerators = recList.accelerators }) -- Ctrl+I / Ctrl+P / Ctrl+T
-- menus: createMenuItem("&Select Places...", recList.actions.select, nil, nil, { text = RecordList.keys.select })
ptrs = recList.pointers() -- the records to process
recList.save() -- remember them as the Previous Selection
Ctrl+A (select all) and Del (remove) are bound on the list itself; Ctrl+I / Ctrl+P / Ctrl+T are
window-global through the dialog's accelerators, so they fire from anywhere in the window.
@V1.0: Initial version (record-list glue lifted from Add Notes / Add Trees / Add Maps).
]]
local M = {}
-- The shortcut text to show in menus (display only: RecordList.accelerators does the binding).
M.keys = { select = "Ctrl+I", previous = "Ctrl+P", remove = "Del", clear = "Ctrl+T", selectAll = "Ctrl+A" }
local FILE_MAGIC = "RecordList 1"
--==================== PURE ====================--
---Capitalise the first ASCII letter (locale-proof: no %a/%u classes).
---@param s string
---@return string
function M.capitalise(s)
s = tostring(s or "")
return (s:gsub("^[a-z]", function(c)
return string.char(c:byte() - 32)
end))
end
---The key that identifies a record in the list: "TAG:id".
---@param tag string
---@param id integer|string
---@return string
function M.recordKey(tag, id)
return tostring(tag) .. ":" .. tostring(id)
end
---The duplicate-detection key of an entry (nil when it has neither a stored key nor a pointer).
---@param entry table
---@return string|nil
function M.entryKey(entry)
if entry.key then
return entry.key
end
local ptr = entry.recordPointer
if not ptr then
return nil
end
return M.recordKey(fhGetTag(ptr), fhGetRecordId(ptr))
end
---Add `newEntries` to `existing` (in place) skipping duplicates, whether against what is already
---there or within the new batch. Linear in the total count (one key set is built), so a
---40,000-record selection loads in moments. An entry with no key at all is kept (nothing to compare).
---@param newEntries table[]
---@param existing table[]
---@return integer added how many entries were actually added
function M.addWithDuplicateCheck(newEntries, existing)
local seen = {}
for _, e in ipairs(existing) do
local key = M.entryKey(e)
if key then
seen[key] = true
end
end
local added = 0
for _, e in ipairs(newEntries) do
local key = M.entryKey(e)
if not key then
existing[#existing + 1] = e
added = added + 1
elseif not seen[key] then
seen[key] = true
existing[#existing + 1] = e
added = added + 1
end
end
return added
end
---The text of the Previous Selection file: a magic line, the record tag, then one record id per line.
---@param tag string
---@param entries table[] entries carrying `id`
---@return string
function M.serialiseIds(tag, entries)
local lines = { FILE_MAGIC, tostring(tag) }
for _, e in ipairs(entries) do
if e.id ~= nil then
lines[#lines + 1] = tostring(e.id)
end
end
return table.concat(lines, "\n") .. "\n"
end
---Parse a Previous Selection file back into record ids (duplicates dropped, junk lines ignored).
---@param text string|nil
---@param tag string the record tag the caller expects
---@return integer[]|nil ids, string|nil problem
function M.parseIds(text, tag)
if type(text) ~= "string" or text == "" then
return nil, "empty"
end
local lines = {}
for line in text:gmatch("[^\r\n]+") do
lines[#lines + 1] = line
end
if lines[1] ~= FILE_MAGIC then
return nil, "not a selection file"
end
if lines[2] ~= tostring(tag) then
return nil, "a different record type"
end
local ids, seen = {}, {}
for i = 3, #lines do
local n = lines[i]:match("^[ \t]*([0-9]+)[ \t]*$")
n = n and tonumber(n)
if n and not seen[n] then
seen[n] = true
ids[#ids + 1] = n
end
end
return ids
end
---"Remove 3 places from the list?"
function M.removeQuestion(noun, nouns, count)
return string.format("Remove %d %s from the list?", count, count == 1 and noun or nouns)
end
---Wording for records of the previous selection that no longer exist.
---@return string
function M.skippedMessage(noun, nouns, skipped, loaded)
return string.format(
"%d %s from the previous selection no longer %s (for example, merged or deleted by an earlier run) and %s skipped.\n\n%d %s %s loaded.",
skipped,
skipped == 1 and noun or nouns,
skipped == 1 and "exists" or "exist",
skipped == 1 and "was" or "were",
loaded,
loaded == 1 and noun or nouns,
loaded == 1 and "was" or "were"
)
end
--==================== END PURE ====================--
---Build one entry for a record pointer.
---@param label string capitalised noun, e.g. "Place"
---@param ptr userdata record pointer
---@return table
function M.makeEntry(label, ptr)
local tag, id = fhGetTag(ptr), fhGetRecordId(ptr)
return {
recordPointer = ptr,
displayText = "[" .. label .. "] " .. fhGetDisplayText(ptr) .. " (ID: " .. id .. ")",
recordType = tag,
key = M.recordKey(tag, id),
id = id,
}
end
---Create a record list controller (see the USAGE note in the header).
---@param cfg table { tag, noun, nouns, label?, fileName?, parentDialog?, parentHwnd?, onChange?, visiblelines?, visiblecolumns?, name? }
---@return table L { list, entries, actions, accelerators, pointers, count, loadInitial, save, loadPrevious, refresh }
function M.new(cfg)
local tag = assert(cfg.tag, "RecordList.new: tag required")
local noun = cfg.noun or "record"
local nouns = cfg.nouns or (noun .. "s")
local label = cfg.label or M.capitalise(noun)
local fileName = cfg.fileName or "Previous Selection.txt"
local L = { entries = {}, actions = {}, accelerators = {} }
local list
local function parentDialog()
return cfg.parentDialog and cfg.parentDialog() or nil
end
---Redraw the control from the entries and tell the owner.
local function refresh()
if list then
if #L.entries > 0 then
local texts = {}
for i, e in ipairs(L.entries) do
texts[i] = e.displayText
end
populateList(list, texts)
else
list.REMOVEITEM = "ALL"
end
end
if cfg.onChange then
cfg.onChange(#L.entries)
end
end
L.refresh = refresh
---Turn record pointers into entries behind a Progress dialog (it appears from 500 records up).
---@return table[]|nil entries (nil when the user cancelled), table|nil progress (caller finishes it)
local function entriesFor(ptrs)
local progress = Progress.new(#ptrs, 5, 500, parentDialog(), "Loading " .. nouns .. "...")
local entries = {}
for i, p in ipairs(ptrs) do
entries[i] = M.makeEntry(label, p:Clone())
progress:update(entries[i].displayText)
if progress:isCancelled() then
progress:finish()
return nil
end
if i % 100 == 0 then
fhExhibitResponsiveness()
end
end
progress:status("Filling the list...")
return entries, progress
end
---Select: add records chosen in Family Historian's own picker.
local function select()
local hwnd = cfg.parentHwnd and cfg.parentHwnd() or getParentWindowHandle()
local picked = fhPromptUserForRecordSel(tag, -1, hwnd)
if not picked or #picked == 0 then
return
end
local entries, progress = entriesFor(picked)
if not entries then
return -- cancelled: leave the list unchanged
end
local added = M.addWithDuplicateCheck(entries, L.entries)
refresh()
progress:finish()
if added == 0 then
MessageBox("info", "Those " .. nouns .. " are already in the list.", "OK")
end
end
---Remove the ticked rows, after confirmation.
local function remove()
if not list then
return
end
local count = #getSelectedValues(list, true)
if count == 0 then
MessageBox("info", "Tick one or more " .. nouns .. " to remove.\n\n(Ctrl-click or Shift-click to select several.)", "OK")
return
end
if MessageBox("question", M.removeQuestion(noun, nouns, count), "YESNO") ~= "Yes" then
return
end
removeSelectedItems(list, L.entries, refresh)
end
---Empty the list.
local function clear()
if #L.entries == 0 then
return
end
for i = #L.entries, 1, -1 do
L.entries[i] = nil
end
refresh()
end
---Tick every row (Ctrl+A).
local function selectAll()
if not list then
return
end
local count = tonumber(list.COUNT) or 0
if count > 0 then
list.value = string.rep("+", count)
end
end
---Start from the records currently selected in Family Historian (else leave the list empty).
local function loadInitial()
local ok, current = pcall(fhGetCurrentRecordSel, tag)
if ok and type(current) == "table" then
local entries = {}
for _, p in ipairs(current) do
if fhGetTag(p) == tag then
entries[#entries + 1] = M.makeEntry(label, p:Clone())
end
end
M.addWithDuplicateCheck(entries, L.entries)
end
refresh()
end
---Path of this project's Previous Selection file (nil for a project with no data folder).
local function dataFile()
local ok, folder = pcall(fhGetPluginDataFileName, "CURRENT_PROJECT", true)
if not ok or type(folder) ~= "string" or folder == "" then
return nil
end
return folder .. "\\" .. fileName
end
---Remember the list's record ids as the Previous Selection. Never raises.
---@return boolean saved
local function save()
local path = dataFile()
if not path then
return false
end
local ok, res = pcall(fhSaveTextFile, path, M.serialiseIds(tag, L.entries), "UTF-8")
return ok and res ~= false
end
---Reload the Previous Selection into the list (replacing it), skipping records that no longer exist.
local function loadPrevious()
local path = dataFile()
local text
if path then
local ok, res = pcall(fhLoadTextFile, path, "UTF-8")
text = ok and res or nil
end
local ids = text and M.parseIds(text, tag)
if not ids or #ids == 0 then
MessageBox(
"info",
"There is no previous selection to load for this project yet. One is remembered each time you run Preview or Apply.",
"OK"
)
return
end
if #L.entries > 0 then
local q = string.format(
"Replace the %d %s in the list with the previous selection (%d)?",
#L.entries,
#L.entries == 1 and noun or nouns,
#ids
)
if MessageBox("question", q, "YESNO") ~= "Yes" then
return
end
end
local progress = Progress.new(#ids, 5, 500, parentDialog(), "Loading " .. nouns .. "...")
local entries, skipped = {}, 0
for i, id in ipairs(ids) do
local p = fhNewItemPtr()
p:MoveToRecordById(tag, id)
if p:IsNotNull() then
entries[#entries + 1] = M.makeEntry(label, p)
else
skipped = skipped + 1
end
progress:update(tostring(id))
if progress:isCancelled() then
progress:finish()
return -- leave the list unchanged
end
if i % 100 == 0 then
fhExhibitResponsiveness()
end
end
progress:finish()
if #entries == 0 then
MessageBox("info", M.skippedMessage(noun, nouns, skipped, 0), "OK")
return
end
for i = #L.entries, 1, -1 do
L.entries[i] = nil
end
M.addWithDuplicateCheck(entries, L.entries)
refresh()
if skipped > 0 then
MessageBox("info", M.skippedMessage(noun, nouns, skipped, #entries), "OK")
end
end
---The record pointers to process.
local function pointers()
local ptrs = {}
for i, e in ipairs(L.entries) do
ptrs[i] = e.recordPointer
end
return ptrs
end
-- The control. Ctrl+A and Del are list-scoped (Del must never clobber text editing elsewhere);
-- Ctrl+I / Ctrl+P / Ctrl+T are window-global through the dialog's accelerators.
list = makeList({
dropdown = "NO",
expand = "YES",
multiple = "YES",
visiblelines = tostring(cfg.visiblelines or 8),
visiblecolumns = tostring(cfg.visiblecolumns or 50),
name = cfg.name,
tip = "The "
.. nouns
.. " that Preview and Apply will process.\n\nShortcuts:\n- "
.. M.keys.select
.. ": Select "
.. M.capitalise(nouns)
.. "\n- "
.. M.keys.previous
.. ": Previous selection\n- "
.. M.keys.clear
.. ": Clear list\n- "
.. M.keys.selectAll
.. ": Select all\n- "
.. M.keys.remove
.. ": Remove selected",
})
list.k_any = function(self, c)
if c == iup.K_cA then
selectAll()
return iup.IGNORE
elseif c == iup.K_DEL then
remove()
return iup.IGNORE
end
return iup.CONTINUE
end
L.list = list
L.actions = { select = select, remove = remove, clear = clear, selectAll = selectAll, previous = loadPrevious }
L.accelerators = {
{ key = iup.K_cI, action = select },
{ key = iup.K_cP, action = loadPrevious },
{ key = iup.K_cT, action = clear },
}
L.pointers = pointers
L.count = function()
return #L.entries
end
L.loadInitial = loadInitial
L.save = save
L.loadPrevious = loadPrevious
return L
end
return M
end)()
--<<FS_SPLICE_END>>
--------------------------------------------------------------
--CONFIGURATION (persisted per Windows user to <plugin>.ini)
--------------------------------------------------------------
-- The Options dialog holds the user's DEFAULT settings for the five options, remembered between runs.
-- They are stored as the dropdown WORDING ("Move to address", "5" ...); SplitOptions (src/options.lua)
-- maps wording <-> the engine's internal values in one place and upgrades what version 0.2 saved (the
-- words "address"/"place" and a number). The main window's dropdowns are the settings FOR THIS RUN:
-- they start from these defaults and are never saved from there.
local OPT_SECTION = "Options"
local OPT_LABELS = {
columns = "Address columns:",
geocode = "Geocode:",
stan = "Standardised value:",
notes = "Notes:",
media = "Media:",
}
local OPT_DESCRIPTIONS = {
columns = "How many comma-separated columns, counting from the left, become the address. The rest stay as the place.",
geocode = "Where the old place's map coordinates (and geocode status) go: onto the new address, or left on the place. "
.. "A place that merges into an existing place always passes them to the address.",
stan = "Where the old place's standardised value goes: onto the new address, or left on the place. "
.. "A place that merges into an existing place always passes it to the address.",
notes = "Where the old place's notes go: onto the new address, or onto the place (the existing place, in a merge).",
media = "Where the old place's media links go: onto the new address, or onto the place (the existing place, in a merge).",
}
local optionFields = {}
for i, key in ipairs(SplitOptions.keys) do
optionFields[i] = {
key = key,
label = OPT_LABELS[key],
type = "list", -- keeps the order of `options` (the value is mapped by position)
options = SplitOptions.choicesFor(key),
default = SplitOptions.defaultFor(key),
description = OPT_DESCRIPTIONS[key],
}
end
myConfig = Config.new({
title = cstrPluginName .. " - Options",
sections = { { title = OPT_SECTION, fields = optionFields } },
}, "CURRENT_USER", cstrPluginName .. ".ini")
-- Upgrade anything saved by version 0.2 (or hand-edited) to the current wording, once, up front, so
-- the Options dialog always finds a valid choice to select.
for _, key in ipairs(SplitOptions.keys) do
local stored = myConfig:getValue(OPT_SECTION, key, SplitOptions.defaultFor(key))
local wording = SplitOptions.normalise(key, stored)
if wording ~= stored then
myConfig:setValues(OPT_SECTION, nil, { [key] = wording })
end
end
---The saved DEFAULT settings (what the main window's dropdowns start from), as engine values.
---@return table opts { columns = integer 1-9, geocode, stan, notes, media = "address"|"place" }
local function getPreferences()
return SplitOptions.fromStored(function(key)
return myConfig:getValue(OPT_SECTION, key, SplitOptions.defaultFor(key))
end)
end
-- The main window's five dropdowns (key -> iup.list), filled in by makeMainDialog. They are the
-- settings for THIS RUN, so getOptions() reads them, not the saved preferences.
local runControls = {}
---The settings for the run about to happen: the main window's dropdowns (the saved defaults if the
---window has not been built yet).
---@return table opts same shape as getPreferences()
local function getOptions()
if next(runControls) == nil then
return getPreferences()
end
return SplitOptions.fromIndexes(function(key)
return runControls[key] and runControls[key].value
end)
end
---Set the main window's dropdowns to the saved defaults (at start-up, and again after the Options
---dialog closes - the defaults may just have changed).
local function resetRunControls()
for _, key in ipairs(SplitOptions.keys) do
local lst = runControls[key]
if lst then
local stored = myConfig:getValue(OPT_SECTION, key, SplitOptions.defaultFor(key))
lst.value = tostring(SplitOptions.indexOf(key, SplitOptions.normalise(key, stored)) or 1)
end
end
end
--------------------------------------------------------------
--STATE
--------------------------------------------------------------
local recList -- the places to process (RecordList controller), built with the main dialog
local running = false -- re-entrancy guard: the progress dialog pumps events during a run
local rowsWritten = 0 -- Result Set rows added this session (gates the final display)
local dlgmain -- forward declaration: the imperative close needs the dialog handle
--------------------------------------------------------------
--FH ACCESS HELPERS
--------------------------------------------------------------
-- Fields a Place record is known to hold. Anything else is an "extra" that is reported and blocks
-- deletion of a merged place (nothing may be lost).
-- _RNOT = a link to a Research Note record; NOTE = a link to a shared Note record (moved on a best-effort basis).
local PLACE_KNOWN_TAGS = { TEXT = true, LATLONG = true, STAN = true, STAT = true, NOTE2 = true, OBJE = true, _RNOT = true, NOTE = true }
---Pointer to a named child of `parent` (null pointer when absent).
local function childPtr(parent, tag)
local p = fhNewItemPtr()
p:MoveTo(parent, "~." .. tag)
return p
end
---Clones of all direct children of `parent`.
local function childrenOf(parent)
local list = {}
local c = fhNewItemPtr()
c:MoveToFirstChildItem(parent)
while c:IsNotNull() do
list[#list + 1] = c:Clone()
c:MoveNext()
end
return list
end
---Clones of the direct children of `parent` carrying `tag`.
local function childrenWithTag(parent, tag)
local list = {}
for _, c in ipairs(childrenOf(parent)) do
if fhGetTag(c) == tag then
list[#list + 1] = c
end
end
return list
end
---Pointer to a record by id (null when it no longer exists - FH deletes a place that loses
---its last link and holds no data, so a place must be re-resolved by id after relinking).
local function resolveRecord(tag, id)
local p = fhNewItemPtr()
p:MoveToRecordById(tag, id)
return p
end
---Set an item's text, reporting success without ever raising.
---`fhSetValueAsText` returns false (leaving the text unchanged) when it is refused, e.g. a Place
---with that text already exists (compared without regard to case) - the merge signal. A nil
---return is treated as success.
---@return boolean ok
local function trySetText(item, text)
local ok, result = pcall(fhSetValueAsText, item, text)
return ok and result ~= false
end
---Delete an item or record without raising. `fhDeleteItem` returns false on failure rather than
---raising, so success needs the call not to throw AND to return true.
---@return boolean ok
local function tryDelete(item)
local ok, result = pcall(fhDeleteItem, item)
return ok and result == true
end
---A move copies an item to its destination, then deletes the source. When Family Historian refuses to
---delete the SOURCE, the item has not moved: remove the copy just made so nothing is duplicated, and
---say plainly what happened. If the copy cannot be removed either, say that the item is now on BOTH
---records (the old record also keeps it, so a merge keeps the old place and a rename keeps the data).
---@param copy userdata|nil the copy made on the destination
---@param what string wording for the item, e.g. "a note" or "the geocode"
---@param log function(msg) records a warning for the Results row
local function undoCopy(copy, what, log)
local undone = true
if copy and copy:IsNotNull() then
undone = tryDelete(copy)
end
if undone then
log(what .. " could not be moved: Family Historian would not remove it from the old record (left there)")
else
log(what .. " could not be moved: Family Historian would not remove it from the old record, or the copy from the destination - it is now on BOTH records")
end
end
---Copy an item with all its sub-items under `dstParent` (the FH "CopyChildBranch" idiom).
---Rich-text format codes (`_FMT`) are skipped (they are regenerated with the text), and a source
---template field (`_FIELD`) is created under its metafield shortcut rather than the bare tag.
---@return userdata|nil new item pointer (nil for a skipped `_FMT`). Raises on failure: call inside pcall.
local function copyBranch(src, dstParent)
local tag = fhGetTag(src)
if tag == "_FMT" then
return nil
end
local createTag = tag
if tag == "_FIELD" then
createTag = fhGetMetafieldShortcut(src)
end
local n = fhCreateItem(createTag, dstParent)
if not n or n:IsNull() then
error("could not create " .. tostring(createTag))
end
fhSetValue_Copy(n, src)
for _, c in ipairs(childrenOf(src)) do
if fhGetTag(c) ~= "_FMT" then
copyBranch(c, n)
end
end
return n
end
---Plain text of a note/rich-text field ("" on failure).
local function plainTextOf(field)
local ok, rt = pcall(fhGetValueAsRichText, field)
if ok and rt then
local ok2, t = pcall(function()
return rt:GetPlainText()
end)
if ok2 and t then
return t
end
end
return ""
end
---Add a NEW plain-text note under `owner` (a fact, a source's DATA, a source or citation...), unless
---an identical one is already there (so a re-run never duplicates). Existing notes are never touched.
---@return boolean ok
local function addPlainNote(owner, text)
for _, n in ipairs(childrenWithTag(owner, "NOTE2")) do
if plainTextOf(n) == text then
return true
end
end
local ok = pcall(function()
local n = fhCreateItem("NOTE2", owner)
if not n or n:IsNull() then
error("create failed")
end
local rt = fhNewRichText()
rt:SetText(text, false)
fhSetValueAsRichText(n, rt)
end)
return ok
end
---Append one line to a record's first note (creating the note when there is none). Used for the
---small bookkeeping lines (former geocode, geocode status). Never overwrites: the existing note
---(with its record links and citations) is read back, extended and saved.
---@return boolean ok
local function appendNoteLine(rec, line)
local notes = childrenWithTag(rec, "NOTE2")
if #notes == 0 then
return addPlainNote(rec, line)
end
local ok = pcall(function()
local rt = fhGetValueAsRichText(notes[1])
if rt:GetPlainText():find(line, 1, true) then
return -- already recorded (re-run)
end
rt:AddText("\n" .. line, false)
fhSetValueAsRichText(notes[1], rt)
end)
return ok
end
--------------------------------------------------------------
--PLACE INDEX AND NAMED LISTS (read once per run)
--------------------------------------------------------------
-- Place names are unique WITHOUT REGARD TO CASE (FH refuses to rename a place to another place's
-- name in a different case), so every merge prediction/lookup compares Split.fold() keys.
---Index every Place record: folded text -> { id, text }, so a merge can be predicted (Preview) and
---its target found (Apply). `text` is the place's current (or, in Preview, predicted) real text.
local function buildPlaceIndex()
local ix = {}
local p = fhNewItemPtr()
p:MoveToFirstRecord("_PLAC")
while p:IsNotNull() do
local text = fhGetItemText(p, "~.TEXT")
local key = Split.fold(text)
if ix[key] == nil then
ix[key] = { id = fhGetRecordId(p), text = text }
end
p:MoveNext()
end
return ix
end
---@return integer|nil id, string|nil text the place whose name matches `text` caselessly
local function indexFind(ix, text)
local hit = ix[Split.fold(text)]
if hit then
return hit.id, hit.text
end
return nil
end
local function indexSet(ix, text, id)
ix[Split.fold(text)] = { id = id, text = text }
end
local function indexRemove(ix, text, id)
local key = Split.fold(text)
if ix[key] and ix[key].id == id then
ix[key] = nil
end
end
---Which Place records sit in a named list. The plugin API can READ named lists but not change
---them, so a place in a list must never be deleted (that would silently drop the membership).
---@return table set record id -> comma-separated list names, boolean complete (false if reading failed)
local function buildNamedListSet()
local set = {}
local complete = true
local okAll = pcall(function()
for i = 1, fhGetNamedListCount() do
local okList = pcall(function()
local list = fhGetNamedListByIndex(i)
local name = list:GetName()
for j = 1, list:GetItemCount() do
local item = list:GetItem(j)
if item and item:IsNotNull() and fhGetTag(item) == "_PLAC" then
local id = fhGetRecordId(item)
set[id] = set[id] and (set[id] .. ", " .. name) or name
end
end
end)
if not okList then
complete = false
end
end
end)
return set, complete and okAll
end
--------------------------------------------------------------
--REFERENCES TO A PLACE
--------------------------------------------------------------
---Group every inbound link to place `P` by what it is and where a note for it belongs.
--- factPlac { item, fact } a fact's PLAC (the fact can take an ADDR)
--- placeOnly { item, owner } a PLAC that is not on a fact (source "events recorded"); a note
--- goes on the source's DATA, or the parent when there is none
--- secondPlace { item, fact } a fact's second place (_PLAC on e.g. emigration)
--- templateField { item, field, owner } a source-template Place field (hidden PLAC under a `_FIELD`
--- metafield): relinked through the `_FIELD`, noted on its owner
--- (the source record, or the citation)
--- existingAddr { item, addr, id } the _PLAC of an Address record that belongs to this place
--- richLink { item, field } a record link inside a note (hidden _LINK_x child of the field)
--- other { item, tag } anything unrecognised: blocks deletion and is reported
---@param P userdata Place record pointer
---@return table
local function classifyLinks(P)
local c = {
factPlac = {},
placeOnly = {},
secondPlace = {},
templateField = {},
existingAddr = {},
richLink = {},
other = {},
}
for _, item in ipairs(fhGetRecordLinks(P)) do
local tag = fhGetTag(item)
local parent = fhNewItemPtr()
parent:MoveToParentItem(item)
local ptag = parent:IsNotNull() and fhGetTag(parent) or ""
if tag == "PLAC" and parent:IsNotNull() then
if ptag == "_FIELD" then
-- A source-template Place field. The hidden PLAC cannot be reached by MoveTo, so it is
-- relinked by setting the text of the _FIELD parent.
local owner = fhNewItemPtr()
owner:MoveToParentItem(parent)
c.templateField[#c.templateField + 1] = {
item = item:Clone(),
field = parent:Clone(),
owner = owner:IsNotNull() and owner:Clone() or nil,
}
elseif fhIsFact(parent) then
c.factPlac[#c.factPlac + 1] = { item = item:Clone(), fact = parent:Clone() }
else
local owner = parent
if ptag == "EVEN" then
local gp = fhNewItemPtr()
gp:MoveToParentItem(parent)
if gp:IsNotNull() and fhGetTag(gp) == "DATA" then
owner = gp
end
end
c.placeOnly[#c.placeOnly + 1] = { item = item:Clone(), owner = owner:Clone() }
end
elseif tag == "_PLAC" and parent:IsNotNull() then
if ptag == "_ADDR" then
c.existingAddr[#c.existingAddr + 1] = { item = item:Clone(), addr = parent:Clone(), id = fhGetRecordId(parent) }
elseif fhIsFact(parent) then
c.secondPlace[#c.secondPlace + 1] = { item = item:Clone(), fact = parent:Clone() }
else
c.other[#c.other + 1] = { item = item:Clone(), tag = tag }
end
elseif tag:sub(1, 6) == "_LINK_" and parent:IsNotNull() then
c.richLink[#c.richLink + 1] = { item = item:Clone(), field = parent:Clone() }
else
c.other[#c.other + 1] = { item = item:Clone(), tag = tag }
end
end
return c
end
---Human-readable reference counts for the Results "References" column.
local function refSummary(cl)
local parts = {}
local function add(n, singular, plural)
if n > 0 then
parts[#parts + 1] = n .. " " .. (n == 1 and singular or plural)
end
end
add(#cl.factPlac, "fact", "facts")
add(#cl.placeOnly, "source place", "source places")
add(#cl.secondPlace, "second place", "second places")
add(#cl.templateField, "template field", "template fields")
add(#cl.existingAddr, "existing address", "existing addresses")
add(#cl.richLink, "note link", "note links")
add(#cl.other, "other link", "other links")
if #parts == 0 then
return "none"
end
return table.concat(parts, ", ")
end
---What a Place record holds, for the data-transfer decisions and the Preview wording.
---@return table { geo, stan, stat, notes, media, rnotes, sharedNotes, extras = { tags } }
local function describeData(P)
local d = { geo = false, stan = false, stat = false, notes = 0, media = 0, rnotes = 0, sharedNotes = 0, extras = {} }
for _, c in ipairs(childrenOf(P)) do
local tag = fhGetTag(c)
if tag == "LATLONG" then
d.geo = fhGetValueAsText(c) ~= ""
elseif tag == "STAN" then
d.stan = fhGetValueAsText(c) ~= ""
elseif tag == "STAT" then
d.stat = true
elseif tag == "NOTE2" then
d.notes = d.notes + 1
elseif tag == "OBJE" then
d.media = d.media + 1
elseif tag == "_RNOT" then
d.rnotes = d.rnotes + 1
elseif tag == "NOTE" then
d.sharedNotes = d.sharedNotes + 1
elseif not PLACE_KNOWN_TAGS[tag] then
d.extras[#d.extras + 1] = tag
end
end
return d
end
--------------------------------------------------------------
--ADDRESS RECORD HELPERS
--------------------------------------------------------------
---The Address record a fact currently points at (nil if it has none).
local function addressRecordOfFact(fact)
local a = childPtr(fact, "ADDR")
if a:IsNull() then
return nil
end
local ok, rec = pcall(fhGetValueAsLink, a)
if ok and rec and rec:IsNotNull() then
return rec
end
return nil
end
---Find the Address record with `text` (compared without regard to case, to be safe) under `placeRec`
---(nil when there is none). `except` skips one record (the one being renamed).
local function findAddressUnder(placeRec, text, except)
local want = Split.fold(text)
for _, item in ipairs(fhGetRecordLinks(placeRec)) do
if fhGetTag(item) == "_PLAC" then
local parent = fhNewItemPtr()
parent:MoveToParentItem(item)
if
parent:IsNotNull()
and fhGetTag(parent) == "_ADDR"
and Split.fold(fhGetItemText(parent, "~.TEXT")) == want
and not (except and parent:IsSame(except))
then
return parent:Clone()
end
end
end
return nil
end
---Create an Address record directly, for data that must land on the address when no fact created
---one (e.g. the place is referenced only by sources). The order matters: link the place FIRST,
---then set the text. If setting the text is refused, an address with that text already exists
---under the place: delete the half-made record and use the existing one. (Not yet proven live:
---failures are returned for the Results table, never raised.)
---@return userdata|nil addressRecord, string|nil problem
local function createAddressDirectly(placeRec, text)
local ok, a = pcall(fhCreateItem, "_ADDR")
if not ok or not a or a:IsNull() then
return nil, "could not create an Address record"
end
local okBuild, err = pcall(function()
local plac = childPtr(a, "_PLAC")
if plac:IsNull() then
plac = fhCreateItem("_PLAC", a)
end
fhSetValueAsLink(plac, placeRec)
end)
if not okBuild then
tryDelete(a)
return nil, "could not link the new address to its place (" .. tostring(err) .. ")"
end
local textItem = childPtr(a, "TEXT")
if textItem:IsNull() then
local okT, created = pcall(fhCreateItem, "TEXT", a)
textItem = okT and created or textItem
end
if textItem:IsNull() or not trySetText(textItem, text) then
local existing = findAddressUnder(placeRec, text, a)
tryDelete(a)
if existing then
return existing
end
return nil, "could not set the new address text"
end
return a
end
--------------------------------------------------------------
--MOVING DATA OFF A PLACE (or an orphaned address)
--------------------------------------------------------------
---Move one simple text-valued field (LATLONG or STAN) from `src` to `dst`. If `dst` already has a
---DIFFERENT value it keeps its own and the former value is written into a note on `dst` - so
---coordinates are never discarded. The source item is deleted only once its value is safe.
---@param tagLabel string wording for the note ("geocode", "standardised value")
---@param log function(msg) records a warning for the Results row
---@return string|nil status "moved" (now on `dst`), "same" (already there), "conflict" (the former
--- value went into `dst`'s note), "failed" (left on `src`: nothing was moved, and no copy is left
--- on `dst`), or nil when there was nothing to move
local function moveSimpleField(src, tag, dst, srcLabel, tagLabel, log)
local s = childPtr(src, tag)
if s:IsNull() then
return nil
end
local sv = fhGetValueAsText(s)
if sv == "" then
tryDelete(s) -- an empty field holds nothing
return nil
end
local d = childPtr(dst, tag)
local dv = d:IsNotNull() and fhGetValueAsText(d) or ""
if dv == sv then
if not tryDelete(s) then -- identical already: nothing to carry across
log("the " .. tagLabel .. " is the same on the destination, but Family Historian would not remove it from the old record (left there)")
end
return "same"
elseif dv == "" then
local target
local ok = pcall(function()
target = d
if target:IsNull() then
target = fhCreateItem(tag, dst)
end
if not target or target:IsNull() then
error("create failed")
end
if not pcall(fhSetValue_Copy, target, s) or fhGetValueAsText(target) ~= sv then
if not trySetText(target, sv) then
error("set failed")
end
end
end)
if ok then
if tryDelete(s) then
return "moved"
end
undoCopy(target, "the " .. tagLabel, log)
return "failed"
else
log("could not move the " .. tagLabel .. " (left on the old record)")
return "failed"
end
else
-- Conflict: the destination already has its own, different value. It wins.
if appendNoteLine(dst, 'Former ' .. tagLabel .. ' of "' .. srcLabel .. '": ' .. sv) then
if not tryDelete(s) then
-- The note line cannot be taken back out of the note safely, so say where the value is now.
log("the " .. tagLabel .. " already on the destination differs; the former one is in its note, but Family Historian would not remove it from the old record (left there as well)")
return "failed"
end
log("the " .. tagLabel .. " already on the destination differs; the former one is in its note")
return "conflict"
else
log("the " .. tagLabel .. " already on the destination differs and could not be recorded (left on the old record)")
return "failed"
end
end
end
local function moveLatLong(src, srcLabel, dst, log)
return moveSimpleField(src, "LATLONG", dst, srcLabel, "geocode", log)
end
local function moveStan(src, srcLabel, dst, log)
return moveSimpleField(src, "STAN", dst, srcLabel, "standardised value", log)
end
---Move a place's geocode status (STAT) to `dst`. An address has no STAT, so for an address the
---status value is written into its note instead and the status removed from the source (which lets
---FH geocode the town again).
---@return string|nil status "moved" (copied to a place), "noted" (written into `dst`'s note), "failed"
--- (left on `src`), or nil when there was no status to move
local function moveStat(src, srcLabel, dst, dstIsAddress, log)
local s = childPtr(src, "STAT")
if s:IsNull() then
return nil
end
local v = fhGetValueAsText(s)
local safe = true
local status, created
if dstIsAddress then
if v ~= "" then
safe = appendNoteLine(dst, 'Geocode status from former place "' .. srcLabel .. '": ' .. v)
status = "noted"
end
else
local d = childPtr(dst, "STAT")
if d:IsNull() then
safe = pcall(function()
created = fhCreateItem("STAT", dst)
fhSetValue_Copy(created, s)
end)
status = "moved"
elseif fhGetValueAsText(d) ~= v and v ~= "" then
safe = appendNoteLine(dst, 'Geocode status from former place "' .. srcLabel .. '": ' .. v)
status = "noted"
end
end
if safe then
if tryDelete(s) then
return status
end
if created then
undoCopy(created, "the geocode status", log)
return "failed"
elseif status == "noted" then
log("the geocode status could not be moved: Family Historian would not remove it from the old place (left there; its value is also in the destination's note)")
return "failed"
end
return status -- nothing was moved (empty, or the destination already had it): leaving the old one harms nothing
end
log("could not keep the geocode status (left on the old place)")
return "failed"
end
---Copy every note of `src` to `dst` as a NEW note headed with the former place's name, then delete
---the source note. The old note is deleted only after the new one is saved. Notes already on `dst`
---are never touched. Embedded citations and record links travel with the rich text; any ordinary
---(non-embedded) citation on the old note is copied across too.
---@return integer moved how many notes were moved
local function moveNotes(src, srcLabel, dst, log)
local moved = 0
for _, srcNote in ipairs(childrenWithTag(src, "NOTE2")) do
local newNote
local ok, err = pcall(function()
local rt = fhGetValueAsRichText(srcNote)
local _, _, _, cits = rt:GetText()
local out = fhNewRichText()
out:AddText('Note from former place "' .. srcLabel .. '":\n', false)
out:AddRichText(rt)
newNote = fhCreateItem("NOTE2", dst)
if not newNote or newNote:IsNull() then
error("create failed")
end
fhSetValueAsRichText(newNote, out)
-- Non-embedded citations (SOUR children not referenced from the text) would otherwise be lost.
for _, c in ipairs(childrenWithTag(srcNote, "SOUR")) do
local embedded = false
for _, cit in pairs(cits or {}) do
if cit:IsNotNull() and cit:IsSame(c) then
embedded = true
break
end
end
if not embedded then
copyBranch(c, newNote)
end
end
end)
if ok then
if tryDelete(srcNote) then
moved = moved + 1
else
undoCopy(newNote, "a note", log)
end
else
log("could not move a note (left on the old record): " .. tostring(err))
end
end
return moved
end
---Move the record-link children of `src` carrying `tag` (`_RNOT` = Research Note, `NOTE` = shared Note)
---to `dst`. A link whose target `dst` already links (same record) is NOT duplicated: the source item is
---just deleted. Otherwise a new link item is created on `dst` and pointed at the same record; the source
---item is deleted only once the destination link is confirmed. A refusal (e.g. `dst` cannot hold the
---link) leaves the source item in place and is logged, so the old place is kept.
---@return integer moved, integer already links created on `dst` / links `dst` already had
local function moveLinkItems(src, dst, tag, what, log)
local moved, already = 0, 0
for _, srcItem in ipairs(childrenWithTag(src, tag)) do
local okT, target = pcall(fhGetValueAsLink, srcItem)
if not okT or not target or target:IsNull() then
log("a " .. what .. " link has no target record (left on the old record)")
else
local present = false
for _, dstItem in ipairs(childrenWithTag(dst, tag)) do
local okD, dt = pcall(fhGetValueAsLink, dstItem)
if okD and dt and dt:IsNotNull() and dt:IsSame(target) then
present = true
break
end
end
if present then
if tryDelete(srcItem) then
already = already + 1
else
log("a " .. what .. " link is already on the destination, but Family Historian would not remove it from the old record (left there)")
end
else
local created
local ok = pcall(function()
created = fhCreateItem(tag, dst)
if not created or created:IsNull() then
error("the destination does not accept it")
end
fhSetValueAsLink(created, target)
end)
if ok then
if tryDelete(srcItem) then
moved = moved + 1
else
undoCopy(created, "a " .. what .. " link", log)
end
else
if created and created:IsNotNull() then
tryDelete(created) -- never leave a half-made empty link behind
end
log("could not move a " .. what .. " link (left on the old record)")
end
end
end
end
return moved, already
end
---Move a record's Research Note links (and any shared-Note links) to `dst`, deduplicated; they follow
---the same destination as the record's notes. Reports into the Results row via `log`, in the past tense.
---@param where string wording for the destination ("the address" / "the place")
---@return integer moved how many links were moved (both kinds)
local function moveNoteLinks(src, dst, where, log)
local total = 0
for _, kind in ipairs({ { "_RNOT", "research note" }, { "NOTE", "shared note" } }) do
local moved, already = moveLinkItems(src, dst, kind[1], kind[2], log)
if moved > 0 then
log(moved .. " " .. kind[2] .. " link" .. (moved == 1 and "" or "s") .. " moved to " .. where)
end
if already > 0 then
log(already .. " " .. kind[2] .. " link" .. (already == 1 and "" or "s") .. " already on " .. where .. " (not duplicated)")
end
total = total + moved
end
return total
end
---Copy every media link of `src` (with all sub-fields such as captions) to `dst`, deleting each
---source link only after its copy succeeded.
---@return integer moved how many media links were moved
local function moveMedia(src, dst, log)
local moved = 0
for _, srcMedia in ipairs(childrenWithTag(src, "OBJE")) do
local ok, copy = pcall(copyBranch, srcMedia, dst)
if ok then
if tryDelete(srcMedia) then
moved = moved + 1
else
undoCopy(copy, "a media link", log)
end
else
log("could not move a media link (left on the old record): " .. tostring(copy))
end
end
return moved
end
---Retarget the rich-text record links that point at `oldRec`. FH cannot retarget a link by setting
---it, so the note's text AND its record-link table are rewritten:
--- newRec given, already in the table -> renumber the old links onto it and drop the old entry;
--- newRec given, not in the table -> swap the table entry;
--- newRec nil -> table untouched (only the text is edited).
---When `fixDisplay` is given, AUTO links to the old record are converted to fixed-text links showing
---that wording, so the note still reads as it did (an auto link would show the new target's name).
---@param links table[] richLink entries from classifyLinks
---@return integer fixedCount notes rewritten, integer failedCount
local function retargetRichLinks(links, oldRec, newRec, fixDisplay)
local done = {} -- each note field is rewritten once, however many links it holds
local fixed, failed = 0, 0
for _, rl in ipairs(links) do
local field = rl.field
local seen = false
for _, f in ipairs(done) do
if f:IsSame(field) then
seen = true
break
end
end
if not seen then
done[#done + 1] = field
local ok, err = pcall(function()
local rt = fhGetValueAsRichText(field)
local txt, bRich, recs, cits = rt:GetText()
if not recs then
return false
end
local idxOld, idxNew
for i, r in ipairs(recs) do
if r:IsNotNull() then
if r:IsSame(oldRec) then
idxOld = idxOld or i
elseif newRec and r:IsSame(newRec) then
idxNew = idxNew or i
end
end
end
if not idxOld then
return false
end
if fixDisplay then
txt = RichLinks.fixAutoText(txt, idxOld, fixDisplay)
end
if newRec then
if idxNew then
txt = RichLinks.renumber(txt, idxOld, idxNew)
table.remove(recs, idxOld)
else
recs[idxOld] = newRec:Clone()
end
end
local out = fhNewRichText()
out:SetText(txt, bRich, false, recs, cits)
local saved = fhSetValueAsRichText(field, out)
if saved == false then
error("the note could not be saved")
end
return true
end)
if ok and err then
fixed = fixed + 1
elseif not ok then
failed = failed + 1
end
end
end
return fixed, failed
end
---How many source-template fields link to Address record `A` (link items whose PARENT is a `_FIELD`).
---@return integer
local function templateLinkCount(A)
local n = 0
for _, item in ipairs(fhGetRecordLinks(A)) do
local parent = fhNewItemPtr()
parent:MoveToParentItem(item)
if parent:IsNotNull() and fhGetTag(parent) == "_FIELD" then
n = n + 1
end
end
return n
end
---Merge Address record `A` into the existing address `N` (the same place): optionally relink every
---fact that uses A (`relinkFacts`: fact ADDR items are set to N's text, which FH resolves to N), move
---A's geocode, standardised value, notes and media across under the usual conflict rules, retarget
---rich-text links to A at N, and delete A once nothing refers to it and it holds nothing.
---A source-template field that links to A (a link item whose PARENT is a `_FIELD`; by design FH keeps
---source-template Address fields as plain text, so this is a defensive branch that should not fire) is relinked like the place case - by setting the
---text of the `_FIELD` - whether or not `relinkFacts` is set (FH carries facts across by itself, never
---template fields), and counted in `row.tplAddr`. Any other kind of link keeps the record.
---@return boolean deleted true when A is gone
local function mergeAddressInto(A, N, log, row, relinkFacts)
local aid = fhGetRecordId(A)
local aText, nText = fhGetItemText(A, "~.TEXT"), fhGetItemText(N, "~.TEXT")
for _, item in ipairs(fhGetRecordLinks(A)) do
local parent = fhNewItemPtr()
parent:MoveToParentItem(item)
if parent:IsNotNull() and fhGetTag(parent) == "_FIELD" then
if trySetText(parent, nText) then
row.tplAddr = row.tplAddr + 1
else
log('could not relink a source-template address field from "' .. aText .. '" to "' .. nText .. '"')
end
elseif relinkFacts and fhGetTag(item) == "ADDR" and not trySetText(item, nText) then
log('could not relink a fact from address "' .. aText .. '" to "' .. nText .. '"')
end
end
local A2 = resolveRecord("_ADDR", aid)
if A2:IsNull() then
return true -- FH already removed the emptied address
end
moveLatLong(A2, aText, N, log)
moveStan(A2, aText, N, log)
moveNotes(A2, aText, N, log)
moveNoteLinks(A2, N, "the address", log)
moveMedia(A2, N, log)
local links = classifyLinks(A2)
local nfix, nfail = retargetRichLinks(links.richLink, A2, N, (aText ~= nText) and aText or nil)
row.richFixed = row.richFixed + nfix
if nfail > 0 then
log(nfail .. " note link(s) on an old address could not be retargeted")
end
local A3 = resolveRecord("_ADDR", aid)
if A3:IsNull() then
return true
end
if #fhGetRecordLinks(A3) == 0 and #childrenOf(A3) <= 2 then
-- Only its TEXT and _PLAC remain: its data is all carried over.
if tryDelete(A3) then
return true
end
log('old address "' .. aText .. '" was kept (Family Historian would not delete it)')
return false
end
log('old address "' .. aText .. '" was kept (still in use or holds data)')
return false
end
--------------------------------------------------------------
--THE ENGINE (Preview and Apply share runPlan)
--------------------------------------------------------------
---Where each kind of the old place's data goes: true = onto the new address (a split with the option
---set to "Move to address"); otherwise it stays on the place, or follows a merge to the existing place.
---EXCEPTION: when a SPLIT place merges into an existing place the old place is deleted, so "Leave on
---place" cannot apply to its geocode (with its status) and standardised value - street-level data must
---not land on the merge-target town - and they go to the address whatever the options say
---(`geoForced` / `stanForced` flag that the option was overridden, for the Preview wording). Notes
---and media follow their options unchanged ("Move to place" in a merge means the existing place). A
---tidy merge has no address, so everything follows the merge to the existing place as before.
---@param merging boolean the old place merges into an existing place
local function destinations(opts, isSplit, merging)
local forced = isSplit and merging == true
return {
geo = isSplit and (forced or opts.geocode == "address"),
stan = isSplit and (forced or opts.stan == "address"),
notes = isSplit and opts.notes == "address",
media = isSplit and opts.media == "address",
geoForced = forced and opts.geocode ~= "address",
stanForced = forced and opts.stan ~= "address",
}
end
-- Said in the Preview after a data item whose "Leave on place" option a merge overrides.
local MERGE_OVERRIDE = " (the old place merges into an existing place)"
---Describe, in the FUTURE tense, what Apply will do with the old place's data (Preview wording).
local function dataWording(d, dest, merging)
local parts = {}
local function part(has, what, toAddress, forced)
if not has then
return
end
if toAddress then
parts[#parts + 1] = what .. " will move to the address" .. (forced and MERGE_OVERRIDE or "")
elseif merging then
parts[#parts + 1] = what .. " will move to the merged place"
else
parts[#parts + 1] = what .. " will stay on the place"
end
end
part(d.geo or d.stat, "the geocode", dest.geo, dest.geoForced)
if dest.geo and d.stat then
parts[#parts + 1] = "the geocode status will be recorded in the address note"
end
part(d.stan, "the standardised value", dest.stan, dest.stanForced)
part(d.notes > 0, d.notes .. (d.notes == 1 and " note" or " notes"), dest.notes)
part(d.rnotes > 0, d.rnotes .. (d.rnotes == 1 and " research note link" or " research note links"), dest.notes)
part(d.sharedNotes > 0, d.sharedNotes .. (d.sharedNotes == 1 and " shared note link" or " shared note links"), dest.notes)
part(d.media > 0, d.media .. " media", dest.media)
return parts
end
---Why a merged old place can or cannot be deleted. Returns a list of blockers (empty = deletable).
local function deletionBlockers(ctx, P, id)
local blockers = {}
local nlinks = #fhGetRecordLinks(P)
if nlinks > 0 then
blockers[#blockers + 1] = nlinks .. (nlinks == 1 and " link remains" or " links remain")
end
local extras = {}
for _, c in ipairs(childrenOf(P)) do
local tag = fhGetTag(c)
if tag ~= "TEXT" then
extras[#extras + 1] = tag
end
end
if #extras > 0 then
blockers[#blockers + 1] = "still holds " .. table.concat(extras, ", ")
end
if ctx.named[id] then
blockers[#blockers + 1] = "in named list " .. ctx.named[id]
end
if not ctx.namedComplete then
blockers[#blockers + 1] = "named lists could not be read"
end
return blockers
end
---PREVIEW one place: work out exactly what Apply WILL do, writing nothing. Every note is in the
---future tense: nothing in a Preview row may read as if it had been done.
---@return nil (fills `row`)
local function previewOne(ctx, e, P, cl, row)
local parsed, opts = e.parsed, ctx.opts
local isSplit = parsed.kind == "split"
local addr = isSplit and parsed.addr or nil
local note = function(msg)
row.notes[#row.notes + 1] = msg
end
local targetId, targetText = indexFind(ctx.index, parsed.place) -- caseless, like FH
local merging = targetId ~= nil and targetId ~= e.id
local kindWord = isSplit and "Split" or "Tidy"
if merging then
row.action = kindWord .. " - will merge into existing place"
row.newPlaceId = targetId
-- Say the target's text as the index predicts it: an earlier place in this run (a tidy rename)
-- may become the target, and Preview has not renamed it, so the record's current text would be old.
local msg = 'will merge into existing place "' .. targetText .. '"'
if targetText ~= parsed.place then
msg = msg .. " (differs only in case)"
end
note(msg)
row.newPlaceText = targetText
else
row.action = kindWord .. " - will rename place"
row.newPlaceId = e.id
row.newPlaceText = parsed.place
end
row.addr = addr or ""
if isSplit then
-- D4: existing addresses that will have the split-off address prefixed - or, when that would
-- duplicate another address under this place, will be merged into it.
-- Walks the addresses in Apply's order and keeps the fold -> id table in step with what Apply
-- will have done by then (a prefixed address takes its new text, a merged one disappears).
local byFold = {}
for _, ea in ipairs(cl.existingAddr) do
byFold[Split.fold(fhGetItemText(ea.addr, "~.TEXT"))] = ea.id
end
local prefixed = 0
local tplAddr = 0
for _, ea in ipairs(cl.existingAddr) do
local oldText = fhGetItemText(ea.addr, "~.TEXT")
local newText = Split.prefixAddress(addr, oldText)
if newText then
local otherId = byFold[Split.fold(newText)]
byFold[Split.fold(oldText)] = nil
if otherId and otherId ~= ea.id then
note('existing address "' .. oldText .. '" will be merged into the existing address "' .. newText .. '"')
tplAddr = tplAddr + templateLinkCount(ea.addr)
else
byFold[Split.fold(newText)] = ea.id
prefixed = prefixed + 1
end
end
end
if tplAddr > 0 then
note(tplAddr .. " source-template address field(s) will be re-pointed")
end
if prefixed > 0 then
note(prefixed .. (prefixed == 1 and " existing address will be prefixed" or " existing addresses will be prefixed"))
end
-- New addresses on facts that have none.
local fresh = 0
for _, f in ipairs(cl.factPlac) do
if childPtr(f.fact, "ADDR"):IsNull() then
fresh = fresh + 1
end
end
if fresh > 0 then
note("an address will be added to " .. fresh .. (fresh == 1 and " fact" or " facts"))
end
-- D3: notes on fields that cannot hold an address.
local d3 = #cl.placeOnly + #cl.secondPlace + #cl.templateField
if d3 > 0 then
note(d3 .. (d3 == 1 and " 'Address:' note will be added" or " 'Address:' notes will be added"))
end
end
-- Source-template place fields: their footnotes are built from the place text.
if #cl.templateField > 0 then
local n = #cl.templateField
if isSplit then
note(n .. " source-template place field(s) will change; footnotes built from them will no longer include the address")
else
note(n .. " source-template place field(s) will be updated")
end
end
-- Data held on the old place.
local d = describeData(P)
local dest = destinations(opts, isSplit, merging)
for _, w in ipairs(dataWording(d, dest, merging)) do
note(w)
end
if merging and d.geo and not dest.geo then
-- Predict a geocode conflict only when the destination is the merge target itself.
local T = resolveRecord("_PLAC", targetId)
if T:IsNotNull() then
local tv = childPtr(T, "LATLONG")
local sv = childPtr(P, "LATLONG")
if tv:IsNotNull() and sv:IsNotNull() and fhGetValueAsText(tv) ~= fhGetValueAsText(sv) then
note("geocode conflict: the existing place will keep its own coordinates; the former ones will go in a note")
end
end
end
if #d.extras > 0 then
note("holds unrecognised field(s) " .. table.concat(d.extras, ", ") .. ": old place will be KEPT")
end
-- Rich-text links.
if #cl.richLink > 0 then
if isSplit then
note(#cl.richLink .. " note link(s) will be retargeted and fixed to the old wording")
elseif merging then
note(#cl.richLink .. " note link(s) will be retargeted")
end
end
-- Deletion / keep prediction (merge only).
if merging then
local keep = {}
if #cl.other > 0 then
keep[#keep + 1] = #cl.other .. " unrecognised link(s)"
end
if #d.extras > 0 then
keep[#keep + 1] = "unrecognised field(s)"
end
if ctx.named[e.id] then
keep[#keep + 1] = "in named list " .. ctx.named[e.id]
end
if not ctx.namedComplete then
keep[#keep + 1] = "named lists could not be read"
end
if #keep == 0 then
note("old place will be deleted")
indexRemove(ctx.index, e.text, e.id)
else
row.keptOld = true -- shown in the Action cell too (see addRow)
note("old place will be KEPT: " .. table.concat(keep, "; "))
end
else
indexRemove(ctx.index, e.text, e.id)
indexSet(ctx.index, parsed.place, e.id)
end
if #cl.other > 0 then
note(#cl.other .. " link(s) of an unrecognised kind will not be changed")
end
end
---APPLY one place. Steps follow the agreed algorithm:
--- 1 prefix existing addresses (D4), merging duplicates 2 rename, or merge into the existing place
--- 3 add the address to facts without one 4 notes on fields that cannot hold an address (D3)
--- 5 move the old place's data 6 retarget note links
--- 7 delete the old place if safe (merge only)
---Every step reports into `row`; a refused step is a warning, not an abort, so nothing already
---moved is left half-explained. Raises only for a programming error (caught per place).
local function applyOne(ctx, e, P, cl, row)
local parsed, opts = e.parsed, ctx.opts
local isSplit = parsed.kind == "split"
local addr = isSplit and parsed.addr or nil
local note = function(msg)
row.notes[#row.notes + 1] = msg
end
row.addr = addr or ""
row.newPlaceText = parsed.place
-- Capture before anything changes: each fact's current address (if any).
local factInfo = {}
for i, f in ipairs(cl.factPlac) do
local rec = addressRecordOfFact(f.fact)
factInfo[i] = {
fact = f.fact,
hadAddr = childPtr(f.fact, "ADDR"):IsNotNull(),
oldAddrId = rec and fhGetRecordId(rec) or nil,
}
end
-- 1. D4: prefix the split-off address onto addresses that already exist under this place. Done
-- BEFORE any relinking so a merge carries the prefixed text across. FH does NOT refuse to rename
-- an Address to the text of a sibling under the same place (proven live 7 Oct 2026 - it accepted
-- it and left two identical addresses), so a sibling with the prefixed text (caselessly) is looked
-- for FIRST and the two are merged instead of renaming.
if isSplit then
for _, ea in ipairs(cl.existingAddr) do
local A = resolveRecord("_ADDR", ea.id)
if A:IsNotNull() then
local oldText = fhGetItemText(A, "~.TEXT")
local newText = Split.prefixAddress(addr, oldText)
if newText then
local other = findAddressUnder(P, newText, A)
if not other then
if trySetText(childPtr(A, "TEXT"), newText) and fhGetItemText(A, "~.TEXT") == newText then
row.prefixed = row.prefixed + 1
else
-- Belt and braces: a refusal can still mean a same-text sibling exists.
other = findAddressUnder(P, newText, A)
if not other then
note('could not prefix existing address "' .. oldText .. '" (left unchanged)')
end
end
end
if other then
-- Counted and reported as merged only when the old address record really went;
-- otherwise mergeAddressInto has already said why it was kept.
local otherText = fhGetItemText(other, "~.TEXT")
if mergeAddressInto(A, other, note, row, true) then
note('existing address "' .. oldText .. '" merged into "' .. otherText .. '"')
row.addrMerged = row.addrMerged + 1
end
end
end
end
end
end
-- 2. Rename, or (when FH refuses because the text exists) merge into the existing place.
local mode, T
local renamed = trySetText(childPtr(P, "TEXT"), parsed.place) and fhGetItemText(P, "~.TEXT") == parsed.place
if renamed then
mode = "rename"
indexRemove(ctx.index, e.text, e.id)
indexSet(ctx.index, parsed.place, e.id)
row.action = (isSplit and "Split" or "Tidy") .. " - renamed place"
row.newPlaceId = e.id
else
mode = "merge"
local targetId = indexFind(ctx.index, parsed.place) -- caseless, like FH
if not targetId or targetId == e.id then
row.action = "Error"
row.newPlaceId = e.id
note("the new place name was refused but no existing place with that text was found; nothing further was changed")
return
end
T = resolveRecord("_PLAC", targetId)
if T:IsNull() then
row.action = "Error"
row.newPlaceId = e.id
note("the existing place to merge into could not be found; nothing further was changed")
return
end
local targetText = fhGetItemText(T, "~.TEXT")
row.action = (isSplit and "Split" or "Tidy") .. " - merged into existing place"
row.newPlaceId = targetId
row.newPlaceText = targetText
-- Point every reference at the existing place by setting its text. A source-template place
-- field is relinked through its `_FIELD` parent.
local failedLinks = 0
for _, group in ipairs({ cl.factPlac, cl.placeOnly, cl.secondPlace, cl.templateField }) do
for _, ref in ipairs(group) do
if not trySetText(ref.field or ref.item, targetText) then
failedLinks = failedLinks + 1
end
end
end
if failedLinks > 0 then
note(failedLinks .. " reference(s) could not be moved to the existing place")
end
P = resolveRecord("_PLAC", e.id) -- FH may already have removed an emptied old place
end
local finalPlace = (mode == "rename") and P or T
-- Existing addresses after a merge: FH carries each fact's address to the target place by
-- itself; whatever data the OLD address record holds must follow it to the address the facts now
-- use, and the old record goes once nothing refers to it.
if mode == "merge" then
local targetText = fhGetItemText(T, "~.TEXT")
for _, ea in ipairs(cl.existingAddr) do
local A = resolveRecord("_ADDR", ea.id)
if A:IsNotNull() then
local N
for _, fi in ipairs(factInfo) do
if fi.oldAddrId == ea.id then
local rec = addressRecordOfFact(fi.fact)
if rec and fhGetRecordId(rec) ~= ea.id then
N = rec
break
end
end
end
if N then
mergeAddressInto(A, N, note, row, false)
else
-- No fact uses this address: move the address itself under the existing place.
if not trySetText(ea.item, targetText) then
note('address "' .. fhGetItemText(A, "~.TEXT") .. '" could not be moved to the existing place')
end
end
end
end
end
-- 3. Add the address to facts that had none.
local destAddr
if isSplit then
for _, fi in ipairs(factInfo) do
if not fi.hadAddr then
local ok = pcall(function()
local a = fhCreateItem("ADDR", fi.fact, true)
if not a or a:IsNull() or not trySetText(a, addr) then
error("refused")
end
end)
if ok then
row.addrAdded = row.addrAdded + 1
destAddr = destAddr or addressRecordOfFact(fi.fact)
else
note("could not add the address to one fact")
end
end
end
end
-- 4. D3: fields that cannot hold an address keep it in a note instead. A source-template place
-- field's note goes on the field's owner: the source record, or the citation.
if isSplit then
local text = Split.noteText(addr)
for _, ref in ipairs(cl.placeOnly) do
if addPlainNote(ref.owner, text) then
row.d3 = row.d3 + 1
else
note("could not add an address note to a source")
end
end
for _, ref in ipairs(cl.secondPlace) do
if addPlainNote(ref.fact, text) then
row.d3 = row.d3 + 1
else
note("could not add an address note to a fact")
end
end
for _, ref in ipairs(cl.templateField) do
if ref.owner and addPlainNote(ref.owner, text) then
row.d3 = row.d3 + 1
else
note("could not add an address note to a source or citation")
end
end
end
-- 5. Move the old place's data to where the options say it goes. With "Leave on place" nothing is
-- touched on a renamed place; on a merged place the data follows to the existing place.
local hasOld = P and P:IsNotNull()
if hasOld then
local d = describeData(P)
local srcLabel = e.text
local log = function(m)
note(m)
end
local merged = mode == "merge"
local dest = destinations(opts, isSplit, merged)
-- Say what moved, in the past tense, for the Results row.
local function movedCount(count, singular, plural, where)
if count > 0 then
note(count .. " " .. (count == 1 and singular or plural) .. " moved to " .. where)
end
end
-- The address is created only when data actually has to land on it.
if
(dest.geo and (d.geo or d.stat))
or (dest.stan and d.stan)
or (dest.notes and (d.notes > 0 or d.rnotes > 0 or d.sharedNotes > 0))
or (dest.media and d.media > 0)
then
destAddr = destAddr or findAddressUnder(finalPlace, addr)
if not destAddr then
local created, problem = createAddressDirectly(finalPlace, addr)
destAddr = created
if problem then
note(problem .. (merged and ": the data goes to the existing place instead" or ": the data stays on the place"))
end
end
end
-- Geocode (LATLONG, and STAT whose value goes into the address note). A merged SPLIT place sends
-- these to the address whatever the option says (see destinations()); only when no address could
-- be had do they follow the merge to the existing place, as for a tidy merge.
if dest.geo and destAddr then
if moveLatLong(P, srcLabel, destAddr, log) == "moved" then
note("geocode moved to the address")
end
if moveStat(P, srcLabel, destAddr, true, log) == "noted" then
note("geocode status recorded in the address note")
end
elseif merged then
if moveLatLong(P, srcLabel, finalPlace, log) == "moved" then
note("geocode moved to the place")
end
local st = moveStat(P, srcLabel, finalPlace, false, log)
if st == "moved" then
note("geocode status moved to the place")
elseif st == "noted" then
note("geocode status recorded in the place's note")
end
end
-- Standardised value.
if dest.stan and destAddr then
if moveStan(P, srcLabel, destAddr, log) == "moved" then
note("standardised value moved to the address")
end
elseif merged then
if moveStan(P, srcLabel, finalPlace, log) == "moved" then
note("standardised value moved to the place")
end
end
-- Notes.
if dest.notes and destAddr then
movedCount(moveNotes(P, srcLabel, destAddr, log), "note", "notes", "the address")
elseif merged then
movedCount(moveNotes(P, srcLabel, finalPlace, log), "note", "notes", "the place")
end
-- Research Note (and shared Note) links follow the notes. They report themselves.
if dest.notes and destAddr then
moveNoteLinks(P, destAddr, "the address", log)
elseif merged then
moveNoteLinks(P, finalPlace, "the place", log)
end
-- Media.
if dest.media and destAddr then
movedCount(moveMedia(P, destAddr, log), "media link", "media links", "the address")
elseif merged then
movedCount(moveMedia(P, finalPlace, log), "media link", "media links", "the place")
end
-- Anything else on a merged place cannot be placed safely: copy it across for review but keep
-- the old place (see step 7), and say so.
if merged and #d.extras > 0 then
for _, c in ipairs(childrenOf(P)) do
if not PLACE_KNOWN_TAGS[fhGetTag(c)] then
pcall(copyBranch, c, finalPlace)
end
end
row.keptOld = true
note("unrecognised field(s) " .. table.concat(d.extras, ", ") .. " copied to the existing place; old place kept for review")
end
end
-- 6. Rich-text links in notes.
if #cl.richLink > 0 and P and P:IsNotNull() then
local newRec, fixText
if isSplit then
-- A split place's links read as the OLD wording and land on the address when there is one.
destAddr = destAddr or findAddressUnder(finalPlace, addr)
newRec = destAddr
fixText = e.text
if not newRec and mode == "merge" then
newRec = finalPlace
end
elseif mode == "merge" then
newRec = finalPlace -- tidy merge: links follow the place and stay automatic
end
if newRec or fixText then
local nfix, nfail = retargetRichLinks(cl.richLink, P, newRec, fixText)
row.richFixed = row.richFixed + nfix
if nfail > 0 then
note(nfail .. " note link(s) could not be retargeted")
end
end
end
-- 7. Delete the old place, merge only, and only when nothing refers to it and it holds nothing.
if mode == "merge" then
local P2 = resolveRecord("_PLAC", e.id)
if P2:IsNull() then
indexRemove(ctx.index, e.text, e.id)
row.removed = true
note("old place removed")
else
local blockers = deletionBlockers(ctx, P2, e.id)
if #blockers == 0 then
-- fhDeleteItem reports failure by returning false; confirm the record is really gone.
if tryDelete(P2) and resolveRecord("_PLAC", e.id):IsNull() then
indexRemove(ctx.index, e.text, e.id)
row.removed = true
note("old place deleted")
else
row.keptOld = true
note("old place KEPT: Family Historian would not delete it")
end
else
row.keptOld = true
note("old place KEPT: " .. table.concat(blockers, "; "))
end
end
end
-- The Address record used for this place (the Result Set's Address cell links to it): the address
-- made or found for the split-off text, else the first fact's address.
if isSplit then
local rec = destAddr or findAddressUnder(finalPlace, addr)
if not rec then
for _, fi in ipairs(factInfo) do
rec = addressRecordOfFact(fi.fact)
if rec then
break
end
end
end
if rec and rec:IsNotNull() then
row.addrId = fhGetRecordId(rec)
end
end
-- A plain summary of what was written.
local did = {}
if row.prefixed > 0 then
did[#did + 1] = row.prefixed .. " existing address(es) prefixed"
end
if row.addrMerged > 0 then
did[#did + 1] = row.addrMerged .. " duplicate address(es) merged"
end
if row.addrAdded > 0 then
did[#did + 1] = "address added to " .. row.addrAdded .. " fact(s)"
end
if row.d3 > 0 then
did[#did + 1] = row.d3 .. " address note(s) added"
end
if row.tplAddr > 0 then
did[#did + 1] = row.tplAddr .. " source-template address field(s) re-pointed"
end
if row.richFixed > 0 then
did[#did + 1] = row.richFixed .. " note(s) with links retargeted"
end
for i = #did, 1, -1 do
table.insert(row.notes, 1, did[i])
end
end
---Run Preview or Apply over `places`, writing one Result Set row per place.
---@param places userdata[] Place record pointers to process
---@param opts table getOptions()
---@param dryRun boolean true = Preview (no writes)
local function runPlan(places, opts, dryRun)
-- Parse every place first: the majority column count needs them all.
local entries, counts = {}, {}
for _, p in ipairs(places) do
local text = fhGetItemText(p, "~.TEXT")
local parsed = Split.split(text, opts.columns)
entries[#entries + 1] = { id = fhGetRecordId(p), text = text, parsed = parsed, kind = parsed.kind }
counts[#counts + 1] = parsed.columns
end
local majority = Split.majorityColumns(counts)
-- Tidy places go first so the canonical town records keep their identity and geocode when the
-- split places then merge into them.
entries = Split.orderPlan(entries)
local named, namedComplete = buildNamedListSet()
local ctx = {
opts = opts,
dryRun = dryRun,
index = buildPlaceIndex(),
named = named,
namedComplete = namedComplete,
}
-- Result Set layout. Apply links the new place and the address used (record links); Preview shows
-- their predicted text.
myResults.Title(dryRun and "Split Addresses from Places - Preview" or "Split Addresses from Places - Applied")
myResults.Headings({ "Old Place", "Old Place Id", "Action", "New Place", "Address", "Country", "References", "Notes / Warnings" })
local linkType = dryRun and "text" or "item"
myResults.Types({ "text", "integer", "text", linkType, linkType, "text", "text", "text" })
myResults.Visibility({ "show", "show", "show", "show", "show", "show", "show", "show" })
myResults.Sort({ 1, 0, 0, 0, 0, 0, 0, 0 })
myResults.Width({ 320, 70, 220, 280, 240, 100, 200, 520 })
myResults.NoResults(nil) -- an idle close is silent: no rows, no message
local function addRow(row)
local placeCell, addrCell
if dryRun then
placeCell = row.newPlaceText or row.old
addrCell = row.addr or ""
else
local rec = resolveRecord("_PLAC", row.newPlaceId)
placeCell = rec:IsNotNull() and rec or fhNewItemPtr()
-- A null pointer when no address was used for this place.
local a = row.addrId and resolveRecord("_ADDR", row.addrId) or fhNewItemPtr()
addrCell = a:IsNotNull() and a or fhNewItemPtr()
end
-- A merge that leaves the old place in place says so in the Action cell as well (the reason is in
-- Notes / Warnings, which can be cut off). Preview stays in the future tense.
local action = row.action
if row.keptOld then
action = action .. (dryRun and "; old place will be KEPT" or "; old place KEPT")
end
myResults.Update({
row.old,
row.oldId or 0, -- every row has it; the integer column needs a number, never nil (Update stops at a nil)
action,
placeCell,
addrCell,
row.country or "",
row.refs or "",
table.concat(row.notes, "; "),
})
rowsWritten = rowsWritten + 1
end
local prog = Progress.new(#entries, 5, 20, dlgmain, dryRun and "Previewing..." or "Splitting addresses...")
local cancelled = false
for i, e in ipairs(entries) do
prog:update(e.text)
if prog:isCancelled() then
cancelled = true
-- Rows for everything not reached, so nothing is silent.
for j = i, #entries do
local r = entries[j]
addRow({
old = r.text,
oldId = r.id,
action = "Not processed",
newPlaceId = r.id,
country = r.parsed.country,
notes = { "cancelled before this place was reached" },
})
end
break
end
local row = {
old = e.text,
oldId = e.id, -- the old place's record id, before anything is renamed or merged
action = "",
newPlaceId = e.id,
country = e.parsed.country,
refs = "",
notes = {},
prefixed = 0,
addrMerged = 0,
addrAdded = 0,
d3 = 0,
richFixed = 0,
tplAddr = 0,
}
local ok, err = pcall(function()
local P = resolveRecord("_PLAC", e.id)
if P:IsNull() then
row.action = "Skipped"
row.notes[#row.notes + 1] = "the place no longer exists"
return
end
local warning = Split.checkWarning(e.parsed.columns, majority)
if e.kind == "skip" then
row.action = "Skipped"
row.notes[#row.notes + 1] = e.parsed.reason
if warning then
row.notes[#row.notes + 1] = warning
end
return
end
local cl = classifyLinks(P)
row.refs = refSummary(cl)
if warning then
row.notes[#row.notes + 1] = warning
end
if dryRun then
previewOne(ctx, e, P, cl, row)
else
applyOne(ctx, e, P, cl, row)
end
end)
if not ok then
-- One place failing must not abort the run: record it and carry on.
row.action = "Error"
row.keptOld = nil
row.notes[#row.notes + 1] = "unexpected error: " .. tostring(err)
end
addRow(row)
end
prog:finish()
return cancelled
end
--------------------------------------------------------------
--MAIN DIALOG
--------------------------------------------------------------
-- The host swallows a MENU callback's returned iup.CLOSE under iup.MainLoop (the documented
-- menu-action trap; button actions and the title-bar X are honoured), and makeButton DISCARDS a
-- plain callback's return unless close=true. So File > Exit and the Preview/Apply "and Exit" paths
-- close IMPERATIVELY: fire the tracked close_cb ourselves (Config's window position/size save -
-- exactly what the X triggers), then hide the dialog. Hiding the last visible dialog ends
-- iup.MainLoop, so the EXECUTE tail (destroyAllDialogs, results display) runs as after the X.
local function closeMainDialog()
if dlgmain and dlgmain.close_cb then
dlgmain.close_cb(dlgmain)
end
if dlgmain then
dlgmain:hide()
end
return iup.DEFAULT
end
---The work behind "Preview and Exit" / "Apply and Exit". Returns true when a run happened (the
---dialog then closes) and false at a fixable gate (nothing to process, confirmation declined),
---where the window stays open for fix-and-retry.
---@param dryRun boolean
---@return boolean ran
local function runAction(dryRun)
if running then
return false
end
local places = recList.pointers()
if #places == 0 then
MessageBox(
"warning",
"There are no places in the list. Choose some places first with Select Places.\n\n"
.. "To process every place, select them all in Family Historian's picker.",
"OK"
)
return false
end
local opts = getOptions()
if not dryRun then
local msg = string.format(
"About to split addresses from %d place%s, changing the places and everything that refers to them "
.. "(facts, sources, notes and addresses).\n\n"
.. "Please make sure you have a backup of your project first. Use Preview to see exactly what will "
.. "happen, and Undo Plugin Updates afterwards to revert the whole run.\n\nContinue?",
#places,
#places == 1 and "" or "s"
)
if MessageBox("question", msg, "YESNO") ~= "Yes" then
return false
end
end
-- Remember this selection so a Preview followed by an Apply (or a re-run) needs no re-selecting.
recList.save()
running = true
local ok, err = pcall(runPlan, places, opts, dryRun)
running = false
fhUpdateDisplay()
if not ok then
MessageBox("error", "The run stopped unexpectedly: " .. tostring(err), "OK")
-- If some places had already been handled the Result Set records them, so close and show it.
return rowsWritten > 0
end
return true
end
---Wording for the Places label.
local function placesLabelText(count)
return string.format("Places to process (%d):", count)
end
---Build the main window: the places list, the five options, and the action buttons.
---@return userdata dialog
local function makeMainDialog()
local lblPlaces = makeLongLabel({ title = placesLabelText(0), name = "cstPlacesLabel" })
-- The reusable record list (Select / Remove / Clear / Previous Selection), starting from the places
-- selected in Family Historian. An empty list is allowed; running with it is a fixable gate.
recList = RecordList.new({
tag = "_PLAC",
noun = "place",
nouns = "places",
name = "cstPlacesList",
parentDialog = function()
return dlgmain
end,
onChange = function(count)
lblPlaces.title = placesLabelText(count)
end,
})
recList.loadInitial()
-- Every list action is ignored while a run is in progress (the progress dialog pumps events).
local function guarded(fn)
return function()
if not running then
fn()
end
end
end
local btnSelect = makeButton({
title = "&Select Places...",
close = false,
callback = guarded(recList.actions.select),
tip = "Add places chosen in Family Historian's picker. To process every place, select them all there.",
})
local btnPrevious = makeButton({
title = "Pre&vious Selection",
close = false,
callback = guarded(recList.actions.previous),
tip = "Reload the places from the last Preview or Apply",
})
local btnRemove = makeButton({
title = "&Remove Selected",
close = false,
callback = guarded(recList.actions.remove),
tip = "Remove the ticked places from the list",
})
local btnClear = makeButton({
title = "Clear &List",
close = false,
callback = guarded(recList.actions.clear),
tip = "Empty the list",
})
-- The settings FOR THIS RUN. Each dropdown starts from the saved default (resetRunControls, once the
-- dialog is built) and is deliberately NOT saved when changed here: defaults live in the Options
-- dialog. Preview and Apply read the dropdowns back through getOptions().
runControls = {}
local function makeRunList(name, key, tip)
local lst = makeList({
dropdown = "YES",
sort = "NO", -- keep the declared order: the selection is mapped to a value by position
values = SplitOptions.choicesFor(key),
value = "1",
name = name,
expand = "NO", -- natural width, never the full window width
visiblecolumns = "16", -- a floor wide enough for "Move to address"
visibleitems = (key == "columns") and SplitOptions.COLUMN_MAX or 5,
tip = tip,
})
runControls[key] = lst
return lst
end
local lstColumns = makeRunList(
"cstColumns",
"columns",
"How many comma-separated columns, counting from the left, become the address. The rest stay as the place."
)
local lstGeocode = makeRunList(
"cstGeocode",
"geocode",
"Where the old place's map coordinates (and geocode status) go: onto the new address, or left on the place. "
.. "A place that merges into an existing place always passes them to the address."
)
local lstStan = makeRunList(
"cstStan",
"stan",
"Where the old place's standardised value goes: onto the new address, or left on the place. "
.. "A place that merges into an existing place always passes it to the address."
)
local lstNotes = makeRunList(
"cstNotes",
"notes",
"Where the old place's notes go: onto the new address, or onto the place (the existing place, in a merge)"
)
local lstMedia = makeRunList(
"cstMedia",
"media",
"Where the old place's media links go: onto the new address, or onto the place (the existing place, in a merge)"
)
resetRunControls()
-- One row per option (label + dropdown): plain hboxes, not a gridbox (a gridbox pins its cells).
local function optionRow(labelText, control)
return iup.hbox({ makeLabel({ title = labelText }), control, alignment = "ACENTER", gap = "5" })
end
-- The buttons run through runAction. close=false throughout: a refused gate keeps the window open,
-- and a successful run closes imperatively (the makeButton close trap).
local btnPreview = makeButton({
title = "Previe&w and Exit",
close = false,
callback = function()
if runAction(true) then
closeMainDialog()
end
end,
tip = "Show what will happen, without changing anything, then close",
})
local btnApply = makeButton({
title = "Appl&y and Exit",
close = false,
callback = function()
if runAction(false) then
closeMainDialog()
end
end,
tip = "Split the addresses, then close and show the results",
})
-- No callback + close=true: makeButton treats this as a cancel button.
local btnClose = makeButton({
title = "&Close",
close = true,
tip = "Close without making changes",
})
local content = iup.vbox({
makeLongLabel({ title = "Moves street-level detail out of place names into Address records." }),
makeLongLabel({
title = "The first N comma-separated columns become the address (blank columns at its end are dropped); the rest stay as the place.",
}),
lblPlaces,
recList.list,
iup.hbox({ btnSelect, btnPrevious, btnRemove, btnClear, gap = "5" }),
makeLongLabel({
title = "Settings for this run - set your defaults in Options",
tip = "These dropdowns apply to this run only and are not saved. They start from your defaults; "
.. "change those with Options in the menu bar.",
}),
optionRow("Address columns:", lstColumns),
optionRow("Geocode:", lstGeocode),
optionRow("Standardised value:", lstStan),
optionRow("Notes:", lstNotes),
optionRow("Media:", lstMedia),
iup.hbox({
iup.fill({}),
btnPreview,
btnApply,
btnClose,
gap = "5",
}),
gap = "8",
margin = "10x10",
})
-- Menu items advertise the shortcuts with display text only; the keys themselves are bound
-- window-globally through the dialog's accelerators (below), so they fire from anywhere.
local function menuAction(fn)
return function()
if not running then
fn()
end
return iup.DEFAULT
end
end
local keys = RecordList.keys
-- Mnemonics across the menu bar and the buttons are all distinct: File F, Places P, Options O,
-- Help H; buttons Select S, Previous Selection V, Remove R, Clear List L, Preview W, Apply Y, Close C.
local menuBarData = MenuBar.createMenuBar({
items = {
MenuBar.helpers.createSubmenu("&Places", {
MenuBar.helpers.createMenuItem("&Select Places...", menuAction(recList.actions.select), nil, nil, { text = keys.select }),
MenuBar.helpers.createMenuItem("Pre&vious Selection", menuAction(recList.actions.previous), nil, nil, { text = keys.previous }),
{}, -- separator
MenuBar.helpers.createMenuItem("&Remove Selected", menuAction(recList.actions.remove), nil, nil, { text = keys.remove }),
MenuBar.helpers.createMenuItem("Clear &List", menuAction(recList.actions.clear), nil, nil, { text = keys.clear }),
}, "placesMenu"),
-- The user's default settings. The dialog is modal; once it closes the main window's dropdowns
-- are reset to the (possibly changed) defaults.
MenuBar.helpers.createMenuItem("&Options", menuAction(function()
myConfig:showConfigDialog(nil, "split-addresses-from-places-reference#configuration-options")
resetRunControls()
end)),
},
fileMenu = {
-- Keys sort into menu order; digits decide it because the host collates case-insensitively.
a1preview = MenuBar.helpers.createMenuItem("Previe&w and Exit", function()
if runAction(true) then
closeMainDialog()
end
return iup.DEFAULT
end),
a2apply = MenuBar.helpers.createMenuItem("Appl&y and Exit", function()
if runAction(false) then
closeMainDialog()
end
return iup.DEFAULT
end),
-- Supplying our own `exit` suppresses MenuBar's automatic Exit item, whose
-- `return iup.CLOSE` the host swallows (the menu-action trap).
exit = MenuBar.helpers.createMenuItem("E&xit", closeMainDialog),
},
-- Help + About: with both supplied MenuBar builds a "&Help" submenu holding the two items.
helpMenu = {
help = MenuBar.helpers.createMenuItem("&Help", function()
help:show("split-addresses-from-places")
return iup.DEFAULT
end),
about = MenuBar.helpers.createMenuItem("&About", function()
MessageBox("info", (cstrPluginName or "Plugin") .. " v" .. cstrPluginVersion, "OK")
return iup.DEFAULT
end),
},
})
-- Ctrl+I (select), Ctrl+P (previous selection) and Ctrl+T (clear) work from anywhere in the window;
-- Ctrl+A and Del are bound on the list itself. Ignored while a run is in progress.
local accelerators = {}
for i, a in ipairs(recList.accelerators) do
accelerators[i] = {
key = a.key,
action = function()
if not running then
a.action()
end
end,
}
end
local dlg = makeDialog(content, {
title = cstrPluginName or "Split Addresses from Places",
size = "HALFx",
resize = "YES",
menubox = "YES",
menu = menuBarData.menuBar,
name = "MainDialog",
accelerators = accelerators,
-- Esc = Close (native DEFAULTESC via the cancel option). Deliberately NO default button:
-- Enter must not fire a batch data edit from anywhere in the window.
cancel = btnClose,
})
-- makeDialog normalises every list in a dialog to the width of the widest - here the 50-column
-- places list - which stretched the option dropdowns across the whole window. Give the five
-- dropdowns their OWN normaliser instead (they match one another, nothing else) and clear the
-- width the dialog's normaliser pinned on them.
local runNorm = iup.normalizer({})
for _, key in ipairs(SplitOptions.keys) do
local lst = runControls[key]
lst.normalizergroup = runNorm
lst.size = iup.NULL
end
runNorm.normalize = "HORIZONTAL"
return dlg
end
--------------------------------------------------------------
--EXECUTE
--------------------------------------------------------------
myResults = Results(8) -- Old Place | Old Place Id | Action | New Place | Address | Country | References | Notes / Warnings
do
local anyPlace = fhNewItemPtr()
anyPlace:MoveToFirstRecord("_PLAC")
if anyPlace:IsNull() then
MessageBox("error", "There are no places in this project.", "OK")
return
end
end
dlgmain = makeMainDialog()
DoNormalize()
myConfig:showTrackedDialog(dlgmain, "Main") -- tracks window size/position across runs
iup.MainLoop()
destroyAllDialogs()
-- ONE results display, after the window has gone. Display() with no rows is silent (NoResults(nil)),
-- so a plain Close shows nothing.
if rowsWritten > 0 and myResults and myResults.Display then
myResults.Display()
end
Source: Split-Addresses-from-Places.fh_lua