--[[ @Title: Change Specific Fact Tag @Type: Standard @Author: Mark Draper @Version: 1.2.1 @LastUpdated: 14 Jun 2025 @Licence: This plugin is copyright (c) 2025 Mark Draper and is licensed under the MIT License which is hereby incorporated by reference (see https://pluginstore.family-historian.co.uk/fh-plugin-licence) @Description: Converts an Individual or Family Record fact to a new fact type. All facts are supported, including local local project facts. ]] --[[ May 2022 - 1.0 First store version, based on FHUG prototype 0.4 Ju1 2022 - 1.0.1 Improved robustness for inconsistent Fact Set structures (various FH bugs) - List of facts can be anywhere in Fact definition file, not just at beginning - Does not process facts with no name or label (e.g. Alternative Name in RM Import) - Ignores missing Fact Sets - Sets can be in any priority order, not in sequence Aug 2022 - 1.0.2 Ignores facts missing either index entry or definition to cope with flawed Military Fact Set Rewritten Fact Set loading for fewer passes through file Oct 2024 - 1.1 FH5/6 Compatiblility added Improved menu behaviour and message boxes General code tidy-up Apr 2025 - 1.2 Support for multiple monitors added Enhanced tool tips added Jun 2025 - 1.2.1 Fixed handling of witnessed facts ]] fhInitialise(5,0,0, 'save_recommended') require 'luacom' require('iuplua') if fhGetAppVersion() > 6 then fh = require('fhUtils') fh.setIupDefaults() else iup.SetGlobal('CUSTOMQUITMESSAGE','YES') end FSO = luacom.CreateObject('Scripting.FileSystemObject') function main() -- select Individual Record local pI = fhGetCurrentRecordSel('INDI')[1] if not pI then MessageBox('No individual selected.', 'OK', 'ERROR') return end -- determine fact set priority and read in sets in increasing priority order local tblFactSets = GetFactSets() local tblFacts = {} for _, fs in ipairs(tblFactSets) do GetFactSet(fs .. '.fhf', tblFacts) end local tblFactGroups = PopulateFactTables(tblFacts) if not tblFactGroups then return end -- end if duplicate fact labels found -- present main menu Menu(pI, tblFacts, tblFactGroups) end -- ************************************************************************** -- function Menu(pI, tblFacts, tblFactGroups) local tblRecordFacts local lstOE, lstOA, lstNE, lstNA, btnOK, btnClear, btnHelp, btnClose -- forward definitions local function PopulateOldFact() tblRecordFacts = GetRecordFacts(pI) -- Pointers to the Individual's Facts local tblOldEvents, tblOldAttributes = GetOldFacts(tblFacts, tblRecordFacts) for k, v in ipairs(tblOldEvents) do lstOE[k+1] = v end for k, v in ipairs(tblOldAttributes) do lstOA[k+1] = v end end local function PopulateNewFact(parent, label) lstNE.RemoveItem = 'All' lstNA.RemoveItem = 'All' lstNE.AppendItem = '(none selected)' lstNA.AppendItem = '(none selected)' if parent == 'I' then for _, v in ipairs(tblFactGroups.IE) do if v ~= label then lstNE.AppendItem = v end end for _, v in ipairs(tblFactGroups.IA) do if v ~= label then lstNA.AppendItem = v end end else for _, v in ipairs(tblFactGroups.FE) do if v ~= label then lstNE.AppendItem = v end end for _, v in ipairs(tblFactGroups.FA) do if v ~= label then lstNA.AppendItem = v end end end for _, control in ipairs({lstNE, lstNA}) do control.active = 'YES' control.value = 1 end btnOK.active = 'NO' end local function ClearFacts() for _, control in ipairs({lstNE, lstNA, lstOE, lstOA}) do control.value = 1 end lstOE.active = 'YES' lstOA.active = 'YES' lstNE.active = 'NO' lstNA.active = 'NO' btnOK.active = 'NO' end local OldFactText, NewFactType, NewFactLabel local parent, label tblRecordFacts = GetRecordFacts(pI) -- Pointers to the Individual's Facts -- define menu elements lstOE = iup.list{'(none selected)', dropdown = 'YES', size = 250, value = 1, expand = 'YES', VisibleItems = 10, tip = 'Select existing event'} local OldEvent = iup.hbox{iup.label{title = 'Event:', size = 40}, lstOE} lstOA = iup.list{'(none selected)', dropdown = 'YES', size = 250, value = 1, expand = 'YES', VisibleItems = 10, tip = 'Select existing attribute'} local OldAttribute = iup.hbox{iup.label{title = 'Attribute:', size = 40}, lstOA} lstNE = iup.list{'(none selected)', dropdown = 'YES', size = 250, value = 1, expand = 'YES', VisibleItems = 10, tip = 'Select new event', active = 'NO'} local NewEvent = iup.hbox{iup.label{title = 'Event:', size = 40}, lstNE} lstNA = iup.list{'(none selected)', dropdown = 'YES', size = 250, value = 1, expand = 'YES', VisibleItems = 10, tip = 'Select new attribute', active = 'NO'} local NewAttribute = iup.hbox{iup.label{title = 'Attribute:', size = 40}, lstNA} local OldFact = iup.vbox{OldEvent, OldAttribute; margin = '15x5', gap = 5} local NewFact = iup.vbox{NewEvent, NewAttribute; margin = '15x5', gap = 5} btnOK = iup.button{title = 'OK', active = 'NO', tip = 'Process change', action = function(self) ChangeFact(OldFactText, NewFactLabel, tblRecordFacts, tblFacts) PopulateOldFact() ClearFacts() end} btnClear = iup.button{title = 'Clear', tip = 'Clear form', padding = '10x3', action = function(self) ClearFacts() end} btnClose = iup.button{title = 'Close', tip = 'Close plugin', action = function(self) return iup.CLOSE end} btnHelp = iup.button{title = 'Help', tip = 'Show help', action = function(self) fhShellExecute('https://pluginstore.family-historian.co.uk/page/help/' .. 'change-specific-fact-tag') end} local Buttons = iup.hbox{iup.fill{}, btnOK, btnClear, btnHelp, btnClose, iup.fill{}; margin = '10x10', normalizesize = 'BOTH', padding = 10, gap = 40} -- define enhanced tool tips local enhanced = true -- comment out this line if enhanced tool tips are not required if enhanced then local tblH = {lstOE, lstOA, lstNE, lstNA, btnOK, btnClear, btnHelp, btnClose} for _, control in ipairs(tblH) do control.TipBalloon = 'YES' control.TipBalloonTitleIcon = 1 -- modify individually if different end lstOE.TipBalloonTitle = 'Select Existing Event' lstOE.tip = 'This is the event that will be changed' lstOA.TipBalloonTitle = 'Select Existing Attribute' lstOA.tip = 'This is the attribute that will be changed' lstNE.TipBalloonTitle = 'Select New Event' lstNE.tip = 'This is the new event that will replace the existing selected attribute or event' lstNA.TipBalloonTitle = 'Select New Attribute' lstNA.tip = 'This is the new attribute that will replace the existing selected attribute or event' btnOK.TipBalloonTitle = 'Process Changes' btnOK.TipBalloonTitleIcon = '2' btnOK.tip = 'Implement the attribute and/or event replacement' btnClear.TipBalloonTitle = 'Clear Selections' btnClear.tip = 'Clear all form input ready for new selection' btnHelp.TipBalloonTitle = 'Help' btnHelp.tip = 'Display Plugin Store help file for this plugin' btnClose.TipBalloonTitle = 'Close Plugin' btnClose.tip = 'Close plugin and return to main application' end -- define list callbacks function lstOE:valuechanged_cb() if tonumber(lstOE.value) > 1 then OldFactText = lstOE[lstOE.value] label, parent = GetOldFactDetails(OldFactText, tblRecordFacts, tblFacts) PopulateNewFact(parent, label) lstOA.value = 1 lstOA.active = 'NO' else lstOA.active = 'YES' if tonumber(lstOA.value) == 1 then -- no old fact now selected ClearFacts() end end end function lstOA:valuechanged_cb() if tonumber(lstOA.value) > 1 then OldFactText = lstOA[lstOA.value] label, parent = GetOldFactDetails(OldFactText, tblRecordFacts, tblFacts) PopulateNewFact(parent, label) lstOE.value = 1 lstOE.active = 'NO' else lstOE.active = 'YES' if tonumber(lstOE.value) == 1 then -- no old fact now selected ClearFacts() end end end function lstNE:valuechanged_cb() if tonumber(lstNE.value) > 1 then NewFactLabel = lstNE[lstNE.value] lstNA.value = 1 lstNA.active = 'NO' btnOK.active = 'YES' else lstNA.active = 'YES' btnOK.active = 'NO' end end function lstNA:valuechanged_cb() if tonumber(lstNA.value) > 1 then NewFactLabel = lstNA[lstNA.value] lstNE.value = 1 lstNE.active = 'NO' btnOK.active = 'YES' else lstNE.active = 'YES' btnOK.active = 'NO' end end PopulateOldFact() -- assemble final menu local Container = iup.vbox{ iup.frame{OldFact; title = 'Original Event or Attribute:'}, iup.frame{NewFact; title = 'New Event or Attribute:'}, Buttons; margin = '20x20', gap = 20} local dlg = iup.dialog{Container, title = 'Change Fact for ' .. fhGetDisplayText(pI); resize = 'YES', maxbox = 'NO', minbox = 'NO'} dlg:map() local DialogWidth = dlg.NaturalSize:match('^%d+') local DialogHeight = dlg.NaturalSize:match('%d+$') if fhGetAppVersion() > 6 then iup.SetAttribute(dlg, 'NATIVEPARENT', fhGetContextInfo('CI_PARENT_HWND')) end -- fixing height not supported in WINE, so keep fully resizable if not FSO:FolderExists('Z:\\bin') and not FSO:FolderExists('Z:\\etc') then dlg.minsize = DialogWidth .. 'x' .. DialogHeight dlg.maxsize = 'x' .. DialogHeight end dlg:popup() end -- ************************************************************************** -- function GetFactSets() -- read in fact set priority list and store in ordered table local file, folder local tblUnsorted = {} -- get Project Fact Sets folder = fhGetContextInfo('CI_PROJECT_DATA_FOLDER') .. '\\Fact Types\\' file = folder .. 'GroupIndex.fhdata' local tblFile = ReadUTF16File(file) or {} for _, v in ipairs(tblFile) do local set, seq = v:match('^(.+)=(%d+)$') if set and seq then seq = math.floor(tonumber(seq)) table.insert(tblUnsorted, string.format('%04i', 1000 - seq) .. folder .. set) end end -- get System Fact Sets folder = fhGetContextInfo('CI_APP_DATA_FOLDER') .. '\\Fact Types\\' file = folder .. 'Standard\\GroupIndex.fhdata' tblFile = ReadUTF16File(file) or {} for _, v in ipairs(tblFile) do local set, seq = v:match('^(.+)=(%d+)$') if set and seq then seq = math.floor(tonumber(seq)) if set == 'Standard' then table.insert(tblUnsorted, '0000' .. folder .. 'Standard\\' .. set) else table.insert(tblUnsorted, string.format('%04i', 500 - seq) .. folder .. 'Custom\\' .. set) end end end -- sort and populate final table table.sort(tblUnsorted) local tblFactSets = {} for _, fs in ipairs(tblUnsorted) do table.insert(tblFactSets, fs:sub(5)) end return tblFactSets end -- ************************************************************************** -- function GetFactSet(File, tblFacts) local tblFile = ReadUTF16File(File) if not tblFile then return end -- identify facts listed in index local tblIndexFacts = {} local index = false local tblFactDefs = {} local ActiveFact, WitnessFact for _, line in ipairs(tblFile) do if index then local FactTag = line:match('^Item%d+=(.+)$') if FactTag then tblIndexFacts[FactTag] = true end if line:sub(1,1) == '[' then -- end of index section index = false end elseif line == '[.index]' then index = true ActiveFact = nil end if line:match('^%[FCT-') and line:match('-[IF][EA]]$') then -- start of fact definition ActiveFact = line:sub(6, -2) tblFactDefs[ActiveFact] = {} end if ActiveFact then local term, value = line:match('^(%a+)=(.+)$') if term == 'Name' then tblFactDefs[ActiveFact].Name = value end if term == 'Label' then tblFactDefs[ActiveFact].Label = value end if term == 'Hidden' then tblFactDefs[ActiveFact].Hidden = value end end end -- second pass to identify witness roles (not always listed with main fact definition) local witnesses for _, line in ipairs(tblFile) do if line:match('^%[FCT-') and line:match('-ROLE]$') then -- start of witness definition WitnessFact = line:sub(6, -7) end local _, Role = line:match('^Role(%d+)=(.+)$') if Role then if not tblFactDefs[WitnessFact].Witnesses then tblFactDefs[WitnessFact].Witnesses = {} end table.insert(tblFactDefs[WitnessFact].Witnesses, Role) end end -- add valid facts to main table for tag, fact in pairs(tblFactDefs) do if not (fact.Hidden and fact.Hidden == 'Y') then if fact.Name and fact.Label and tblIndexFacts[tag] then tblFacts[tag] = {} tblFacts[tag].Name = fact.Name tblFacts[tag].Label = fact.Label if fact.Witnesses then tblFacts[tag].Witnesses = fact.Witnesses end end end end end -- ************************************************************************** -- function PopulateFactTables(tblFacts) -- check for duplicate fact labels local tblFactLabels = {} for _, FactDetails in pairs(tblFacts) do if tblFactLabels[FactDetails.Label] then local msg = 'There is more than one Fact labelled "' .. FactDetails.Label .. '". Please check your Fact definitions' MessageBox(msg, 'OK', 'ERROR') return end tblFactLabels[FactDetails.Label] = true end -- populate tables used in interface dialogue local tblF = {IE = {}, IA = {}, FE = {}, FA = {}} for tag, FactDetails in pairs(tblFacts) do if tag:match('IE$') then table.insert(tblF.IE, FactDetails.Label) end if tag:match('IA$') then table.insert(tblF.IA, FactDetails.Label) end if tag:match('FE$') then table.insert(tblF.FE, FactDetails.Label) end if tag:match('FA$') then table.insert(tblF.FA, FactDetails.Label) end end for _, t in pairs(tblF) do table.sort(t) end return tblF end -- ************************************************************************** -- function GetRecordFacts(p) -- get Individual and Family Facts for subject individual local pF = fhNewItemPtr() local tblT = {} pF:MoveToFirstChildItem(p) while pF:IsNotNull() do if fhGetTag(pF) == 'FAMS' then local pL = fhGetValueAsLink(pF) local pFL = fhNewItemPtr() pFL:MoveToFirstChildItem(pL) while pFL:IsNotNull() do if fhIsFact(pFL) then tblT[fhGetDisplayText(pFL)] = pFL:Clone() end pFL:MoveNext() end elseif fhIsFact(pF) then tblT[fhGetDisplayText(pF)] = pF:Clone() end pF:MoveNext() end return tblT end -- ************************************************************************** -- function GetOldFacts(tblFacts, tblRecordFacts) -- get fact details local tblE = {} local tblA = {} for RecordText, pF in pairs(tblRecordFacts) do local FactLabel = GetOldFactDetails(RecordText, tblRecordFacts, tblFacts) if FactLabel == '' then FactLabel = fhGetDisplayText(pF):match('^%S+') end local DayNumber = fhCallBuiltInFunction('Daynumber', fhGetValueAsDate(fhGetItemPtr(pF, '~.DATE')):GetDatePt1()) or '' local SortTag = FactLabel .. DayNumber .. '<>' if fhIsEvent(pF) then table.insert(tblE, SortTag .. fhGetDisplayText(pF)) end if fhIsAttribute(pF) then table.insert(tblA, SortTag .. fhGetDisplayText(pF)) end end table.sort(tblE) -- alphabetical/chronological table.sort(tblA) -- strip out sort tags prior to display for k, v in ipairs(tblE) do tblE[k] = v:sub(v:find('<>') + 2) end for k, v in ipairs(tblA) do tblA[k] = v:sub(v:find('<>') + 2) end return tblE, tblA end -- ************************************************************************** -- function GetOldFactDetails(OldFactText, tblRecordFacts, tblFacts) -- return parent (I or F) and label of specified old fact local pF = tblRecordFacts[OldFactText] local pR = fhNewItemPtr() pR:MoveToParentItem(pF) local parent = fhGetTag(pR):sub(1, 1) local tag = fhGetTag(pF) if fhIsEvent(pF) then tag = tag .. '-' .. parent .. 'E' else tag = tag .. '-' .. parent .. 'A' end if tblFacts[tag] then return tblFacts[tag].Label, parent else return '', parent -- custom fact does not have a label end end -- ************************************************************************** -- function ChangeFact(OldFactText, NewFactLabel, tblRecordFacts, tblFacts) -- get pointer to old fact local pOldFact = tblRecordFacts[OldFactText] -- get name and tag of new fact local OldFactType, NewFactName, NewFactTag, NewFactType for FactTag, FactDetail in pairs(tblFacts) do if FactDetail.Label == NewFactLabel then NewFactName = FactDetail.Name NewFactTag = FactTag:sub(1, -4) NewFactType = FactTag:match('%u$') if fhIsEvent(pOldFact) then OldFactType = 'E' else OldFactType = 'A' end break end end -- check witness roles before confirming change and making substitution if not WitnessCheck(pOldFact, NewFactTag, tblFacts) then return end local msg = 'Change ' .. OldFactText .. ' to ' .. NewFactLabel .. '?' if OldFactType == 'E' and NewFactType == 'A' then msg = msg .. '\n\nCAUTION: You are converting an Event to an Attribute.' elseif OldFactType == 'A' and NewFactType == 'E' then msg = msg .. '\n\nCAUTION: You are converting an Attribute to an Event.' .. '\nAny Attribute value will be saved as a local note in the new Event fact.' end if MessageBox(msg, 'OKCANCEL', 'QUESTION', nil, 2) == 2 then return end -- find fact parent record local pR = fhNewItemPtr() pR:MoveToParentItem(pOldFact) -- add new fact and clone details from old one local pNewFact = fhCreateItem(NewFactTag, pR) if pNewFact:IsNull() then MessageBox('Failed to create new fact.', 'OK', 'ERROR') return end fhMoveItemBefore(pNewFact, pOldFact) fhSetValue_Copy(pNewFact, pOldFact) CopyChildren(pOldFact, pNewFact) -- recursive copy as per FHUG snippet -- get any fact value and save as local note if lost local update_msg = '' local OldFactValue = fhGetValueAsText(pOldFact) or '' local NewFactValue = fhGetValueAsText(pNewFact) or '' if OldFactValue ~= '' and NewFactValue == '' then if fhGetItemPtr(pOldFact, '~.NOTE2'):IsNotNull() then update_msg = '\n\nTo view the saved Attribute value, ' .. 'click on the All tab in the FH Property Box.' end local pN = fhCreateItem('NOTE2', pNewFact) fhSetValueAsText(pN, OldFactValue) end -- delete old fact fhDeleteItem(pOldFact) fhUpdateDisplay() MessageBox('Fact updated.' .. update_msg, 'OK', 'INFORMATION') end -- ************************************************************************** -- function CopyBranch(pSource, pTarget) local tag = fhGetTag(pSource) if tag == '_FMT' then return end -- Skip rich text format code if tag == '_FIELD' then -- Substitute metafield shortcut tag = fhGetMetafieldShortcut(pSource) end local pNew = fhCreateItem(tag, pTarget, true) if pNew:IsNull() then return end -- Escape if item not created fhSetValue_Copy(pNew, pSource) CopyChildren(pSource, pNew) end -- ************************************************************************** -- function CopyChildren(pSource, pTarget) local pFrom = fhNewItemPtr() pFrom = pSource:Clone() pFrom:MoveToFirstChildItem(pFrom) while pFrom:IsNotNull() do CopyBranch(pFrom, pTarget) pFrom:MoveNext() end end -- ************************************************************************** -- function WitnessCheck(pOldFact, NewFactTag, tblFacts) local pW = fhNewItemPtr() local tblMissing = {} -- identify witnesses and check if role exists in new fact definition pW:MoveToFirstChildItem(pOldFact) while pW:IsNotNull() do if fhGetTag(pW) == '_SHAR' or fhGetTag(pW) == '_SHAN' then local pR = fhNewItemPtr() pR:MoveTo(pW, '~.ROLE') local role = fhGetValueAsText(pR) -- does this role exist in proposed new fact? local found = false for tag, v in pairs(tblFacts) do local OldTag = tag:sub(1, tag:len() - 3) if NewFactTag == OldTag then for _, WitnessRole in ipairs(v.Witnesses) do print(WitnessRole) if WitnessRole == role then found = true end end end end if not found then table.insert(tblMissing) end end pW:MoveNext() end if #tblMissing == 0 then return true end -- no missing witness roles local msg = 'The following Witness Roles are present in the old Fact Record but ' .. 'missing from the definition of the new Fact Record. Please update the new ' .. 'Fact definition before changing this fact name. \n\n' for role, _ in pairs(tblMissing) do msg = msg .. role .. '\n' end MessageBox(msg, 'OK', 'ERROR') end -- ************************************************************************** -- function ReadUTF16File(File) if not FSO:FileExists(File) then return end local ts = FSO:OpenTextFile(File, 1, false, -1) local tblFile = {} while not ts.AtEndOfStream do table.insert(tblFile, ts:ReadLine()) end ts:Close() return tblFile end -- ************************************************************************** -- function MessageBox(Message, Buttons, Icon, BoxTitle, Default) -- replaces built-in function with custom version containing more options local msgdlg = iup.messagedlg{value = Message, buttons = Buttons, dialogtype = Icon, title = BoxTitle or fhGetContextInfo('CI_PLUGIN_NAME'), buttondefault = Default, parentdialog = menudialog} -- display message box and return selection msgdlg:popup() return tonumber(msgdlg.ButtonResponse) end -- ********************************************************************* main()