Give Witnesses Their Own Facts.fh_lua--[[
@Title: Give Witnesses Their Own Facts
@Type: Standard
@Author: Mike Tate
@Contributors:
@Version: 1.5
@Keywords:
@LastUpdated: 11 Feb 2026
@Licence: This plugin is copyright (c) 2026 Mike Tate & 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: Remove chosen Individual Witnesses from Facts and create their own Facts instead.
@V1.5: Centre windows on FH window; Check Version in Store button; fhInitialise();
@V1.4: Handle rich text notes and citation metafields; Privacy brackets option; Add Family Residence; Cater for _SDATE;
@V1.3: FH V7 Lua 3.5 IUP 3.28;
@V1.2: Avoid duplicating any Fact when same Individual is a multiple Principal/Witness, plus copy all Role Citations;
@V1.1: Added Census (family);
@V1.0: First published in Plugin Store;
]]
require "iuplua"
iup.SetGlobal("CUSTOMQUITMESSAGE","YES") -- Needed for IUP 3.28
local strVersion = "1.5" -- Update when version changes
--[[
@Function: CheckVersionInStore
@Author: Mike Tate
@Version: 1.4
@LastUpdated: 20 Jan 2026
@Description: Check plugin version against version in Plugin Store
@Parameter: Plugin name and version
@Returns: None
@Requires: luacom
@V1.4: Dispense with files and assume called via IUP button;
@V1.3: Save and retrieve latest version in file;
@V1.2: Ensure the Plugin Data folder exists;
@V1.1: Monthly interval between checks; Report if Internet is inaccessible;
@V1.0: Initial version;
]]
function CheckVersionInStore(strPlugin,strVersion) -- Check if later Version available in Plugin Store
require "luacom"
local function httpRequest(strRequest) -- Luacom http request protected by pcall() below
local http = luacom.CreateObject("winhttp.winhttprequest.5.1")
http:Open("GET",strRequest,false)
http:Send()
return http.Responsebody
end -- local function httpRequest
local function intVersion(strVersion) -- Convert version string to comparable integer
local intVersion = 0
local arrNumbers = {}
strVersion:gsub("(%d+)", function(strDigits) table.insert(arrNumbers,strDigits) end)
for i = 1, 5 do
intVersion = intVersion * 100 + tonumber(arrNumbers[i] or 0)
end
return intVersion
end -- local function intVersion
local strLatest = "0"
if strPlugin then
local strRequest ="http://www.family-historian.co.uk/lnk/checkpluginversion.php?name="..tostring(strPlugin)
local isOK, strReturn = pcall(httpRequest,strRequest)
if not isOK then -- Problem with Internet access
fhMessageBox(strReturn.."\n The Internet appears to be inaccessible. ")
elseif strReturn then
strLatest = strReturn:match("([%d%.]*),%d*") -- Version digits & dots then comma and Id digits
end
end
local strMessage = "No later Version"
if intVersion(strLatest) > intVersion(strVersion or "0") then
strMessage = "Later Version "..strLatest
end
fhMessageBox(strMessage.." of this Plugin is available from the 'Plugin Store'.")
end -- function CheckVersionInStore
function getParamEmulator() -- Prototype for iup.GetParam(...) -- V1.5
if fhGetAppVersion() > 6 then unpack = table.unpack end -- Needed for Lua 5.3
local iupDialog = nil
local arrValues = {}
local isSuccess = false
local strPlugin = fhGetContextInfo("CI_PLUGIN_NAME"):gsub(" %- .*","")
local function handleButton(iupDialog,intIndex,strTitle) -- Handle the dialog buttons
if intIndex == (iup.GETPARAM_OK or -1) then
-- strTitle sometimes needed to determine the function -- 1st button action -- FH V5 needs -1
isSuccess = true
elseif intIndex == (iup.GETPARAM_CANCEL or -3) then -- 2nd 'Cancel Plugin' button -- FH V5 needs -3
isSuccess = false
elseif intIndex == (iup.GETPARAM_HELP or -4) then -- 3rd 'Later Version?' button -- FH V5 needs -4
iupDialog.Active = "NO"
CheckVersionInStore(strPlugin,strVersion)
iupDialog.Active = "YES"
iupDialog.BringFront = "YES"
return 0
end
return 1
end -- function handleButton
local function makeDialog(strTitle,strFormat) -- Make emulated iup.GetParam(...) dialog
local arrFormat = {}
for strForm in strFormat:gmatch(".-\n") do -- Construct parameters from format
local iupParam = iup.param{ format=strForm; }
table.insert(arrFormat,iupParam)
end
local iupParams = iup.parambox{ unpack(arrFormat) }
local iupButton = iup.button{ Title="Help && Advice"; Padding="12x8"; } -- Example of extra button
-- iupDialog = iup.dialog{ Title=strTitle; iup.vbox{ iupParams; iupButton; ALIGNMENT="ACENTER"; MARGIN="10x10"; }; close_cb=function() isSuccess = false return iup.CLOSE end; }
iupDialog = iup.dialog{ Title=strTitle; iupParams; close_cb=function() isSuccess = false return iup.CLOSE end; }
if fhGetAppVersion() > 6 then -- Window centres on FH parent
iup.SetAttribute(iupDialog,"NATIVEPARENT",fhGetContextInfo("CI_PARENT_HWND"))
end
for intParam = 1, iupParams.ParamCount do -- Set all parameter values
local iupParam = iupParams:GetParamParam(intParam-1)
local iupCntrl = iupParam.Control
local anyValue = arrValues[intParam]
if iupParam.Type == "LIST" then anyValue = anyValue + 1 end -- Droplists need an adjustment
iupCntrl.Value = anyValue
end
function iupParams:param_cb(intIndex) -- Parameter call back actions
if intIndex >= 0 then
local iupParam = iupParams:GetParamParam(intIndex) -- Save any parameter value
arrValues[intIndex+1] = tonumber(iupParam.Value) or iupParam.Value
return 1
else
return handleButton(iupDialog,intIndex,strTitle) -- Handle buttons
end
end -- function iupParams:param_cb
function iupButton:action(intButton) -- Display Help Page
local strPlugin = strPlugin:gsub(" ","-"):lower()
fhShellExecute("https://pluginstore.family-historian.co.uk/page/help/"..strPlugin,"","","open")
fhSleep(3000,500)
iupDialog.BringFront = "YES"
return 1
end -- function iupButton:action
end -- local function makeDialog
local function getParam(strTitle,strSize,strFormat,...) -- Emulate iup.GetParam(...) for FH V7 or later
arrValues = {...}
if fhGetAppVersion() > 6 then
makeDialog(strTitle,strFormat)
if strSize then iupDialog.Size = strSize end
iupDialog:map()
iupDialog.MinSize = iupDialog.NaturalSize
iupDialog:showxy(iup.CENTERPARENT,iup.CENTERPARENT)
if iup.MainLoopLevel()==0 then iup.MainLoop() end
iup.Destroy(iupDialog)
iupDialog = nil
else
local function fncAction(iupDialog,intIndex)
return handleButton(iupDialog,intIndex,strTitle) -- Handle buttons
end -- local function fncAction
arrValues = { iup.GetParam(strTitle,fncAction,strFormat,unpack(arrValues)) }
isSuccess = arrValues[1]
table.remove(arrValues,1)
end
return isSuccess, unpack(arrValues)
end -- local function getParam
return getParam
end -- function getParamEmulator
local getParam = getParamEmulator()
function Find(dicFact) -- Find the Facts that have Witness Roles
local intFind = 0
local ptrRec = fhNewItemPtr()
for intType, strType in ipairs ( { "INDI"; "FAM"; } ) do -- V1.1
ptrRec:MoveToFirstRecord(strType) -- Loop through all Individual & Family Records
while ptrRec:IsNotNull() do
local ptrFact = fhNewItemPtr()
ptrFact:MoveToFirstChildItem(ptrRec) -- Loop through all Facts
while ptrFact:IsNotNull() do
local strFact = fhGetTag(ptrFact)
if fhIsFact(ptrFact) -- Any Individual Fact or Family Census or Family Residence -- V1.1 -- V1.4
and ( strType == "INDI" or strFact == "CENS" or strFact == "RESI" ) then
local ptrShar = fhNewItemPtr()
ptrShar:MoveTo(ptrFact,"~._SHAR") -- Loop through all Fact Witness Roles
while ptrShar:IsNotNull() do
local strName = strFact..strType -- V1.1
local strRole = fhGetItemText(ptrShar,"~.ROLE")
if not dicFact[strName] then
dicFact[strName] = {} -- Add new Fact and its Label
dicFact[strName][0] = fhCallBuiltInFunction("FactLabel",ptrFact)
end
local intRole = dicFact[strName][strRole]
if not intRole then
intFind = intFind + 1 -- Count different Fact Witness Roles
intRole = 0
end
dicFact[strName][strRole] = intRole + 1 -- Count duplicate Fact Witness Roles
ptrShar:MoveNext("SAME_TAG")
end
end
ptrFact:MoveNext()
end
ptrRec:MoveNext()
end
end
dicFact[0] = intFind -- Save count of different Roles found
return ( intFind > 0 )
end -- function Find
function Pick(dicFact) -- Pick which Witness Roles to give own Facts
local arrReply = { } -- GetParam reply tick values
local isTicked = true -- GetParam reply status
local intParam = 0 -- Count of params per page
local strParam = "" -- Format of params per page
local intSize = 25 -- Maximum params per page
local intPage = 1 -- Current page number
local intLast = math.ceil( dicFact[0] / intSize ) -- Number of pages needed
intSize = math.ceil( dicFact[0] / intLast ) -- Even out params per page
dicFact[0] = nil
local function Zeros(intParam) -- Return same number of 0's as Witness Role params
intParam = intParam - 1
if intParam == 0 then return 0 end -- End recursion
return 0,Zeros(intParam)
end -- local function Zeros
local function GetParam(strButton) -- GetParam user dialogue with OK button name -- V1.5
strParam = "Tick the Witness Roles to be given their own Facts: %t\n"..strParam
strParam = strParam.."For all Roles ticked above, this Plugin copies the \r"
strParam = strParam.."principal Fact on the left to each of its Witnesses,\r"
if dicFact["CENSFAM"] then
strParam = strParam.."(Any 'Census (family)' becomes 'Census' events.) \r"
end
strParam = strParam.."then provides a Result Set list of all the changes. \r"
strParam = strParam.."Undo changes with 'Edit > Undo Plugin Updates' \r"
strParam = strParam.."or 'File > Backup/Restore > Revert to Snapshot'. %t\n"
strParam = strParam.."%u[ "..strButton.." , Close Plugin , Later Version? ]\n" -- V1.5
local strTitle = " Select Witness Roles "..strVersion.." Page "..intPage.." of "..intLast
local arrParam = { getParam( strTitle, nil, strParam, Zeros(intParam) ) } -- V1.5
isTicked = arrParam[1] -- Save reply status: true=OK, false=Cancel
for intParam = 2, #arrParam do
table.insert(arrReply,arrParam[intParam]) -- Append reply ticks to previous replies
end
end -- local function GetParam
for strFact, arrRole in pairs (dicFact) do -- Loop through all Fact Witness Roles
local strLabel = arrRole[0]
strLabel = strLabel..string.rep(" ",13 - #strLabel) -- Suffix Fact Label with spaces to fixed width
arrRole[0] = nil
for strRole, intRole in pairs (arrRole) do -- Compose GetParam format text of boolean per Role
intParam = intParam + 1
local strCount = tostring(intRole) -- Prefix Role count with spaces to fixed width
strCount = string.rep(" ",5 - #strCount)..strCount.." x "
strParam = strParam..strCount..strLabel.." ~ "..strRole.." %b\n"
if intPage < intLast and intParam >= intSize then
GetParam("Next Page") -- Get a page of user params -- V1.5
if not isTicked then break end
intParam = 0
strParam = "" -- Reset for next page
intPage = intPage + 1
end
end
if not isTicked then break end -- User cancelled dialogue
end
if isTicked then
strParam = strParam.."Put each Witness Role note in [[privacy]] brackets? %b\n"
intParam = intParam + 1 -- Privacy brackets? -- V1.4
GetParam("Copy Facts") -- Get last page of user params -- V1.5
if isTicked then
isTicked = false
for strName, arrRole in pairs (dicFact) do -- Check if any ticks -- V1.1
for strRole, intRole in pairs (arrRole) do
local isTick = (table.remove(arrReply,1) == 1) -- Reply = 1 if tick and thus true -- V1.1
dicFact[strName][strRole] = isTick -- Assign tick is true or false to each Role -- V1.1
isTicked = isTicked or isTick
end
end
dicFact.Privacy = (arrReply[#arrReply] == 1) -- Privacy brackets? -- V1.4
end
end
return isTicked
end -- function Pick
function Error(strAct,...) -- Report error and abort the plugin
local arg = {...}
local strErr = "\n"..strAct.." (\n "
for intArg = 1, #arg do
local strArg = arg[intArg]
local strTyp = type(strArg) -- Convert pointer or boolean args to text
if strTyp == "userdata" then
strArg = fhGetDisplayText(strArg)
elseif strTyp == "boolean" then
strArg = tostring(strArg)
end
strErr = strErr..strArg
if intArg < #arg then strErr = strErr.." ,\n " end
end
strErr = strErr.." )\nfailed"
local intArg = 1
if type(arg[intArg]) == "string" then intArg = #arg end -- Find target pointer
if type(arg[intArg]) == "boolean" then intArg = intArg-1 end
local ptrRec = arg[intArg]:Clone()
if fhHasParentItem(ptrRec) then -- Is target a record?
ptrRec:MoveToRecordItem(ptrRec)
strErr = strErr.." for "..fhGetDisplayText(ptrRec) -- Add name of record
end
error("\n\nError: "..strErr,3) -- Report and abort
end -- function Error
dicAct = { fhCreateItem=fhCreateItem; fhSetValue_Copy=fhSetValue_Copy; fhGetValueAsText=fhGetValueAsText; fhGetValueAsRichText=fhGetValueAsRichText; fhGetValueAsLink=fhGetValueAsLink; fhSetValueAsText=fhSetValueAsText; fhSetValueAsRichText=fhSetValueAsRichText; fhSetValueAsLink=fhSetValueAsLink; fhDeleteItem=fhDeleteItem; }
function Perform(strAct,...) -- Perform FH API function
local anyAns = dicAct[strAct](...)
if ( type(anyAns) == "boolean" and not anyAns ) -- Most return true or false
or ( type(anyAns) == "userdata" and anyAns:IsNull() ) -- fhCreateItem returns pointer
then Error(strAct,...) end
return anyAns -- Others can return text
end -- function Perform
function CopyBranch(ptrSource,ptrTarget) -- Copy one child branch
local strTag = fhGetTag(ptrSource)
if strTag == "HUSB" or strTag == "WIFE" then return end -- Family Census tags not allowed in Individual Census -- V1.1
if strTag == "_FMT" then return end -- Skip rich text format tag -- V1.4
if strTag == "_FIELD" then
strTag = fhGetMetafieldShortcut(ptrSource) -- Handle citation metafield -- V1.4
end
local ptrNew = Perform("fhCreateItem",strTag,ptrTarget,true)
Perform("fhSetValue_Copy",ptrNew,ptrSource)
CopyChildren(ptrSource,ptrNew)
end -- function CopyBranch
local dicExclude = { AGE=true; _SHAR=true; _SHAN=true; _SENT=true; } -- V1.4
function CopyChildren(ptrSource,ptrTarget) -- Copy children branches
local ptrFrom = fhNewItemPtr()
ptrFrom = ptrSource:Clone()
ptrFrom:MoveToFirstChildItem(ptrFrom)
while ptrFrom:IsNotNull() do
local strTag = fhGetTag(ptrFrom)
if not dicExclude[strTag] then -- Exclude AGE and custom tags _SHAR, _SHAN, _SENT, but not _SDATE -- V1.4
CopyBranch(ptrFrom,ptrTarget)
end
ptrFrom:MoveNext()
end
end -- function CopyChildren
function PerRidData(tblRid,intRid,strNote,ptrFact,ptrRole) -- Get/Set per RecId for Fact & Note & Role -- V1.2
local dicRid = tblRid[intRid]
if not dicRid or ptrRole then
dicRid = dicRid or {}
dicRid.Note = strNote or ""
dicRid.Fact = ptrFact or fhNewItemPtr()
dicRid.Role = ptrRole or fhNewItemPtr()
tblRid[intRid] = dicRid
end
return dicRid
end -- function PerRidData
function Make(dicFact) -- Make the Facts for chosen Witness Roles
local intMake = 0
local arrRec = {} -- Result Set tables
local arrRole = {}
local arrFact = {}
local strPref = ""
local strSuff = ""
if dicFact.Privacy then -- Privacy brackets? -- V1.4
strPref = "[["
strSuff = "]]"
end
local ptrRec = fhNewItemPtr()
for intType, strType in ipairs ( { "INDI"; "FAM"; } ) do -- V1.1
ptrRec:MoveToFirstRecord(strType) -- Loop through all Individual & Family Records
while ptrRec:IsNotNull() do
local ptrFact = fhNewItemPtr()
ptrFact:MoveToFirstChildItem(ptrRec) -- Loop through all Facts
while ptrFact:IsNotNull() do
local strFact = fhGetTag(ptrFact)
if fhIsFact(ptrFact) -- Any Individual Fact or Family Census or Family Residence -- V1.1 -- V1.4
and ( strType == "INDI" or strFact == "CENS" or strFact == "RESI" ) then
local isPrincipal = true
local tblRid = {} -- Table per RecId for Fact & Note & Role -- V1.2
local ptrShar = fhNewItemPtr()
ptrShar:MoveTo(ptrFact,"~._SHAR") -- Loop through all Fact Witness Roles
while ptrShar:IsNotNull() do
local strName = strFact..strType -- V1.1
local ptrRole = ptrShar:Clone()
local strRole = fhGetItemText(ptrRole,"~.ROLE")
ptrShar:MoveNext("SAME_TAG")
if dicFact[strName][strRole] then -- Give the Witness their own Fact? -- V1.1
if isPrincipal then
table.insert(arrRec ,ptrRec:Clone()) -- Update the Result Set tables for Principal
table.insert(arrRole,"PRINCIPAL")
table.insert(arrFact,ptrFact:Clone())
local intRid = fhGetRecordId(ptrRec) -- Save principal Note & Fact & no Role -- V1.2
PerRidData(tblRid,intRid,strPref.."Principal Role"..strSuff.."\n",ptrFact) -- V1.4
isPrincipal = false
end
local ptrWitn = fhGetValueAsLink(ptrRole) -- Find the Witness and their RecId
local intRid = fhGetRecordId(ptrWitn) -- V1.2
local dicRid = PerRidData(tblRid,intRid) -- Get Note & Fact & Role for RecId, may be from principal -- V1.2
local strNote = dicRid.Note..strPref.."Witness Role: "..strRole..strSuff.."\n" -- V1.4
local ptrCopy = dicRid.Fact
if ptrCopy:IsNull() then -- Create a copy of principal Fact ? -- V1.2
ptrCopy = Perform("fhCreateItem",strFact,ptrWitn)
Perform("fhSetValue_Copy",ptrCopy,ptrFact) -- Copy all principal Fact fields except AGE, _SHAR, _SHAN, _SENT, etc
CopyChildren(ptrFact,ptrCopy)
end -- Save witness Note & Fact & Role for RecId -- V1.2
tblRid[intRid] = PerRidData(tblRid,intRid,strNote,ptrCopy,ptrRole)
table.insert(arrRec ,ptrWitn:Clone()) -- Update the Result Set tables
table.insert(arrRole,strRole)
table.insert(arrFact,ptrCopy:Clone())
dicFact[strType] = true -- Signal that INDI/FAM Witnesses have been handled, but only needed for Census (family)
end
end
for intRid, dicRid in pairs (tblRid) do -- Add the Role text to each Fact Note and copy any Role Citations -- V1.2
local strNote = dicRid.Note -- Cannot add earlier if Principal is own Witness as gets copied to every other Witness
local ptrFact = dicRid.Fact
local ptrRole = dicRid.Role
local strMode = "fhSetValueAsText" -- V1.4 -- Can this plain & rich text manipulation be simplified?
local ptrNote = fhGetItemPtr(ptrRole,"~.NOTE2")
if fhGetValueType(ptrNote) == "richtext" then -- Witness Role note is rich text -- V1.4
local strRich = fhNewRichText(strNote)
strRich:AddRichText(fhGetValueAsRichText(ptrNote))
strNote = strRich
strMode = "fhSetValueAsRichText"
else
strNote = strNote..fhGetValueAsText(ptrNote) -- Witness Role note is plain text
end
local ptrNote = fhGetItemPtr(ptrFact,"~.NOTE2")
if ptrNote:IsNull() then -- Add a local Note to the Fact
ptrNote = Perform("fhCreateItem","NOTE2",ptrFact)
else -- Get existing local Note and Witness Role note
if fhGetValueType(ptrNote) == "richtext" then -- Local Note is rich text -- V1.4
local strRich = fhGetValueAsRichText(ptrNote)
if strMode == "fhSetValueAsRichText" then -- Witness Role note is rich text -- V1.4
strRich:AddRichText(fhNewRichText("\n"))
strRich:AddRichText(strNote)
else
strRich:AddRichText(fhNewRichText("\n"..strNote))
end
strNote = strRich
strMode = "fhSetValueAsRichText"
elseif strMode == "fhSetValueAsRichText" then -- Local Note is plain text but Witness Role note is rich text -- V1.4
local strRich = fhNewRichText(fhGetValueAsText(ptrNote).."\n")
strRich:AddRichText(strNote)
else -- Local Note & Witness Role note are plain txt
strNote = fhGetValueAsText(ptrNote).."\n"..strNote
end
end
Perform(strMode,ptrNote,strNote) -- Update the local Note with Witness Role note -- V1.4
for strTag, strType in pairs ({SOUR="Link";SOUR2="Text";}) do
local ptrCite = fhNewItemPtr()
ptrCite:MoveTo(ptrRole,"~."..strTag) -- Loop through all Role Citations and copy them
while ptrCite:IsNotNull() do
local anyType = Perform("fhGetValueAs"..strType,ptrCite)
local ptrSour = Perform("fhCreateItem",strTag,ptrFact)
Perform("fhSetValueAs"..strType,ptrSour,anyType)
CopyChildren(ptrCite,ptrSour) -- Copy citation subsidiary fields -- V1.4
ptrCite:MoveNext("SAME_TAG")
end
end
if ptrRole:IsNotNull() then
Perform("fhDeleteItem",ptrRole) -- Delete original Witness Role (i.e. when not just Principal)
intMake = intMake + 1 -- Count them
end
end
end
ptrFact:MoveNext()
end
ptrRec:MoveNext()
end
end
if #arrRec > 0 then -- Output Result Set
fhOutputResultSetTitles("Give Witnesses Their Own Facts "..strVersion)
fhOutputResultSetColumn("Record","item",arrRec ,#arrRec,120,"align_left")
fhOutputResultSetColumn("Role" ,"text",arrRole,#arrRec, 50,"align_left")
fhOutputResultSetColumn("Fact" ,"item",arrFact,#arrRec,300,"align_left")
end
return intMake, intMike
end -- function Make()
function Main()
local dicFact = {} -- Dictionary of Facts with Witness Roles
if Find(dicFact) then -- Find the Facts that have Witness Roles
if Pick(dicFact) then -- Pick the Witness Roles to get own Facts
if "Yes" == fhMessageBox("\n Are you sure you can recover from unwanted changes? \n e.g. \n Used 'File > Backup/Restore > Small Backup' \n","MB_YESNO","MB_ICONQUESTION") then
local intMake = Make(dicFact) -- Make the Facts for chosen Witness Roles
local strHelp = ""
if dicFact["FAM"] then -- Census (family) Witnesses were removed
strHelp = "\n Consider using 'Migrate Census Family to Individual Events' Plugin. \n"
end
local strMake = " Witnesses were given their own Individual Facts. \n"
if intMake == 1 then
strMake = strMake:gsub("es "," "):gsub("s%. ",". ")
end
fhMessageBox("\n "..intMake..strMake.."\n Undo changes with 'Edit > Undo Plugin Updates' \n or 'File > Backup/Restore > Revert to Snapshot'.\n"..strHelp)
end
else
fhMessageBox("\n No Individual Fact Witnesses Changed. \n")
end
else
fhMessageBox("\n No Individual Fact Witnesses Found. \n")
end
end -- function Main()
-- Main Code Section Starts Here --
fhInitialise(6,0,0,"save_recommended") -- V1.5
Main()
--[[
@Title: Give Witnesses Their Own Facts
@Type: Standard
@Author: Mike Tate
@Contributors:
@Version: 1.5
@Keywords:
@LastUpdated: 11 Feb 2026
@Licence: This plugin is copyright (c) 2026 Mike Tate & 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: Remove chosen Individual Witnesses from Facts and create their own Facts instead.
@V1.5: Centre windows on FH window; Check Version in Store button; fhInitialise();
@V1.4: Handle rich text notes and citation metafields; Privacy brackets option; Add Family Residence; Cater for _SDATE;
@V1.3: FH V7 Lua 3.5 IUP 3.28;
@V1.2: Avoid duplicating any Fact when same Individual is a multiple Principal/Witness, plus copy all Role Citations;
@V1.1: Added Census (family);
@V1.0: First published in Plugin Store;
]]
require "iuplua"
iup.SetGlobal("CUSTOMQUITMESSAGE","YES") -- Needed for IUP 3.28
local strVersion = "1.5" -- Update when version changes
--[[
@Function: CheckVersionInStore
@Author: Mike Tate
@Version: 1.4
@LastUpdated: 20 Jan 2026
@Description: Check plugin version against version in Plugin Store
@Parameter: Plugin name and version
@Returns: None
@Requires: luacom
@V1.4: Dispense with files and assume called via IUP button;
@V1.3: Save and retrieve latest version in file;
@V1.2: Ensure the Plugin Data folder exists;
@V1.1: Monthly interval between checks; Report if Internet is inaccessible;
@V1.0: Initial version;
]]
function CheckVersionInStore(strPlugin,strVersion) -- Check if later Version available in Plugin Store
require "luacom"
local function httpRequest(strRequest) -- Luacom http request protected by pcall() below
local http = luacom.CreateObject("winhttp.winhttprequest.5.1")
http:Open("GET",strRequest,false)
http:Send()
return http.Responsebody
end -- local function httpRequest
local function intVersion(strVersion) -- Convert version string to comparable integer
local intVersion = 0
local arrNumbers = {}
strVersion:gsub("(%d+)", function(strDigits) table.insert(arrNumbers,strDigits) end)
for i = 1, 5 do
intVersion = intVersion * 100 + tonumber(arrNumbers[i] or 0)
end
return intVersion
end -- local function intVersion
local strLatest = "0"
if strPlugin then
local strRequest ="http://www.family-historian.co.uk/lnk/checkpluginversion.php?name="..tostring(strPlugin)
local isOK, strReturn = pcall(httpRequest,strRequest)
if not isOK then -- Problem with Internet access
fhMessageBox(strReturn.."\n The Internet appears to be inaccessible. ")
elseif strReturn then
strLatest = strReturn:match("([%d%.]*),%d*") -- Version digits & dots then comma and Id digits
end
end
local strMessage = "No later Version"
if intVersion(strLatest) > intVersion(strVersion or "0") then
strMessage = "Later Version "..strLatest
end
fhMessageBox(strMessage.." of this Plugin is available from the 'Plugin Store'.")
end -- function CheckVersionInStore
function getParamEmulator() -- Prototype for iup.GetParam(...) -- V1.5
if fhGetAppVersion() > 6 then unpack = table.unpack end -- Needed for Lua 5.3
local iupDialog = nil
local arrValues = {}
local isSuccess = false
local strPlugin = fhGetContextInfo("CI_PLUGIN_NAME"):gsub(" %- .*","")
local function handleButton(iupDialog,intIndex,strTitle) -- Handle the dialog buttons
if intIndex == (iup.GETPARAM_OK or -1) then
-- strTitle sometimes needed to determine the function -- 1st button action -- FH V5 needs -1
isSuccess = true
elseif intIndex == (iup.GETPARAM_CANCEL or -3) then -- 2nd 'Cancel Plugin' button -- FH V5 needs -3
isSuccess = false
elseif intIndex == (iup.GETPARAM_HELP or -4) then -- 3rd 'Later Version?' button -- FH V5 needs -4
iupDialog.Active = "NO"
CheckVersionInStore(strPlugin,strVersion)
iupDialog.Active = "YES"
iupDialog.BringFront = "YES"
return 0
end
return 1
end -- function handleButton
local function makeDialog(strTitle,strFormat) -- Make emulated iup.GetParam(...) dialog
local arrFormat = {}
for strForm in strFormat:gmatch(".-\n") do -- Construct parameters from format
local iupParam = iup.param{ format=strForm; }
table.insert(arrFormat,iupParam)
end
local iupParams = iup.parambox{ unpack(arrFormat) }
local iupButton = iup.button{ Title="Help && Advice"; Padding="12x8"; } -- Example of extra button
-- iupDialog = iup.dialog{ Title=strTitle; iup.vbox{ iupParams; iupButton; ALIGNMENT="ACENTER"; MARGIN="10x10"; }; close_cb=function() isSuccess = false return iup.CLOSE end; }
iupDialog = iup.dialog{ Title=strTitle; iupParams; close_cb=function() isSuccess = false return iup.CLOSE end; }
if fhGetAppVersion() > 6 then -- Window centres on FH parent
iup.SetAttribute(iupDialog,"NATIVEPARENT",fhGetContextInfo("CI_PARENT_HWND"))
end
for intParam = 1, iupParams.ParamCount do -- Set all parameter values
local iupParam = iupParams:GetParamParam(intParam-1)
local iupCntrl = iupParam.Control
local anyValue = arrValues[intParam]
if iupParam.Type == "LIST" then anyValue = anyValue + 1 end -- Droplists need an adjustment
iupCntrl.Value = anyValue
end
function iupParams:param_cb(intIndex) -- Parameter call back actions
if intIndex >= 0 then
local iupParam = iupParams:GetParamParam(intIndex) -- Save any parameter value
arrValues[intIndex+1] = tonumber(iupParam.Value) or iupParam.Value
return 1
else
return handleButton(iupDialog,intIndex,strTitle) -- Handle buttons
end
end -- function iupParams:param_cb
function iupButton:action(intButton) -- Display Help Page
local strPlugin = strPlugin:gsub(" ","-"):lower()
fhShellExecute("https://pluginstore.family-historian.co.uk/page/help/"..strPlugin,"","","open")
fhSleep(3000,500)
iupDialog.BringFront = "YES"
return 1
end -- function iupButton:action
end -- local function makeDialog
local function getParam(strTitle,strSize,strFormat,...) -- Emulate iup.GetParam(...) for FH V7 or later
arrValues = {...}
if fhGetAppVersion() > 6 then
makeDialog(strTitle,strFormat)
if strSize then iupDialog.Size = strSize end
iupDialog:map()
iupDialog.MinSize = iupDialog.NaturalSize
iupDialog:showxy(iup.CENTERPARENT,iup.CENTERPARENT)
if iup.MainLoopLevel()==0 then iup.MainLoop() end
iup.Destroy(iupDialog)
iupDialog = nil
else
local function fncAction(iupDialog,intIndex)
return handleButton(iupDialog,intIndex,strTitle) -- Handle buttons
end -- local function fncAction
arrValues = { iup.GetParam(strTitle,fncAction,strFormat,unpack(arrValues)) }
isSuccess = arrValues[1]
table.remove(arrValues,1)
end
return isSuccess, unpack(arrValues)
end -- local function getParam
return getParam
end -- function getParamEmulator
local getParam = getParamEmulator()
function Find(dicFact) -- Find the Facts that have Witness Roles
local intFind = 0
local ptrRec = fhNewItemPtr()
for intType, strType in ipairs ( { "INDI"; "FAM"; } ) do -- V1.1
ptrRec:MoveToFirstRecord(strType) -- Loop through all Individual & Family Records
while ptrRec:IsNotNull() do
local ptrFact = fhNewItemPtr()
ptrFact:MoveToFirstChildItem(ptrRec) -- Loop through all Facts
while ptrFact:IsNotNull() do
local strFact = fhGetTag(ptrFact)
if fhIsFact(ptrFact) -- Any Individual Fact or Family Census or Family Residence -- V1.1 -- V1.4
and ( strType == "INDI" or strFact == "CENS" or strFact == "RESI" ) then
local ptrShar = fhNewItemPtr()
ptrShar:MoveTo(ptrFact,"~._SHAR") -- Loop through all Fact Witness Roles
while ptrShar:IsNotNull() do
local strName = strFact..strType -- V1.1
local strRole = fhGetItemText(ptrShar,"~.ROLE")
if not dicFact[strName] then
dicFact[strName] = {} -- Add new Fact and its Label
dicFact[strName][0] = fhCallBuiltInFunction("FactLabel",ptrFact)
end
local intRole = dicFact[strName][strRole]
if not intRole then
intFind = intFind + 1 -- Count different Fact Witness Roles
intRole = 0
end
dicFact[strName][strRole] = intRole + 1 -- Count duplicate Fact Witness Roles
ptrShar:MoveNext("SAME_TAG")
end
end
ptrFact:MoveNext()
end
ptrRec:MoveNext()
end
end
dicFact[0] = intFind -- Save count of different Roles found
return ( intFind > 0 )
end -- function Find
function Pick(dicFact) -- Pick which Witness Roles to give own Facts
local arrReply = { } -- GetParam reply tick values
local isTicked = true -- GetParam reply status
local intParam = 0 -- Count of params per page
local strParam = "" -- Format of params per page
local intSize = 25 -- Maximum params per page
local intPage = 1 -- Current page number
local intLast = math.ceil( dicFact[0] / intSize ) -- Number of pages needed
intSize = math.ceil( dicFact[0] / intLast ) -- Even out params per page
dicFact[0] = nil
local function Zeros(intParam) -- Return same number of 0's as Witness Role params
intParam = intParam - 1
if intParam == 0 then return 0 end -- End recursion
return 0,Zeros(intParam)
end -- local function Zeros
local function GetParam(strButton) -- GetParam user dialogue with OK button name -- V1.5
strParam = "Tick the Witness Roles to be given their own Facts: %t\n"..strParam
strParam = strParam.."For all Roles ticked above, this Plugin copies the \r"
strParam = strParam.."principal Fact on the left to each of its Witnesses,\r"
if dicFact["CENSFAM"] then
strParam = strParam.."(Any 'Census (family)' becomes 'Census' events.) \r"
end
strParam = strParam.."then provides a Result Set list of all the changes. \r"
strParam = strParam.."Undo changes with 'Edit > Undo Plugin Updates' \r"
strParam = strParam.."or 'File > Backup/Restore > Revert to Snapshot'. %t\n"
strParam = strParam.."%u[ "..strButton.." , Close Plugin , Later Version? ]\n" -- V1.5
local strTitle = " Select Witness Roles "..strVersion.." Page "..intPage.." of "..intLast
local arrParam = { getParam( strTitle, nil, strParam, Zeros(intParam) ) } -- V1.5
isTicked = arrParam[1] -- Save reply status: true=OK, false=Cancel
for intParam = 2, #arrParam do
table.insert(arrReply,arrParam[intParam]) -- Append reply ticks to previous replies
end
end -- local function GetParam
for strFact, arrRole in pairs (dicFact) do -- Loop through all Fact Witness Roles
local strLabel = arrRole[0]
strLabel = strLabel..string.rep(" ",13 - #strLabel) -- Suffix Fact Label with spaces to fixed width
arrRole[0] = nil
for strRole, intRole in pairs (arrRole) do -- Compose GetParam format text of boolean per Role
intParam = intParam + 1
local strCount = tostring(intRole) -- Prefix Role count with spaces to fixed width
strCount = string.rep(" ",5 - #strCount)..strCount.." x "
strParam = strParam..strCount..strLabel.." ~ "..strRole.." %b\n"
if intPage < intLast and intParam >= intSize then
GetParam("Next Page") -- Get a page of user params -- V1.5
if not isTicked then break end
intParam = 0
strParam = "" -- Reset for next page
intPage = intPage + 1
end
end
if not isTicked then break end -- User cancelled dialogue
end
if isTicked then
strParam = strParam.."Put each Witness Role note in [[privacy]] brackets? %b\n"
intParam = intParam + 1 -- Privacy brackets? -- V1.4
GetParam("Copy Facts") -- Get last page of user params -- V1.5
if isTicked then
isTicked = false
for strName, arrRole in pairs (dicFact) do -- Check if any ticks -- V1.1
for strRole, intRole in pairs (arrRole) do
local isTick = (table.remove(arrReply,1) == 1) -- Reply = 1 if tick and thus true -- V1.1
dicFact[strName][strRole] = isTick -- Assign tick is true or false to each Role -- V1.1
isTicked = isTicked or isTick
end
end
dicFact.Privacy = (arrReply[#arrReply] == 1) -- Privacy brackets? -- V1.4
end
end
return isTicked
end -- function Pick
function Error(strAct,...) -- Report error and abort the plugin
local arg = {...}
local strErr = "\n"..strAct.." (\n "
for intArg = 1, #arg do
local strArg = arg[intArg]
local strTyp = type(strArg) -- Convert pointer or boolean args to text
if strTyp == "userdata" then
strArg = fhGetDisplayText(strArg)
elseif strTyp == "boolean" then
strArg = tostring(strArg)
end
strErr = strErr..strArg
if intArg < #arg then strErr = strErr.." ,\n " end
end
strErr = strErr.." )\nfailed"
local intArg = 1
if type(arg[intArg]) == "string" then intArg = #arg end -- Find target pointer
if type(arg[intArg]) == "boolean" then intArg = intArg-1 end
local ptrRec = arg[intArg]:Clone()
if fhHasParentItem(ptrRec) then -- Is target a record?
ptrRec:MoveToRecordItem(ptrRec)
strErr = strErr.." for "..fhGetDisplayText(ptrRec) -- Add name of record
end
error("\n\nError: "..strErr,3) -- Report and abort
end -- function Error
dicAct = { fhCreateItem=fhCreateItem; fhSetValue_Copy=fhSetValue_Copy; fhGetValueAsText=fhGetValueAsText; fhGetValueAsRichText=fhGetValueAsRichText; fhGetValueAsLink=fhGetValueAsLink; fhSetValueAsText=fhSetValueAsText; fhSetValueAsRichText=fhSetValueAsRichText; fhSetValueAsLink=fhSetValueAsLink; fhDeleteItem=fhDeleteItem; }
function Perform(strAct,...) -- Perform FH API function
local anyAns = dicAct[strAct](...)
if ( type(anyAns) == "boolean" and not anyAns ) -- Most return true or false
or ( type(anyAns) == "userdata" and anyAns:IsNull() ) -- fhCreateItem returns pointer
then Error(strAct,...) end
return anyAns -- Others can return text
end -- function Perform
function CopyBranch(ptrSource,ptrTarget) -- Copy one child branch
local strTag = fhGetTag(ptrSource)
if strTag == "HUSB" or strTag == "WIFE" then return end -- Family Census tags not allowed in Individual Census -- V1.1
if strTag == "_FMT" then return end -- Skip rich text format tag -- V1.4
if strTag == "_FIELD" then
strTag = fhGetMetafieldShortcut(ptrSource) -- Handle citation metafield -- V1.4
end
local ptrNew = Perform("fhCreateItem",strTag,ptrTarget,true)
Perform("fhSetValue_Copy",ptrNew,ptrSource)
CopyChildren(ptrSource,ptrNew)
end -- function CopyBranch
local dicExclude = { AGE=true; _SHAR=true; _SHAN=true; _SENT=true; } -- V1.4
function CopyChildren(ptrSource,ptrTarget) -- Copy children branches
local ptrFrom = fhNewItemPtr()
ptrFrom = ptrSource:Clone()
ptrFrom:MoveToFirstChildItem(ptrFrom)
while ptrFrom:IsNotNull() do
local strTag = fhGetTag(ptrFrom)
if not dicExclude[strTag] then -- Exclude AGE and custom tags _SHAR, _SHAN, _SENT, but not _SDATE -- V1.4
CopyBranch(ptrFrom,ptrTarget)
end
ptrFrom:MoveNext()
end
end -- function CopyChildren
function PerRidData(tblRid,intRid,strNote,ptrFact,ptrRole) -- Get/Set per RecId for Fact & Note & Role -- V1.2
local dicRid = tblRid[intRid]
if not dicRid or ptrRole then
dicRid = dicRid or {}
dicRid.Note = strNote or ""
dicRid.Fact = ptrFact or fhNewItemPtr()
dicRid.Role = ptrRole or fhNewItemPtr()
tblRid[intRid] = dicRid
end
return dicRid
end -- function PerRidData
function Make(dicFact) -- Make the Facts for chosen Witness Roles
local intMake = 0
local arrRec = {} -- Result Set tables
local arrRole = {}
local arrFact = {}
local strPref = ""
local strSuff = ""
if dicFact.Privacy then -- Privacy brackets? -- V1.4
strPref = "[["
strSuff = "]]"
end
local ptrRec = fhNewItemPtr()
for intType, strType in ipairs ( { "INDI"; "FAM"; } ) do -- V1.1
ptrRec:MoveToFirstRecord(strType) -- Loop through all Individual & Family Records
while ptrRec:IsNotNull() do
local ptrFact = fhNewItemPtr()
ptrFact:MoveToFirstChildItem(ptrRec) -- Loop through all Facts
while ptrFact:IsNotNull() do
local strFact = fhGetTag(ptrFact)
if fhIsFact(ptrFact) -- Any Individual Fact or Family Census or Family Residence -- V1.1 -- V1.4
and ( strType == "INDI" or strFact == "CENS" or strFact == "RESI" ) then
local isPrincipal = true
local tblRid = {} -- Table per RecId for Fact & Note & Role -- V1.2
local ptrShar = fhNewItemPtr()
ptrShar:MoveTo(ptrFact,"~._SHAR") -- Loop through all Fact Witness Roles
while ptrShar:IsNotNull() do
local strName = strFact..strType -- V1.1
local ptrRole = ptrShar:Clone()
local strRole = fhGetItemText(ptrRole,"~.ROLE")
ptrShar:MoveNext("SAME_TAG")
if dicFact[strName][strRole] then -- Give the Witness their own Fact? -- V1.1
if isPrincipal then
table.insert(arrRec ,ptrRec:Clone()) -- Update the Result Set tables for Principal
table.insert(arrRole,"PRINCIPAL")
table.insert(arrFact,ptrFact:Clone())
local intRid = fhGetRecordId(ptrRec) -- Save principal Note & Fact & no Role -- V1.2
PerRidData(tblRid,intRid,strPref.."Principal Role"..strSuff.."\n",ptrFact) -- V1.4
isPrincipal = false
end
local ptrWitn = fhGetValueAsLink(ptrRole) -- Find the Witness and their RecId
local intRid = fhGetRecordId(ptrWitn) -- V1.2
local dicRid = PerRidData(tblRid,intRid) -- Get Note & Fact & Role for RecId, may be from principal -- V1.2
local strNote = dicRid.Note..strPref.."Witness Role: "..strRole..strSuff.."\n" -- V1.4
local ptrCopy = dicRid.Fact
if ptrCopy:IsNull() then -- Create a copy of principal Fact ? -- V1.2
ptrCopy = Perform("fhCreateItem",strFact,ptrWitn)
Perform("fhSetValue_Copy",ptrCopy,ptrFact) -- Copy all principal Fact fields except AGE, _SHAR, _SHAN, _SENT, etc
CopyChildren(ptrFact,ptrCopy)
end -- Save witness Note & Fact & Role for RecId -- V1.2
tblRid[intRid] = PerRidData(tblRid,intRid,strNote,ptrCopy,ptrRole)
table.insert(arrRec ,ptrWitn:Clone()) -- Update the Result Set tables
table.insert(arrRole,strRole)
table.insert(arrFact,ptrCopy:Clone())
dicFact[strType] = true -- Signal that INDI/FAM Witnesses have been handled, but only needed for Census (family)
end
end
for intRid, dicRid in pairs (tblRid) do -- Add the Role text to each Fact Note and copy any Role Citations -- V1.2
local strNote = dicRid.Note -- Cannot add earlier if Principal is own Witness as gets copied to every other Witness
local ptrFact = dicRid.Fact
local ptrRole = dicRid.Role
local strMode = "fhSetValueAsText" -- V1.4 -- Can this plain & rich text manipulation be simplified?
local ptrNote = fhGetItemPtr(ptrRole,"~.NOTE2")
if fhGetValueType(ptrNote) == "richtext" then -- Witness Role note is rich text -- V1.4
local strRich = fhNewRichText(strNote)
strRich:AddRichText(fhGetValueAsRichText(ptrNote))
strNote = strRich
strMode = "fhSetValueAsRichText"
else
strNote = strNote..fhGetValueAsText(ptrNote) -- Witness Role note is plain text
end
local ptrNote = fhGetItemPtr(ptrFact,"~.NOTE2")
if ptrNote:IsNull() then -- Add a local Note to the Fact
ptrNote = Perform("fhCreateItem","NOTE2",ptrFact)
else -- Get existing local Note and Witness Role note
if fhGetValueType(ptrNote) == "richtext" then -- Local Note is rich text -- V1.4
local strRich = fhGetValueAsRichText(ptrNote)
if strMode == "fhSetValueAsRichText" then -- Witness Role note is rich text -- V1.4
strRich:AddRichText(fhNewRichText("\n"))
strRich:AddRichText(strNote)
else
strRich:AddRichText(fhNewRichText("\n"..strNote))
end
strNote = strRich
strMode = "fhSetValueAsRichText"
elseif strMode == "fhSetValueAsRichText" then -- Local Note is plain text but Witness Role note is rich text -- V1.4
local strRich = fhNewRichText(fhGetValueAsText(ptrNote).."\n")
strRich:AddRichText(strNote)
else -- Local Note & Witness Role note are plain txt
strNote = fhGetValueAsText(ptrNote).."\n"..strNote
end
end
Perform(strMode,ptrNote,strNote) -- Update the local Note with Witness Role note -- V1.4
for strTag, strType in pairs ({SOUR="Link";SOUR2="Text";}) do
local ptrCite = fhNewItemPtr()
ptrCite:MoveTo(ptrRole,"~."..strTag) -- Loop through all Role Citations and copy them
while ptrCite:IsNotNull() do
local anyType = Perform("fhGetValueAs"..strType,ptrCite)
local ptrSour = Perform("fhCreateItem",strTag,ptrFact)
Perform("fhSetValueAs"..strType,ptrSour,anyType)
CopyChildren(ptrCite,ptrSour) -- Copy citation subsidiary fields -- V1.4
ptrCite:MoveNext("SAME_TAG")
end
end
if ptrRole:IsNotNull() then
Perform("fhDeleteItem",ptrRole) -- Delete original Witness Role (i.e. when not just Principal)
intMake = intMake + 1 -- Count them
end
end
end
ptrFact:MoveNext()
end
ptrRec:MoveNext()
end
end
if #arrRec > 0 then -- Output Result Set
fhOutputResultSetTitles("Give Witnesses Their Own Facts "..strVersion)
fhOutputResultSetColumn("Record","item",arrRec ,#arrRec,120,"align_left")
fhOutputResultSetColumn("Role" ,"text",arrRole,#arrRec, 50,"align_left")
fhOutputResultSetColumn("Fact" ,"item",arrFact,#arrRec,300,"align_left")
end
return intMake, intMike
end -- function Make()
function Main()
local dicFact = {} -- Dictionary of Facts with Witness Roles
if Find(dicFact) then -- Find the Facts that have Witness Roles
if Pick(dicFact) then -- Pick the Witness Roles to get own Facts
if "Yes" == fhMessageBox("\n Are you sure you can recover from unwanted changes? \n e.g. \n Used 'File > Backup/Restore > Small Backup' \n","MB_YESNO","MB_ICONQUESTION") then
local intMake = Make(dicFact) -- Make the Facts for chosen Witness Roles
local strHelp = ""
if dicFact["FAM"] then -- Census (family) Witnesses were removed
strHelp = "\n Consider using 'Migrate Census Family to Individual Events' Plugin. \n"
end
local strMake = " Witnesses were given their own Individual Facts. \n"
if intMake == 1 then
strMake = strMake:gsub("es "," "):gsub("s%. ",". ")
end
fhMessageBox("\n "..intMake..strMake.."\n Undo changes with 'Edit > Undo Plugin Updates' \n or 'File > Backup/Restore > Revert to Snapshot'.\n"..strHelp)
end
else
fhMessageBox("\n No Individual Fact Witnesses Changed. \n")
end
else
fhMessageBox("\n No Individual Fact Witnesses Found. \n")
end
end -- function Main()
-- Main Code Section Starts Here --
fhInitialise(6,0,0,"save_recommended") -- V1.5
Main()
Source:Give-Witnesses-Their-Own-Facts-3.fh_lua