Add Notes.fh_lua--[[
@Title: Add Notes
@Type: standard
@Author: Helen Wright
@Contributors:
@Version: 1.6
@LastUpdated: 15 June 2026
@Licence: This plugin is copyright (c) 2025 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: Plugin to attach Shared Notes or Research Notes to selected target records. Supports both selecting existing notes and creating new notes from autotext.
]]
--
--[[ChangeLog:
Version 1.6: the Options dialog's Help menu no longer shows an About item (it pointed at a non-existent help page); About is unchanged on the main window.
Version 1.5: The Options dialog now follows the Windows theme - light, dark or High Contrast - instead of always being white. Colours are read from the system at run time (via the shared Theme helper); in plain dark mode the buttons keep a light face with dark text, because Windows paints native button faces itself and a plugin can't repaint them. Behaviour and output are unchanged.
Version 1.4: The Name/Link placeholder fields now force UPPERCASE. Typed input converts to upper-case live (the field's UPPERCASE filter, which still allows digits); an existing token shows upper-cased when Options opens; and it is upper-cased on save. Because a placeholder is matched against the template text case-sensitively (as {NAME}), forcing upper-case removes the template/config case mismatch that 1.3's free-text fields allowed.
Version 1.3: Fixes: the name/link placeholder fields no longer restrict input to a single uppercase character (the input mask is removed - placeholder text is user-defined); when an AutoText template save is rejected for being outside the AutoText folder, the dialog reopens back in the AutoText folder.
Version 1.2: Reliability: the Options dialog is now built once and reused, rather than rebuilt on every open. Rebuilding it (a fresh dialog, menu and re-registered controls) corrupted native state and could silently crash FH the second time Options was opened in one run (the same fault fixed in the Add Trees plugin). Reopening now reloads the saved values into the existing dialog.
Version 1.1: Bug fixes: File menu items now appear in a fixed order (was random); number fields in Options regained their numeric input mask; corrected trailing-slash handling in autotext path validation; assistant ("...") buttons no longer lose their action and close the containing dialog; the Options dialog closes and reopens cleanly (no close callback fighting the popup teardown, and no destroy that corrupted menu state when closed via Save/Cancel)
Version 1.0: Initial release
]]
--------------------------------------------------------------
--FH VERSION CHECK
--------------------------------------------------------------
-- Ensure this plugin runs on Family Historian version 7 or higher
-- It might run on earlier versions but I'm not in a position to test or support them.
if fhGetAppVersion() < 7 then
fhMessageBox("This plugin requires FH7 and will exit.")
return
end
--------------------------------------------------------------
--EXTERNAL LIBRARIES
--------------------------------------------------------------
do
utf8 = require(".utf8"):init()
require("iuplua") -- UI
fh = require("fhUtils") --useful stuff
--fh.setIupDefaults() --initialise iup, including CUSTOMQUIT message and font and UTF8 support in FH and iup
--fh.setIupDefaults has a bug in emulators, so workaround instead of calling it directly
local function setIupDefaults()
local function getRegKey(key)
local sh = luacom.CreateObject("WScript.Shell")
local ans
if pcall(function()
ans = sh:RegRead(key)
end) then
return ans
else
return nil, true
end
end
iup.SetGlobal("CUSTOMQUITMESSAGE", "YES")
local isEmulated = os.getenv("WINEPREFIX") ~= nil
if not isEmulated then
-- Get Font and size from the Registry for the property box and use as the default font.
local v
v = getRegKey("HKEY_CURRENT_USER\\Software\\Calico Pie\\Family Historian\\2.0\\Preferences\\PDX Font")
local t = { stringx.splitv(v, ",") } -- first value is size and 14th is font name
iup.SetGlobal("DEFAULTFONT", t[14] .. " " .. t[1] / 20)
end --if not emulated, use the registry font, otherwise default to whatever the system provides
iup.SetGlobal("UTF8MODE", "YES")
iup.SetGlobal("UTF8MODE_FILE", "YES") --use UTF8 file names
fhSetStringEncoding("UTF-8")
end
setIupDefaults()
fhfu = require("fhFileUtils") -- utf8 compatible file handling library
end
-------------------------------------------------------------
--CONSTANTS
--------------------------------------------------------------
-- Environment variables
local PLUGIN_VERSION = "1.6"
local AUTOTEXT_DIR = fhGetContextInfo("CI_APP_DATA_FOLDER") .. "/Autotext"
-- Note Types
local NOTE_TYPE_SHARED = "Shared Notes"
local NOTE_TYPE_RESEARCH = "Research Notes"
-- Operation Modes
local MODE_SELECT_EXISTING = "Select Existing"
local MODE_CREATE_FROM_AUTOTEXT = "Create from AutoText"
-- Record Tags
local TAG_NOTE = "NOTE"
local TAG_RESEARCH_NOTE = "_RNOT"
local TAG_TEXT = "TEXT"
-- File Extensions
local EXT_AUTOTEXT = "ftf"
--------------------------------------------------------------
-- RECORD DISPLAY NAMES
--------------------------------------------------------------
local displayNames = {
INDI = "Individual",
FAM = "Family",
SOUR = "Source",
REPO = "Repository",
NOTE = "Note",
OBJE = "Media",
SUBN = "Submitter",
SUBM = "Submission",
_PLAC = "Place",
_HEAD = "Header",
_RNOT = "Research Note",
_SRCT = "Source Template",
}
local menuNames = {
INDI = "&Individuals",
FAM = "&Families",
SOUR = "&Sources",
REPO = "&Repositories",
NOTE = "Notes",
OBJE = "&Media",
SUBN = "Submitters",
SUBM = "Submissions",
_PLAC = "&Places",
_HEAD = "Headers",
_RNOT = "Research Notes",
_SRCT = "Source &Templates",
}
--------------------------------------------------------------
--RECORD TYPE SUPPORT CONFIGURATION
--------------------------------------------------------------
-- Configuration for which record types are supported for each note type
-- This makes it easy to modify support without changing code logic
local recordTypeSupport = {
-- Record types that are completely unsupported (won't appear in menu)
completelyUnsupported = {
"HEAD", --can't have notes on headers
"NOTE", --can't have notes on notes except by embedding them
"_RNOT", --can't have notes on research notes except by embedding them
"SUBM", --technically valid, but I don't think anyone would want to use this
"SUBN", --technically valid, but I don't think anyone would want to use this
},
-- Record types that are unsupported for Shared Notes only
sharedNotesUnsupported = {
-- Currently none, but add here
},
-- Record types that are unsupported for Research Notes only
researchNotesUnsupported = {
"_SRCT", --can only have notes on source templates, not research notes
},
}
--------------------------------------------------------------
--THEME HELPER
--------------------------------------------------------------
--[[
Reads the Windows theme so the IUP dialog follows the user's light / dark /
High Contrast preference. Colours come 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`, consumed by the Dialog helper below (Theme.iupColours /
Theme.isDarkMode / Theme.isHighContrast). Kept in sync with 2 Boilerplate\Theme.lua.
]]
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 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"),
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
_G.Theme = M
end
--------------------------------------------------------------
--HELP HANDLING
--------------------------------------------------------------
--[[
@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
-- Resolve Help once to a real instance so consumers can assume it exists
help = Help.new({})
--------------------------------------------------------------
--UTILITY MODULES AND CLASSES
--------------------------------------------------------------
--[[
Dialog V8
@Author: Helen Wright
@Version: 1.2
@LastUpdated: 15 June 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.
]]
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")
--fh.setIupDefaults() --turns on CUSTOMQUIT Message and sets default font to match the property box; commented out because a workaround is implemented at top of file
-- Use the global help instance resolved by the main plugin (referenced as `help`)
do --colours, layout, theming
local th = Theme.iupColours() -- resolved system colours, light-theme fallback (see Theme helper)
-- 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.isDarkMode() and not Theme.isHighContrast()
-- 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
-- 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
--- 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 = 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
--- 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",
}) ---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
--- 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 {}
local 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.
iup.SetHandle(options.name or options.title or generateRandomDigitString(), d)
-- 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
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_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
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
dlg:popup(iup.CENTER, iup.CENTER)
local result = dlg.buttonresponse
dlg:destroy()
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
local dialogOptions = {
title = strPrompt,
size = "QUARTERx",
}
local dialog = makeDialog(dialogContent, dialogOptions)
-- 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))
iup.SetHandle(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)
--TESTED: makeAssistantButton
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
iup.SetHandle(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
local selectionState = list.value -- 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
self.value = 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
iup.SetHandle(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))
iup.SetHandle(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 -- Additional UI components
--- 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
end
end
---Displays a hierarchical file/folder selection dialog using a tree control.
---The dialog allows users to browse through directories and select files or folders
---based on specified criteria. Files can be filtered by extension, and the dialog
---supports both single and multiple selection modes.
---
---The function builds a recursive tree structure starting from the root directory,
---separating folders and files, applying extension filters, and sorting items
---alphabetically. Folders are displayed as expandable branches, while files appear
---as leaf nodes. The dialog includes Select All and Clear All buttons for convenience.
---
---@param rootDirectory string Root directory to start browsing from
---@param extensions string[] File extensions to filter by (empty table shows all files)
---@param allowMultiple boolean Whether multiple files can be selected
---@param rootLabel string Label for the root item in the tree
---@param dialogTitle string Title for the selection dialog
---@param showExtensions boolean Whether to show file extensions in the tree
---@param foldersOnly boolean Whether to show only folders (if true, returns folder paths instead of file paths)
---@param parentDialog iup.dialog Optional parent dialog to use for positioning
---@return boolean Whether the function was successful
---@return string[] Table of selected files/folders or nil if unsuccessful
function FileSelector(
rootDirectory,
extensions,
allowMultiple,
rootLabel,
dialogTitle,
showExtensions,
foldersOnly,
parentDialog
)
fhfu = require("fhFileUtils")
if not rootDirectory then
return false, nil --root directory is required
end
extensions = extensions or {}
allowMultiple = allowMultiple or false
rootLabel = rootLabel or "Root"
dialogTitle = dialogTitle or "File Selector"
showExtensions = showExtensions == nil and true or showExtensions
foldersOnly = foldersOnly or false
-- Helper function to recursively build tree structure
local function buildTreeStructure(directoryPath, isRoot)
local treeData = {}
-- Get contents of current directory (non-recursive)
local contents, error = fhfu.getFolderContents(directoryPath, false, false)
if not contents then
if isRoot then
MessageBox("error", "Error reading root directory: " .. error, "OK")
return nil -- Return nil to indicate error
else
return treeData -- Return empty tree if subdirectory can't be read
end
end
local folders = {}
local files = {}
-- Separate folders and files, filter files by extension
for _, item in ipairs(contents) do
local pathParts = fhfu.splitPath(item.path)
if fhfu.folderExists(item.path) then
-- It's a folder
table.insert(folders, {
name = item.name,
path = item.path,
})
else
-- It's a file - check if extension matches
local fileExt = string.lower(pathParts.ext or "")
local matchesExtension = #extensions == 0 -- if no extensions specified, show all files
if not matchesExtension then
for _, ext in ipairs(extensions) do
if string.lower(ext) == fileExt then
matchesExtension = true
break
end
end
end
if matchesExtension then
-- Determine display name based on showExtensions parameter
local displayName = item.name
if not showExtensions then
displayName = pathParts.basename
end
table.insert(files, {
name = item.name,
displayName = displayName,
path = item.path,
})
end
end
end
-- Sort folders and files alphabetically
table.sort(folders, function(a, b)
return a.name < b.name
end)
table.sort(files, function(a, b)
return a.displayName < b.displayName
end)
-- Add folders with their subtrees
for _, folder in ipairs(folders) do
local folderNode = {
branchname = folder.name,
}
-- In folders-only mode, add userid for the folder itself
if foldersOnly then
folderNode.userid = { path = folder.path }
end
-- Recursively build subtree for this folder
local subtree = buildTreeStructure(folder.path, false)
if subtree then
for _, node in ipairs(subtree) do
table.insert(folderNode, node)
end
end
table.insert(treeData, folderNode)
end
-- Add files at this level (only if not in folders-only mode)
if not foldersOnly then
for _, file in ipairs(files) do
table.insert(treeData, {
leafname = file.displayName,
userid = { path = file.path },
})
end
end
return treeData
end
-- Build the tree structure starting from root directory
local rootTree = {
branchname = rootLabel,
}
local subtreeData = buildTreeStructure(rootDirectory, true)
if not subtreeData then
return false, nil -- Error already shown by buildTreeStructure
end
for _, item in ipairs(subtreeData) do
table.insert(rootTree, item)
end
local tree = iup.tree({
IMAGELEAF = "IMGPAPER",
markmode = allowMultiple and "MULTIPLE" or "SINGLE",
})
-- Track OK/Cancel
local okPressed = false
local selectedPaths = {}
-- Create selection buttons
local selectAllBtn = makeButton({
title = "Select All",
size = "64x",
expand = "NO",
callback = function()
tree.MARK = "MARKALL"
return iup.DEFAULT
end,
})
local clearAllBtn = makeButton({
title = "Clear All",
size = "64x",
expand = "NO",
callback = function()
tree.MARK = "CLEARALL"
return iup.DEFAULT
end,
})
-- Create OK button
local okBtn = makeButton({
title = "OK",
size = "64x",
expand = "NO",
close = true,
callback = function()
-- Collect selected paths using userid
selectedPaths = {}
-- Get selection state
local markedNodes = tree.MARKEDNODES
-- Get total node count
local nodeCount = tree.count
if nodeCount and tonumber(nodeCount) > 0 then
for i = 1, tonumber(nodeCount) do
local isSelected = markedNodes and markedNodes:sub(i, i) == "+"
if isSelected then
-- The MARKEDNODES string is 1-based, but GetUserId is 0-based
local userid = iup.TreeGetUserId(tree, i - 1)
if userid then
if type(userid) == "table" and userid.path then
table.insert(selectedPaths, userid.path)
end
end
end
end
end
okPressed = true
return true
end,
})
-- Create Cancel button
local cancelBtn = makeButton({
title = "Cancel",
size = "64x",
expand = "NO",
close = true,
})
-- Create dialog content
local buttonBox = {}
-- Only add Select All and Clear All buttons if not in folders-only mode
if not foldersOnly then
table.insert(buttonBox, selectAllBtn)
table.insert(buttonBox, clearAllBtn)
end
-- New Folder button omitted
table.insert(buttonBox, iup.fill({}))
table.insert(buttonBox, okBtn)
table.insert(buttonBox, cancelBtn)
local content = iup.vbox({
tree,
iup.hbox(buttonBox),
})
-- Create dialog
local dlg = makeDialog(content, {
title = dialogTitle,
resize = "YES",
size = "HALFxHALF",
})
dlg:map() --must map the dialog before adding nodes
-- Prepare tree for bulk node insert without redraw
tree.autoredraw = "NO"
-- Ensure nodes are added collapsed
tree.addexpanded = "NO"
iup.TreeAddNodes(tree, rootTree)
-- Set initial focus to root (avoid expanding entire tree)
tree.value = 0
-- Expand only the root
tree["STATE0"] = "EXPANDED"
tree.autoredraw = "YES"
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
dlg:destroy()
return okPressed, selectedPaths
end
--- Create and manage a lightweight progress dialog
--- @param totalSteps number Total number of iterations to perform
--- @param title? string Optional dialog title
--- @param parentDialog? any Optional parent dialog handle
--- @return table Progress controller with methods: show(), update(step, text), isCancelled(), close()
local function createProgressDialog(totalSteps, title, parentDialog)
local cancelled = false
local lbl = makeLabel({
title = "Starting...",
})
local bar = iup.progressbar({
min = 0,
max = 1,
value = 0,
expand = "HORIZONTAL",
})
local btnCancel = makeButton({
title = "Cancel",
callback = function(self)
cancelled = true
return true
end,
})
local buttons = iup.hbox({ iup.fill({}), btnCancel })
local box = iup.vbox({
lbl,
bar,
buttons,
margin = "10x10",
gap = "8",
expand = "YES",
})
local dlg = makeDialog(box, {
title = title or "Applying...",
size = "QUARTERxEIGHTH",
dialogframe = "YES",
menubox = "NO",
topmost = "YES",
})
local function update(step, text)
local denom = (totalSteps and totalSteps > 0) and totalSteps or 1
bar.value = math.min(1, (step or 0) / denom)
if text and text ~= "" then
lbl.title = text
end
iup.Refresh(dlg)
iup.LoopStep()
end
return {
show = function()
dlg:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
iup.LoopStep()
end,
update = update,
isCancelled = function()
return cancelled
end,
close = function()
if dlg then
dlg:destroy()
dlg = nil
end
end,
}
end
do
--- ProgressController handles showing and throttling progress updates based on parameters
--- @class ProgressController
--- @field totalSteps number
--- @field showThreshold number
--- @field updateStepFraction number
--- @field step number
--- @field lastFraction number
--- @field dlg any
--- @field _parentDialog any
local ProgressController = {}
ProgressController.__index = ProgressController
--- Create a new ProgressController
--- @param totalSteps number
--- @param updatePercent? number Percentage step for updates (1-100). Default 5
--- @param showThreshold? number Minimum total steps to show progress. Default 20
--- @param parentDialog? any Optional parent dialog handle for modality
--- @return ProgressController
function ProgressController.new(totalSteps, updatePercent, showThreshold, parentDialog)
local UPDATE_PERCENT = (type(updatePercent) == "number" and updatePercent >= 1 and updatePercent <= 100)
and updatePercent
or 5
local SHOW_THRESHOLD = (type(showThreshold) == "number" and showThreshold >= 0) and showThreshold or 20
local self = setmetatable({}, ProgressController)
self.totalSteps = totalSteps or 0
self.showThreshold = SHOW_THRESHOLD
self.updateStepFraction = math.max(0.01, UPDATE_PERCENT / 100)
self.step = 0
self.lastFraction = -1
self._parentDialog = parentDialog
self.dlg = nil
return self
end
function ProgressController:shouldShow()
return (self.totalSteps or 0) >= (self.showThreshold or 0)
end
function ProgressController:ensureShown()
if not self.dlg and self:shouldShow() and (self.totalSteps or 0) > 0 then
self.dlg = createProgressDialog(self.totalSteps, "Applying...", self._parentDialog)
self.dlg.show()
end
end
function ProgressController:isCancelled()
return self.dlg and self.dlg.isCancelled() or false
end
function ProgressController:update(text)
self.step = self.step + 1
if not self.dlg then
-- Defer showing until needed
self:ensureShown()
end
if not self.dlg then
return -- Not showing progress (below threshold or zero steps)
end
local fraction = (self.totalSteps > 0) and (self.step / self.totalSteps) or 1
local shouldUpdate = (self.totalSteps <= 20)
or (self.step == 1)
or (self.step == self.totalSteps)
or (fraction >= (self.lastFraction + self.updateStepFraction))
if shouldUpdate then
self.dlg.update(self.step, text)
self.lastFraction = fraction
end
end
function ProgressController:finish()
if self.dlg then
self.dlg.update(self.totalSteps, "Finishing...")
self.dlg.close()
self.dlg = nil
end
end
-- Module-style export akin to Config.lua
Progress = {
new = ProgressController.new,
}
end
-------------------------------------------------------------
--CONFIGURATION STRUCTURE
--------------------------------------------------------------
--[[
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.0
@Date: 2024
]]
do
local M = {} -- Module table
---@class Config
---@field filePath string
---@field defaultConfig table
---@field cache table>
---@field callbacks table>
local Config = {}
Config.__index = Config
--- 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
--- 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 configuration
local x = self:getNumber("Dialogs", "Dialog_" .. dialogId .. ".x")
local y = self:getNumber("Dialogs", "Dialog_" .. dialogId .. ".y")
local rastersize = self:getString("Dialogs", "Dialog_" .. dialogId .. ".rastersize")
-- 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 configuration
config:setValues("Dialogs", "Dialog_" .. dialogId, {
x = tonumber(closeX),
y = tonumber(closeY),
rastersize = dlg.rastersize,
})
end
end
end
-- Call the original close callback if it exists
if originalClose then
return originalClose(dlg)
end
return iup.CLOSE
end
-- Temporarily remove minimum size constraint to get true natural size
dialog.minsize = iup.NULL
iup.Refresh(dialog)
local true_natural = dialog.naturalsize
-- Restore the previously saved size if available
if rastersize and rastersize ~= "" then
dialog.rastersize = rastersize
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
-- Position the dialog based on saved coordinates or center it
if x and y and (x ~= 0 or y ~= 0) then
-- Get monitor information to validate position
local mi = iup.GetGlobal("MONITORSINFO")
local monitorX, monitorY
-- Parse monitor information to find which monitor the coordinates belong to
for m in mi:gmatch("[^\r\n]+") do
local mx, my = m:match("^(%S+)%s(%S+)")
mx, my = tonumber(mx), tonumber(my)
-- Check if the saved position is within this monitor's bounds
if x >= mx and y >= my then
monitorX, monitorY = mx, my
break
end
end
-- Show dialog at saved position if it's on a valid monitor
if monitorX and monitorY then
dialog:showxy(x, y)
else
-- Fall back to centering if position is invalid
dialog:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
end
else
-- Center the dialog if no saved position or position is (0,0)
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
function Config:createControl(field, value)
if field.type == "text" then
local opts = { value = value }
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 = (value or ""):upper()
opts.filter = "UPPERCASE"
opts.tip = "Placeholder word, e.g. NAME; reference it in templates as {NAME}"
elseif field.mask then
opts.mask = field.mask
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
return makeList({
dropdown = "YES",
values = field.options or {},
value = tostring(selectedIndex),
})
else
error("Unsupported field type: " .. field.type)
end
end
function Config:showConfigDialog(options, help_topic)
options = options or self.defaultConfig
help_topic = help_topic or "options"
-- 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
self._optionsDialog:popup(iup.CENTERPARENT, iup.CENTERPARENT)
return
end
local controls = {}
local mainContent
local allSections = {}
local sectionMenuItems = {}
-- Move showSection function to higher scope
local function showSection(sectionToShow)
for _, section in pairs(allSections) do
if section == sectionToShow then
section.visible = "YES"
section.floating = "NO"
else
section.visible = "NO"
section.floating = "YES"
end
end
local dialog = iup.GetDialog(mainContent)
if dialog then
iup.Refresh(dialog)
end
end
mainContent = iup.vbox({})
for _, section in ipairs(options.sections or {}) do
local sectionContent = iup.vbox({})
controls[section.title] = {}
for _, field in ipairs(section.fields) do
local value = self:getValue(section.title, field.key, field.default)
local label = makeLabel({ title = field.label })
local control = self:createControl(field, value)
controls[section.title][field.key] = {
control = control,
field = field,
}
sectionContent:append(iup.hbox({
label,
control,
alignment = "ACENTER",
}))
end
local sectionBox = iup.scrollbox({ sectionContent, visible = "NO", floating = "YES", expand = "YES" })
table.insert(allSections, sectionBox)
mainContent:append(sectionBox)
table.insert(sectionMenuItems, {
title = section.title,
action = function()
showSection(sectionBox)
return iup.DEFAULT
end,
})
end
-- helper to save all control values back to config
local function saveAll()
for section, fields in pairs(controls) do
local values = {}
for key, info in pairs(fields) do
local control = info.control
local field = info.field
local value = control.value
if field.type == "number" then
value = tonumber(value)
if value == nil then
value = field.default
end
elseif field.type == "boolean" then
value = control.value == "ON"
elseif field.type == "list" then
if control.value == "0" then
value = field.default
else
value = field.options[tonumber(control.value)]
end
end
if field.mask == "TOKEN" then
value = (value or ""):upper()
end
values[key] = value
end
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 section, sectionControls in pairs(controls) do
for key, info in pairs(sectionControls) do
local control = info.control
local field = info.field
local value = field.default
if field.type == "boolean" then
control.value = value and "ON" or "OFF"
elseif field.type == "list" then
for i, opt in ipairs(field.options) do
if opt == value then
control.value = tostring(i)
break
end
end
else
control.value = tostring(value)
end
end
end
end
return iup.DEFAULT
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()
saveAll()
return iup.CLOSE
end,
},
bResetAll = {
title = "&Reset All",
action = function()
return resetAllToDefaultsAndRefresh()
end,
},
cancel = {
title = "&Cancel",
action = function()
return iup.CLOSE
end,
},
},
helpMenu = {
help = {
title = "&Help",
action = function()
help:show(help_topic)
return iup.DEFAULT
end,
},
},
})
local dialog = makeDialog(
iup.vbox({
mainContent,
margin = "10x10",
}),
-- 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,
}
)
-- 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. Mirrors the field types
-- this plugin uses (boolean / list / text).
local function reload()
for sectionTitle, sectionControls in pairs(controls) do
for key, info in pairs(sectionControls) do
local control, field = info.control, info.field
local value = self:getValue(sectionTitle, key, field.default)
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)
break
end
end
else
control.value = tostring(value)
end
end
end
if #allSections > 0 then
showSection(allSections[1])
end
end
self._optionsReload = reload
self._optionsDialog = dialog
DoNormalize()
if #allSections > 0 then
showSection(allSections[1])
end
-- Never destroyed (FH frees plugin dialogs at script end); reused on
-- the next open via the fast path at the top.
dialog:popup(iup.CENTERPARENT, iup.CENTERPARENT)
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
local defaultConfig = {
title = "Add Notes Configuration",
sections = {
{
title = "Preferences",
fields = {
{
key = "noteType",
label = "Default Note Type:",
type = "list",
default = NOTE_TYPE_SHARED,
options = { NOTE_TYPE_SHARED, NOTE_TYPE_RESEARCH },
description = "The type of notes to work with by default",
},
{
key = "operationMode",
label = "Default Operation Mode:",
type = "list",
default = MODE_SELECT_EXISTING,
options = { MODE_SELECT_EXISTING, MODE_CREATE_FROM_AUTOTEXT },
description = "Whether to select existing notes or create new ones from autotext by default",
},
{
key = "useLastSettings",
label = "Use Last Settings:",
type = "boolean",
default = true,
description = "Whether to start with the last used settings",
},
{
key = "nameToken",
label = "Name Placeholder:",
type = "text",
default = "",
description = "The placeholder for the name of the target record in the note",
mask = "TOKEN",
},
{
key = "linkToken",
label = "Link Placeholder:",
type = "text",
default = "",
description = "The placeholder for the link to the target record in the note",
mask = "TOKEN",
},
},
},
},
}
local myConfig = Config.new(defaultConfig, "LOCAL_MACHINE", fhGetContextInfo("CI_PLUGIN_NAME") .. ".ini")
-- Current state variables (initialized from config where appropriate)
local currentNoteType = myConfig:getValue("Preferences", "noteType", NOTE_TYPE_SHARED)
local currentOperationMode = myConfig:getValue("Preferences", "operationMode", MODE_SELECT_EXISTING)
local selectedTargets = {} -- Array of TargetRecord objects
local selectedNotes = {}
-- Central UI registry for local references (avoid global IUP handles)
---@type {menuBarData: MenuBarData, menuBar: iup.menu, contentArea: iup.vbox, targetRecordsList: iup.list, notesList: iup.list, notesLabel: iup.label, mainVBox: iup.vbox}
local ui = {}
--------------------------------------------------------------
--RESULT HANDLING
--------------------------------------------------------------
---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.)
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], -- Sort priority
true, -- Sortable
"default", -- Sort direction
tblVisibility[i] -- Visibility setting
)
end
-- Update the display to show the results
fhUpdateDisplay()
else
-- No results found - show informational message
fhMessageBox(strNoResults, "MB_OK", "MB_ICONINFORMATION")
end
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
local myResults = Results(3)
myResults.Title("Add Notes")
myResults.NoResults("No notes added") -- Message when no notes are added
myResults.Headings({ "Action", "Target", "Note" }) -- Column headers
myResults.Types({ "text", "item", "item" }) -- Data types for each column
myResults.Visibility({ "show", "show", "show" }) -- Which columns to show
myResults.Width({ 200, 200, 200 }) -- Width for each column
myResults.Sort({ 1, 2, 3 }) -- Sort by Action, then Target, and then Note
--------------------------------------------------------------
--MENU HANDLING
--------------------------------------------------------------
do
-- Load MenuBar library
--[[
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.0
@LastUpdated: Current Date
@Description: Enhanced helper function for creating IUP menu bars with dynamic state management
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")
}
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)
- MenuBar.helpers.createRadioMenuItem(title, currentValue, expectedValue, action, capture, registryKey)
- MenuBar.helpers.createSubmenu(title, items, registryKey)
]]
---@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)
--- 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 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 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
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
--- Controls visibility and floating state of UI elements
---@param uiElements table 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
local menuItem = iup.item({
title = item.title,
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
-- 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
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)
local firstItem = true
for _, key in ipairs(keys) do
if not firstItem then
table.insert(fileMenuItems, {}) -- separator between items
end
table.insert(fileMenuItems, createMenuItem(options.fileMenu[key]))
firstItem = false
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 iup.CLOSE
end,
})
)
end
local fileMenu = iup.submenu({
iup.menu(fileMenuItems),
title = "&File",
})
-- Define 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 helpItems = {
createMenuItem(options.helpMenu and options.helpMenu.help or {
title = "&Help",
action = function(self)
if Help then
Help.new({}):show("")
end
return iup.DEFAULT
end,
}),
}
if options.helpMenu and options.helpMenu.about then
helpItems[#helpItems + 1] = {} -- separator
helpItems[#helpItems + 1] = createMenuItem(options.helpMenu.about)
end
local helpMenu = iup.submenu({
iup.menu(helpItems),
title = "&Help",
})
-- 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,
--- Update menu item title dynamically
---@param key string Registry key of the menu item
---@param title string New title
updateTitle = function(key, title)
if menuItems[key] then
menuItems[key].title = 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
---@return menuItem Menu item definition
M.helpers.createMenuItem = function(title, action, capture, registryKey)
return {
title = title,
action = action,
capture = capture,
registryKey = registryKey,
}
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
_G.MenuBar = M
end
--- Compute dynamic Notes menu item titles based on mode and type
function getSelectMenuTitle()
if currentOperationMode == MODE_SELECT_EXISTING then
return "&Select Existing " .. currentNoteType
else
return "&Select AutoText"
end
end
function getNewMenuTitle()
if currentOperationMode == MODE_SELECT_EXISTING then
return "&New " .. currentNoteType:gsub("s$", "")
else
return "&New AutoText Template"
end
end
function updateMenuTitles()
if ui.menuBarData then
ui.menuBarData.updateTitle("menuNewItem", getNewMenuTitle())
ui.menuBarData.updateTitle("menuSelectItem", getSelectMenuTitle())
end
end
-- Update check marks for Note Type and Mode menu items
function updateMenuChecks()
if ui.menuBarData then
ui.menuBarData.updateValue("menuNoteTypeShared", (currentNoteType == NOTE_TYPE_SHARED) and "ON" or "OFF")
ui.menuBarData.updateValue("menuNoteTypeResearch", (currentNoteType == NOTE_TYPE_RESEARCH) and "ON" or "OFF")
ui.menuBarData.updateValue("menuModeSelect", (currentOperationMode == MODE_SELECT_EXISTING) and "ON" or "OFF")
ui.menuBarData.updateValue(
"menuModeCreate",
(currentOperationMode == MODE_CREATE_FROM_AUTOTEXT) and "ON" or "OFF"
)
end
end
--- Enable/disable Apply menu items depending on selections
function updateApplyMenuState()
local hasTargets = (#selectedTargets or 0) > 0
local hasNotes = (#selectedNotes or 0) > 0
local enabled = (hasTargets and hasNotes) and "YES" or "NO"
if ui.menuBarData then
ui.menuBarData.updateActive("menuApplyExit", enabled)
ui.menuBarData.updateActive("menuApplyContinue", enabled)
end
end
--- Update the active state of target menu items based on current note type
function updateTargetMenuStates()
if ui.menuBarData then
ui.menuBarData.updateRegistryGroup("targets", function(key, menuItem)
local isSupported = getSupportedRecordTypes()[key] ~= false
menuItem.active = isSupported and "YES" or "NO"
end)
end
end
--- Check if a record type should be visible in the menu (supported for at least one note type)
--- @param recordTag string The record type tag to check
--- @return boolean True if the record type should be visible
function isRecordTypeVisible(recordTag)
-- Check if it's completely unsupported (won't appear in menu)
for _, tag in ipairs(recordTypeSupport.completelyUnsupported) do
if tag == recordTag then
return false
end
end
-- If not completely unsupported, it should be visible
return true
end
--- Create the main menu bar with all menu items
--- @return table Menu bar definition
function createMainMenuBar()
-- Build the targets submenu dynamically
local targetsSubmenu = {}
-- Get supported record types and add menu items for each
local supportedTypes = getSupportedRecordTypes()
local recordTypes = getRecordTypesInfo(supportedTypes)
-- Add record type selection menu items first
for _, recordType in ipairs(recordTypes) do
-- Only show record types that are supported for at least one note type
if isRecordTypeVisible(recordType.tag) then
local accel = ""
if recordType.tag == "INDI" then
accel = "\tCtrl+I"
end
if recordType.tag == "FAM" then
accel = "\tCtrl+F"
end
-- no shortcut for NOTE in Targets menu
local menuItem = MenuBar.helpers.createMenuItem("Select: " .. recordType.menuName .. accel, function(self)
if recordType.isSupported then
selectTargetRecords(recordType.tag)
end
return iup.DEFAULT
end, nil, recordType.tag)
menuItem.active = recordType.isSupported and "YES" or "NO"
menuItem.registryGroup = "targets"
table.insert(targetsSubmenu, menuItem)
end
end
-- Add separator
table.insert(targetsSubmenu, {})
-- Add utility menu items
table.insert(
targetsSubmenu,
MenuBar.helpers.createMenuItem("R&emove Selected\tDel", function(self)
removeSelectedTargets()
return iup.DEFAULT
end)
)
table.insert(
targetsSubmenu,
MenuBar.helpers.createMenuItem("&Clear Targets\tCtrl+T", function(self)
selectedTargets = {}
populateTargetRecords()
return iup.DEFAULT
end)
)
-- Create menu bar
local menuBarData = MenuBar.createMenuBar({
registries = {
targets = {}, -- For target menu items
modes = {}, -- For mode menu items
noteTypes = {}, -- For note type menu items
},
items = {
MenuBar.helpers.createSubmenu("&Targets", targetsSubmenu, "targetsMenu"),
MenuBar.helpers.createSubmenu("&Notes", {
MenuBar.helpers.createMenuItem(getNewMenuTitle(), function(self)
createNewNote()
return iup.DEFAULT
end, nil, "menuNewItem"),
MenuBar.helpers.createMenuItem(getSelectMenuTitle(), function(self)
if currentOperationMode == MODE_SELECT_EXISTING then
selectExistingNotes(currentNoteType)
else
selectAutotextViaTree()
end
return iup.DEFAULT
end, nil, "menuSelectItem"),
{}, -- separator
MenuBar.helpers.createSubmenu("Note &Type", {
MenuBar.helpers.createRadioMenuItem("&Shared Notes", currentNoteType, NOTE_TYPE_SHARED, function()
return changeNoteType(NOTE_TYPE_SHARED)
end, nil, "menuNoteTypeShared"),
MenuBar.helpers.createRadioMenuItem(
"&Research Notes",
currentNoteType,
NOTE_TYPE_RESEARCH,
function()
return changeNoteType(NOTE_TYPE_RESEARCH)
end,
nil,
"menuNoteTypeResearch"
),
}, "noteTypeMenu"),
MenuBar.helpers.createSubmenu("&Mode", {
MenuBar.helpers.createRadioMenuItem(
"&Select Existing",
currentOperationMode,
MODE_SELECT_EXISTING,
function()
return changeMode(MODE_SELECT_EXISTING)
end,
nil,
"menuModeSelect"
),
MenuBar.helpers.createRadioMenuItem(
"&Create from AutoText",
currentOperationMode,
MODE_CREATE_FROM_AUTOTEXT,
function()
return changeMode(MODE_CREATE_FROM_AUTOTEXT)
end,
nil,
"menuModeCreate"
),
}, "modeMenu"),
{}, -- separator
MenuBar.helpers.createMenuItem("&New...\tCtrl+N", function(self)
createNewNote()
return iup.DEFAULT
end),
MenuBar.helpers.createMenuItem("&View/Edit Selected\tCtrl+E", function(self)
editSelectedNoteOrAutotext()
return iup.DEFAULT
end),
MenuBar.helpers.createMenuItem("R&emove Selected\tDel", function(self)
removeSelectedNotes()
return iup.DEFAULT
end),
MenuBar.helpers.createMenuItem("&Clear Notes\tCtrl+Shift+C", function(self)
selectedNotes = {}
populateNotesList()
return iup.DEFAULT
end),
}, "notesMenu"),
MenuBar.helpers.createMenuItem("&Options", function(self)
myConfig:showConfigDialog(
nil,
"add-notes-reference#configuration-options"
)
return iup.DEFAULT
end),
},
fileMenu = {
applyContinue = MenuBar.helpers.createMenuItem("Apply and &Continue", function(self)
showApplyResults(applySelections())
return iup.DEFAULT
end, nil, "menuApplyContinue"),
applyExit = MenuBar.helpers.createMenuItem("&Apply and Exit", function(self)
showApplyResults(applySelections())
return iup.CLOSE
end, nil, "menuApplyExit"),
},
helpMenu = {
about = MenuBar.helpers.createMenuItem("&About", function(self)
MessageBox(
"info",
"Add Notes Plugin v"
.. PLUGIN_VERSION
.. "\n\nPlugin to attach Shared Notes or Research Notes to selected target records.\n\nSupports both selecting existing notes and creating new notes from autotext."
)
return iup.DEFAULT
end),
},
})
-- Store references for dynamic updates
ui.menuBarData = menuBarData
ui.menuBar = menuBarData.menuBar
-- Initialize menu item titles and states
updateMenuTitles()
updateTargetMenuStates()
updateApplyMenuState()
return menuBarData.menuBar
end
--------------------------------------------------------------
--APPLY FUNCTIONALITY
--------------------------------------------------------------
--- Create a new note record from a RichText object for a given note type
--- @param noteType string Either NOTE_TYPE_RESEARCH or NOTE_TYPE_SHARED
--- @param richText RichText The rich text object to use as content
--- @return ItemPointer|nil ptrNote The created note record pointer, or nil on error
---@class ItemPointer
---@field IsNotNull fun(self:ItemPointer):boolean
---@field Clone fun(self:ItemPointer):ItemPointer
---@class RichText
---@field SetText fun(self:RichText, text:string, format?:boolean, tokens?:boolean)
---@field GetText fun(self:RichText):string
---@field Clone fun(self:RichText):RichText
local function createNoteRecordFromRichText(noteType, richText)
local tag = (noteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
local ptrNote = fhCreateItem(tag)
local ptrText = fhCreateItem(TAG_TEXT, ptrNote, true)
local success = fhSetValueAsRichText(ptrText, richText)
if not success then
MessageBox("error", "Failed to set content for new " .. noteType:gsub("s$", ""))
return nil
end
return ptrNote
end
--- Check whether a target already has a link to the given note
--- @param targetPtr ItemPointer Target record pointer
--- @param notePtr ItemPointer Note record pointer
--- @param noteLinkTag string Link tag to scan on the target ("NOTE" or "_RNOT")
--- @return boolean
local function isNoteLinkedToTarget(targetPtr, notePtr, noteLinkTag)
--we can't use LinksTo or LinksFrom because they include embedded links in the counts they return, so we need to scan the target manually
local p = fhNewItemPtr()
p:MoveTo(targetPtr, "~." .. noteLinkTag)
while p:IsNotNull() do
local linked = fhGetValueAsLink(p)
if linked and linked:IsNotNull() and linked:IsSame(notePtr) then
return true
end
p:MoveNext("SAME_TAG")
end
return false
end
--- Link an existing note record to a target if not already linked
--- @param targetPtr ItemPointer
--- @param notePtr ItemPointer
--- @param noteLinkTag string
--- @return boolean linked True if a link was created
local function linkNoteToTarget(targetPtr, notePtr, noteLinkTag)
if isNoteLinkedToTarget(targetPtr, notePtr, noteLinkTag) then
return false -- Already linked
end
local linkItem = fhCreateItem(noteLinkTag, targetPtr)
fhSetValueAsLink(linkItem, notePtr)
return true
end
--- Create a new note record from an AutoText template file
--- @param template table One entry from selectedNotes with fields .filePath
--- @param noteType string Either NOTE_TYPE_SHARED or NOTE_TYPE_RESEARCH
--- @param targetPtr? ItemPointer Optional target record pointer for link embedding
--- @return ItemPointer|nil ptrNewNote The created note record pointer
local function createNoteRecordFromAutoText(template, noteType, targetPtr)
local content, err = fhfu.readTextFile(template.filePath, true, 8)
if not content then
MessageBox(
"error",
"AutoText file could not be read: "
.. (template.displayText or template.filePath)
.. (err and (" - " .. err) or "")
)
return nil
end
if fh.isSet(targetPtr) then
local linkToken = myConfig:getValue("Preferences", "linkToken", "")
local safeRecordName = fhGetDisplayText(targetPtr)
if not fh.isSet(safeRecordName) then
safeRecordName = "(unnamed)"
end --handle the case where the name comes back blank, to avoid leaving tokens in the autotext
safeRecordName = fhFtfEncode(safeRecordName)
if fh.isSet(linkToken) then
local link = string.format('', fhGetQualifiedRecordId(targetPtr), safeRecordName)
content = string.gsub(content, "{" .. linkToken .. "}", link)
end
local nameToken = myConfig:getValue("Preferences", "nameToken", "")
if fh.isSet(nameToken) then
content = string.gsub(content, "{" .. nameToken .. "}", safeRecordName)
end
end
local rt = fhNewRichText()
rt:SetText(content, true, true)
local ptrNote = createNoteRecordFromRichText(noteType, rt) --create the note record
if not ptrNote then
MessageBox("error", "Failed to set content from AutoText: " .. (template.displayText or template.filePath))
return nil
else
return ptrNote
end
end
--- Apply the current selections to targets
function applySelections()
local noteLinkTag = (currentNoteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
local totalLinked = 0
local totalCreated = 0
-- Prepare progress controller
local numNotes = #selectedNotes
local numTargets = #selectedTargets
local totalSteps = numNotes * numTargets
-- Tunables: tweak here in code (not user-configurable)
local UPDATE_PERCENT = 5
local SHOW_THRESHOLD = 20
local progress = Progress.new(totalSteps, UPDATE_PERCENT, SHOW_THRESHOLD, dlgmain)
-- progress dialog shows itself on demand via ProgressController
if currentOperationMode == MODE_SELECT_EXISTING then
-- Link existing notes to each target
for _, note in ipairs(selectedNotes) do
local notePtr = note.recordPointer
if fh.isSet(notePtr) then
for _, target in ipairs(selectedTargets) do
local targetPtr = target.recordPointer
if fh.isSet(targetPtr) then
if progress:isCancelled() then
progress:update("Cancelling...")
progress:finish()
fhUpdateDisplay()
return { created = totalCreated, linked = totalLinked }
end
if linkNoteToTarget(targetPtr, notePtr, noteLinkTag) then
myResults.Update({
currentNoteType:gsub("s$", "") .. " Linked", -- Action
targetPtr:Clone(), -- Target
notePtr:Clone(), -- Note
})
totalLinked = totalLinked + 1
else
-- Note was already linked
myResults.Update({
currentNoteType:gsub("s$", "") .. " Already Linked", -- Action
targetPtr:Clone(), -- Target
notePtr:Clone(), -- Note
})
end
progress:update(string.format("%d/%d", (progress.step + 1), progress.totalSteps))
end
end
end
end
else
-- Create a new note from each AutoText for each target and link it
for _, template in ipairs(selectedNotes) do
if template.type == "autotext" and template.filePath then
for _, target in ipairs(selectedTargets) do
local targetPtr = target.recordPointer
if fh.isSet(targetPtr) then
if progress:isCancelled() then
progress:update("Cancelling...")
progress:finish()
fhUpdateDisplay()
return { created = totalCreated, linked = totalLinked }
end
local ptrNote = createNoteRecordFromAutoText(template, currentNoteType, targetPtr)
if fh.isSet(ptrNote) then
-- For autotext mode, create a single result entry for "Created and Linked"
local linkItem = fhCreateItem(noteLinkTag, targetPtr)
fhSetValueAsLink(linkItem, ptrNote)
myResults.Update({
currentNoteType:gsub("s$", "") .. " Created and Linked", -- Action
targetPtr:Clone(), -- Target
ptrNote:Clone(), -- Note
})
totalCreated = totalCreated + 1
totalLinked = totalLinked + 1
end
progress:update(string.format("%d/%d", (progress.step + 1), progress.totalSteps))
end
end
end
end
end
progress:finish()
fhUpdateDisplay()
return { created = totalCreated, linked = totalLinked }
end
-- Helper function to show apply results message
function showApplyResults(res)
local message
if currentOperationMode == MODE_CREATE_FROM_AUTOTEXT then
-- For autotext mode, show more informative message
if res.created and res.created > 0 then
local numTemplates = #selectedNotes
local numTargets = #selectedTargets
if numTemplates > 0 and numTargets > 0 then
local noteText = res.created == 1 and "note" or "notes"
local targetText = numTargets == 1 and "target" or "targets"
message = string.format("%d %s created for %d %s", res.created, noteText, numTargets, targetText)
else
local noteText = res.created == 1 and "note" or "notes"
message = string.format("%d %s created", res.created, noteText)
end
else
message = "No notes created"
end
else
-- For existing notes mode, show traditional message
local parts = {}
if res.created and res.created > 0 then
table.insert(parts, string.format("%d created", res.created))
end
if res.linked and res.linked > 0 then
table.insert(parts, string.format("%d linked", res.linked))
end
if #parts > 0 then
message = "Applied: " .. table.concat(parts, ", ")
else
message = "No changes applied"
end
end
MessageBox("info", message)
end
--------------------------------------------------------------
--NEW NOTE CREATION FUNCTIONALITY
--------------------------------------------------------------
--- Create a new autotext template file
--- @param richText RichText The rich text object to save
function createNewAutoTextTemplate(richText)
-- Re-open dialog until user selects valid path inside AUTOTEXT_DIR or cancels
local function normalizePath(p)
if not p or p == "" then
return ""
end
local s = p:gsub("\\", "/")
-- remove trailing slash ("$" is the end-of-string anchor; "%$" would match a literal dollar sign)
s = s:gsub("/+$", "")
return s
end
local rootNormLower = normalizePath(AUTOTEXT_DIR):lower()
local filedlg = iup.filedlg({
dialogtype = "SAVE",
title = "Save AutoText Template",
directory = AUTOTEXT_DIR,
extfilter = "Family Historian AutoText (*.ftf)|*.ftf",
file = "New AutoText Template",
nochangedir = "YES",
parentdialog = identifyActiveWindow(),
})
while true do
-- Always (re)open in the AutoText folder, so a rejected attempt
-- outside it returns the user to the right place instead of wherever
-- they had navigated to.
filedlg.directory = AUTOTEXT_DIR
filedlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if filedlg.status ~= "1" then
break
end
local chosenPath = filedlg.value or ""
if chosenPath ~= "" then
-- Ensure .ftf extension using fhFileUtils.splitPath
local pathWithExt = chosenPath
local parts = fhfu.splitPath(chosenPath)
if (parts.ext or ""):lower() ~= EXT_AUTOTEXT then
pathWithExt = chosenPath .. "." .. EXT_AUTOTEXT
end
-- Ensure within AUTOTEXT_DIR (normalize separators and compare prefix)
local fileNormLower = normalizePath(pathWithExt):lower()
if
fileNormLower:sub(1, #rootNormLower) == rootNormLower
and (
fileNormLower:sub(#rootNormLower + 1, #rootNormLower + 1) == "/"
or #fileNormLower == #rootNormLower
)
then
-- Overwrite check
if
not fhfu.fileExists(pathWithExt)
or MessageBox("question", "File already exists. Do you want to overwrite it?", "YESNO") == "Yes"
then
local rtString = richText:GetText()
local success, error = fhfu.createTextFile(pathWithExt, true, true, rtString, 8)
if success then
local templates = convertFilePathsToTemplates({ pathWithExt })
local template = templates[1]
table.insert(selectedNotes, template)
populateNotesList()
MessageBox("info", "AutoText template created successfully: " .. template.displayText)
myResults.Update({
"AutoText Template Created: " .. template.displayText, -- Action
fhNewItemPtr(), -- Null pointer for target
fhNewItemPtr(), -- Null pointer for note
})
break
else
MessageBox("error", "Failed to save AutoText template: " .. error)
-- Loop again to allow user to retry
end
end
else
MessageBox("error", "AutoText templates must be saved within the AutoText directory.")
-- Loop continues to re-open the dialog
end
end
end
filedlg:destroy()
end
--- Create a new note record
--- @param richText RichText The rich text object for the note
function createNewNoteRecord(richText)
local ptrNote = createNoteRecordFromRichText(currentNoteType, richText)
if not ptrNote then
return
end
-- Add result entry for the created note
myResults.Update({
currentNoteType:gsub("s$", "") .. " Created", -- Action
fhNewItemPtr(), -- Null pointer for target
ptrNote:Clone(), -- Note,
})
local tag = (currentNoteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
local note = {
recordPointer = ptrNote,
displayText = fhGetDisplayText(ptrNote) .. " (ID: " .. fhGetRecordId(ptrNote) .. ")",
recordType = tag,
noteType = currentNoteType,
}
table.insert(selectedNotes, note)
populateNotesList()
MessageBox("info", "New " .. currentNoteType:gsub("s$", "") .. " created successfully: " .. note.displayText)
end
--- Create a new note or autotext template based on current mode
function createNewNote()
-- Create a new rich text object for the prompt
local initialRichText = fhNewRichText("", true)
-- Display rich text prompt
local richText = fhPromptUserForRichText(initialRichText)
if not richText then
-- User cancelled
return
end
if currentOperationMode == MODE_CREATE_FROM_AUTOTEXT then
createNewAutoTextTemplate(richText)
else
createNewNoteRecord(richText)
end
fhUpdateDisplay()
end
--------------------------------------------------------------
--HELPER FUNCTIONS
--------------------------------------------------------------
--- Convert selected records to a standardized format
--- @param selectedRecords ItemPointer[] Array of selected record pointers
--- @param recordTag string The record type tag
--- @param additionalFields? table Additional fields to add to each record
--- @return table[] Array of formatted records
function convertRecordsToStandardFormat(selectedRecords, recordTag, additionalFields)
local records = {}
for i = 1, #selectedRecords do
local displayText = "["
.. (displayNames[recordTag] or recordTag)
.. "] "
.. fhGetDisplayText(selectedRecords[i])
.. " (ID: "
.. fhGetRecordId(selectedRecords[i])
.. ")"
local record = {
recordPointer = selectedRecords[i],
displayText = displayText,
recordType = recordTag,
}
-- Add any additional fields
if additionalFields then
for key, value in pairs(additionalFields) do
record[key] = value
end
end
table.insert(records, record)
end
return records
end
--- Check if a record already exists in a collection by comparing pointers
--- @param newRecord table The new record to check
--- @param existingRecords table[] Array of existing records
--- @return boolean True if the record is a duplicate
function isRecordDuplicate(newRecord, existingRecords)
for _, existingRecord in ipairs(existingRecords) do
-- For autotext templates, compare file paths
if newRecord.type == "autotext" and existingRecord.type == "autotext" then
if newRecord.filePath == existingRecord.filePath then
return true
end
-- For regular records, compare record pointers
elseif newRecord.recordPointer and existingRecord.recordPointer then
if existingRecord.recordPointer:IsSame(newRecord.recordPointer) then
return true
end
end
end
return false
end
--- Add records to a collection with duplicate checking
--- @param newRecords table[] New records to add
--- @param existingRecords table[] Existing records collection
--- @param updateDisplay function Function to call to update the display
function addRecordsWithDuplicateCheck(newRecords, existingRecords, updateDisplay)
for i, newRecord in ipairs(newRecords) do
if not isRecordDuplicate(newRecord, existingRecords) then
table.insert(existingRecords, newRecord)
end
end
-- Update the display
if updateDisplay then
updateDisplay()
end
end
--- Convert a single record pointer to standard format
--- @param recordPtr ItemPointer The record pointer
--- @param additionalFields? table Additional fields to add
--- @return table Formatted record
function convertRecordPointerToStandardFormat(recordPtr, additionalFields)
local recordType = fhGetTag(recordPtr)
local displayText = "["
.. (displayNames[recordType] or recordType)
.. "] "
.. fhGetDisplayText(recordPtr)
.. " (ID: "
.. fhGetRecordId(recordPtr)
.. ")"
local record = {
recordPointer = recordPtr,
displayText = displayText,
recordType = recordType,
}
-- Add any additional fields
if additionalFields then
for key, value in pairs(additionalFields) do
record[key] = value
end
end
return record
end
--- Compute and apply the dialog title based on current mode and note type
function getDialogTitle()
local modePart = (currentOperationMode == MODE_SELECT_EXISTING) and "Select Existing" or "Create from AutoText"
local typePart = currentNoteType
return "Add Notes — " .. modePart .. " — " .. typePart
end
function updateAddNotesDialogTitle()
if dlgmain and dlgmain.title then
updateDialogTitle(dlgmain, getDialogTitle())
end
end
--- Clear the Notes list UI and any stored selections
function clearNotesList()
selectedNotes = {}
if ui.notesList then
ui.notesList.REMOVEITEM = "ALL"
iup.Refresh(ui.notesList)
end
end
--- Remove selected entries from the Notes/AutoText list
function removeSelectedNotes()
local list = ui.notesList
if not list then
return
end
local positions = getSelectedValues(list, true)
local count = #positions
if count == 0 then
MessageBox("info", "Select one or more notes/autotext entries to remove.")
return
end
if not confirmRemoveSelected("Notes", count) then
return
end
removeSelectedItems(list, selectedNotes, populateNotesList)
end
--- View/Edit the currently selected note or autotext entry
function editSelectedNoteOrAutotext()
local list = ui.notesList
if not list then
return
end
local positions = getSelectedValues(list, true)
if #positions == 0 then
MessageBox("info", "Select a note or AutoText to view/edit.")
return
end
if #positions > 1 then
MessageBox("warning", "Please select only one item to view/edit.")
return
end
local index = tonumber(positions[1]) or 0
if index < 1 or index > #selectedNotes then
return
end
local entry = selectedNotes[index]
-- Editing an existing Note record
if entry and entry.recordPointer and entry.recordPointer:IsNotNull() then
local ptrText = fhGetItemPtr(entry.recordPointer, "~.TEXT")
if not (ptrText and ptrText:IsNotNull()) then
MessageBox("error", "Could not locate the TEXT field for this note.")
return
end
local currentRt = fhGetValueAsRichText(ptrText)
local originalText = currentRt:GetText()
local editedRt = fhPromptUserForRichText(currentRt)
if not editedRt then
return -- user cancelled
end
local editedText = editedRt:GetText()
if originalText == editedText then
-- No changes made, just viewed
return
end
local ok = fhSetValueAsRichText(ptrText, editedRt)
if not ok then
MessageBox("error", "Failed to save edited note content.")
return
end
-- Refresh display text for the edited note
entry.displayText = fhGetDisplayText(entry.recordPointer)
.. " (ID: "
.. fhGetRecordId(entry.recordPointer)
.. ")"
-- Report change in results
myResults.Update({
currentNoteType:gsub("s$", "") .. " Edited",
fhNewItemPtr(), -- no specific target
entry.recordPointer:Clone(),
})
populateNotesList()
fhUpdateDisplay()
return
end
-- Editing an AutoText template file
if entry and entry.type == "autotext" and entry.filePath then
local content, err = fhfu.readTextFile(entry.filePath, true, 8)
if not content then
MessageBox(
"error",
"AutoText file could not be read: "
.. (entry.displayText or entry.filePath)
.. (err and (" - " .. err) or "")
)
return
end
local rt = fhNewRichText()
rt:SetText(content, true, true)
local originalRtString = rt:GetText()
local editedRt = fhPromptUserForRichText(rt)
if not editedRt then
return -- user cancelled
end
local editedRtString = editedRt:GetText()
if originalRtString == editedRtString then
-- No changes made, just viewed
return
end
local success, error = fhfu.createTextFile(entry.filePath, true, true, editedRtString, 8)
if success then
MessageBox("info", "AutoText template saved: " .. (entry.displayText or entry.filePath))
-- Report change in results
myResults.Update({
"AutoText Edited: " .. (entry.displayText or entry.filePath),
fhNewItemPtr(), -- target N/A
fhNewItemPtr(), -- note N/A (file-based)
})
else
MessageBox("error", "Failed to save AutoText template: " .. (error or "unknown error"))
end
return
end
MessageBox("error", "Unsupported selection type for view/edit.")
end
--- Ask user to confirm clearing the Notes list before a context-changing action
--- @return boolean proceed True if user confirmed
function confirmClearNotes(contextLabel, newValue)
local function notesExistToClear()
local notesList = ui.notesList
if not notesList then
return false
end
local count = tonumber(notesList.COUNT) or 0
return count > 0
end
if not notesExistToClear() then
return true
end
local answer = MessageBox(
"question",
"Switching "
.. contextLabel
.. " to '"
.. tostring(newValue)
.. "' will clear the selected Notes list. Do you want to continue?",
"YESNO"
)
if answer == "Yes" then
clearNotesList()
return true
end
return false
end
--- Ask for confirmation before removing selected list items
--- @param listLabel string Human-readable list name (e.g., "Targets", "Notes")
--- @param count integer Number of items to remove
--- @return boolean proceed True if user confirmed
function confirmRemoveSelected(listLabel, count)
local itemWord = (count == 1) and "item" or "items"
local answer =
MessageBox("question", string.format("Remove %d %s from the %s list?", count, itemWord, listLabel), "YESNO")
return answer == "Yes"
end
--- Get the current note type from configuration or default
---@return string
function getCurrentNoteType()
if myConfig:getBool("Preferences", "useLastSettings", true) then
return myConfig:getString("Preferences", "noteType", NOTE_TYPE_SHARED)
else
return NOTE_TYPE_SHARED
end
end
--- Get the current operation mode from configuration or default
---@return string
function getCurrentOperationMode()
if myConfig:getBool("Preferences", "useLastSettings", true) then
return myConfig:getString("Preferences", "operationMode", MODE_SELECT_EXISTING)
else
return MODE_SELECT_EXISTING
end
end
--- Save current preferences to configuration
function savePreferences()
if myConfig:getBool("Preferences", "useLastSettings", true) then
myConfig:setValues("Preferences", nil, {
noteType = currentNoteType,
operationMode = currentOperationMode,
})
end
end
--- Update the content area when mode or note type changes
function updateContentArea()
if not (ui.contentArea and ui.targetRecordsList and ui.notesList and ui.notesLabel) then
return
end
-- Update label title and list selection mode
if currentOperationMode == MODE_SELECT_EXISTING then
ui.notesLabel.title = "Existing " .. currentNoteType .. ":"
ui.notesList.multiple = "YES"
ui.notesList.tip =
"Selected existing notes. Use the Notes menu to add or remove.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
else
ui.notesLabel.title = "AutoText for " .. currentNoteType .. ":"
ui.notesList.multiple = "YES"
ui.notesList.tip =
"Selected AutoText templates for note creation. Use the Notes menu to add or remove.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- F5: Refresh list\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
end
-- Repopulate the lists
populateTargetRecords()
populateNotesList()
iup.Refresh(dlgmain)
updateMenuTitles()
updateMenuChecks()
updateAddNotesDialogTitle()
end
-- Handle state transitions
function changeNoteType(newType)
if currentNoteType == newType then
return iup.DEFAULT
end
if not confirmClearNotes("Note Type", newType) then
updateMenuChecks()
return iup.DEFAULT
end
currentNoteType = newType
savePreferences()
updateContentArea()
updateMenuTitles()
updateMenuChecks()
updateTargetMenuStates() -- Update target menu item states
updateAddNotesDialogTitle()
return iup.DEFAULT
end
function changeMode(newMode)
if currentOperationMode == newMode then
return iup.DEFAULT
end
if
not confirmClearNotes(
"Mode",
(newMode == MODE_SELECT_EXISTING) and "Select Existing Notes" or MODE_CREATE_FROM_AUTOTEXT
)
then
updateMenuChecks()
return iup.DEFAULT
end
currentOperationMode = newMode
savePreferences()
updateContentArea()
updateMenuTitles()
updateMenuChecks()
updateAddNotesDialogTitle()
return iup.DEFAULT
end
--- Create the content area with two separate boxes side by side
---@return iup.vbox
function createContentArea()
local content = iup.vbox({ expand = "YES" })
ui.contentArea = content
-- Create the two boxes side by side
local leftBox = iup.vbox({ expand = "YES" })
local rightBox = iup.vbox({ expand = "YES" })
-- Target records box (left side)
local targetLabel = makeLongLabel({
title = "Target Records:",
})
iup.Append(leftBox, targetLabel)
local targetList = makeList({
dropdown = "NO",
expand = "YES",
multiple = "YES",
visiblelines = "12",
name = "targetRecordsList",
tip = "Selected target records. Use the Targets menu to add or remove.\n\nUse the File menu to add notes to the selected records\n\nShortcuts:\n- Ctrl+I: Select Individuals\n- Ctrl+F: Select Families\n- Ctrl+T: Clear Targets\n- Ctrl+A: Select all\n- Del: Remove selected",
})
iup.Append(leftBox, targetList)
ui.targetRecordsList = targetList
-- Keyboard shortcuts for targets list
targetList.k_any = function(self, c)
if c == iup.K_cA then -- Ctrl+A: select all (only if multi-select)
if self.multiple == "YES" then
local count = tonumber(self.COUNT) or 0
if count > 0 then
self.value = string.rep("+", count)
return iup.IGNORE
end
end
elseif c == iup.K_cT then -- Ctrl+T: clear targets
selectedTargets = {}
populateTargetRecords()
return iup.IGNORE
elseif c == iup.K_DEL then -- Delete: remove selected
removeSelectedTargets()
return iup.IGNORE
end
return iup.CONTINUE
end
-- Notes/Autotext box (right side)
local notesLabel = makeLongLabel({
title = (currentOperationMode == MODE_SELECT_EXISTING) and ("Existing " .. currentNoteType .. ":")
or ("AutoText Templates for " .. currentNoteType .. ":"),
})
iup.Append(rightBox, notesLabel)
ui.notesLabel = notesLabel
local notesList = makeList({
dropdown = "NO",
expand = "YES",
multiple = "YES",
visiblelines = "12",
name = "notesList",
tip = (function()
local common =
"\n\nUse the Notes menu to add or remove, or to change the Note Type or Mode.\n\nUse the Apply menu to add notes to the selected records.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- F5: Refresh list\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
local prefix = (currentOperationMode == MODE_SELECT_EXISTING) and "Selected existing notes."
or "Selected AutoText templates for note creation."
return prefix .. common
end)(),
})
iup.Append(rightBox, notesList)
ui.notesList = notesList
-- Keyboard shortcuts for notes list
notesList.k_any = function(self, c)
if c == iup.K_cA then -- Ctrl+A: select all (only if multi-select)
if self.multiple == "YES" then
local count = tonumber(self.COUNT) or 0
if count > 0 then
self.value = string.rep("+", count)
return iup.IGNORE
end
end
elseif c == iup.K_cN then -- Ctrl+N: new note/autotext
createNewNote()
return iup.IGNORE
elseif c == iup.K_cE then -- Ctrl+E: edit selected
editSelectedNoteOrAutotext()
return iup.IGNORE
elseif c == iup.K_F5 then -- Refresh list
populateNotesList()
return iup.IGNORE
elseif c == iup.K_CR or c == iup.K_SP then -- Enter or Space: view/edit
editSelectedNoteOrAutotext()
return iup.IGNORE
elseif c == iup.K_DEL then -- Delete: remove selected
removeSelectedNotes()
return iup.IGNORE
end
return iup.CONTINUE
end
-- Put the two boxes side by side using a grid to unify widths
local grid = makeGridbox({
leftBox,
rightBox,
numdiv = "2",
alignmentlin = "ATOP",
expandchildren = "HORIZONTAL",
shrink = "YES",
})
iup.Append(content, grid)
return content
end
--- Populate the target records list
function populateTargetRecords()
local targetList = ui.targetRecordsList
if targetList then
-- Clear the list first
targetList.REMOVEITEM = "ALL"
-- Only populate if we have targets
if #selectedTargets > 0 then
-- Create display text array for populateList
local displayTexts = {}
for _, target in ipairs(selectedTargets) do
table.insert(displayTexts, target.displayText or tostring(target))
end
-- Use populateList helper
populateList(targetList, displayTexts)
end
updateApplyMenuState()
end
end
--- Populate the notes/autotext list
function populateNotesList()
local notesList = ui.notesList
if notesList then
-- Clear the list first
notesList.REMOVEITEM = "ALL"
-- Only populate if we have notes
if #selectedNotes > 0 then
-- Create display text array for populateList
local displayTexts = {}
for _, note in ipairs(selectedNotes) do
table.insert(displayTexts, note.displayText)
end
-- Use populateList helper
populateList(notesList, displayTexts)
end
updateApplyMenuState()
end
end
--------------------------------------------------------------
--TARGET AND NOTE SELECTION FUNCTIONALITY
--------------------------------------------------------------
--- Convert file paths to autotext template format
--- @param filePaths string[] Array of file paths
--- @return table[] Array of autotext template objects
function convertFilePathsToTemplates(filePaths)
local templates = {}
for _, filePath in ipairs(filePaths) do
-- Use fhFileUtils.splitPath to decompose the path
local pathParts = fhfu.splitPath(filePath)
-- Create relative path under autotext root for display
local relativePath = filePath:sub(#AUTOTEXT_DIR + 2) -- Remove root dir + separator
local displayText = relativePath:gsub("\\", " / ") -- Replace backslashes with forward slashes for readability
displayText = displayText:gsub("%." .. EXT_AUTOTEXT .. "$", "") -- Remove .ftf extension
local template = {
type = "autotext",
filePath = filePath,
fileName = pathParts.filename,
baseName = pathParts.basename,
directory = pathParts.parent,
extension = pathParts.ext,
displayText = displayText,
}
table.insert(templates, template)
end
return templates
end
--- @class RecordTypeInfo
--- @field tag string The record type tag (e.g., "INDI", "FAM", "SOUR")
--- @field displayName string Human-readable name for the record type
--- @field menuName string Menu string for the record type
--- @field isSupported boolean Whether this record type is supported in the current mode
--- @field hasRecords boolean Whether there are any records of this type in the current project
--- @field count number Number of records of this type in the current project
--- @class TargetRecord
--- @field recordPointer ItemPointer The Family Historian record pointer
--- @field displayText string The display text for the record
--- @field recordType string The record type tag
--- Gets information about all available record types
--- @param supportedTypes? table Table of record type tags that are supported in current mode
--- @return RecordTypeInfo[] Array of record type information
function getRecordTypesInfo(supportedTypes)
supportedTypes = supportedTypes or {}
local recordTypes = {}
-- Get count of record types
local iCount = fhGetRecordTypeCount()
-- Loop through record types and gather information
for i = 1, iCount do
local tag = fhGetRecordTypeTag(i)
local isSupported = supportedTypes[tag] ~= false -- Default to supported unless explicitly disabled
-- Create display name from tag (convert INDI to Individual, FAM to Family, etc.)
local displayName = displayNames[tag] or tag
local menuName = menuNames[tag] or (displayName .. "s")
table.insert(recordTypes, {
tag = tag,
displayName = displayName,
menuName = menuName,
isSupported = isSupported,
})
end
return recordTypes
end
--- Prompts user to select records of a specific type
--- @param recordTag string The record type tag to select
--- @param parentWindow? any Parent window handle for record selection
function selectTargetRecords(recordTag, parentWindow)
-- Get parent window handle
local hParentWnd = getParentWindowHandle(parentWindow)
-- Prompt user for record selection
local selectedRecords = fhPromptUserForRecordSel(recordTag, -1, hParentWnd)
if selectedRecords and #selectedRecords > 0 then
-- Convert to target record format and add to selected targets
local targets = convertRecordsToStandardFormat(selectedRecords, recordTag)
addRecordsWithDuplicateCheck(targets, selectedTargets, populateTargetRecords)
end
end
--- Prompts user to select existing notes
--- @param noteType string The note type ("Shared Notes" or "Research Notes")
--- @param parentWindow? any Parent window handle for record selection
function selectExistingNotes(noteType, parentWindow)
-- Get parent window handle
local hParentWnd = getParentWindowHandle(parentWindow)
-- Determine the record tag based on note type
local recordTag = (noteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
-- Prompt user for note selection
local selectedRecords = fhPromptUserForRecordSel(recordTag, -1, hParentWnd)
if selectedRecords and #selectedRecords > 0 then
-- Build note entries directly without prefixes
local notes = {}
for i = 1, #selectedRecords do
local ptr = selectedRecords[i]
table.insert(notes, {
recordPointer = ptr,
displayText = fhGetDisplayText(ptr) .. " (ID: " .. fhGetRecordId(ptr) .. ")",
recordType = recordTag,
noteType = noteType,
})
end
addRecordsWithDuplicateCheck(notes, selectedNotes, populateNotesList)
end
end
--- Present Autotext file selection dialog
function selectAutotextViaTree()
-- Use simple file dialog instead of complex FileSelector
local filedlg = iup.filedlg({
dialogtype = "OPEN",
title = "Select AutoText Template(s)",
directory = AUTOTEXT_DIR,
extfilter = "AutoText Templates (*.ftf)|*.ftf",
multiple = "YES",
nochangedir = "YES",
parentdialog = dlgmain,
})
filedlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if filedlg.status == "1" or filedlg.status == "0" then
local selectedPaths = {}
local value = filedlg.value
if value and value ~= "" then
-- Handle multiple file selection
if filedlg.status == "1" then
-- Single file
table.insert(selectedPaths, value)
else
-- Multiple files - value contains multiple paths separated by semicolons
for path in value:gmatch("[^;]+") do
path = path:match("^%s*(.-)%s*$") -- trim whitespace
if path ~= "" then
table.insert(selectedPaths, path)
end
end
end
end
filedlg:destroy()
if #selectedPaths > 0 then
-- Convert selected file paths to autotext template format
local templates = convertFilePathsToTemplates(selectedPaths)
-- Add templates to selected notes with duplicate checking
addRecordsWithDuplicateCheck(templates, selectedNotes, populateNotesList)
end
else
filedlg:destroy()
end
end
--- Gets initial targets from current selection or property box
--- @param initialTargets? TargetRecord[] Pre-defined initial targets
--- @return TargetRecord[] Array of initial targets
function getInitialTargets(initialTargets)
if initialTargets and #initialTargets > 0 then
return initialTargets
end
local targets = {}
-- Try to get current selection first
local currentSelection = fhGetCurrentRecordSel()
if currentSelection and #currentSelection > 0 then
for _, recordPtr in ipairs(currentSelection) do
table.insert(targets, convertRecordPointerToStandardFormat(recordPtr))
end
else
-- Fallback to property box record
local propertyBoxRecord = fhGetCurrentPropertyBoxRecord()
if propertyBoxRecord and propertyBoxRecord:IsNotNull() then
table.insert(targets, convertRecordPointerToStandardFormat(propertyBoxRecord))
end
end
return targets
end
--- Get the supported record types based on current note type and mode
--- @return table Table of supported record type tags
function getSupportedRecordTypes()
local supportedTypes = {}
-- Get all record types and check their support status
local iCount = fhGetRecordTypeCount()
for i = 1, iCount do
local tag = fhGetRecordTypeTag(i)
supportedTypes[tag] = isRecordTypeSupportedForNoteType(tag, currentNoteType)
end
return supportedTypes
end
--- Check if a record type is supported for a specific note type
--- @param recordTag string The record type tag to check
--- @param noteType string The note type to check ("Shared Notes" or "Research Notes")
--- @return boolean True if the record type is supported for the note type
function isRecordTypeSupportedForNoteType(recordTag, noteType)
-- Check if it's completely unsupported
for _, tag in ipairs(recordTypeSupport.completelyUnsupported) do
if tag == recordTag then
return false
end
end
-- Check note-type-specific restrictions
if noteType == NOTE_TYPE_RESEARCH then
for _, tag in ipairs(recordTypeSupport.researchNotesUnsupported) do
if tag == recordTag then
return false
end
end
else -- Shared Notes
for _, tag in ipairs(recordTypeSupport.sharedNotesUnsupported) do
if tag == recordTag then
return false
end
end
end
return true
end
--- Get all record types that are supported for a specific note type
--- @param noteType string The note type to check ("Shared Notes" or "Research Notes")
--- @return string[] Array of supported record type tags
function getSupportedRecordTypesForNoteType(noteType)
local supportedTypes = {}
local iCount = fhGetRecordTypeCount()
for i = 1, iCount do
local tag = fhGetRecordTypeTag(i)
if isRecordTypeSupportedForNoteType(tag, noteType) then
table.insert(supportedTypes, tag)
end
end
return supportedTypes
end
--- Get the current record type support configuration
--- @return table The current record type support configuration
function getRecordTypeSupportConfig()
return recordTypeSupport
end
--- Initialize targets from current selection or property box
function initializeTargets()
selectedTargets = getInitialTargets()
populateTargetRecords()
end
--- Add new targets to the current selection
--- @param newTargets TargetRecord[] New targets to add
function addTargets(newTargets)
addRecordsWithDuplicateCheck(newTargets, selectedTargets, populateTargetRecords)
end
--- Add new notes to the current selection
--- @param newNotes table[] New notes to add
function addNotes(newNotes)
addRecordsWithDuplicateCheck(newNotes, selectedNotes, populateNotesList)
end
--- Remove selected targets from the list
function removeSelectedTargets()
local list = ui.targetRecordsList
if not list then
return
end
local positions = getSelectedValues(list, true)
local count = #positions
if count == 0 then
MessageBox("info", "Select one or more targets to remove.")
return
end
if not confirmRemoveSelected("Targets", count) then
return
end
removeSelectedItems(list, selectedTargets, populateTargetRecords)
end
--------------------------------------------------------------
--MAIN DIALOG ACTIONS
--------------------------------------------------------------
function makeMainDialog()
-- Create content area
local contentArea = createContentArea()
-- Create the main content area
local mainVBox = iup.vbox({
contentArea,
margin = "10x10",
gap = "10",
})
ui.mainVBox = mainVBox
-- Populate the lists initially
initializeTargets()
populateNotesList()
-- Create menu bar using the centralized function
local menuBar = createMainMenuBar()
-- Create the main dialog
local dialog = makeDialog(mainVBox, {
title = "Add Notes",
size = "HALFxHALF",
expand = "YES",
resize = "YES",
menubox = "YES",
menu = menuBar,
help_topic = "",
close_cb = function(self)
return iup.CLOSE
end,
})
-- Apply initial dynamic title reflecting current state
dialog.title = getDialogTitle()
return dialog
end
--------------------------------------------------------------
--EXECUTE
--------------------------------------------------------------
-- Create and show the main dialog
dlgmain = makeMainDialog()
myConfig:showTrackedDialog(dlgmain, "Main")
DoNormalize()
if iup.MainLoopLevel() == 0 then
iup.MainLoop()
end
destroyAllDialogs()
myResults.Display()
--[[
@Title: Add Notes
@Type: standard
@Author: Helen Wright
@Contributors:
@Version: 1.6
@LastUpdated: 15 June 2026
@Licence: This plugin is copyright (c) 2025 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: Plugin to attach Shared Notes or Research Notes to selected target records. Supports both selecting existing notes and creating new notes from autotext.
]]
--
--[[ChangeLog:
Version 1.6: the Options dialog's Help menu no longer shows an About item (it pointed at a non-existent help page); About is unchanged on the main window.
Version 1.5: The Options dialog now follows the Windows theme - light, dark or High Contrast - instead of always being white. Colours are read from the system at run time (via the shared Theme helper); in plain dark mode the buttons keep a light face with dark text, because Windows paints native button faces itself and a plugin can't repaint them. Behaviour and output are unchanged.
Version 1.4: The Name/Link placeholder fields now force UPPERCASE. Typed input converts to upper-case live (the field's UPPERCASE filter, which still allows digits); an existing token shows upper-cased when Options opens; and it is upper-cased on save. Because a placeholder is matched against the template text case-sensitively (as {NAME}), forcing upper-case removes the template/config case mismatch that 1.3's free-text fields allowed.
Version 1.3: Fixes: the name/link placeholder fields no longer restrict input to a single uppercase character (the input mask is removed - placeholder text is user-defined); when an AutoText template save is rejected for being outside the AutoText folder, the dialog reopens back in the AutoText folder.
Version 1.2: Reliability: the Options dialog is now built once and reused, rather than rebuilt on every open. Rebuilding it (a fresh dialog, menu and re-registered controls) corrupted native state and could silently crash FH the second time Options was opened in one run (the same fault fixed in the Add Trees plugin). Reopening now reloads the saved values into the existing dialog.
Version 1.1: Bug fixes: File menu items now appear in a fixed order (was random); number fields in Options regained their numeric input mask; corrected trailing-slash handling in autotext path validation; assistant ("...") buttons no longer lose their action and close the containing dialog; the Options dialog closes and reopens cleanly (no close callback fighting the popup teardown, and no destroy that corrupted menu state when closed via Save/Cancel)
Version 1.0: Initial release
]]
--------------------------------------------------------------
--FH VERSION CHECK
--------------------------------------------------------------
-- Ensure this plugin runs on Family Historian version 7 or higher
-- It might run on earlier versions but I'm not in a position to test or support them.
if fhGetAppVersion() < 7 then
fhMessageBox("This plugin requires FH7 and will exit.")
return
end
--------------------------------------------------------------
--EXTERNAL LIBRARIES
--------------------------------------------------------------
do
utf8 = require(".utf8"):init()
require("iuplua") -- UI
fh = require("fhUtils") --useful stuff
--fh.setIupDefaults() --initialise iup, including CUSTOMQUIT message and font and UTF8 support in FH and iup
--fh.setIupDefaults has a bug in emulators, so workaround instead of calling it directly
local function setIupDefaults()
local function getRegKey(key)
local sh = luacom.CreateObject("WScript.Shell")
local ans
if pcall(function()
ans = sh:RegRead(key)
end) then
return ans
else
return nil, true
end
end
iup.SetGlobal("CUSTOMQUITMESSAGE", "YES")
local isEmulated = os.getenv("WINEPREFIX") ~= nil
if not isEmulated then
-- Get Font and size from the Registry for the property box and use as the default font.
local v
v = getRegKey("HKEY_CURRENT_USER\\Software\\Calico Pie\\Family Historian\\2.0\\Preferences\\PDX Font")
local t = { stringx.splitv(v, ",") } -- first value is size and 14th is font name
iup.SetGlobal("DEFAULTFONT", t[14] .. " " .. t[1] / 20)
end --if not emulated, use the registry font, otherwise default to whatever the system provides
iup.SetGlobal("UTF8MODE", "YES")
iup.SetGlobal("UTF8MODE_FILE", "YES") --use UTF8 file names
fhSetStringEncoding("UTF-8")
end
setIupDefaults()
fhfu = require("fhFileUtils") -- utf8 compatible file handling library
end
-------------------------------------------------------------
--CONSTANTS
--------------------------------------------------------------
-- Environment variables
local PLUGIN_VERSION = "1.6"
local AUTOTEXT_DIR = fhGetContextInfo("CI_APP_DATA_FOLDER") .. "/Autotext"
-- Note Types
local NOTE_TYPE_SHARED = "Shared Notes"
local NOTE_TYPE_RESEARCH = "Research Notes"
-- Operation Modes
local MODE_SELECT_EXISTING = "Select Existing"
local MODE_CREATE_FROM_AUTOTEXT = "Create from AutoText"
-- Record Tags
local TAG_NOTE = "NOTE"
local TAG_RESEARCH_NOTE = "_RNOT"
local TAG_TEXT = "TEXT"
-- File Extensions
local EXT_AUTOTEXT = "ftf"
--------------------------------------------------------------
-- RECORD DISPLAY NAMES
--------------------------------------------------------------
local displayNames = {
INDI = "Individual",
FAM = "Family",
SOUR = "Source",
REPO = "Repository",
NOTE = "Note",
OBJE = "Media",
SUBN = "Submitter",
SUBM = "Submission",
_PLAC = "Place",
_HEAD = "Header",
_RNOT = "Research Note",
_SRCT = "Source Template",
}
local menuNames = {
INDI = "&Individuals",
FAM = "&Families",
SOUR = "&Sources",
REPO = "&Repositories",
NOTE = "Notes",
OBJE = "&Media",
SUBN = "Submitters",
SUBM = "Submissions",
_PLAC = "&Places",
_HEAD = "Headers",
_RNOT = "Research Notes",
_SRCT = "Source &Templates",
}
--------------------------------------------------------------
--RECORD TYPE SUPPORT CONFIGURATION
--------------------------------------------------------------
-- Configuration for which record types are supported for each note type
-- This makes it easy to modify support without changing code logic
local recordTypeSupport = {
-- Record types that are completely unsupported (won't appear in menu)
completelyUnsupported = {
"HEAD", --can't have notes on headers
"NOTE", --can't have notes on notes except by embedding them
"_RNOT", --can't have notes on research notes except by embedding them
"SUBM", --technically valid, but I don't think anyone would want to use this
"SUBN", --technically valid, but I don't think anyone would want to use this
},
-- Record types that are unsupported for Shared Notes only
sharedNotesUnsupported = {
-- Currently none, but add here
},
-- Record types that are unsupported for Research Notes only
researchNotesUnsupported = {
"_SRCT", --can only have notes on source templates, not research notes
},
}
--------------------------------------------------------------
--THEME HELPER
--------------------------------------------------------------
--[[
Reads the Windows theme so the IUP dialog follows the user's light / dark /
High Contrast preference. Colours come 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`, consumed by the Dialog helper below (Theme.iupColours /
Theme.isDarkMode / Theme.isHighContrast). Kept in sync with 2 Boilerplate\Theme.lua.
]]
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 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"),
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
_G.Theme = M
end
--------------------------------------------------------------
--HELP HANDLING
--------------------------------------------------------------
--[[
@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
-- Resolve Help once to a real instance so consumers can assume it exists
help = Help.new({})
--------------------------------------------------------------
--UTILITY MODULES AND CLASSES
--------------------------------------------------------------
--[[
Dialog V8
@Author: Helen Wright
@Version: 1.2
@LastUpdated: 15 June 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.
]]
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")
--fh.setIupDefaults() --turns on CUSTOMQUIT Message and sets default font to match the property box; commented out because a workaround is implemented at top of file
-- Use the global help instance resolved by the main plugin (referenced as `help`)
do --colours, layout, theming
local th = Theme.iupColours() -- resolved system colours, light-theme fallback (see Theme helper)
-- 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.isDarkMode() and not Theme.isHighContrast()
-- 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
-- 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
--- 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 = 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
--- 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",
}) ---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
--- 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 {}
local 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.
iup.SetHandle(options.name or options.title or generateRandomDigitString(), d)
-- 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
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_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
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
dlg:popup(iup.CENTER, iup.CENTER)
local result = dlg.buttonresponse
dlg:destroy()
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
local dialogOptions = {
title = strPrompt,
size = "QUARTERx",
}
local dialog = makeDialog(dialogContent, dialogOptions)
-- 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))
iup.SetHandle(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)
--TESTED: makeAssistantButton
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
iup.SetHandle(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
local selectionState = list.value -- 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
self.value = 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
iup.SetHandle(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))
iup.SetHandle(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 -- Additional UI components
--- 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
end
end
---Displays a hierarchical file/folder selection dialog using a tree control.
---The dialog allows users to browse through directories and select files or folders
---based on specified criteria. Files can be filtered by extension, and the dialog
---supports both single and multiple selection modes.
---
---The function builds a recursive tree structure starting from the root directory,
---separating folders and files, applying extension filters, and sorting items
---alphabetically. Folders are displayed as expandable branches, while files appear
---as leaf nodes. The dialog includes Select All and Clear All buttons for convenience.
---
---@param rootDirectory string Root directory to start browsing from
---@param extensions string[] File extensions to filter by (empty table shows all files)
---@param allowMultiple boolean Whether multiple files can be selected
---@param rootLabel string Label for the root item in the tree
---@param dialogTitle string Title for the selection dialog
---@param showExtensions boolean Whether to show file extensions in the tree
---@param foldersOnly boolean Whether to show only folders (if true, returns folder paths instead of file paths)
---@param parentDialog iup.dialog Optional parent dialog to use for positioning
---@return boolean Whether the function was successful
---@return string[] Table of selected files/folders or nil if unsuccessful
function FileSelector(
rootDirectory,
extensions,
allowMultiple,
rootLabel,
dialogTitle,
showExtensions,
foldersOnly,
parentDialog
)
fhfu = require("fhFileUtils")
if not rootDirectory then
return false, nil --root directory is required
end
extensions = extensions or {}
allowMultiple = allowMultiple or false
rootLabel = rootLabel or "Root"
dialogTitle = dialogTitle or "File Selector"
showExtensions = showExtensions == nil and true or showExtensions
foldersOnly = foldersOnly or false
-- Helper function to recursively build tree structure
local function buildTreeStructure(directoryPath, isRoot)
local treeData = {}
-- Get contents of current directory (non-recursive)
local contents, error = fhfu.getFolderContents(directoryPath, false, false)
if not contents then
if isRoot then
MessageBox("error", "Error reading root directory: " .. error, "OK")
return nil -- Return nil to indicate error
else
return treeData -- Return empty tree if subdirectory can't be read
end
end
local folders = {}
local files = {}
-- Separate folders and files, filter files by extension
for _, item in ipairs(contents) do
local pathParts = fhfu.splitPath(item.path)
if fhfu.folderExists(item.path) then
-- It's a folder
table.insert(folders, {
name = item.name,
path = item.path,
})
else
-- It's a file - check if extension matches
local fileExt = string.lower(pathParts.ext or "")
local matchesExtension = #extensions == 0 -- if no extensions specified, show all files
if not matchesExtension then
for _, ext in ipairs(extensions) do
if string.lower(ext) == fileExt then
matchesExtension = true
break
end
end
end
if matchesExtension then
-- Determine display name based on showExtensions parameter
local displayName = item.name
if not showExtensions then
displayName = pathParts.basename
end
table.insert(files, {
name = item.name,
displayName = displayName,
path = item.path,
})
end
end
end
-- Sort folders and files alphabetically
table.sort(folders, function(a, b)
return a.name < b.name
end)
table.sort(files, function(a, b)
return a.displayName < b.displayName
end)
-- Add folders with their subtrees
for _, folder in ipairs(folders) do
local folderNode = {
branchname = folder.name,
}
-- In folders-only mode, add userid for the folder itself
if foldersOnly then
folderNode.userid = { path = folder.path }
end
-- Recursively build subtree for this folder
local subtree = buildTreeStructure(folder.path, false)
if subtree then
for _, node in ipairs(subtree) do
table.insert(folderNode, node)
end
end
table.insert(treeData, folderNode)
end
-- Add files at this level (only if not in folders-only mode)
if not foldersOnly then
for _, file in ipairs(files) do
table.insert(treeData, {
leafname = file.displayName,
userid = { path = file.path },
})
end
end
return treeData
end
-- Build the tree structure starting from root directory
local rootTree = {
branchname = rootLabel,
}
local subtreeData = buildTreeStructure(rootDirectory, true)
if not subtreeData then
return false, nil -- Error already shown by buildTreeStructure
end
for _, item in ipairs(subtreeData) do
table.insert(rootTree, item)
end
local tree = iup.tree({
IMAGELEAF = "IMGPAPER",
markmode = allowMultiple and "MULTIPLE" or "SINGLE",
})
-- Track OK/Cancel
local okPressed = false
local selectedPaths = {}
-- Create selection buttons
local selectAllBtn = makeButton({
title = "Select All",
size = "64x",
expand = "NO",
callback = function()
tree.MARK = "MARKALL"
return iup.DEFAULT
end,
})
local clearAllBtn = makeButton({
title = "Clear All",
size = "64x",
expand = "NO",
callback = function()
tree.MARK = "CLEARALL"
return iup.DEFAULT
end,
})
-- Create OK button
local okBtn = makeButton({
title = "OK",
size = "64x",
expand = "NO",
close = true,
callback = function()
-- Collect selected paths using userid
selectedPaths = {}
-- Get selection state
local markedNodes = tree.MARKEDNODES
-- Get total node count
local nodeCount = tree.count
if nodeCount and tonumber(nodeCount) > 0 then
for i = 1, tonumber(nodeCount) do
local isSelected = markedNodes and markedNodes:sub(i, i) == "+"
if isSelected then
-- The MARKEDNODES string is 1-based, but GetUserId is 0-based
local userid = iup.TreeGetUserId(tree, i - 1)
if userid then
if type(userid) == "table" and userid.path then
table.insert(selectedPaths, userid.path)
end
end
end
end
end
okPressed = true
return true
end,
})
-- Create Cancel button
local cancelBtn = makeButton({
title = "Cancel",
size = "64x",
expand = "NO",
close = true,
})
-- Create dialog content
local buttonBox = {}
-- Only add Select All and Clear All buttons if not in folders-only mode
if not foldersOnly then
table.insert(buttonBox, selectAllBtn)
table.insert(buttonBox, clearAllBtn)
end
-- New Folder button omitted
table.insert(buttonBox, iup.fill({}))
table.insert(buttonBox, okBtn)
table.insert(buttonBox, cancelBtn)
local content = iup.vbox({
tree,
iup.hbox(buttonBox),
})
-- Create dialog
local dlg = makeDialog(content, {
title = dialogTitle,
resize = "YES",
size = "HALFxHALF",
})
dlg:map() --must map the dialog before adding nodes
-- Prepare tree for bulk node insert without redraw
tree.autoredraw = "NO"
-- Ensure nodes are added collapsed
tree.addexpanded = "NO"
iup.TreeAddNodes(tree, rootTree)
-- Set initial focus to root (avoid expanding entire tree)
tree.value = 0
-- Expand only the root
tree["STATE0"] = "EXPANDED"
tree.autoredraw = "YES"
dlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
dlg:destroy()
return okPressed, selectedPaths
end
--- Create and manage a lightweight progress dialog
--- @param totalSteps number Total number of iterations to perform
--- @param title? string Optional dialog title
--- @param parentDialog? any Optional parent dialog handle
--- @return table Progress controller with methods: show(), update(step, text), isCancelled(), close()
local function createProgressDialog(totalSteps, title, parentDialog)
local cancelled = false
local lbl = makeLabel({
title = "Starting...",
})
local bar = iup.progressbar({
min = 0,
max = 1,
value = 0,
expand = "HORIZONTAL",
})
local btnCancel = makeButton({
title = "Cancel",
callback = function(self)
cancelled = true
return true
end,
})
local buttons = iup.hbox({ iup.fill({}), btnCancel })
local box = iup.vbox({
lbl,
bar,
buttons,
margin = "10x10",
gap = "8",
expand = "YES",
})
local dlg = makeDialog(box, {
title = title or "Applying...",
size = "QUARTERxEIGHTH",
dialogframe = "YES",
menubox = "NO",
topmost = "YES",
})
local function update(step, text)
local denom = (totalSteps and totalSteps > 0) and totalSteps or 1
bar.value = math.min(1, (step or 0) / denom)
if text and text ~= "" then
lbl.title = text
end
iup.Refresh(dlg)
iup.LoopStep()
end
return {
show = function()
dlg:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
iup.LoopStep()
end,
update = update,
isCancelled = function()
return cancelled
end,
close = function()
if dlg then
dlg:destroy()
dlg = nil
end
end,
}
end
do
--- ProgressController handles showing and throttling progress updates based on parameters
--- @class ProgressController
--- @field totalSteps number
--- @field showThreshold number
--- @field updateStepFraction number
--- @field step number
--- @field lastFraction number
--- @field dlg any
--- @field _parentDialog any
local ProgressController = {}
ProgressController.__index = ProgressController
--- Create a new ProgressController
--- @param totalSteps number
--- @param updatePercent? number Percentage step for updates (1-100). Default 5
--- @param showThreshold? number Minimum total steps to show progress. Default 20
--- @param parentDialog? any Optional parent dialog handle for modality
--- @return ProgressController
function ProgressController.new(totalSteps, updatePercent, showThreshold, parentDialog)
local UPDATE_PERCENT = (type(updatePercent) == "number" and updatePercent >= 1 and updatePercent <= 100)
and updatePercent
or 5
local SHOW_THRESHOLD = (type(showThreshold) == "number" and showThreshold >= 0) and showThreshold or 20
local self = setmetatable({}, ProgressController)
self.totalSteps = totalSteps or 0
self.showThreshold = SHOW_THRESHOLD
self.updateStepFraction = math.max(0.01, UPDATE_PERCENT / 100)
self.step = 0
self.lastFraction = -1
self._parentDialog = parentDialog
self.dlg = nil
return self
end
function ProgressController:shouldShow()
return (self.totalSteps or 0) >= (self.showThreshold or 0)
end
function ProgressController:ensureShown()
if not self.dlg and self:shouldShow() and (self.totalSteps or 0) > 0 then
self.dlg = createProgressDialog(self.totalSteps, "Applying...", self._parentDialog)
self.dlg.show()
end
end
function ProgressController:isCancelled()
return self.dlg and self.dlg.isCancelled() or false
end
function ProgressController:update(text)
self.step = self.step + 1
if not self.dlg then
-- Defer showing until needed
self:ensureShown()
end
if not self.dlg then
return -- Not showing progress (below threshold or zero steps)
end
local fraction = (self.totalSteps > 0) and (self.step / self.totalSteps) or 1
local shouldUpdate = (self.totalSteps <= 20)
or (self.step == 1)
or (self.step == self.totalSteps)
or (fraction >= (self.lastFraction + self.updateStepFraction))
if shouldUpdate then
self.dlg.update(self.step, text)
self.lastFraction = fraction
end
end
function ProgressController:finish()
if self.dlg then
self.dlg.update(self.totalSteps, "Finishing...")
self.dlg.close()
self.dlg = nil
end
end
-- Module-style export akin to Config.lua
Progress = {
new = ProgressController.new,
}
end
-------------------------------------------------------------
--CONFIGURATION STRUCTURE
--------------------------------------------------------------
--[[
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.0
@Date: 2024
]]
do
local M = {} -- Module table
---@class Config
---@field filePath string
---@field defaultConfig table
---@field cache table>
---@field callbacks table>
local Config = {}
Config.__index = Config
--- 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
--- 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 configuration
local x = self:getNumber("Dialogs", "Dialog_" .. dialogId .. ".x")
local y = self:getNumber("Dialogs", "Dialog_" .. dialogId .. ".y")
local rastersize = self:getString("Dialogs", "Dialog_" .. dialogId .. ".rastersize")
-- 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 configuration
config:setValues("Dialogs", "Dialog_" .. dialogId, {
x = tonumber(closeX),
y = tonumber(closeY),
rastersize = dlg.rastersize,
})
end
end
end
-- Call the original close callback if it exists
if originalClose then
return originalClose(dlg)
end
return iup.CLOSE
end
-- Temporarily remove minimum size constraint to get true natural size
dialog.minsize = iup.NULL
iup.Refresh(dialog)
local true_natural = dialog.naturalsize
-- Restore the previously saved size if available
if rastersize and rastersize ~= "" then
dialog.rastersize = rastersize
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
-- Position the dialog based on saved coordinates or center it
if x and y and (x ~= 0 or y ~= 0) then
-- Get monitor information to validate position
local mi = iup.GetGlobal("MONITORSINFO")
local monitorX, monitorY
-- Parse monitor information to find which monitor the coordinates belong to
for m in mi:gmatch("[^\r\n]+") do
local mx, my = m:match("^(%S+)%s(%S+)")
mx, my = tonumber(mx), tonumber(my)
-- Check if the saved position is within this monitor's bounds
if x >= mx and y >= my then
monitorX, monitorY = mx, my
break
end
end
-- Show dialog at saved position if it's on a valid monitor
if monitorX and monitorY then
dialog:showxy(x, y)
else
-- Fall back to centering if position is invalid
dialog:showxy(iup.CENTERPARENT, iup.CENTERPARENT)
end
else
-- Center the dialog if no saved position or position is (0,0)
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
function Config:createControl(field, value)
if field.type == "text" then
local opts = { value = value }
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 = (value or ""):upper()
opts.filter = "UPPERCASE"
opts.tip = "Placeholder word, e.g. NAME; reference it in templates as {NAME}"
elseif field.mask then
opts.mask = field.mask
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
return makeList({
dropdown = "YES",
values = field.options or {},
value = tostring(selectedIndex),
})
else
error("Unsupported field type: " .. field.type)
end
end
function Config:showConfigDialog(options, help_topic)
options = options or self.defaultConfig
help_topic = help_topic or "options"
-- 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
self._optionsDialog:popup(iup.CENTERPARENT, iup.CENTERPARENT)
return
end
local controls = {}
local mainContent
local allSections = {}
local sectionMenuItems = {}
-- Move showSection function to higher scope
local function showSection(sectionToShow)
for _, section in pairs(allSections) do
if section == sectionToShow then
section.visible = "YES"
section.floating = "NO"
else
section.visible = "NO"
section.floating = "YES"
end
end
local dialog = iup.GetDialog(mainContent)
if dialog then
iup.Refresh(dialog)
end
end
mainContent = iup.vbox({})
for _, section in ipairs(options.sections or {}) do
local sectionContent = iup.vbox({})
controls[section.title] = {}
for _, field in ipairs(section.fields) do
local value = self:getValue(section.title, field.key, field.default)
local label = makeLabel({ title = field.label })
local control = self:createControl(field, value)
controls[section.title][field.key] = {
control = control,
field = field,
}
sectionContent:append(iup.hbox({
label,
control,
alignment = "ACENTER",
}))
end
local sectionBox = iup.scrollbox({ sectionContent, visible = "NO", floating = "YES", expand = "YES" })
table.insert(allSections, sectionBox)
mainContent:append(sectionBox)
table.insert(sectionMenuItems, {
title = section.title,
action = function()
showSection(sectionBox)
return iup.DEFAULT
end,
})
end
-- helper to save all control values back to config
local function saveAll()
for section, fields in pairs(controls) do
local values = {}
for key, info in pairs(fields) do
local control = info.control
local field = info.field
local value = control.value
if field.type == "number" then
value = tonumber(value)
if value == nil then
value = field.default
end
elseif field.type == "boolean" then
value = control.value == "ON"
elseif field.type == "list" then
if control.value == "0" then
value = field.default
else
value = field.options[tonumber(control.value)]
end
end
if field.mask == "TOKEN" then
value = (value or ""):upper()
end
values[key] = value
end
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 section, sectionControls in pairs(controls) do
for key, info in pairs(sectionControls) do
local control = info.control
local field = info.field
local value = field.default
if field.type == "boolean" then
control.value = value and "ON" or "OFF"
elseif field.type == "list" then
for i, opt in ipairs(field.options) do
if opt == value then
control.value = tostring(i)
break
end
end
else
control.value = tostring(value)
end
end
end
end
return iup.DEFAULT
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()
saveAll()
return iup.CLOSE
end,
},
bResetAll = {
title = "&Reset All",
action = function()
return resetAllToDefaultsAndRefresh()
end,
},
cancel = {
title = "&Cancel",
action = function()
return iup.CLOSE
end,
},
},
helpMenu = {
help = {
title = "&Help",
action = function()
help:show(help_topic)
return iup.DEFAULT
end,
},
},
})
local dialog = makeDialog(
iup.vbox({
mainContent,
margin = "10x10",
}),
-- 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,
}
)
-- 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. Mirrors the field types
-- this plugin uses (boolean / list / text).
local function reload()
for sectionTitle, sectionControls in pairs(controls) do
for key, info in pairs(sectionControls) do
local control, field = info.control, info.field
local value = self:getValue(sectionTitle, key, field.default)
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)
break
end
end
else
control.value = tostring(value)
end
end
end
if #allSections > 0 then
showSection(allSections[1])
end
end
self._optionsReload = reload
self._optionsDialog = dialog
DoNormalize()
if #allSections > 0 then
showSection(allSections[1])
end
-- Never destroyed (FH frees plugin dialogs at script end); reused on
-- the next open via the fast path at the top.
dialog:popup(iup.CENTERPARENT, iup.CENTERPARENT)
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
local defaultConfig = {
title = "Add Notes Configuration",
sections = {
{
title = "Preferences",
fields = {
{
key = "noteType",
label = "Default Note Type:",
type = "list",
default = NOTE_TYPE_SHARED,
options = { NOTE_TYPE_SHARED, NOTE_TYPE_RESEARCH },
description = "The type of notes to work with by default",
},
{
key = "operationMode",
label = "Default Operation Mode:",
type = "list",
default = MODE_SELECT_EXISTING,
options = { MODE_SELECT_EXISTING, MODE_CREATE_FROM_AUTOTEXT },
description = "Whether to select existing notes or create new ones from autotext by default",
},
{
key = "useLastSettings",
label = "Use Last Settings:",
type = "boolean",
default = true,
description = "Whether to start with the last used settings",
},
{
key = "nameToken",
label = "Name Placeholder:",
type = "text",
default = "",
description = "The placeholder for the name of the target record in the note",
mask = "TOKEN",
},
{
key = "linkToken",
label = "Link Placeholder:",
type = "text",
default = "",
description = "The placeholder for the link to the target record in the note",
mask = "TOKEN",
},
},
},
},
}
local myConfig = Config.new(defaultConfig, "LOCAL_MACHINE", fhGetContextInfo("CI_PLUGIN_NAME") .. ".ini")
-- Current state variables (initialized from config where appropriate)
local currentNoteType = myConfig:getValue("Preferences", "noteType", NOTE_TYPE_SHARED)
local currentOperationMode = myConfig:getValue("Preferences", "operationMode", MODE_SELECT_EXISTING)
local selectedTargets = {} -- Array of TargetRecord objects
local selectedNotes = {}
-- Central UI registry for local references (avoid global IUP handles)
---@type {menuBarData: MenuBarData, menuBar: iup.menu, contentArea: iup.vbox, targetRecordsList: iup.list, notesList: iup.list, notesLabel: iup.label, mainVBox: iup.vbox}
local ui = {}
--------------------------------------------------------------
--RESULT HANDLING
--------------------------------------------------------------
---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.)
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], -- Sort priority
true, -- Sortable
"default", -- Sort direction
tblVisibility[i] -- Visibility setting
)
end
-- Update the display to show the results
fhUpdateDisplay()
else
-- No results found - show informational message
fhMessageBox(strNoResults, "MB_OK", "MB_ICONINFORMATION")
end
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
local myResults = Results(3)
myResults.Title("Add Notes")
myResults.NoResults("No notes added") -- Message when no notes are added
myResults.Headings({ "Action", "Target", "Note" }) -- Column headers
myResults.Types({ "text", "item", "item" }) -- Data types for each column
myResults.Visibility({ "show", "show", "show" }) -- Which columns to show
myResults.Width({ 200, 200, 200 }) -- Width for each column
myResults.Sort({ 1, 2, 3 }) -- Sort by Action, then Target, and then Note
--------------------------------------------------------------
--MENU HANDLING
--------------------------------------------------------------
do
-- Load MenuBar library
--[[
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.0
@LastUpdated: Current Date
@Description: Enhanced helper function for creating IUP menu bars with dynamic state management
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")
}
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)
- MenuBar.helpers.createRadioMenuItem(title, currentValue, expectedValue, action, capture, registryKey)
- MenuBar.helpers.createSubmenu(title, items, registryKey)
]]
---@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)
--- 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 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 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
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
--- Controls visibility and floating state of UI elements
---@param uiElements table 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
local menuItem = iup.item({
title = item.title,
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
-- 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
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)
local firstItem = true
for _, key in ipairs(keys) do
if not firstItem then
table.insert(fileMenuItems, {}) -- separator between items
end
table.insert(fileMenuItems, createMenuItem(options.fileMenu[key]))
firstItem = false
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 iup.CLOSE
end,
})
)
end
local fileMenu = iup.submenu({
iup.menu(fileMenuItems),
title = "&File",
})
-- Define 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 helpItems = {
createMenuItem(options.helpMenu and options.helpMenu.help or {
title = "&Help",
action = function(self)
if Help then
Help.new({}):show("")
end
return iup.DEFAULT
end,
}),
}
if options.helpMenu and options.helpMenu.about then
helpItems[#helpItems + 1] = {} -- separator
helpItems[#helpItems + 1] = createMenuItem(options.helpMenu.about)
end
local helpMenu = iup.submenu({
iup.menu(helpItems),
title = "&Help",
})
-- 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,
--- Update menu item title dynamically
---@param key string Registry key of the menu item
---@param title string New title
updateTitle = function(key, title)
if menuItems[key] then
menuItems[key].title = 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
---@return menuItem Menu item definition
M.helpers.createMenuItem = function(title, action, capture, registryKey)
return {
title = title,
action = action,
capture = capture,
registryKey = registryKey,
}
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
_G.MenuBar = M
end
--- Compute dynamic Notes menu item titles based on mode and type
function getSelectMenuTitle()
if currentOperationMode == MODE_SELECT_EXISTING then
return "&Select Existing " .. currentNoteType
else
return "&Select AutoText"
end
end
function getNewMenuTitle()
if currentOperationMode == MODE_SELECT_EXISTING then
return "&New " .. currentNoteType:gsub("s$", "")
else
return "&New AutoText Template"
end
end
function updateMenuTitles()
if ui.menuBarData then
ui.menuBarData.updateTitle("menuNewItem", getNewMenuTitle())
ui.menuBarData.updateTitle("menuSelectItem", getSelectMenuTitle())
end
end
-- Update check marks for Note Type and Mode menu items
function updateMenuChecks()
if ui.menuBarData then
ui.menuBarData.updateValue("menuNoteTypeShared", (currentNoteType == NOTE_TYPE_SHARED) and "ON" or "OFF")
ui.menuBarData.updateValue("menuNoteTypeResearch", (currentNoteType == NOTE_TYPE_RESEARCH) and "ON" or "OFF")
ui.menuBarData.updateValue("menuModeSelect", (currentOperationMode == MODE_SELECT_EXISTING) and "ON" or "OFF")
ui.menuBarData.updateValue(
"menuModeCreate",
(currentOperationMode == MODE_CREATE_FROM_AUTOTEXT) and "ON" or "OFF"
)
end
end
--- Enable/disable Apply menu items depending on selections
function updateApplyMenuState()
local hasTargets = (#selectedTargets or 0) > 0
local hasNotes = (#selectedNotes or 0) > 0
local enabled = (hasTargets and hasNotes) and "YES" or "NO"
if ui.menuBarData then
ui.menuBarData.updateActive("menuApplyExit", enabled)
ui.menuBarData.updateActive("menuApplyContinue", enabled)
end
end
--- Update the active state of target menu items based on current note type
function updateTargetMenuStates()
if ui.menuBarData then
ui.menuBarData.updateRegistryGroup("targets", function(key, menuItem)
local isSupported = getSupportedRecordTypes()[key] ~= false
menuItem.active = isSupported and "YES" or "NO"
end)
end
end
--- Check if a record type should be visible in the menu (supported for at least one note type)
--- @param recordTag string The record type tag to check
--- @return boolean True if the record type should be visible
function isRecordTypeVisible(recordTag)
-- Check if it's completely unsupported (won't appear in menu)
for _, tag in ipairs(recordTypeSupport.completelyUnsupported) do
if tag == recordTag then
return false
end
end
-- If not completely unsupported, it should be visible
return true
end
--- Create the main menu bar with all menu items
--- @return table Menu bar definition
function createMainMenuBar()
-- Build the targets submenu dynamically
local targetsSubmenu = {}
-- Get supported record types and add menu items for each
local supportedTypes = getSupportedRecordTypes()
local recordTypes = getRecordTypesInfo(supportedTypes)
-- Add record type selection menu items first
for _, recordType in ipairs(recordTypes) do
-- Only show record types that are supported for at least one note type
if isRecordTypeVisible(recordType.tag) then
local accel = ""
if recordType.tag == "INDI" then
accel = "\tCtrl+I"
end
if recordType.tag == "FAM" then
accel = "\tCtrl+F"
end
-- no shortcut for NOTE in Targets menu
local menuItem = MenuBar.helpers.createMenuItem("Select: " .. recordType.menuName .. accel, function(self)
if recordType.isSupported then
selectTargetRecords(recordType.tag)
end
return iup.DEFAULT
end, nil, recordType.tag)
menuItem.active = recordType.isSupported and "YES" or "NO"
menuItem.registryGroup = "targets"
table.insert(targetsSubmenu, menuItem)
end
end
-- Add separator
table.insert(targetsSubmenu, {})
-- Add utility menu items
table.insert(
targetsSubmenu,
MenuBar.helpers.createMenuItem("R&emove Selected\tDel", function(self)
removeSelectedTargets()
return iup.DEFAULT
end)
)
table.insert(
targetsSubmenu,
MenuBar.helpers.createMenuItem("&Clear Targets\tCtrl+T", function(self)
selectedTargets = {}
populateTargetRecords()
return iup.DEFAULT
end)
)
-- Create menu bar
local menuBarData = MenuBar.createMenuBar({
registries = {
targets = {}, -- For target menu items
modes = {}, -- For mode menu items
noteTypes = {}, -- For note type menu items
},
items = {
MenuBar.helpers.createSubmenu("&Targets", targetsSubmenu, "targetsMenu"),
MenuBar.helpers.createSubmenu("&Notes", {
MenuBar.helpers.createMenuItem(getNewMenuTitle(), function(self)
createNewNote()
return iup.DEFAULT
end, nil, "menuNewItem"),
MenuBar.helpers.createMenuItem(getSelectMenuTitle(), function(self)
if currentOperationMode == MODE_SELECT_EXISTING then
selectExistingNotes(currentNoteType)
else
selectAutotextViaTree()
end
return iup.DEFAULT
end, nil, "menuSelectItem"),
{}, -- separator
MenuBar.helpers.createSubmenu("Note &Type", {
MenuBar.helpers.createRadioMenuItem("&Shared Notes", currentNoteType, NOTE_TYPE_SHARED, function()
return changeNoteType(NOTE_TYPE_SHARED)
end, nil, "menuNoteTypeShared"),
MenuBar.helpers.createRadioMenuItem(
"&Research Notes",
currentNoteType,
NOTE_TYPE_RESEARCH,
function()
return changeNoteType(NOTE_TYPE_RESEARCH)
end,
nil,
"menuNoteTypeResearch"
),
}, "noteTypeMenu"),
MenuBar.helpers.createSubmenu("&Mode", {
MenuBar.helpers.createRadioMenuItem(
"&Select Existing",
currentOperationMode,
MODE_SELECT_EXISTING,
function()
return changeMode(MODE_SELECT_EXISTING)
end,
nil,
"menuModeSelect"
),
MenuBar.helpers.createRadioMenuItem(
"&Create from AutoText",
currentOperationMode,
MODE_CREATE_FROM_AUTOTEXT,
function()
return changeMode(MODE_CREATE_FROM_AUTOTEXT)
end,
nil,
"menuModeCreate"
),
}, "modeMenu"),
{}, -- separator
MenuBar.helpers.createMenuItem("&New...\tCtrl+N", function(self)
createNewNote()
return iup.DEFAULT
end),
MenuBar.helpers.createMenuItem("&View/Edit Selected\tCtrl+E", function(self)
editSelectedNoteOrAutotext()
return iup.DEFAULT
end),
MenuBar.helpers.createMenuItem("R&emove Selected\tDel", function(self)
removeSelectedNotes()
return iup.DEFAULT
end),
MenuBar.helpers.createMenuItem("&Clear Notes\tCtrl+Shift+C", function(self)
selectedNotes = {}
populateNotesList()
return iup.DEFAULT
end),
}, "notesMenu"),
MenuBar.helpers.createMenuItem("&Options", function(self)
myConfig:showConfigDialog(
nil,
"add-notes-reference#configuration-options"
)
return iup.DEFAULT
end),
},
fileMenu = {
applyContinue = MenuBar.helpers.createMenuItem("Apply and &Continue", function(self)
showApplyResults(applySelections())
return iup.DEFAULT
end, nil, "menuApplyContinue"),
applyExit = MenuBar.helpers.createMenuItem("&Apply and Exit", function(self)
showApplyResults(applySelections())
return iup.CLOSE
end, nil, "menuApplyExit"),
},
helpMenu = {
about = MenuBar.helpers.createMenuItem("&About", function(self)
MessageBox(
"info",
"Add Notes Plugin v"
.. PLUGIN_VERSION
.. "\n\nPlugin to attach Shared Notes or Research Notes to selected target records.\n\nSupports both selecting existing notes and creating new notes from autotext."
)
return iup.DEFAULT
end),
},
})
-- Store references for dynamic updates
ui.menuBarData = menuBarData
ui.menuBar = menuBarData.menuBar
-- Initialize menu item titles and states
updateMenuTitles()
updateTargetMenuStates()
updateApplyMenuState()
return menuBarData.menuBar
end
--------------------------------------------------------------
--APPLY FUNCTIONALITY
--------------------------------------------------------------
--- Create a new note record from a RichText object for a given note type
--- @param noteType string Either NOTE_TYPE_RESEARCH or NOTE_TYPE_SHARED
--- @param richText RichText The rich text object to use as content
--- @return ItemPointer|nil ptrNote The created note record pointer, or nil on error
---@class ItemPointer
---@field IsNotNull fun(self:ItemPointer):boolean
---@field Clone fun(self:ItemPointer):ItemPointer
---@class RichText
---@field SetText fun(self:RichText, text:string, format?:boolean, tokens?:boolean)
---@field GetText fun(self:RichText):string
---@field Clone fun(self:RichText):RichText
local function createNoteRecordFromRichText(noteType, richText)
local tag = (noteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
local ptrNote = fhCreateItem(tag)
local ptrText = fhCreateItem(TAG_TEXT, ptrNote, true)
local success = fhSetValueAsRichText(ptrText, richText)
if not success then
MessageBox("error", "Failed to set content for new " .. noteType:gsub("s$", ""))
return nil
end
return ptrNote
end
--- Check whether a target already has a link to the given note
--- @param targetPtr ItemPointer Target record pointer
--- @param notePtr ItemPointer Note record pointer
--- @param noteLinkTag string Link tag to scan on the target ("NOTE" or "_RNOT")
--- @return boolean
local function isNoteLinkedToTarget(targetPtr, notePtr, noteLinkTag)
--we can't use LinksTo or LinksFrom because they include embedded links in the counts they return, so we need to scan the target manually
local p = fhNewItemPtr()
p:MoveTo(targetPtr, "~." .. noteLinkTag)
while p:IsNotNull() do
local linked = fhGetValueAsLink(p)
if linked and linked:IsNotNull() and linked:IsSame(notePtr) then
return true
end
p:MoveNext("SAME_TAG")
end
return false
end
--- Link an existing note record to a target if not already linked
--- @param targetPtr ItemPointer
--- @param notePtr ItemPointer
--- @param noteLinkTag string
--- @return boolean linked True if a link was created
local function linkNoteToTarget(targetPtr, notePtr, noteLinkTag)
if isNoteLinkedToTarget(targetPtr, notePtr, noteLinkTag) then
return false -- Already linked
end
local linkItem = fhCreateItem(noteLinkTag, targetPtr)
fhSetValueAsLink(linkItem, notePtr)
return true
end
--- Create a new note record from an AutoText template file
--- @param template table One entry from selectedNotes with fields .filePath
--- @param noteType string Either NOTE_TYPE_SHARED or NOTE_TYPE_RESEARCH
--- @param targetPtr? ItemPointer Optional target record pointer for link embedding
--- @return ItemPointer|nil ptrNewNote The created note record pointer
local function createNoteRecordFromAutoText(template, noteType, targetPtr)
local content, err = fhfu.readTextFile(template.filePath, true, 8)
if not content then
MessageBox(
"error",
"AutoText file could not be read: "
.. (template.displayText or template.filePath)
.. (err and (" - " .. err) or "")
)
return nil
end
if fh.isSet(targetPtr) then
local linkToken = myConfig:getValue("Preferences", "linkToken", "")
local safeRecordName = fhGetDisplayText(targetPtr)
if not fh.isSet(safeRecordName) then
safeRecordName = "(unnamed)"
end --handle the case where the name comes back blank, to avoid leaving tokens in the autotext
safeRecordName = fhFtfEncode(safeRecordName)
if fh.isSet(linkToken) then
local link = string.format('', fhGetQualifiedRecordId(targetPtr), safeRecordName)
content = string.gsub(content, "{" .. linkToken .. "}", link)
end
local nameToken = myConfig:getValue("Preferences", "nameToken", "")
if fh.isSet(nameToken) then
content = string.gsub(content, "{" .. nameToken .. "}", safeRecordName)
end
end
local rt = fhNewRichText()
rt:SetText(content, true, true)
local ptrNote = createNoteRecordFromRichText(noteType, rt) --create the note record
if not ptrNote then
MessageBox("error", "Failed to set content from AutoText: " .. (template.displayText or template.filePath))
return nil
else
return ptrNote
end
end
--- Apply the current selections to targets
function applySelections()
local noteLinkTag = (currentNoteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
local totalLinked = 0
local totalCreated = 0
-- Prepare progress controller
local numNotes = #selectedNotes
local numTargets = #selectedTargets
local totalSteps = numNotes * numTargets
-- Tunables: tweak here in code (not user-configurable)
local UPDATE_PERCENT = 5
local SHOW_THRESHOLD = 20
local progress = Progress.new(totalSteps, UPDATE_PERCENT, SHOW_THRESHOLD, dlgmain)
-- progress dialog shows itself on demand via ProgressController
if currentOperationMode == MODE_SELECT_EXISTING then
-- Link existing notes to each target
for _, note in ipairs(selectedNotes) do
local notePtr = note.recordPointer
if fh.isSet(notePtr) then
for _, target in ipairs(selectedTargets) do
local targetPtr = target.recordPointer
if fh.isSet(targetPtr) then
if progress:isCancelled() then
progress:update("Cancelling...")
progress:finish()
fhUpdateDisplay()
return { created = totalCreated, linked = totalLinked }
end
if linkNoteToTarget(targetPtr, notePtr, noteLinkTag) then
myResults.Update({
currentNoteType:gsub("s$", "") .. " Linked", -- Action
targetPtr:Clone(), -- Target
notePtr:Clone(), -- Note
})
totalLinked = totalLinked + 1
else
-- Note was already linked
myResults.Update({
currentNoteType:gsub("s$", "") .. " Already Linked", -- Action
targetPtr:Clone(), -- Target
notePtr:Clone(), -- Note
})
end
progress:update(string.format("%d/%d", (progress.step + 1), progress.totalSteps))
end
end
end
end
else
-- Create a new note from each AutoText for each target and link it
for _, template in ipairs(selectedNotes) do
if template.type == "autotext" and template.filePath then
for _, target in ipairs(selectedTargets) do
local targetPtr = target.recordPointer
if fh.isSet(targetPtr) then
if progress:isCancelled() then
progress:update("Cancelling...")
progress:finish()
fhUpdateDisplay()
return { created = totalCreated, linked = totalLinked }
end
local ptrNote = createNoteRecordFromAutoText(template, currentNoteType, targetPtr)
if fh.isSet(ptrNote) then
-- For autotext mode, create a single result entry for "Created and Linked"
local linkItem = fhCreateItem(noteLinkTag, targetPtr)
fhSetValueAsLink(linkItem, ptrNote)
myResults.Update({
currentNoteType:gsub("s$", "") .. " Created and Linked", -- Action
targetPtr:Clone(), -- Target
ptrNote:Clone(), -- Note
})
totalCreated = totalCreated + 1
totalLinked = totalLinked + 1
end
progress:update(string.format("%d/%d", (progress.step + 1), progress.totalSteps))
end
end
end
end
end
progress:finish()
fhUpdateDisplay()
return { created = totalCreated, linked = totalLinked }
end
-- Helper function to show apply results message
function showApplyResults(res)
local message
if currentOperationMode == MODE_CREATE_FROM_AUTOTEXT then
-- For autotext mode, show more informative message
if res.created and res.created > 0 then
local numTemplates = #selectedNotes
local numTargets = #selectedTargets
if numTemplates > 0 and numTargets > 0 then
local noteText = res.created == 1 and "note" or "notes"
local targetText = numTargets == 1 and "target" or "targets"
message = string.format("%d %s created for %d %s", res.created, noteText, numTargets, targetText)
else
local noteText = res.created == 1 and "note" or "notes"
message = string.format("%d %s created", res.created, noteText)
end
else
message = "No notes created"
end
else
-- For existing notes mode, show traditional message
local parts = {}
if res.created and res.created > 0 then
table.insert(parts, string.format("%d created", res.created))
end
if res.linked and res.linked > 0 then
table.insert(parts, string.format("%d linked", res.linked))
end
if #parts > 0 then
message = "Applied: " .. table.concat(parts, ", ")
else
message = "No changes applied"
end
end
MessageBox("info", message)
end
--------------------------------------------------------------
--NEW NOTE CREATION FUNCTIONALITY
--------------------------------------------------------------
--- Create a new autotext template file
--- @param richText RichText The rich text object to save
function createNewAutoTextTemplate(richText)
-- Re-open dialog until user selects valid path inside AUTOTEXT_DIR or cancels
local function normalizePath(p)
if not p or p == "" then
return ""
end
local s = p:gsub("\\", "/")
-- remove trailing slash ("$" is the end-of-string anchor; "%$" would match a literal dollar sign)
s = s:gsub("/+$", "")
return s
end
local rootNormLower = normalizePath(AUTOTEXT_DIR):lower()
local filedlg = iup.filedlg({
dialogtype = "SAVE",
title = "Save AutoText Template",
directory = AUTOTEXT_DIR,
extfilter = "Family Historian AutoText (*.ftf)|*.ftf",
file = "New AutoText Template",
nochangedir = "YES",
parentdialog = identifyActiveWindow(),
})
while true do
-- Always (re)open in the AutoText folder, so a rejected attempt
-- outside it returns the user to the right place instead of wherever
-- they had navigated to.
filedlg.directory = AUTOTEXT_DIR
filedlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if filedlg.status ~= "1" then
break
end
local chosenPath = filedlg.value or ""
if chosenPath ~= "" then
-- Ensure .ftf extension using fhFileUtils.splitPath
local pathWithExt = chosenPath
local parts = fhfu.splitPath(chosenPath)
if (parts.ext or ""):lower() ~= EXT_AUTOTEXT then
pathWithExt = chosenPath .. "." .. EXT_AUTOTEXT
end
-- Ensure within AUTOTEXT_DIR (normalize separators and compare prefix)
local fileNormLower = normalizePath(pathWithExt):lower()
if
fileNormLower:sub(1, #rootNormLower) == rootNormLower
and (
fileNormLower:sub(#rootNormLower + 1, #rootNormLower + 1) == "/"
or #fileNormLower == #rootNormLower
)
then
-- Overwrite check
if
not fhfu.fileExists(pathWithExt)
or MessageBox("question", "File already exists. Do you want to overwrite it?", "YESNO") == "Yes"
then
local rtString = richText:GetText()
local success, error = fhfu.createTextFile(pathWithExt, true, true, rtString, 8)
if success then
local templates = convertFilePathsToTemplates({ pathWithExt })
local template = templates[1]
table.insert(selectedNotes, template)
populateNotesList()
MessageBox("info", "AutoText template created successfully: " .. template.displayText)
myResults.Update({
"AutoText Template Created: " .. template.displayText, -- Action
fhNewItemPtr(), -- Null pointer for target
fhNewItemPtr(), -- Null pointer for note
})
break
else
MessageBox("error", "Failed to save AutoText template: " .. error)
-- Loop again to allow user to retry
end
end
else
MessageBox("error", "AutoText templates must be saved within the AutoText directory.")
-- Loop continues to re-open the dialog
end
end
end
filedlg:destroy()
end
--- Create a new note record
--- @param richText RichText The rich text object for the note
function createNewNoteRecord(richText)
local ptrNote = createNoteRecordFromRichText(currentNoteType, richText)
if not ptrNote then
return
end
-- Add result entry for the created note
myResults.Update({
currentNoteType:gsub("s$", "") .. " Created", -- Action
fhNewItemPtr(), -- Null pointer for target
ptrNote:Clone(), -- Note,
})
local tag = (currentNoteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
local note = {
recordPointer = ptrNote,
displayText = fhGetDisplayText(ptrNote) .. " (ID: " .. fhGetRecordId(ptrNote) .. ")",
recordType = tag,
noteType = currentNoteType,
}
table.insert(selectedNotes, note)
populateNotesList()
MessageBox("info", "New " .. currentNoteType:gsub("s$", "") .. " created successfully: " .. note.displayText)
end
--- Create a new note or autotext template based on current mode
function createNewNote()
-- Create a new rich text object for the prompt
local initialRichText = fhNewRichText("", true)
-- Display rich text prompt
local richText = fhPromptUserForRichText(initialRichText)
if not richText then
-- User cancelled
return
end
if currentOperationMode == MODE_CREATE_FROM_AUTOTEXT then
createNewAutoTextTemplate(richText)
else
createNewNoteRecord(richText)
end
fhUpdateDisplay()
end
--------------------------------------------------------------
--HELPER FUNCTIONS
--------------------------------------------------------------
--- Convert selected records to a standardized format
--- @param selectedRecords ItemPointer[] Array of selected record pointers
--- @param recordTag string The record type tag
--- @param additionalFields? table Additional fields to add to each record
--- @return table[] Array of formatted records
function convertRecordsToStandardFormat(selectedRecords, recordTag, additionalFields)
local records = {}
for i = 1, #selectedRecords do
local displayText = "["
.. (displayNames[recordTag] or recordTag)
.. "] "
.. fhGetDisplayText(selectedRecords[i])
.. " (ID: "
.. fhGetRecordId(selectedRecords[i])
.. ")"
local record = {
recordPointer = selectedRecords[i],
displayText = displayText,
recordType = recordTag,
}
-- Add any additional fields
if additionalFields then
for key, value in pairs(additionalFields) do
record[key] = value
end
end
table.insert(records, record)
end
return records
end
--- Check if a record already exists in a collection by comparing pointers
--- @param newRecord table The new record to check
--- @param existingRecords table[] Array of existing records
--- @return boolean True if the record is a duplicate
function isRecordDuplicate(newRecord, existingRecords)
for _, existingRecord in ipairs(existingRecords) do
-- For autotext templates, compare file paths
if newRecord.type == "autotext" and existingRecord.type == "autotext" then
if newRecord.filePath == existingRecord.filePath then
return true
end
-- For regular records, compare record pointers
elseif newRecord.recordPointer and existingRecord.recordPointer then
if existingRecord.recordPointer:IsSame(newRecord.recordPointer) then
return true
end
end
end
return false
end
--- Add records to a collection with duplicate checking
--- @param newRecords table[] New records to add
--- @param existingRecords table[] Existing records collection
--- @param updateDisplay function Function to call to update the display
function addRecordsWithDuplicateCheck(newRecords, existingRecords, updateDisplay)
for i, newRecord in ipairs(newRecords) do
if not isRecordDuplicate(newRecord, existingRecords) then
table.insert(existingRecords, newRecord)
end
end
-- Update the display
if updateDisplay then
updateDisplay()
end
end
--- Convert a single record pointer to standard format
--- @param recordPtr ItemPointer The record pointer
--- @param additionalFields? table Additional fields to add
--- @return table Formatted record
function convertRecordPointerToStandardFormat(recordPtr, additionalFields)
local recordType = fhGetTag(recordPtr)
local displayText = "["
.. (displayNames[recordType] or recordType)
.. "] "
.. fhGetDisplayText(recordPtr)
.. " (ID: "
.. fhGetRecordId(recordPtr)
.. ")"
local record = {
recordPointer = recordPtr,
displayText = displayText,
recordType = recordType,
}
-- Add any additional fields
if additionalFields then
for key, value in pairs(additionalFields) do
record[key] = value
end
end
return record
end
--- Compute and apply the dialog title based on current mode and note type
function getDialogTitle()
local modePart = (currentOperationMode == MODE_SELECT_EXISTING) and "Select Existing" or "Create from AutoText"
local typePart = currentNoteType
return "Add Notes — " .. modePart .. " — " .. typePart
end
function updateAddNotesDialogTitle()
if dlgmain and dlgmain.title then
updateDialogTitle(dlgmain, getDialogTitle())
end
end
--- Clear the Notes list UI and any stored selections
function clearNotesList()
selectedNotes = {}
if ui.notesList then
ui.notesList.REMOVEITEM = "ALL"
iup.Refresh(ui.notesList)
end
end
--- Remove selected entries from the Notes/AutoText list
function removeSelectedNotes()
local list = ui.notesList
if not list then
return
end
local positions = getSelectedValues(list, true)
local count = #positions
if count == 0 then
MessageBox("info", "Select one or more notes/autotext entries to remove.")
return
end
if not confirmRemoveSelected("Notes", count) then
return
end
removeSelectedItems(list, selectedNotes, populateNotesList)
end
--- View/Edit the currently selected note or autotext entry
function editSelectedNoteOrAutotext()
local list = ui.notesList
if not list then
return
end
local positions = getSelectedValues(list, true)
if #positions == 0 then
MessageBox("info", "Select a note or AutoText to view/edit.")
return
end
if #positions > 1 then
MessageBox("warning", "Please select only one item to view/edit.")
return
end
local index = tonumber(positions[1]) or 0
if index < 1 or index > #selectedNotes then
return
end
local entry = selectedNotes[index]
-- Editing an existing Note record
if entry and entry.recordPointer and entry.recordPointer:IsNotNull() then
local ptrText = fhGetItemPtr(entry.recordPointer, "~.TEXT")
if not (ptrText and ptrText:IsNotNull()) then
MessageBox("error", "Could not locate the TEXT field for this note.")
return
end
local currentRt = fhGetValueAsRichText(ptrText)
local originalText = currentRt:GetText()
local editedRt = fhPromptUserForRichText(currentRt)
if not editedRt then
return -- user cancelled
end
local editedText = editedRt:GetText()
if originalText == editedText then
-- No changes made, just viewed
return
end
local ok = fhSetValueAsRichText(ptrText, editedRt)
if not ok then
MessageBox("error", "Failed to save edited note content.")
return
end
-- Refresh display text for the edited note
entry.displayText = fhGetDisplayText(entry.recordPointer)
.. " (ID: "
.. fhGetRecordId(entry.recordPointer)
.. ")"
-- Report change in results
myResults.Update({
currentNoteType:gsub("s$", "") .. " Edited",
fhNewItemPtr(), -- no specific target
entry.recordPointer:Clone(),
})
populateNotesList()
fhUpdateDisplay()
return
end
-- Editing an AutoText template file
if entry and entry.type == "autotext" and entry.filePath then
local content, err = fhfu.readTextFile(entry.filePath, true, 8)
if not content then
MessageBox(
"error",
"AutoText file could not be read: "
.. (entry.displayText or entry.filePath)
.. (err and (" - " .. err) or "")
)
return
end
local rt = fhNewRichText()
rt:SetText(content, true, true)
local originalRtString = rt:GetText()
local editedRt = fhPromptUserForRichText(rt)
if not editedRt then
return -- user cancelled
end
local editedRtString = editedRt:GetText()
if originalRtString == editedRtString then
-- No changes made, just viewed
return
end
local success, error = fhfu.createTextFile(entry.filePath, true, true, editedRtString, 8)
if success then
MessageBox("info", "AutoText template saved: " .. (entry.displayText or entry.filePath))
-- Report change in results
myResults.Update({
"AutoText Edited: " .. (entry.displayText or entry.filePath),
fhNewItemPtr(), -- target N/A
fhNewItemPtr(), -- note N/A (file-based)
})
else
MessageBox("error", "Failed to save AutoText template: " .. (error or "unknown error"))
end
return
end
MessageBox("error", "Unsupported selection type for view/edit.")
end
--- Ask user to confirm clearing the Notes list before a context-changing action
--- @return boolean proceed True if user confirmed
function confirmClearNotes(contextLabel, newValue)
local function notesExistToClear()
local notesList = ui.notesList
if not notesList then
return false
end
local count = tonumber(notesList.COUNT) or 0
return count > 0
end
if not notesExistToClear() then
return true
end
local answer = MessageBox(
"question",
"Switching "
.. contextLabel
.. " to '"
.. tostring(newValue)
.. "' will clear the selected Notes list. Do you want to continue?",
"YESNO"
)
if answer == "Yes" then
clearNotesList()
return true
end
return false
end
--- Ask for confirmation before removing selected list items
--- @param listLabel string Human-readable list name (e.g., "Targets", "Notes")
--- @param count integer Number of items to remove
--- @return boolean proceed True if user confirmed
function confirmRemoveSelected(listLabel, count)
local itemWord = (count == 1) and "item" or "items"
local answer =
MessageBox("question", string.format("Remove %d %s from the %s list?", count, itemWord, listLabel), "YESNO")
return answer == "Yes"
end
--- Get the current note type from configuration or default
---@return string
function getCurrentNoteType()
if myConfig:getBool("Preferences", "useLastSettings", true) then
return myConfig:getString("Preferences", "noteType", NOTE_TYPE_SHARED)
else
return NOTE_TYPE_SHARED
end
end
--- Get the current operation mode from configuration or default
---@return string
function getCurrentOperationMode()
if myConfig:getBool("Preferences", "useLastSettings", true) then
return myConfig:getString("Preferences", "operationMode", MODE_SELECT_EXISTING)
else
return MODE_SELECT_EXISTING
end
end
--- Save current preferences to configuration
function savePreferences()
if myConfig:getBool("Preferences", "useLastSettings", true) then
myConfig:setValues("Preferences", nil, {
noteType = currentNoteType,
operationMode = currentOperationMode,
})
end
end
--- Update the content area when mode or note type changes
function updateContentArea()
if not (ui.contentArea and ui.targetRecordsList and ui.notesList and ui.notesLabel) then
return
end
-- Update label title and list selection mode
if currentOperationMode == MODE_SELECT_EXISTING then
ui.notesLabel.title = "Existing " .. currentNoteType .. ":"
ui.notesList.multiple = "YES"
ui.notesList.tip =
"Selected existing notes. Use the Notes menu to add or remove.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
else
ui.notesLabel.title = "AutoText for " .. currentNoteType .. ":"
ui.notesList.multiple = "YES"
ui.notesList.tip =
"Selected AutoText templates for note creation. Use the Notes menu to add or remove.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- F5: Refresh list\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
end
-- Repopulate the lists
populateTargetRecords()
populateNotesList()
iup.Refresh(dlgmain)
updateMenuTitles()
updateMenuChecks()
updateAddNotesDialogTitle()
end
-- Handle state transitions
function changeNoteType(newType)
if currentNoteType == newType then
return iup.DEFAULT
end
if not confirmClearNotes("Note Type", newType) then
updateMenuChecks()
return iup.DEFAULT
end
currentNoteType = newType
savePreferences()
updateContentArea()
updateMenuTitles()
updateMenuChecks()
updateTargetMenuStates() -- Update target menu item states
updateAddNotesDialogTitle()
return iup.DEFAULT
end
function changeMode(newMode)
if currentOperationMode == newMode then
return iup.DEFAULT
end
if
not confirmClearNotes(
"Mode",
(newMode == MODE_SELECT_EXISTING) and "Select Existing Notes" or MODE_CREATE_FROM_AUTOTEXT
)
then
updateMenuChecks()
return iup.DEFAULT
end
currentOperationMode = newMode
savePreferences()
updateContentArea()
updateMenuTitles()
updateMenuChecks()
updateAddNotesDialogTitle()
return iup.DEFAULT
end
--- Create the content area with two separate boxes side by side
---@return iup.vbox
function createContentArea()
local content = iup.vbox({ expand = "YES" })
ui.contentArea = content
-- Create the two boxes side by side
local leftBox = iup.vbox({ expand = "YES" })
local rightBox = iup.vbox({ expand = "YES" })
-- Target records box (left side)
local targetLabel = makeLongLabel({
title = "Target Records:",
})
iup.Append(leftBox, targetLabel)
local targetList = makeList({
dropdown = "NO",
expand = "YES",
multiple = "YES",
visiblelines = "12",
name = "targetRecordsList",
tip = "Selected target records. Use the Targets menu to add or remove.\n\nUse the File menu to add notes to the selected records\n\nShortcuts:\n- Ctrl+I: Select Individuals\n- Ctrl+F: Select Families\n- Ctrl+T: Clear Targets\n- Ctrl+A: Select all\n- Del: Remove selected",
})
iup.Append(leftBox, targetList)
ui.targetRecordsList = targetList
-- Keyboard shortcuts for targets list
targetList.k_any = function(self, c)
if c == iup.K_cA then -- Ctrl+A: select all (only if multi-select)
if self.multiple == "YES" then
local count = tonumber(self.COUNT) or 0
if count > 0 then
self.value = string.rep("+", count)
return iup.IGNORE
end
end
elseif c == iup.K_cT then -- Ctrl+T: clear targets
selectedTargets = {}
populateTargetRecords()
return iup.IGNORE
elseif c == iup.K_DEL then -- Delete: remove selected
removeSelectedTargets()
return iup.IGNORE
end
return iup.CONTINUE
end
-- Notes/Autotext box (right side)
local notesLabel = makeLongLabel({
title = (currentOperationMode == MODE_SELECT_EXISTING) and ("Existing " .. currentNoteType .. ":")
or ("AutoText Templates for " .. currentNoteType .. ":"),
})
iup.Append(rightBox, notesLabel)
ui.notesLabel = notesLabel
local notesList = makeList({
dropdown = "NO",
expand = "YES",
multiple = "YES",
visiblelines = "12",
name = "notesList",
tip = (function()
local common =
"\n\nUse the Notes menu to add or remove, or to change the Note Type or Mode.\n\nUse the Apply menu to add notes to the selected records.\n\nShortcuts:\n- Ctrl+N: New\n- Ctrl+E: Edit selected\n- F5: Refresh list\n- Ctrl+A: Select all\n- Enter/Space: View/Edit selected\n- Del: Remove selected"
local prefix = (currentOperationMode == MODE_SELECT_EXISTING) and "Selected existing notes."
or "Selected AutoText templates for note creation."
return prefix .. common
end)(),
})
iup.Append(rightBox, notesList)
ui.notesList = notesList
-- Keyboard shortcuts for notes list
notesList.k_any = function(self, c)
if c == iup.K_cA then -- Ctrl+A: select all (only if multi-select)
if self.multiple == "YES" then
local count = tonumber(self.COUNT) or 0
if count > 0 then
self.value = string.rep("+", count)
return iup.IGNORE
end
end
elseif c == iup.K_cN then -- Ctrl+N: new note/autotext
createNewNote()
return iup.IGNORE
elseif c == iup.K_cE then -- Ctrl+E: edit selected
editSelectedNoteOrAutotext()
return iup.IGNORE
elseif c == iup.K_F5 then -- Refresh list
populateNotesList()
return iup.IGNORE
elseif c == iup.K_CR or c == iup.K_SP then -- Enter or Space: view/edit
editSelectedNoteOrAutotext()
return iup.IGNORE
elseif c == iup.K_DEL then -- Delete: remove selected
removeSelectedNotes()
return iup.IGNORE
end
return iup.CONTINUE
end
-- Put the two boxes side by side using a grid to unify widths
local grid = makeGridbox({
leftBox,
rightBox,
numdiv = "2",
alignmentlin = "ATOP",
expandchildren = "HORIZONTAL",
shrink = "YES",
})
iup.Append(content, grid)
return content
end
--- Populate the target records list
function populateTargetRecords()
local targetList = ui.targetRecordsList
if targetList then
-- Clear the list first
targetList.REMOVEITEM = "ALL"
-- Only populate if we have targets
if #selectedTargets > 0 then
-- Create display text array for populateList
local displayTexts = {}
for _, target in ipairs(selectedTargets) do
table.insert(displayTexts, target.displayText or tostring(target))
end
-- Use populateList helper
populateList(targetList, displayTexts)
end
updateApplyMenuState()
end
end
--- Populate the notes/autotext list
function populateNotesList()
local notesList = ui.notesList
if notesList then
-- Clear the list first
notesList.REMOVEITEM = "ALL"
-- Only populate if we have notes
if #selectedNotes > 0 then
-- Create display text array for populateList
local displayTexts = {}
for _, note in ipairs(selectedNotes) do
table.insert(displayTexts, note.displayText)
end
-- Use populateList helper
populateList(notesList, displayTexts)
end
updateApplyMenuState()
end
end
--------------------------------------------------------------
--TARGET AND NOTE SELECTION FUNCTIONALITY
--------------------------------------------------------------
--- Convert file paths to autotext template format
--- @param filePaths string[] Array of file paths
--- @return table[] Array of autotext template objects
function convertFilePathsToTemplates(filePaths)
local templates = {}
for _, filePath in ipairs(filePaths) do
-- Use fhFileUtils.splitPath to decompose the path
local pathParts = fhfu.splitPath(filePath)
-- Create relative path under autotext root for display
local relativePath = filePath:sub(#AUTOTEXT_DIR + 2) -- Remove root dir + separator
local displayText = relativePath:gsub("\\", " / ") -- Replace backslashes with forward slashes for readability
displayText = displayText:gsub("%." .. EXT_AUTOTEXT .. "$", "") -- Remove .ftf extension
local template = {
type = "autotext",
filePath = filePath,
fileName = pathParts.filename,
baseName = pathParts.basename,
directory = pathParts.parent,
extension = pathParts.ext,
displayText = displayText,
}
table.insert(templates, template)
end
return templates
end
--- @class RecordTypeInfo
--- @field tag string The record type tag (e.g., "INDI", "FAM", "SOUR")
--- @field displayName string Human-readable name for the record type
--- @field menuName string Menu string for the record type
--- @field isSupported boolean Whether this record type is supported in the current mode
--- @field hasRecords boolean Whether there are any records of this type in the current project
--- @field count number Number of records of this type in the current project
--- @class TargetRecord
--- @field recordPointer ItemPointer The Family Historian record pointer
--- @field displayText string The display text for the record
--- @field recordType string The record type tag
--- Gets information about all available record types
--- @param supportedTypes? table Table of record type tags that are supported in current mode
--- @return RecordTypeInfo[] Array of record type information
function getRecordTypesInfo(supportedTypes)
supportedTypes = supportedTypes or {}
local recordTypes = {}
-- Get count of record types
local iCount = fhGetRecordTypeCount()
-- Loop through record types and gather information
for i = 1, iCount do
local tag = fhGetRecordTypeTag(i)
local isSupported = supportedTypes[tag] ~= false -- Default to supported unless explicitly disabled
-- Create display name from tag (convert INDI to Individual, FAM to Family, etc.)
local displayName = displayNames[tag] or tag
local menuName = menuNames[tag] or (displayName .. "s")
table.insert(recordTypes, {
tag = tag,
displayName = displayName,
menuName = menuName,
isSupported = isSupported,
})
end
return recordTypes
end
--- Prompts user to select records of a specific type
--- @param recordTag string The record type tag to select
--- @param parentWindow? any Parent window handle for record selection
function selectTargetRecords(recordTag, parentWindow)
-- Get parent window handle
local hParentWnd = getParentWindowHandle(parentWindow)
-- Prompt user for record selection
local selectedRecords = fhPromptUserForRecordSel(recordTag, -1, hParentWnd)
if selectedRecords and #selectedRecords > 0 then
-- Convert to target record format and add to selected targets
local targets = convertRecordsToStandardFormat(selectedRecords, recordTag)
addRecordsWithDuplicateCheck(targets, selectedTargets, populateTargetRecords)
end
end
--- Prompts user to select existing notes
--- @param noteType string The note type ("Shared Notes" or "Research Notes")
--- @param parentWindow? any Parent window handle for record selection
function selectExistingNotes(noteType, parentWindow)
-- Get parent window handle
local hParentWnd = getParentWindowHandle(parentWindow)
-- Determine the record tag based on note type
local recordTag = (noteType == NOTE_TYPE_RESEARCH) and TAG_RESEARCH_NOTE or TAG_NOTE
-- Prompt user for note selection
local selectedRecords = fhPromptUserForRecordSel(recordTag, -1, hParentWnd)
if selectedRecords and #selectedRecords > 0 then
-- Build note entries directly without prefixes
local notes = {}
for i = 1, #selectedRecords do
local ptr = selectedRecords[i]
table.insert(notes, {
recordPointer = ptr,
displayText = fhGetDisplayText(ptr) .. " (ID: " .. fhGetRecordId(ptr) .. ")",
recordType = recordTag,
noteType = noteType,
})
end
addRecordsWithDuplicateCheck(notes, selectedNotes, populateNotesList)
end
end
--- Present Autotext file selection dialog
function selectAutotextViaTree()
-- Use simple file dialog instead of complex FileSelector
local filedlg = iup.filedlg({
dialogtype = "OPEN",
title = "Select AutoText Template(s)",
directory = AUTOTEXT_DIR,
extfilter = "AutoText Templates (*.ftf)|*.ftf",
multiple = "YES",
nochangedir = "YES",
parentdialog = dlgmain,
})
filedlg:popup(iup.CENTERPARENT, iup.CENTERPARENT)
if filedlg.status == "1" or filedlg.status == "0" then
local selectedPaths = {}
local value = filedlg.value
if value and value ~= "" then
-- Handle multiple file selection
if filedlg.status == "1" then
-- Single file
table.insert(selectedPaths, value)
else
-- Multiple files - value contains multiple paths separated by semicolons
for path in value:gmatch("[^;]+") do
path = path:match("^%s*(.-)%s*$") -- trim whitespace
if path ~= "" then
table.insert(selectedPaths, path)
end
end
end
end
filedlg:destroy()
if #selectedPaths > 0 then
-- Convert selected file paths to autotext template format
local templates = convertFilePathsToTemplates(selectedPaths)
-- Add templates to selected notes with duplicate checking
addRecordsWithDuplicateCheck(templates, selectedNotes, populateNotesList)
end
else
filedlg:destroy()
end
end
--- Gets initial targets from current selection or property box
--- @param initialTargets? TargetRecord[] Pre-defined initial targets
--- @return TargetRecord[] Array of initial targets
function getInitialTargets(initialTargets)
if initialTargets and #initialTargets > 0 then
return initialTargets
end
local targets = {}
-- Try to get current selection first
local currentSelection = fhGetCurrentRecordSel()
if currentSelection and #currentSelection > 0 then
for _, recordPtr in ipairs(currentSelection) do
table.insert(targets, convertRecordPointerToStandardFormat(recordPtr))
end
else
-- Fallback to property box record
local propertyBoxRecord = fhGetCurrentPropertyBoxRecord()
if propertyBoxRecord and propertyBoxRecord:IsNotNull() then
table.insert(targets, convertRecordPointerToStandardFormat(propertyBoxRecord))
end
end
return targets
end
--- Get the supported record types based on current note type and mode
--- @return table Table of supported record type tags
function getSupportedRecordTypes()
local supportedTypes = {}
-- Get all record types and check their support status
local iCount = fhGetRecordTypeCount()
for i = 1, iCount do
local tag = fhGetRecordTypeTag(i)
supportedTypes[tag] = isRecordTypeSupportedForNoteType(tag, currentNoteType)
end
return supportedTypes
end
--- Check if a record type is supported for a specific note type
--- @param recordTag string The record type tag to check
--- @param noteType string The note type to check ("Shared Notes" or "Research Notes")
--- @return boolean True if the record type is supported for the note type
function isRecordTypeSupportedForNoteType(recordTag, noteType)
-- Check if it's completely unsupported
for _, tag in ipairs(recordTypeSupport.completelyUnsupported) do
if tag == recordTag then
return false
end
end
-- Check note-type-specific restrictions
if noteType == NOTE_TYPE_RESEARCH then
for _, tag in ipairs(recordTypeSupport.researchNotesUnsupported) do
if tag == recordTag then
return false
end
end
else -- Shared Notes
for _, tag in ipairs(recordTypeSupport.sharedNotesUnsupported) do
if tag == recordTag then
return false
end
end
end
return true
end
--- Get all record types that are supported for a specific note type
--- @param noteType string The note type to check ("Shared Notes" or "Research Notes")
--- @return string[] Array of supported record type tags
function getSupportedRecordTypesForNoteType(noteType)
local supportedTypes = {}
local iCount = fhGetRecordTypeCount()
for i = 1, iCount do
local tag = fhGetRecordTypeTag(i)
if isRecordTypeSupportedForNoteType(tag, noteType) then
table.insert(supportedTypes, tag)
end
end
return supportedTypes
end
--- Get the current record type support configuration
--- @return table The current record type support configuration
function getRecordTypeSupportConfig()
return recordTypeSupport
end
--- Initialize targets from current selection or property box
function initializeTargets()
selectedTargets = getInitialTargets()
populateTargetRecords()
end
--- Add new targets to the current selection
--- @param newTargets TargetRecord[] New targets to add
function addTargets(newTargets)
addRecordsWithDuplicateCheck(newTargets, selectedTargets, populateTargetRecords)
end
--- Add new notes to the current selection
--- @param newNotes table[] New notes to add
function addNotes(newNotes)
addRecordsWithDuplicateCheck(newNotes, selectedNotes, populateNotesList)
end
--- Remove selected targets from the list
function removeSelectedTargets()
local list = ui.targetRecordsList
if not list then
return
end
local positions = getSelectedValues(list, true)
local count = #positions
if count == 0 then
MessageBox("info", "Select one or more targets to remove.")
return
end
if not confirmRemoveSelected("Targets", count) then
return
end
removeSelectedItems(list, selectedTargets, populateTargetRecords)
end
--------------------------------------------------------------
--MAIN DIALOG ACTIONS
--------------------------------------------------------------
function makeMainDialog()
-- Create content area
local contentArea = createContentArea()
-- Create the main content area
local mainVBox = iup.vbox({
contentArea,
margin = "10x10",
gap = "10",
})
ui.mainVBox = mainVBox
-- Populate the lists initially
initializeTargets()
populateNotesList()
-- Create menu bar using the centralized function
local menuBar = createMainMenuBar()
-- Create the main dialog
local dialog = makeDialog(mainVBox, {
title = "Add Notes",
size = "HALFxHALF",
expand = "YES",
resize = "YES",
menubox = "YES",
menu = menuBar,
help_topic = "",
close_cb = function(self)
return iup.CLOSE
end,
})
-- Apply initial dynamic title reflecting current state
dialog.title = getDialogTitle()
return dialog
end
--------------------------------------------------------------
--EXECUTE
--------------------------------------------------------------
-- Create and show the main dialog
dlgmain = makeMainDialog()
myConfig:showTrackedDialog(dlgmain, "Main")
DoNormalize()
if iup.MainLoopLevel() == 0 then
iup.MainLoop()
end
destroyAllDialogs()
myResults.Display()
Source:Add-Notes-3.fh_lua