--[[ @Title: Add Notes @Type: standard @Author: Helen Wright @Contributors: @Version: 1.0 @LastUpdated: 21ST MAY 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.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.0" 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 }, } -------------------------------------------------------------- --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.1 @LastUpdated: 7th August 2025 @Description: Helper functions for IUP dialogs @V1.0: Initial version. ]] 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 -- 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 colors for light theme iup.SetGlobal("DLGBGCOLOR", "255 255 255") -- White background iup.SetGlobal("TXTBGCOLOR", "255 255 255") -- White text background iup.SetGlobal("TXTFGCOLOR", "0 0 0") -- Black text -- 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", bgcolor = "255 255 255", -- White background fgcolor = "0 0 0", -- Black text }), IUPLIST = iup.user({ normalizergroup = textnorm, editbox = "NO", sort = "YES", dropdown = "YES", multiple = "NO", bgcolor = "255 255 255", -- White background fgcolor = "0 0 0", -- Black text }), IUPTEXT = iup.user({ alignment = "ALEFT:ACENTER", normalizergroup = textnorm, wordwrap = "YES", append = "YES", scrollbar = "NO", multiline = "NO", visiblelines = "1", readonly = "NO", padding = "2x", bgcolor = "255 255 255", -- White background fgcolor = "0 0 0", -- Black text }), IUPLABEL = iup.user({ wordwrap = "NO", alignment = "ALEFT:ACENTER", expand = "NO", padding = "20x10", }), 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 = "255 255 255", -- White background fgcolor = "0 0 0", -- Black text }), IUPTOGGLE = iup.user({ alignment = "ALEFT:ACENTER", normalizergroup = btnnorm, bgcolor = "255 255 255", -- White background fgcolor = "0 0 0", -- Black text }), IUPEXPANDER = iup.user({ visible = "YES", }), IUPMATRIX = iup.user({ markmode = "CELL", resizematrix = "YES", scrollbar = "YES", bgcolor = "255 255 255", -- White background fgcolor = "0 0 0", -- Black text }), IUPTREE = iup.user({ expand = "YES", bgcolor = "255 255 255", -- White background fgcolor = "0 0 0", -- Black 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 = "255 255 255", -- White background fgcolor = "0 0 0", -- Black text }), }) iup.SetHandle("myTheme", myTheme) iup.SetGlobal("DEFAULTTHEME", "myTheme") -- Tooltip theming for consistency iup.SetGlobal("TIPBGCOLOR", "255 255 225") -- Light yellow iup.SetGlobal("TIPFGCOLOR", "0 0 0") -- Black 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 = "255 255 255" -- Reset to white 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", } local btn = makeButton(mergeOptions(defaults, options)) 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, mask = field.mask, } if field.mask == "TOKEN" then opts.mask = "[A-Z0-9.]*" opts.tip = "Allowed: A–Z, 0–9, and '.' — or leave blank" end return makeText(opts) elseif field.type == "number" then return makeText({ value = tostring(value), mask = IUP_MASK_FLOAT, }) 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" 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 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, fileMenu = { save = { title = "&Save", action = function() saveAll() return iup.CLOSE end, }, resetAll = { 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", }), { title = options.title or self.defaultConfig.title, resize = "YES", menubox = "YES", menu = menuBarData.menuBar, help_topic = help_topic, close_cb = function(self) self:hide() end, } ) DoNormalize() if #allSections > 0 then showSection(allSections[1]) end dialog:popup(iup.CENTERPARENT, iup.CENTERPARENT) dialog = nil 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: fileMenu = { save = MenuBar.helpers.createMenuItem("&Save", function(self) saveData() return iup.DEFAULT end, nil, "menuSave"), export = 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 (any keys, processed in order) - Cancel/Exit added automatically ---@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 local fileMenuItems = {} if options.fileMenu then local firstItem = true for _, item in pairs(options.fileMenu) do if not firstItem then table.insert(fileMenuItems, {}) -- separator between items end table.insert(fileMenuItems, createMenuItem(item)) 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 local helpMenu = iup.submenu({ iup.menu({ createMenuItem(options.helpMenu and options.helpMenu.help or { title = "&Help", action = function(self) if Help then Help.new({}):show("") end return iup.DEFAULT end, }), {}, -- separator createMenuItem(options.helpMenu and options.helpMenu.about or { title = "&About", action = function(self) if Help then Help.new({}):show("about") end return iup.DEFAULT end, }), }), 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, "https://pluginstore.family-historian.co.uk/page/help/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 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 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()