Find Duplicate Facts.fh_lua

--[[
@Title:			Find Duplicate Facts
@Type:				Standard
@Author:			Mike Tate
@Contributors:	
@Version:			1.4
@Keywords:		
@LastUpdated:		13 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:		Create a Result Set of any duplicate Facts where chosen sub-fields also match.
@V1.4:				Centre windows on FH window; Check for Updates button; Ch-ViS 1.4; Added Media OBJE subtags;
@V1.3:				Disregard white-space and control characters when comparing fields; Handle long text fields; Add Citation Template Fields (_FIELD);
@V1.2:				Disregard Fact order; Fix Place field; Add Sort Date (_SDATE), Fact Flags (_FLGS), Fact Fax (FAX), (RELI) & (RESN), Place Phonetic (FONE), Roman (ROMN) and (MAP), Address hierarchy, and 5.5.1 Object (OBJE) format;
@V1.1:				FH V7 Lua 3.5 IUP 3.28 compatible; Always produce Result Set;
@V1.0:				First published version;
]]

require "iuplua"

local strVersion = "1.4"
local strTitle = "Find Duplicate Facts  "..strVersion							-- V1.1

-- fhSetStringEncoding("UTF-8")		-- Are accents, etc handled correctly???
iup.SetGlobal("CUSTOMQUITMESSAGE","YES")										-- Needed for IUP 3.28

local intTicks = 1																	-- Current tick setting may be 0 or 1	-- V1.4 Used in getParamEmulator
local noToggle = true																-- Toggle ticks button used				-- V1.4 Used in getParamEmulator

--[[
@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.4

	if fhGetAppVersion() > 6 then unpack = table.unpack end

	local iupDialog = nil
	local iupParams = 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 button action				-- FH V5 needs -3
			intTicks = 1 - intTicks
			noToggle = 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

		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(...)
		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()

local arrTags = { }
local arrRefs = {																	-- Level 3 and lower sub-fields
	PLAC  = { "FORM"; "NOTE2"; "NOTE"; "SOUR2"; "SOUR"; "FONE"; "ROMN"; "MAP"; };	-- V1.2
	FONE  = { "TYPE"; };															-- V1.2
	ROMN  = { "TYPE"; };															-- V1.2
	MAP   = { "LATI"; "LONG"; };													-- V1.2
	ADDR  = { "ADR1"; "ADR2"; "ADR3"; "CITY"; "STAE"; "POST"; "CTRY"; };	-- V1.2
	_SHAR = { "ROLE"; "NOTE2"; "SOUR"; "SOUR2"; "_SENT"; };
	_SHAN = { "ROLE"; "NOTE2"; "SOUR"; "SOUR2"; };
	SOUR2 = { "TEXT"; "NOTE2"; "NOTE"; };
	SOUR  = { "DATA"; "NOTE2"; "NOTE"; "OBJE2"; "OBJE"; "QUAY"; "PAGE"; "EVEN"; "_FIELD"; };	-- V1.3
	DATA  = { "DATE"; "TEXT"; };
	EVEN  = { "ROLE"; };
	OBJE2 = { "TITL"; "FORM"; "FILE"; "NOTE2"; "NOTE"; "_NOTE"; "_DATE"; };
	OBJE  = { "_SEQ"; "_CAPT"; "_EXCL"; "_AREA"; "_NOTA"; "SOUR"; "SOUR2"; };		-- V1.4
	FILE  = { "FORM"; };															-- V1.2
	FORM  = { "MEDI"; };															-- V1.2
	NOTE2 = { "SOUR"; "SOUR2"; };
	NOTE  = { "SOUR"; "SOUR2"; };
	HUSB  = { "AGE"; };
	WIFE  = { "AGE"; };
}

function Pick()																		-- Pick which level 2 Fields to include
	local strFactMail = "Fact Email (_EMAIL)"
	local strFactWeb  = "Fact Web Site (_WEB)"
	if fhGetAppVersion() > 6 then													-- Adjust FH V6 GEDCOM 5.5 & FH V7 GEDCOM 5.5.1 tags	-- V1.1
		strFactMail = "Fact Email (EMAIL)"
		strFactWeb  = "Fact Web Site (WWW)"
	end
	local arrField = {
		"Fact Age (AGE)"; "Fact Place (PLAC)"; "Fact Address (ADDR)"; "Fact Cause (CAUS)"; 
		"Fact Phone (PHON)"; strFactMail; strFactWeb; "Fact Descriptor (TYPE)"; 	-- V1.1
		"Witness Individual (_SHAR)"; "Witness Name Only (_SHAN)"; 
		"Narrative Sentence (_SENT)"; "Parents Family (FAMC)"; "Responsible Agency (AGNC)"; 
		"Local Note (NOTE2)"; "Note Record (NOTE)"; 
		"Media Record (OBJE)"; "Local Media (OBJE2)"; 
		"Source Citation (SOUR)"; "Source Note (SOUR2)"; 
	}
	if fhGetAppVersion() > 6 then													-- Add FH V7 Sort Date, Flags, Fax, etc	-- V1.2
		table.insert(arrField, 1,"Sort Date (_SDATE)")
		table.insert(arrField, 2,"Fact Flags (_FLGS)")
		table.insert(arrField, 9,"Fact Fax (FAX)")
		table.insert(arrField,17,"Religious Affiliation (RELI)")
		table.insert(arrField,18,"Restriction Notice (RESN)")
	end
	local arrReply = { }															-- GetParam reply tick values
	local strParam = ""																-- Format of paramaters
	local arrTicks = { " Enable Ticks "; " Remove Ticks "; }					-- Toggle ticks button captions

	local function setTicks(intParam)												-- Return same number of 0's or 1's as Field params
		intParam = intParam - 1
		if intParam == 0 then return intTicks end								-- End recursion
		return intTicks,setTicks(intParam)
	end -- local function setTicks

	local function GetParam(intParam)												-- GetParam user dialogue
		strParam = "Tick fields that must match in duplicate Facts:  %t\n"..strParam
		strParam = strParam.."%u[ Find Facts , Enable Ticks , Later Version? ]\nPlugin provides a Result Set of the duplicates.  %t\n"	-- V1.4
		local arrParam = { }
		repeat
			noToggle = true															-- Repeat until toggle button not used, and swap button caption each time
			strParam = strParam:gsub(arrTicks[2-intTicks],arrTicks[intTicks+1])
			arrParam = { getParam(strTitle,nil,strParam,setTicks(intParam)) }	-- V1.4
		until noToggle
		for intParam = 2, #arrParam do
			table.insert(arrReply,arrParam[intParam])							-- Append parameter tick replies
		end
		return arrParam[1]															-- Find Facts = true, X Close = false
	end -- local function GetParam

	for _, strName in ipairs (arrField) do										-- Loop through every Field to set user parameters
		strParam = strParam..strName.." %b\n"
	end
	if GetParam(#arrField) then													-- Get user parameter tick replies
		for intReply, isReply in ipairs (arrReply) do
			if isReply == 1 then
				local strTags = arrField[intReply]:match(" %((.+)%)$") 		-- Compose list of Field Tags
				table.insert(arrTags,strTags)
				if strTags == "AGE" then											-- Cater for Family Fact Ages
					table.insert(arrTags,"HUSB")
					table.insert(arrTags,"WIFE")
				end
			end
		end
		return true																	-- Continue to Find Facts
	end
	return false																		-- X Close Plugin
end -- function Pick

function Check(ptrThis,ptrThat,strRef)											-- Check any subfield matches
	ptrThis = fhGetItemPtr(ptrThis,"~."..strRef)
	ptrThat = fhGetItemPtr(ptrThat,"~."..strRef)
	while ptrThis:IsNotNull() or ptrThat:IsNotNull() do 						-- Check all instances but disregard control and white-space characters
		if fhGetValueType(ptrThis):match("text") then							-- Text fields need fhGetValueAsText for full text string	-- V1.3
			if fhGetValueAsText(ptrThis):gsub("[%c%s]","") ~= fhGetValueAsText(ptrThat):gsub("[%c%s]","") then return false end
		else																			-- Age, Date, Integer, etc, fields need fhGetDisplayText 	-- V1.3
			if fhGetDisplayText(ptrThis):gsub("[%c%s]","") ~= fhGetDisplayText(ptrThat):gsub("[%c%s]","") then return false end
		end
		for _, strRef in ipairs(arrRefs[strRef] or {}) do						-- Check any subsidiary fields
			if not Check(ptrThis,ptrThat,strRef) then return false end
		end
		ptrThis:MoveNext("SAME_TAG")
		ptrThat:MoveNext("SAME_TAG")
	end
	return true
end -- function Check

function Main()
	local tblRecd = {}																-- Result Set tables
	local tblThis = {}
	local tblThat = {}
	local intRecs = 0

	if not Pick() then return end													-- Pick the Field Tags to check

	for intTag, strTag in ipairs({"INDI","FAM"}) do							-- Check all Individual & Family Records
		local ptrRecd = fhNewItemPtr()
		ptrRecd:MoveToFirstRecord(strTag)
		while ptrRecd:IsNotNull() do												-- Scan all Records
			local tblList = {}
			local tblDate = {}
			local tblFact = {}
			local tblDupl = {}
			local ptrThis = fhNewItemPtr()										-- This Fact pointer
			ptrThis:MoveToFirstChildItem(ptrRecd)
			while ptrThis:IsNotNull() do											-- Scan all Facts	-- V1.2 -- Rewrite
				if fhIsFact(ptrThis) then
					local strDate = fhGetDisplayText(ptrThis,"~.DATE","min")
					local strFact = fhGetTag(ptrThis)..fhGetValueAsText(ptrThis)
					if not tblList[strDate] then tblList[strDate] = {} end
					tblDate = tblList[strDate]										-- Add Date to List
					if not tblDate[strFact] then tblDate[strFact] = {} end
					tblFact = tblDate[strFact]										-- Add Fact to Date
					table.insert(tblFact,ptrThis:Clone())
					if #tblFact > 1 then											-- Found a potential duplicate with same Fact Date & Tag & Value
						tblDupl[strDate..strFact] = tblFact
					end
				end
				ptrThis:MoveNext()
			end
			for _, tblFact in pairs( tblDupl ) do								-- Review potential duplicates	-- V1.2 -- Rewrite
				for intThis = 1, #tblFact do
					local ptrThis = tblFact[intThis]
					for intThat = intThis+1, #tblFact do							-- Cater for multiple duplications
						local ptrThat = tblFact[intThat]
						local flgDuplicate = true
						for _, strRef in ipairs(arrTags) do
							if not Check(ptrThis,ptrThat,strRef) then			-- Check if this Fact and that Fact have matching fields
								flgDuplicate = false
								break
							end
						end
						if flgDuplicate then										-- Duplicate found so save Record and Fact pointers
							table.insert(tblRecd,ptrRecd:Clone())
							table.insert(tblThis,ptrThis:Clone())
							table.insert(tblThat,ptrThat:Clone())
						end
					end
				end
			end
			ptrRecd:MoveNext()
		end
	end
	local strMessage = (tostring(#tblRecd).." duplicate Facts found."):gsub("1 duplicate Facts","1 duplicate Fact")
	local strMode = "item"
	if #tblRecd == 0 then															-- Report no duplicates found	-- V1.1
		strMessage = strMessage:gsub("^0","No")
		strMode = "text"
		table.insert(tblRecd,fhNewItemPtr())
		table.insert(tblThis,strMessage)
		table.insert(tblThat,fhNewItemPtr())
	end
	fhOutputResultSetTitles(strTitle)												-- Output the Result Set		-- V1.1
	fhOutputResultSetColumn("Owner's Record", "item", tblRecd, #tblRecd, 200, "align_left", 1)
	fhOutputResultSetColumn("Original Fact" ,strMode, tblThis, #tblRecd, 250, "align_left", 2)
	fhOutputResultSetColumn("Duplicate Fact", "item", tblThat, #tblRecd, 250, "align_left", 2)
	fhMessageBox(strMessage,"MB_OK","MB_ICONINFORMATION")
end -- function Main()

fhInitialise(6,0,0,"save_recommended")

Main()

Source:Find-Duplicate-Facts-5.fh_lua