Plugins and Language Packs for Family Historian

Find Duplicate Individuals.fh_lua

--[[
@Title:			Find Duplicate Individuals
@Type:				Standard
@Author:			Mike Tate
@Contributors:	
@Version:			3.9
@Keywords:		
@LastUpdated:		25 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:		Find duplicated Individual Records
@Version Log:		See end of file.
@TBD:				www.behindthename.com/api for Related Names as per http://www.fhug.org.uk/forum/viewtopic.php?f=42&t=6089&p=26187#p26187 et seq ...
@TBD:				SynthesiseDates(tblInd) to use datBirthDate:SetSimpleDate(fhCallBuiltInFunction('EstimatedBirthDate',ptrIndi,'EARLIEST')) and datDeathDate:SetSimpleDate(fhCallBuiltInFunction('EstimatedDeathDate',ptrIndi,'LATEST'))
@TBD:				but only once those functions estimate the Spouse dates when needed to estimate Individual dates as per FH support log EstimatedBirth/DeathDate Functions [#622325].
]]

if fhGetAppVersion() > 5 then fhSetStringEncoding("UTF-8") end

--[[
@Title:			aa Library Functions Preamble
@Author:			Mike Tate
@Version:			4.1
@LastUpdated:		18 Feb 2026
@Description:		All the library functions prototype closures for Plugins.
]]

--[[
@Module:			+fh+stringx_v3
@Author:			Mike Tate
@Version:			3.0
@LastUpdated:	19 Sep 2020
@Description:	Extended string functions to supplement LUA string library.
@V3.0:				Function Prototype Closure version with Lua 5.1 & 5.3 comaptibility; Added inert(strTxt) function;
@V2.5:				Support FH V6 Encoding = UTF-8;
@V2.4:				Tolerant of integer & nil parameters just link match & gsub;
@V1.0:				Initial version.
]]

local function stringx_v3()

	local fh = {}									-- Local environment table

	-- Supply current file encoding format --
	function fh.encoding()
		if fhGetAppVersion() > 5 then return fhGetStringEncoding() end
		return "ANSI"
	end -- function encoding

	-- Split a string using "," or chosen separator --
	function fh.split(strTxt,strSep)
		local tblFields = {}
		local strPattern = string.format("([^%s]+)", strSep or ",")
		strTxt = tostring(strTxt or "")
		strTxt:gsub(strPattern, function(strField) tblFields[#tblFields+1] = strField end)
		return tblFields
	end -- function split

	-- Split a string into numbers using " " or "," or "x" separators	-- Any non-number remains as a string
	function fh.splitnumbers(strTxt)
		local tblNum = {}
		strTxt = tostring(strTxt or "")
		strTxt:gsub("([^ ,x]+)", function(strNum) tblNum[#tblNum+1] = tonumber(strNum) or strNum end)
		return tblNum
	end -- function splitnumbers

	local strMagic = "([%^%$%(%)%%%.%[%]%*%+%-%?])"							-- UTF-8 replacement for "(%W)"

	-- Hide magic pattern symbols	^ $ ( ) % . [ ] * + - ?
	function fh.plain(strTxt)
		-- Prefix every magic pattern character with a % escape character,
		-- where %% is the % escape, and %1 is the original character capture.
		strTxt = tostring(strTxt or ""):gsub(strMagic,"%%%1")
		return strTxt
	end -- function plain

	-- matches is plain text version of string.match()
	function fh.matches(strTxt,strFind,intInit)
		strFind = tostring(strFind or ""):gsub(strMagic,"%%%1")			-- Hide magic pattern symbols
		return tostring(strTxt or ""):match(strFind,tonumber(intInit))
	end -- function matches

	-- replace is plain text version of string.gsub()
	function fh.replace(strTxt,strOld,strNew,intNum)
		strOld = tostring(strOld or ""):gsub(strMagic,"%%%1")				-- Hide magic pattern symbols
		return tostring(strTxt or ""):gsub(strOld,function() return strNew end,tonumber(intNum))	-- Hide % capture symbols
	end -- function replace

	-- Hide % escape/capture symbols in replacement so they are inert
	function fh.inert(strTxt)
		strTxt = tostring(strTxt or ""):gsub("%%","%%%%")					-- Hide all % symbols
		return strTxt
	end -- function inert

	-- convert is pattern without captures version of string.gsub()
	function fh.convert(strTxt,strOld,strNew,intNum)
		return tostring(strTxt or ""):gsub(tostring(strOld or ""),function() return strNew end,tonumber(intNum))	-- Hide % capture symbols
	end -- function convert

	local dicUpper = { }
	local dicLower = { }
	local dicCaseX = { }
	-- ASCII unaccented letter translations for Upper, Lower, and Case Insensitive
	for intUpper = string.byte("A"), string.byte("Z") do
		local strUpper = string.char(intUpper)
		local strLower = string.char(intUpper - string.byte("A") + string.byte("a"))
		dicUpper[strLower] = strUpper
		dicLower[strUpper] = strLower
		local strCaseX = "["..strUpper..strLower.."]"
		dicCaseX[strLower] = strCaseX
		dicCaseX[strUpper] = strCaseX
	end

	-- Supply character length of ANSI text --
	function fh.length(strTxt)
		return string.len(strTxt or "")
	end -- function length

	-- Supply character substring of ANSI text --
	function fh.substring(strTxt,i,j)
		return string.sub(strTxt or "",i,j)
	end -- function substring

	-- Translate upper/lower case ANSI letters to pattern that matches both --
	function fh.caseless(strTxt)
		strTxt = tostring(strTxt or ""):gsub("[A-Za-z]",dicCaseX)
		return strTxt
	end -- function caseless

	if fh.encoding() == "UTF-8" then

		-- Supply character length of UTF-8 text --
		function fh.length(strTxt)
			isFlag = fhIsConversionLossFlagSet()
			strTxt = fhConvertUTF8toANSI(strTxt or "")
			fhSetConversionLossFlag(isFlag)
			return string.len(strTxt)
		end -- function length

		local strUTF8 = "([%z\1-\127\194-\244][\128-\191]*)"			-- Cater for Lua 5.1 %z or Lua 5.3 \0
		if fhGetAppVersion() > 6 then
			strUTF8 = "([\0-\127\194-\244][\128-\191]*)"
		end

		-- Supply character substring of UTF-8 text --
		function fh.substring(strTxt,i,j)
			local strSub = ""
			j = j or -1
			if j < 0 then j = j + length(strTxt) + 1 end
			if i < 0 then i = i + length(strTxt) + 1 end
			for strChr in string.gmatch(strTxt or "",strUTF8) do
				if j <= 0 then break end
				j = j - 1
				i = i - 1
				if i <= 0 then strSub = strSub..strChr end
			end
			return strSub
		end -- function substring

		-- Translate lower case to upper case UTF-8 letters --
		function fh.upper(strTxt)
			strTxt = tostring(strTxt or ""):gsub("([a-z\194-\244][\128-\191]*)",dicUpper)
			return strTxt
		end -- function upper

		-- Translate upper case to lower case UTF-8 letters --
		function fh.lower(strTxt)
			strTxt = tostring(strTxt or ""):gsub("([A-Z\194-\244][\128-\191]*)",dicLower)
			return strTxt
		end -- function lower

		-- Translate upper/lower case UTF-8 letters to pattern that matches both --
		function fh.caseless(strTxt)
			strTxt = tostring(strTxt or ""):gsub("([A-Za-z\194-\244][\128-\191]*)",dicCaseX)
			return strTxt
		end -- function caseless

		-- Following tables use ASCII numeric coding to be immune from ANSI/UTF-8 encoding --

		local arrPairs =	-- Upper & Lower case groups of UTF-8 letters with same prefix --
		{--	{ Prefix; Beg ; End ; Inc; Offset Upper > Lower };			-- These include all ANSI letters and many more
			{ "\195"; 0x80; 0x96;  1 ; 32 };								-- 195=0xC3 À U+00C0 to Ö U+00D6 and à U+00E0 to ö U+00F6
			{ "\195"; 0x98; 0x9E;  1 ; 32 };								-- 195=0xC3 Ø U+00D8 to Þ U+00DE and ø U+00F8 to þ U+00FE
			{ "\196"; 0x80; 0xB6;  2 ;  1 };								-- 196=0xC4 A U+0100 to k U+0137 in pairs
			{ "\196"; 0xB9; 0xBD;  2 ;  1 };								-- 196=0xC4 L U+0139 to l U+013E in pairs
			{ "\197"; 0x81; 0x87;  2 ;  1 };								-- 197=0xC5 L U+0141 to n U+0148 in pairs
			{ "\197"; 0x8A; 0xB6;  2 ;  1 };								-- 197=0xC5 ? U+014A to y U+0177 in pairs
			{ "\197"; 0xB9; 0xBD;  2 ;  1 };								-- 197=0xC5 Z U+0179 to ž U+017E in pairs
			{ "\198"; 0x82; 0x84;  2 ;  1 };								-- 198=0xC6 ?  U+0182 to ?  U+0185 in pairs
			-- Add more Unicode groups here as usage increases --
		}
		local dicPairs =	-- Upper v Lower case UTF-8 letters that don't fit groups above --
		{	[string.char(0xC4,0xBF)] = string.char(0xC5,0x80);			-- ? U+013F and ? U+0140
			[string.char(0xC5,0xB8)] = string.char(0xC3,0xBF);			-- Ÿ U+0178 and ÿ U+00FF
		}

		local intBeg1 = string.byte(string.sub("À",1))
		local intBeg2 = string.byte(string.sub("À",2))
		local intEnd1 = string.byte(string.sub("Z",1))
		local intEnd2 = string.byte(string.sub("Z",2))
	--	print(string.format("%#x %#x %#x %#x",intBeg1,intBeg2,intEnd1,intEnd2)) -- Useful to work out numeric coding

		-- Populate the UTF-8 letter translation dictionaries --
		for intGroup, tblGroup in ipairs ( arrPairs ) do				-- UTF-8 accented letter groups
			local strPrefix = tblGroup[1]
			for intUpper = tblGroup[2], tblGroup[3], tblGroup[4] do
				local strUpper = string.char(intUpper)
				local strLower = string.char(intUpper + tblGroup[5])
				local strCaseX = strPrefix.."["..strUpper..strLower.."]"
				strUpper = strPrefix..strUpper
				strLower = strPrefix..strLower
				dicUpper[strLower] = strUpper
				dicLower[strUpper] = strLower
				dicCaseX[strLower] = strCaseX
				dicCaseX[strUpper] = strCaseX
			end
		end
		for strUpper, strLower in pairs ( dicPairs ) do					-- UTF-8 accented letters where upper & lower have different prefix
			dicUpper[strLower] = strUpper
			dicLower[strUpper] = strLower
			local strCaseX = ""
			for intByte = 1, #strUpper do									-- Matches more than just the two letters, but can't do any better
				strCaseX = strCaseX.."["..strUpper:sub(intByte,intByte)..strLower:sub(intByte,intByte).."]"
			end
			dicCaseX[strLower] = strCaseX
			dicCaseX[strUpper] = strCaseX
		end

	end

	-- overload fh functions into string table
	for strIndex, anyValue in pairs(fh) do
		if type(anyValue) == "function" then
			string[strIndex] = anyValue
		end
	end

	return fh

end -- local function stringx_v3

local stringx = stringx_v3()													-- To access FH string extension module

--[[
@Module:			+fh+iterate_v3
@Author:			Mike Tate
@Version:			3.0
@LastUpdated:	25 Aug 2020
@Description:	An iterater functions module to supplement LUA functions.
@V3.0:				Function Prototype Closure version.
@V1.2:				RecordTypes() includes HEAD tag.
@V1.1:				?
@V1.0:				Initial version.
]]

local function iterate_v3()

	local fh = {}																-- Local environment table

	-- Iterator for all records of one chosen type --
	function fh.Records(strType)
		local ptrAll = fhNewItemPtr()										-- Pointer to all records in turn
		local ptrRec = fhNewItemPtr()										-- Pointer to record returned to user
		ptrAll:MoveToFirstRecord(strType)
		return function ()
			ptrRec:MoveTo(ptrAll)
			ptrAll:MoveNext()
			if ptrRec:IsNotNull() then return ptrRec end
		end
	end -- function Records

	-- Iterator for all the record types --
	function fh.RecordTypes()
		local intNext = -1														-- Next record type number
		local intLast = fhGetRecordTypeCount()								-- Last record type number
		return function()
			intNext = intNext + 1
			if intNext == 0 then												-- Includes HEAD tag -- V1.2
				return "HEAD"
			elseif intNext <= intLast then
				return fhGetRecordTypeTag(intNext)							-- Return record type tag
			end
		end
	end -- function RecordTypes

	-- Iterator for all items in all records of chosen types --
	function fh.Items(...)
		local arg = {...}
		local intType = 1														-- Integer record type number
		local tblType = {}														-- Table of record type tags
		local ptrNext = fhNewItemPtr()										-- Pointer to next item in turn
		local ptrItem = fhNewItemPtr()										-- Pointer to item returned to user

		if #arg == 0 then
			for intType = 1, fhGetRecordTypeCount() do					-- No parameters so use all record types
				tblType[intType] = fhGetRecordTypeTag(intType)
			end
		else
			tblType = arg														-- Got parameters so use them instead
		end
	--	print(tblType[intType],intType)
		ptrNext:MoveToFirstRecord(tblType[intType])						-- Get first record of first type

		return function()
			repeat
				while ptrNext:IsNotNull() do									-- Loop through all items
					ptrItem:MoveTo(ptrNext)
					ptrNext:MoveNextSpecial()
					if ptrItem:IsNotNull() then return ptrItem end
				end
				intType = intType + 1											-- Loop through each record type
				if intType <= #tblType then
					ptrNext:MoveToFirstRecord(tblType[intType])
				end
			until intType > #tblType
		end
	end -- function Items

	-- Iterator for all facts of an individual --
	function fh.Facts(ptrIndi)
		local ptrItem = fhNewItemPtr()										-- Pointer to each item at level 1
		local ptrFact = fhNewItemPtr()										-- Pointer to each fact returned to user
		ptrItem:MoveToFirstChildItem(ptrIndi)
		return function ()
			while ptrItem:IsNotNull() do
				ptrFact:MoveTo(ptrItem)
				ptrItem:MoveNext()
				if fhIsFact(ptrFact) then return ptrFact end
			end
		end
	end -- function Facts

	return fh

end -- local function iterate_v3

local iterate = iterate_v3()													-- To access FH iterate items module

--[[
@Module:			+fh+general_v3
@Author:			Mike Tate
@Version:			3.5
@LastUpdated:	12 Dec 2024
@Description:	A general functions module to supplement LUA functions, where filenames use UTF-8 but for a few exceptions.
@V3.5:				Further fix for Crossover/WINE file attributes;
@V3.4:				Further fix for Unix/WINE file attributes;
@V3.3:				Fix problem in fh.MakeFolder(...) when folder path is invalid; Remove Type, ShortName & ShortPath from attributes(...) as unsupported in WINE;
@V3.2:				Added function DetectOldModules(); Updated functions RenameFile(), RenameFolder() & GetFolderContents();
@V3.1:				Functions derived from FH V7 fhFileUtils library using File System Objects, plus additional features;
@V3.0:				Function Prototype Closure version; GetDayNumber() error message reasons;
@V1.5:				Revised SplitFilename(strFilename) for missing extension.
@V1.4:				Revised EstimatedBirthDates() & EstimatedDeathDates() to fix null Dates.
@V1.3:				Add GetDayNumber(), EstimatedBirthDates(), EstimatedDeathDates().
@V1.2:				SplitFilename() updated for directory only paths, and MakeFolder() added.
@V1.1:				pl.path experiment revoked. New DirTree with omit branch option. Avoid using stringx_v2.
@V1.0:				Initial version.
]]

local function general_v3()

	local fh = {}													-- Local environment table

	require("luacom")												-- To create File System Object
	fh.FSO = luacom.CreateObject("Scripting.FileSystemObject")

	-- Report error message --
	local function doError(strMessage,errFunction)
		-- strMessage		~ error message text
		-- errFunction		~ optional error reporting function
		if type(errFunction) == "function" then
			errFunction(strMessage)
		else
			error(strMessage)
		end
	end -- local function doError

	-- Convert filename to ANSI alternative and indicate success --
	function fh.FileNameToANSI(strFileName,strAnsiName)
		-- strFileName		~ full file path
		-- strAnsiFile		~ ANSI file name & type
		-- return values	~ ANSI file path, true if original path was ANSI compatible
		if stringx.encoding() == "ANSI" then return strFileName, true end
		local isFlag = fhIsConversionLossFlagSet()
		fhSetConversionLossFlag(false)
		local strAnsi = fhConvertUTF8toANSI(strFileName)
		local wasAnsi = true
		if fhIsConversionLossFlagSet() then
			strAnsiName = strAnsiName or "ANSI.ANSI"
			strAnsi = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Plugin Data\\"..strAnsiName
			wasAnsi = false
		end
		fhSetConversionLossFlag(isFlag)
		return strAnsi, wasAnsi
	end -- local function FileNameToANSI

	-- Get parent folder --
	function fh.GetParentFolder(strFileName)
		-- strFileName		~ full file path
		-- return value		~ parent folder path
		local strParent = fh.FSO:GetParentFolderName(strFileName)	--! Faulty in FH v6 with Unicode chars in path
		if fhGetAppVersion() == 6 then
			local _, wasAnsi = fh.FileNameToANSI(strFileName)
			if not wasAnsi then
				strParent = strFileName:match("^(.+)[\\/][^\\/]+[\\/]?$")
			end
		end
		return strParent
	end -- function GetParentFolder

	-- Check if file exists --
	function fh.FlgFileExists(strFileName)
		-- strFileName		~ full file path
		-- return value		~ true if it exists
		return fh.FSO:FileExists(strFileName)
	end -- function FlgFileExists

	-- Check if folder exists --
	function fh.FlgFolderExists(strFolderName)
		-- strFolderName	~ full file path
		-- return value		~ true if it exists
		return fh.FSO:FolderExists(strFolderName)
	end -- function FlgFolderExists

	-- Delete a file if it exists --
	function fh.DeleteFile(strFileName,errFunction)
		-- strFileName		~ full file path
		-- errFunction		~ optional error reporting function
		-- return value		~ true if file does not exist or is deleted else false
		if fh.FSO:FileExists(strFileName) then
			fh.FSO:DeleteFile(strFileName,true)
			if fh.FSO:FileExists(strFileName) then
				doError("File Not Deleted:\n"..strFileName.."\n",errFunction)
				return false
			end
		end
		return true
	end -- function DeleteFile

	-- Delete a folder if it exists including contents --
	function fh.DeleteFolder(strFolderName,errFunction)
		-- strFolderName	~ full folder path
		-- errFunction		~ optional error reporting function
		-- return value		~ true if folder does not exist or is deleted else false
		if fh.FSO:FolderExists(strFolderName) then
			fh.FSO:DeleteFolder(strFolderName,true)
			if fh.FSO:FolderExists(strFolderName) then
				doError("Folder Not Deleted:\n"..strFolderName.."\n",errFunction)
				return false
			end
		end
		return true
	end -- function DeleteFolder

	-- Rename a file if it exists --
	function fh.RenameFile(strFileName,strNewName)
		-- strFileName		~ full file path
		-- strNewName		~ new file name & type
		-- return value		~ true if file exists but new name does not and rename is OK else false
		local strNewFile = fh.GetParentFolder(strFileName).."\\"..strNewName
		if fh.FSO:FileExists(strFileName) and not fh.FSO:FileExists(strNewFile) then
			local fileObject = fh.FSO:GetFile(strFileName)
			fileObject.Name = strNewName
			if fh.FSO:FileExists(strNewFile) then
				return true
			end
		end
		return false
	end -- function RenameFile

	-- Rename a folder if it exists --
	function fh.RenameFolder(strFolderName,strNewName)
		-- strFolderName	~ full folder path
		-- strNewName		~ new folder name
		-- return value		~ true if folder exists but new name does not and rename is OK else false
		local strNewFolder = fh.GetParentFolder(strFolderName).."\\"..strNewName
		if fh.FSO:FolderExists(strFolderName) and not fh.FSO:FolderExists(strNewFolder) then
			local folderObject = fh.FSO:GetFolder(strFolderName)
			folderObject.Name = strNewName
			if fh.FSO:FolderExists(strNewFolder) then
				return true
			end
		end
		return false
	end -- function RenameFolder

	-- Copy a file if it exists and destination is not a folder --
	function fh.CopyFile(strFileName,strDestination)
		-- strFileName		~ full source file path
		-- strDestination	~ full target file path
		-- return value		~ true if file exists and is copied else false
		if fh.MakeFolder(fh.GetParentFolder(strDestination)) and fh.FSO:FileExists(strFileName) and not fh.FSO:FolderExists(strDestination) then
			fh.FSO:CopyFile(strFileName,strDestination)
			if fh.FSO:FileExists(strDestination) then
				return true
			end
		end
		return false
	end -- function CopyFile

	-- Copy a folder if it exists and destination is not a file --
	function fh.CopyFolder(strFolderName,strDestination)
		-- strFolderName	~ full source folder path
		-- strDestination	~ full target folder path
		-- return value		~ true if folder exists and is copied else false
		if fh.MakeFolder(fh.GetParentFolder(strDestination)) and fh.FSO:FolderExists(strFolderName) and not fh.FSO:FileExists(strDestination) then
			fh.FSO:CopyFolder(strFolderName,strDestination)
			if fh.FSO:FolderExists(strDestination) then
				return true
			end
		end
		return false
	end -- function CopyFolder

	-- Move a file if it exists and destination is not a folder --
	function fh.MoveFile(strFileName,strDestination)
		-- strFileName		~ full source file path
		-- strDestination	~ full target file path
		-- return value		~ true if file exists and is moved else false
		if fh.MakeFolder(fh.GetParentFolder(strDestination)) and fh.FSO:FileExists(strFileName) and not fh.FSO:FolderExists(strDestination) then
			if fh.DeleteFile(strDestination) then
				fh.FSO:MoveFile(strFileName,strDestination)
				if fh.FSO:FileExists(strDestination) then
					return true
				end
			end
		end
		return false
	end -- function MoveFile

	-- Move a folder if it exists and destination is not a file --
	function fh.MoveFolder(strFolderName,strDestination)
		-- strFolderName	~ full source folder path
		-- strDestination	~ full target folder path
		-- return value		~ true if folder exists and is moved else false
		if fh.MakeFolder(fh.GetParentFolder(strDestination)) and fh.FSO:FolderExists(strFolderName) and not fh.FSO:FileExists(strDestination) then
			if fh.DeleteFolder(strDestination) then
				fh.FSO:MoveFolder(strFolderName,strDestination)
				if fh.FSO:FolderExists(strDestination) then
					return true
				end
			end
		end
		return false
	end -- function MoveFolder

	local function CreateFolder(strFolderName)				-- V3.3
		fh.FSO:CreateFolder(strFolderName)
	end -- local function CreateFolder(strFolderName)

	-- Make subfolder recursively if does not exist --
	function fh.MakeFolder(strFolderName,errFunction)
		-- strFolderName	~ full source folder path
		-- errFunction		~ optional error reporting function
		-- return value		~ true if folder exists or created else false
		if not fh.FSO:FolderExists(strFolderName) then
			if #strFolderName > 4									-- V3.3
			and not fh.MakeFolder(fh.GetParentFolder(strFolderName),errFunction) then
				return false
			end
			if not pcall(CreateFolder,strFolderName)			-- V3.3
			or not fh.FSO:FolderExists(strFolderName) then
				doError("Cannot Make Folder Path:                 \n"..strFolderName.." \n",errFunction)
				return false
			end
		end
		return true
	end -- function MakeFolder

	-- Check if folder writable --
	function fh.FlgFolderWrite(strFolderName)
		-- strFolderName	~ full source folder path
		-- return value		~ true if folder writable else false
		if fh.FlgFolderExists(strFolderName) then
			if fh.MakeFolder(strFolderName.."\\vwxyz") then
				fh.FSO:DeleteFolder(strFolderName.."\\vwxyz",true)
				return true
			end
		end
		return false
	end -- function FlgFolderWrite

	-- Open File with ANSI path and return Handle --
	function fh.OpenFile(strFileName,strMode)
		-- strFileName		~ full file path
		-- strMode			~ "r", "w", "a" optionally suffixed with "+" &/or "b"
		-- return value		~ file handle
		local fileHandle, strError = io.open(strFileName,strMode)
		if fileHandle == nil then
			error("\n Unable to open file in \""..strMode.."\" mode. \n "..strFileName.." \n "..strError.." \n")
		end
		return fileHandle
	end -- function OpenFile

	-- Save string to file --
	function fh.SaveStringToFile(strContents,strFileName,strFormat)
		-- strContents		~ text string
		-- strFileName		~ full file path
		-- strFormat			~ optional "UTF-8" or "UTF-16LE"
		-- return value		~ true if successful else false
		strFormat = strFormat or "UTF-8"
		if fhGetAppVersion() > 6 then
			return fhSaveTextFile(strFileName,strContents,strFormat)
		end
		local strAnsi, wasAnsi = fh.FileNameToANSI(strFileName)
		local fileHandle = fh.OpenFile(strAnsi,"w")
		fileHandle:write(strContents)
		assert(fileHandle:close())
		if not wasAnsi then
			fh.MoveFile(strAnsi,strFileName)
		end
		return true
	end -- function SaveStringToFile

	-- Load string from file --
	function fh.StrLoadFromFile(strFileName,strFormat)
		-- strFileName		~ full file path
		-- strFormat			~ optional "UTF-8" or "UTF-16LE"
		-- return value		~ file contents
		strFormat = strFormat or "UTF-8"
		if fhGetAppVersion() > 6 then
			return fhLoadTextFile(strFileName,strFormat)
		end
		local strAnsi, wasAnsi = fh.FileNameToANSI(strFileName)
		if not wasAnsi then
			fh.CopyFile(strFileName,strAnsi)
		end
		local fileHandle = fh.OpenFile(strAnsi,"r")
		local strContents = fileHandle:read("*all")
		assert(fileHandle:close())
		return strContents
	end -- function StrLoadFromFile

	-- Returns the Path, Filename, and Extension as 3 values --
	function fh.SplitFilename(strFileName)
		-- strFileName		~ full file path
		-- return values	~ path, name.type, type
		if fh.FSO:FolderExists(strFileName) then
			local strPath = strFileName:gsub("[\\/]$","")
			return strPath.."\\","",""
		end
		strFileName = strFileName.."."
		return strFileName:match("^(.-)([^\\/]-%.([^\\/%.]-))%.?$")
	end -- function SplitFilename

	-- Convert dd/mm/yyyy hh:mm:ss format to integer seconds -- (DateTime format is used in attributes returned by GetFolderContents and DirTree below)
	function fh.IntTime(strDateTime)
		-- strDateTime		~ date time string
		-- return value		~ integer seconds since 01/01/1970 00:00:00 
		local strDay,strMonth,strYear,strHour,strMin,strSec = strDateTime:match("^(%d%d)/(%d%d)/(%d+) (%d%d):(%d%d):(%d%d)")
		if tonumber(strYear) < 1970 then return 0 end
		local isDST = false
		if tonumber(strMonth) > 4 and tonumber(strMonth) < 11 then isDST = true end	-- Approximation is sometimes wrong
		local intTime = os.time( { year=strYear; month=strMonth; day=strDay; hour=strHour; min=strMin; sec=strSec; isdst=isDST; } )
		local tblDat = os.date("*t",intTime)
		if tblDat.isdst then
			intTime = intTime + 3600
			isDST = true
		end
		return intTime
	end -- function IntTime

	-- Return table of attributes for folder --
	local function attribFolder(tblAttr)
		-- tblAttr		~ folder attributes table
		-- return value	~ attributes table like LFS except datetimes
		-- WINE only supports the tblAttr.name & tblAttr.path
		return { mode="directory"; name=tblAttr.name; path=tblAttr.path; }
	end -- local function attribFolder

	-- Return table of attributes for file --
	local function attribFile(tblAttr)
		-- tblAttr		~ file attributes table
		-- return value	~ attributes table like LFS except datetimes
		-- WINE does not support the tblAttr.type, tblAttr.shortname, tblAttr.shortpath & sometimes tblAttr.datecreated
		return { mode="file"; name=tblAttr.name; path=tblAttr.path; size=tblAttr.size; modified=tblAttr.datelastmodified; attributes=tblAttr.attributes; }
	end -- local function attribFile

	-- Return attributes table of all files and folders in a specified folder --
	function fh.GetFolderContents(strFolder,doRecurse)
		-- strFolder		~ full folder path
		-- doRecurse		~ true for recursion
		-- return value	~ attributes table
		local arrList = {}
		if fh.FSO:FolderExists(strFolder) then

			local function getFileList(strFolder)
				local tblList = fh.FSO:GetFolder(strFolder)
				local tblEnum = luacom.GetEnumerator(tblList.SubFolders)
				local tblAttr = tblEnum:Next()
				while tblAttr do
					table.insert(arrList,attribFolder(tblAttr))
					if doRecurse then getFileList(tblAttr.path) end
					tblAttr = tblEnum:Next()
				end
				local tblEnum = luacom.GetEnumerator(tblList.Files)
				local tblAttr = tblEnum:Next()
				while tblAttr do
					table.insert(arrList,attribFile(tblAttr))
					tblAttr = tblEnum:Next()
				end
			end

			getFileList(strFolder)
		end
		return arrList
	end -- function GetFolderContents

	-- Return a Directory Tree entry & attributes on each iteration --
	function fh.DirTree(strDir,...)
		-- strDir			~ full folder path
		-- ...				~ list of folders to omit
		-- return value	~ full path, attributes table
		local arg = {...}
		assert( fh.FSO:FolderExists(strDir), "directory parameter is missing or empty" )

		local function yieldtree(strDir)
			local tblList = fh.FSO:GetFolder(strDir)
			local tblEnum = luacom.GetEnumerator(tblList.SubFolders)
			local tblAttr = tblEnum:Next()
			while tblAttr do	--	for _,tblAttr in luacom.pairs(tblList.SubFolders) do	-- pairs not working in FH v6 so use tblEnum code
				coroutine.yield(tblAttr.path,attributes(tblAttr,"directory"))
				local isOK = true
				for _,strOmit in ipairs (arg) do
					if tblAttr.path:match(strOmit) then 	-- Omit tree branch
						isOK = false
						break
					end
				end
				if isOK then yieldtree(tblAttr.path) end
				tblAttr = tblEnum:Next()
			end
			local tblEnum = luacom.GetEnumerator(tblList.Files)
			local tblAttr = tblEnum:Next()
			while tblAttr do	--	for _,tblAttr in luacom.pairs(tblList.Files) do		-- pairs not working in FH v6 so use tblEnum code
				coroutine.yield(tblAttr.path,attributes(tblAttr,"file"))
				tblAttr = tblEnum:Next()
			end
		end

		return coroutine.wrap(function() yieldtree(strDir) end)
	end -- function DirTree

	-- Detect FH V5/6 old library modules and advise removal --
	function fh.DetectOldModules()
		if fhGetAppVersion() > 6 then
			local strPath = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Plugins\\"
			local arrFile = { "compat53.lua"; "ltn12.lua"; "luasql\\sqlite3.dll"; "md5.lua"; "pl\\init.lua"; "socket.lua"; "utf8.lua"; "zip.dll"; }
			for _, strFile in ipairs (arrFile) do
				if fh.FSO:FileExists(strPath..strFile) then
					fhMessageBox("\n  Detected some old FH V6 library modules. \n\nPlease remove them by running the plugin: \n\n     'Delete old FH6 Plugin Module Files' \n","MB_OK","MB_ICONEXCLAMATION")
					break
				end
			end
		end
	end -- function DetectOldModules

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

	-- Invoke FH Shell Execute API --
	function fh.DoExecute(strExecutable,...)
		-- strExecutable	~ full path of executable
		-- ...					~ parameter list and optional error reporting function
		-- return value		~ true if successful else false
		local arg = {...}
		local errFunction = fhMessageBox
		if type(arg[#arg]) == 'function' then
			errFunction = arg[#arg]
			table.remove(arg)
		end
		local isOK, intErrorCode, strErrorText = fhShellExecute(strExecutable,unpack(arg))
		if not isOK then
			errFunction(tostring(strErrorText).." ("..tostring(intErrorCode)..")")
		end
		return isOK
	end -- function DoExecute

	-- Obtain the Day Number for any Date Point --									-- Fix problems with invalid dates in DayNumber function
	function fh.GetDayNumber(datDate)
		-- datDate		~ date point
		-- return value	~ day number
		if datDate:IsNull() then return 0 end
		local intDay = fhCallBuiltInFunction("DayNumber",datDate) 				-- Only works for Gregorian dates that were not skipped nor BC dates
		if not intDay then
			local strError = "because "													-- Error message reason	-- V3.0
			local calendar = datDate:GetCalendar()
			local oldMonth = datDate:GetMonth()
			local oldDayNo = datDate:GetDay()
			local intMonth = math.min( oldMonth, 12 ) 								-- Limit month to 12, and day to last of each month
			local intDayNo = math.min( oldDayNo, ({0;31;28;31;30;31;30;31;31;30;31;30;31;})[intMonth+1] )
			local intYear  = datDate:GetYear()
			if oldDayNo > intDayNo then strError = strError.."day "..oldDayNo.." too big "   end
			if oldMonth > intMonth then strError = strError.."month "..oldMonth.." too big " end
			if calendar == "Hebrew" and intYear > 3761 then
				intYear = intYear - 3761
				strError = strError.."Hebrew year > 3761 "
			elseif calendar ~= "Gregorian" then
				strError = strError..calendar.." disallowed "
			end
			if     intYear == 1752 and intMonth ==  9 and intDayNo <= 13 then	-- Use 2 Sep 1752 for 3 - 13 Sep 1752 dates skipped
				intDayNo = 2
				strError = strError.."3 - 13 Sep 1752 skipped "
			elseif intYear == 1582 and intMonth == 10 and intDayNo <= 14 then	-- Use 4 Oct 1582 for 5 - 14 Oct 1582 dates skipped
				intDayNo = 4
				strError = strError.."5 - 14 Oct 1582 skipped "
			end	
			local setDate = fhNewDatePt(intYear,intMonth,intDayNo,datDate:GetYearDD())
			intDay = fhCallBuiltInFunction("DayNumber",setDate) 					-- Remove BC and Julian, Hebrew, French calendars
			if not intDay then intDay = 0 end
			local oldDate = fhNewDate()		oldDate:SetSimpleDate(datDate)		-- Report problem to user
			local newDate = fhNewDate()		newDate:SetSimpleDate(setDate)
			local strIsBC = ""
			if datDate:GetBC() then
				strError = strError.." B.C. disallowed "
				intDay = -intDay
				strIsBC = "and Day Number negated"
			end
			fhMessageBox("\n Get Day Number issue for date \n "..oldDate:GetDisplayText().." \n "..strError.." \n So replaced it with date \n "..newDate:GetDisplayText().." \n "..strIsBC,"MB_OK","MB_ICONEXCLAMATION")
		end
		return intDay
	end -- function GetDayNumber

	local dtpYearMin = fhNewDatePt(1000)												-- Minimum year to use when earliest estimate is null
	local dtpYearMax = fhNewDatePt(2000)												-- Maximum year to use when latest estimate is null

	function fh.GetYearToday()																-- Get the Year for Today
		-- return value	~ integer year today
		local intYearToday = fhCallBuiltInFunction("Year",fhCallBuiltInFunction("Today"))
		dtpYearMax = fhNewDatePt(intYearToday)											-- Set maximum year date point
		return intYearToday
	end -- function GetYearToday()

	local function getDeathFacts(ptrIndi)												-- Iterate Death, Burial, Cremation facts
		-- ptrIndi		~ pointer to individual
		-- return value	~ pointer to fact
		local arrFact = { "~.DEAT"; "~.BURI"; "~.CREM"; }
		local intFact = 0
		local ptrFact = fhNewItemPtr()													-- Pointer to each fact returned to user
		return function ()
			while intFact < #arrFact do
				intFact = intFact + 1
				ptrFact = fhGetItemPtr(ptrIndi,arrFact[intFact])
				if ptrFact:IsNotNull() then return ptrFact end
			end
		end
	end -- local function getDeathFacts

	-- Ensure Estimated Date EARLIEST <= LATEST <= Fact Date --					-- Fix errors in EstimatedBirth/DeathDate function
	local function estimatedDates(strFunc,ptrIndi,intGens,getFact,intYrs)
		-- strFunc			~ "EstimatedBirthDate" or "EstimatedDeathDate"
		-- ptrIndi			~ Individual of interest
		-- intGens			~ Number of generations (may be nil)
		-- getFact			~ Iterator function for facts
		-- intYrs 			~ Years to add to After dates
		-- return values	~ EARLIEST, MID, LATEST dates
		intGens = intGens or 2
		local dtpMin = fhCallBuiltInFunction(strFunc,ptrIndi,"EARLIEST",intGens)
		local dtpMax = fhCallBuiltInFunction(strFunc,ptrIndi,"LATEST",intGens)
		local dtpMid = fhNewDatePt()
		if not ( dtpMin:IsNull() and dtpMax:IsNull() ) then 						-- Skip if both null	
			if dtpMax:IsNull() then dtpMax = dtpYearMax elseif dtpMin:IsNull() then dtpMin = dtpYearMin end
			for ptrFact in getFact(ptrIndi) do
				local datFact = fhGetValueAsDate(fhGetItemPtr(ptrFact,"~.DATE"))
				if not datFact:IsNull() then												-- Find 1st Fact Date
					local dtpLast = datFact:GetDatePt1()								-- Last date = DatePt1 for Simple, Range, and Before
					local strType = datFact:GetSubtype()								-- Between = DatePt2 and After = DatePt1 + intYrs
					if   strType == "Between" then dtpLast = datFact:GetDatePt2()
					elseif strType == "After" then dtpLast = fhNewDatePt(dtpLast:GetYear()+intYrs,dtpLast:GetMonth(),dtpLast:GetDay()) end		-- Compare only uses Year, Month, Day so omitted	,dtpLast:GetYearDD(),dtpLast:GetBC(),dtpLast:GetCalendar()
					if dtpMax:Compare(dtpLast) > 0 then dtpMax = dtpLast end
					if dtpMin:Compare(dtpMax)  > 0 then dtpMin = dtpMax  end
					if strType ~= "After" then break end								-- Now EARLIEST <= LATEST <= Last date
				end
			end	
			local intDays = ( fh.GetDayNumber(dtpMax) - fh.GetDayNumber(dtpMin) ) / 2
			local intYear,remYear = math.modf( intDays / 365.2422 )				-- Offset year @ 365.2422 days per year, and remainder fraction
			local intMnth = math.floor( ( remYear * 12 ) + 0.1 )					-- Offset month is remainder fraction of year * 12
			dtpMid = fhCallBuiltInFunction("CalcDate",dtpMin,intYear,intMnth)	-- Need approximate MID year & month
		end
		return { Min=dtpMin; Mid=dtpMid; Max=dtpMax; }								-- Return EARLIEST, MID, LATEST dates
	end -- local function estimatedDates

	-- Make EstimatedBirthDate EARLIEST <= LATEST <= 1st Fact Date --			-- Fix errors in EstimatedBirthDate function
	function fh.EstimatedBirthDates(ptrIndi,intGens)
		-- ptrInd				~ pointer to individual
		-- intGens			~ generations to include
		-- return values	~ EARLIEST, MID, LATEST dates
		return estimatedDates("EstimatedBirthDate",ptrIndi,intGens,iterate.Facts,10)
	end -- function EstimatedBirthDates

	-- Make EstimatedDeathDate EARLIEST <= LATEST <= DEAT/BURI/CREM Date --	-- Fix errors in EstimatedDeathDate function
	function fh.EstimatedDeathDates(ptrIndi,intGens)
		-- ptrInd				~ pointer to individual
		-- intGens			~ generations to include
		-- return values	~ EARLIEST, MID, LATEST dates
		return estimatedDates("EstimatedDeathDate",ptrIndi,intGens,getDeathFacts,100)
	end -- function EstimatedDeathDates

	--[[
	@function:		BuildDataRef
	@description:	Get Full Data Reference for Pointer
	@parameters:		Item Pointer
	@returns:			Data Reference String, Record Id Integer, Record Type Tag String
	@requires:		None
	]]
	function fh.BuildDataRef(ptrRef)

		local strDataRef = ""										-- Data Reference with instance indices e.g. INDI.RESI[2].ADDR
		local intRecId   = 0										-- Record Id for associated Record
		local strRecTag  = ""										-- Record Tag of associated Record type i.e. INDI, FAM, NOTE, SOUR, etc

		-- getDataRef() is called recursively per level of the Data Ref
		-- ptrRef points to the upper Data Ref levels yet to be analysed
		-- strRef compiles the lower Data Ref levels including instances

		local function getDataRef(ptrRef,strRef)
			local ptrTag = ptrRef:Clone()
			local strTag = fhGetTag(ptrTag)						-- Current level Tag
			ptrTag:MoveToParentItem(ptrTag)
			if ptrTag:IsNotNull() then							-- Parent level exists
				local intSib = 1
				local ptrSib = ptrRef:Clone()					-- Pointer to siblings with same Tag
				ptrSib:MovePrev("SAME_TAG")
				while ptrSib:IsNotNull() do						-- Count previous siblings with same Tag
					intSib = intSib + 1
					ptrSib:MovePrev("SAME_TAG")
				end
				if intSib > 1 then 	strTag = strTag.."["..intSib.."]" end
				getDataRef(ptrTag,"."..strTag..strRef)			-- Now analyse the parent level
			else
				strDataRef = strTag..strRef						-- Record level reached, so set return values
				intRecId   = fhGetRecordId(ptrRef)
				strRecTag  = strTag
				if not fhIsValidDataRef(strDataRef) then print("BuildDataRef: "..strDataRef.." is Invalid") end
			end
		end -- local function getDataRef

		if type(ptrRef) == "userdata" then getDataRef(ptrRef,"") end

		return strDataRef, intRecId, strRecTag

	end -- function BuildDataRef

	--[[
	@function:		GetDataRefPtr
	@description:	Get Pointer for Full Data Reference
	@parameters:		Data Reference String, Record Id Integer, Record Type Tag String (optional)
	@returns:			Item Pointer which IsNull() if any parameters are invalid
	@requires:		None
	]]
	function fh.GetDataRefPtr(strDataRef,intRecId,strRecTag)
		strDataRef = strDataRef or ""
		if not strRecTag then
			strRecTag = strDataRef:gsub("^(%u+).*$","%1")	-- Extract Record Tag from Data Ref
		end
		local ptrRef = fhNewItemPtr()
		ptrRef:MoveToRecordById(strRecTag,intRecId or 0)	-- Lookup the Record by Id
		ptrRef:MoveTo(ptrRef,strDataRef)						-- Move to the Data Ref
		return ptrRef
	end -- function GetDataRefPtr

	function fh.TblDataRef(ptrRef)
		local tblRef = {}
		tblRef.DataRef, tblRef.RecId, tblRef.RecTag = BuildDataRef(ptrRef)
		return tblRef
	end -- function TblDataRef

	function fh.PtrDataRef(tblRef)
		local tblRef = tblRef or {}								-- Ensure table and its fields exist
		return GetDataRefPtr(tblRef.DataRef or "",tblRef.RecId or 0,tblRef.RecTag or "")
	end -- function PtrDataRef

	return fh

end -- local function general_v3

local general = general_v3()										-- To access FH general tools module

--[[
@Module:			+fh+tablex_v3
@Author:			Mike Tate
@Version:			3.1
@LastUpdated:	08 Jan 2022
@Description:	A Table Load Save Module.
@V3.1:				Cater for full UTF-8 filenames.
@V3.0:				Function Prototype Closure version.
@V1.2:				Added local definitions of _ to ensure nil gets returned on error.
@V1.1:				?
@V1.0:				Initial version 0.94 is Lua 5.1 compatible.
]]

local function tablex_v3()

	local fh = {}									-- Local environment table

	------------------------------------------------------ Start Table Load Save
	-- require "_tableloadsave"
	--[[
	Save Table to File/Stringtable
	Load Table from File/Stringtable
	v 0.94

	Lua 5.1 compatible

	Userdata and indices of these are not saved
	Functions are saved via string.dump, so make sure it has no upvalues
	References are saved
	----------------------------------------------------
	table.save( table [, filename] )

	Saves a table so it can be called via the table.load function again
	table must a object of type 'table'
	filename is optional, and may be a string representing a filename or true/1

	table.save( table )
		on success: returns a string representing the table (stringtable)
		(uses a string as buffer, ideal for smaller tables)
	table.save( table, true or 1 )
		on success: returns a string representing the table (stringtable)
		(uses io.tmpfile() as buffer, ideal for bigger tables)
	table.save( table, "filename" )
		on success: returns 1
		(saves the table to file "filename")
	on failure: returns as second argument an error msg
	----------------------------------------------------
	table.load( filename or stringtable )

	Loads a table that has been saved via the table.save function

	on success: returns a previously saved table
	on failure: returns as second argument an error msg
	----------------------------------------------------

	chillcode, http://lua-users.org/wiki/SaveTableToFile
	Licensed under the same terms as Lua itself.
	]]--

	-- declare local variables
	--// exportstring( string )
	--// returns a "Lua" portable version of the string
	local function exportstring( s )
		s = string.format( "%q",s )
		-- to replace
		s = string.gsub( s,"\\\n","\\n" )
		s = string.gsub( s,"\r","\\r" )
		s = string.gsub( s,string.char(26),"\"..string.char(26)..\"" )
		return s
	end
--// The Save Function
function fh.save( tbl,filename )
	local charS,charE = "   ","\n"
	local file,err,_,stransi,wasansi 					-- V1.2 -- V3.1 -- Added _,stransi,wasansi	--!
	-- create a pseudo file that writes to a string and return the string
	if not filename then
		file =  { write = function( self,newstr ) self.str = self.str..newstr end, str = "" }
		charS,charE = "",""
	-- write table to tmpfile
	elseif filename == true or filename == 1 then
		charS,charE,file = "","",io.tmpfile()
	-- write table to file
	-- use io.open here rather than io.output, since in windows when clicking on a file opened with io.output will create an error
	else
		stransi,wasansi = general.FileNameToANSI(filename)	-- V3.1 -- Cater for non-ANSI filename	--!
		file,err = io.open( stransi, "w" )
		if err then return _,err end
	end
	-- initiate variables for save procedure
	local tables,lookup = { tbl },{ [tbl] = 1 }
	file:write( "return {"..charE )
	for idx,t in ipairs( tables ) do
		if filename and filename ~= true and filename ~= 1 then
			file:write( "-- Table: {"..idx.."}"..charE )
		end
		file:write( "{"..charE )
		local thandled = {}
		for i,v in ipairs( t ) do
			thandled[i] = true
			-- escape functions and userdata
			if type( v ) ~= "userdata" then
				-- only handle value
				if type( v ) == "table" then
					if not lookup[v] then
						table.insert( tables, v )
						lookup[v] = #tables
					end
					file:write( charS.."{"..lookup[v].."},"..charE )
				elseif type( v ) == "function" then
					file:write( charS.."loadstring("..exportstring(string.dump( v )).."),"..charE )
				else
					local value =  ( type( v ) == "string" and exportstring( v ) ) or tostring( v )
					file:write(  charS..value..","..charE )
				end
			end
		end
		for i,v in pairs( t ) do
			-- escape functions and userdata
			if (not thandled[i]) and type( v ) ~= "userdata" then
				-- handle index
				if type( i ) == "table" then
					if not lookup[i] then
						table.insert( tables,i )
						lookup[i] = #tables
					end
					file:write( charS.."[{"..lookup[i].."}]=" )
				else
					local index = ( type( i ) == "string" and "["..exportstring( i ).."]" ) or string.format( "[%d]",i )
					file:write( charS..index.."=" )
				end
				-- handle value
				if type( v ) == "table" then
					if not lookup[v] then
						table.insert( tables,v )
						lookup[v] = #tables
					end
					file:write( "{"..lookup[v].."},"..charE )
				elseif type( v ) == "function" then
					file:write( "loadstring("..exportstring(string.dump( v )).."),"..charE )
				else
					local value =  ( type( v ) == "string" and exportstring( v ) ) or tostring( v )
					file:write( value..","..charE )
				end
			end
		end
		file:write( "},"..charE )
	end
	file:write( "}" )
	-- Return Values
	-- return stringtable from string
	if not filename then
		-- set marker for stringtable
		return file.str.."--|"
	-- return stringttable from file
	elseif filename == true or filename == 1 then
		file:seek ( "set" )
		-- no need to close file, it gets closed and removed automatically
		-- set marker for stringtable
		return file:read( "*a" ).."--|"
	-- close file and return 1
	else
		file:close()
		if not ( wasansi ) then		-- V3.1 -- Cater for non-ANSI filename	--!
			general.MoveFile(stransi,filename)
		end
		return 1
	end
end

--// The Load Function
function fh.load( sfile )
	local tables,err,_ 					-- V1.2 -- Added _
	-- catch marker for stringtable
	if string.sub( sfile,-3,-1 ) == "--|" then
		tables,err = loadstring( sfile )
	else
		local stransi,wasansi = general.FileNameToANSI(sfile)	-- V3.1 -- Cater for non-ANSI filename	--!
		if not ( wasansi ) then
			general.CopyFile(sfile,stransi)
		end
		tables,err = loadfile( stransi )
		if not ( wasansi ) then
			general.DeleteFile(stransi)								-- V3.1 -- Cater for non-ANSI filename	--!
		end
	end
	if err then return _,err
	end
	tables = tables()
	for idx = 1,#tables do
		local tolinkv,tolinki = {},{}
		for i,v in pairs( tables[idx] ) do
			if type( v ) == "table" and tables[v[1]] then
				table.insert( tolinkv,{ i,tables[v[1]] } )
			end
			if type( i ) == "table" and tables[i[1]] then
				table.insert( tolinki,{ i,tables[i[1]] } )
			end
		end
		-- link values, first due to possible changes of indices
		for _,v in ipairs( tolinkv ) do
			tables[idx][v[1]] = v[2]
		end
		-- link indices
		for _,v in ipairs( tolinki ) do
			tables[idx][v[2]],tables[idx][v[1]] =  tables[idx][v[1]],nil
		end
	end
	return tables[1]
end

------------------------------------------------------ End Table Load Save

	-- overload fh functions into table
	for strIndex, anyValue in pairs(fh) do
		if type(anyValue) == "function" then
			table[strIndex] = anyValue
		end
	end

	return fh

end -- local function tablex_v3

local tablex  = tablex_v3 ()													-- To access FH table extension module

--[[
@Module:			+fh+encoder_v3
@Author:			Mike Tate
@Version:			3.6
@LastUpdated:	27 Aug 2024
@Description:	Text encoder module for HTML XHTML XML URI UTF8 UTF16 ISO CP1252/ANSI character codings.
@V3.6:				In fh.FileLines(...) cater for empty file;
@V3.5:				Function Prototype Closure version with Lua 5.1 & 5.3 comaptibility.
@V3.4:				Ensure expressions involving gsub return just text parameter.
@V3.3:				Adds UNICODE U+10000 to U+10FFFF UTF-16 Supplementary Planes.
@V3.2:				Update for ANSI & Unicode to ASCII for sorting, Soundex, etc.
@V3.1:				Update for Unicode UTF-16 & UTF-8 and fhConvertANSItoUTF8 & fhConvertUTF8toANSI, name change UTF to UTF8 & CP to ANSI.
@V2.0:				StrUTF8_Encode() replaced by StrUTF_CP1252() for entire UTF-8 range, plus new StrCP1252_ISO().
@V1.0:				Initial version.
]]

local function encoder_v3()

	local fh = {}													-- Local environment table

	local fhVersion = fhGetAppVersion()

	local br_Tag = "<br />"										-- Markup language break tag default
	local br_Lua = "<br ?/?>"										-- Lua pattern for break tag recognition

	local tblCodePage = {}											-- Code Page to XML/XHTML/HTML/URI/UTF8 encodings: http://en.wikipedia.org/wiki/Windows-1252 & 1250 & etc

	-- Control characters "\000" to "\031" for URI & Markup "[%c]" encodings are disallowed except for "\t" to "\r"

	tblCodePage["\000"] = ""	-- NUL
	tblCodePage["\001"] = ""	-- SOH
	tblCodePage["\002"] = ""	-- STX
	tblCodePage["\003"] = ""	-- ETX
	tblCodePage["\004"] = ""	-- EOT
	tblCodePage["\005"] = ""	-- ENQ
	tblCodePage["\006"] = ""	-- ACK
	tblCodePage["\a"] = ""		-- BEL
	tblCodePage["\b"] = ""		-- BS
	tblCodePage["\t"] = "+"	-- HT	space  in Markup	see setURIEncodings() and setMarkupEncodings() below
	tblCodePage["\n"] = "%0A"	-- LF	br_Tag in Markup
	tblCodePage["\v"] = "%0A"	-- VT	br_Tag in Markup
	tblCodePage["\f"] = "%0A"	-- FF	br_Tag in Markup
	tblCodePage["\r"] = "%0D"	-- CR	br_Tag in Markup
	tblCodePage["\014"] = ""	-- SO
	tblCodePage["\015"] = ""	-- SI
	tblCodePage["\016"] = ""	-- DLE
	tblCodePage["\017"] = ""	-- DC1
	tblCodePage["\018"] = ""	-- DC2
	tblCodePage["\019"] = ""	-- DC3
	tblCodePage["\020"] = ""	-- DC4
	tblCodePage["\021"] = ""	-- NAK
	tblCodePage["\022"] = ""	-- SYN
	tblCodePage["\023"] = ""	-- ETB
	tblCodePage["\024"] = ""	-- CAN
	tblCodePage["\025"] = ""	-- EM
	tblCodePage["\026"] = ""	-- SUB
	tblCodePage["\027"] = ""	-- ESC
	tblCodePage["\028"] = ""	-- FS
	tblCodePage["\029"] = ""	-- GS
	tblCodePage["\030"] = ""	-- RS
	tblCodePage["\031"] = ""	-- US

	-- ASCII characters "\032" to "\127" for URI "[%s%p]" encodings: http://en.wikipedia.org/wiki/URL and http://en.wikipedia.org/wiki/Percent-encoding

	tblCodePage[" "] = "+"		-- or "%20"	Space
	tblCodePage["!"] = "%21"	-- Reserved character
	tblCodePage['"'] = "%22"								--	"&quot;" in Markup	see setURIEncodings() and setMarkupEncodings() below
	tblCodePage["#"] = "%23"	-- Reserved character
	tblCodePage["$"] = "%24"	-- Reserved character
	tblCodePage["%"] = "%25"	-- Must be encoded
	tblCodePage["&"] = "%26"	-- Reserved character	-- "&amp;"  in Markup	see setURIEncodings() and setMarkupEncodings() below
	tblCodePage["'"] = "%27"	-- Reserved character	-- "&apos;" in Markup	see setURIEncodings() and setMarkupEncodings() below
	tblCodePage["("] = "%28"	-- Reserved character
	tblCodePage[")"] = "%29"	-- Reserved character
	tblCodePage["*"] = "%2A"	-- Reserved character
	tblCodePage["+"] = "%2B"	-- Reserved character
	tblCodePage[","] = "%2C"	-- Reserved character
--	tblCodePage["-"] = "%2D"	-- Unreserved character not encoded
--	tblCodePage["."] = "%2E"	-- Unreserved character not encoded
	tblCodePage["/"] = "%2F"	-- Reserved character
--	Digits 0 to 9				-- Unreserved characters not encoded
	tblCodePage[":"] = "%3A"	-- Reserved character
	tblCodePage[";"] = "%3B"	-- Reserved character
	tblCodePage["<"] = "%3C"								-- "&lt;"  in Markup	see setURIEncodings() and setMarkupEncodings() below
	tblCodePage["="] = "%3D"	-- Reserved character
	tblCodePage[">"] = "%3E"								-- "&gt;"  in Markup	see setURIEncodings() and setMarkupEncodings() below
	tblCodePage["?"] = "%3F"	-- Reserved character
	tblCodePage["@"] = "%40"	-- Reserved character
--	Letters A to Z				-- Unreserved characters not encoded
	tblCodePage["["] = "%5B"	-- Reserved character
	tblCodePage["\\"]= "%5C"
	tblCodePage["]"] = "%5D"	-- Reserved character
	tblCodePage["^"] = "%5E"
--	tblCodePage["_"] = "%5F"	-- Unreserved character not encoded
	tblCodePage["`"] = "%60"
--	Letters a to z				-- Unreserved characters not encoded
	tblCodePage["{"] = "%7B"
	tblCodePage["|"] = "%7C"
	tblCodePage["}"] = "%7D"
--	tblCodePage["~"] = "%7E"	-- Unreserved character not encoded
	tblCodePage["\127"] = ""	-- DEL

	-- Code Page 1252 Unicode characters "\128" to "\255" for UTF-8 scheme "[€-ÿ]" encodings: http://en.wikipedia.org/wiki/UTF-8

	tblCodePage["€"] = string.char(0xE2,0x82,0xAC)	-- "&euro;"
	tblCodePage["\129"] = ""  -- Undefined
	tblCodePage["‚"] = string.char(0xE2,0x80,0x9A)
	tblCodePage["ƒ"] = string.char(0xC6,0x92)
	tblCodePage["„"] = string.char(0xE2,0x80,0x9E)
	tblCodePage["…"] = string.char(0xE2,0x80,0xA6)
	tblCodePage["†"] = string.char(0xE2,0x80,0xA0)
	tblCodePage["‡"] = string.char(0xE2,0x80,0xA1)
	tblCodePage["ˆ"] = string.char(0xCB,0x86)
	tblCodePage["‰"] = string.char(0xE2,0x80,0xB0)
	tblCodePage["Š"] = string.char(0xC5,0xA0)
	tblCodePage["‹"] = string.char(0xE2,0x80,0xB9)
	tblCodePage["Œ"] = string.char(0xC5,0x92)
	tblCodePage["\141"] = ""  -- Undefined
	tblCodePage["Ž"] = string.char(0xC5,0xBD)
	tblCodePage["\143"] = ""  -- Undefined
	tblCodePage["\144"] = ""  -- Undefined
	tblCodePage["‘"] = string.char(0xE2,0x80,0x98)
	tblCodePage["’"] = string.char(0xE2,0x80,0x99)
	tblCodePage["“"] = string.char(0xE2,0x80,0x9C)
	tblCodePage["”"] = string.char(0xE2,0x80,0x9D)
	tblCodePage["•"] = string.char(0xE2,0x80,0xA2)
	tblCodePage["–"] = string.char(0xE2,0x80,0x93)
	tblCodePage["—"] = string.char(0xE2,0x80,0x94)
	tblCodePage["\152"] = string.char(0xCB,0x9C)	-- Small Tilde
	tblCodePage["™"] = string.char(0xE2,0x84,0xA2)
	tblCodePage["š"] = string.char(0xC5,0xA1)
	tblCodePage["›"] = string.char(0xE2,0x80,0xBA)
	tblCodePage["œ"] = string.char(0xC5,0x93)
	tblCodePage["\157"] = ""  -- Undefined
	tblCodePage["ž"] = string.char(0xC5,0xBE)
	tblCodePage["Ÿ"] = string.char(0xC5,0xB8)
	tblCodePage["\160"] = string.char(0xC2,0xA0)	-- "&nbsp;"	No Break Space
	tblCodePage["¡"] = string.char(0xC2,0xA1)		-- "&iexcl;"
	tblCodePage["¢"] = string.char(0xC2,0xA2)		-- "&cent;"
	tblCodePage["£"] = string.char(0xC2,0xA3)		-- "&pound;"
	tblCodePage["¤"] = string.char(0xC2,0xA4)		-- "&curren;"
	tblCodePage["¥"] = string.char(0xC2,0xA5)		-- "&yen;"
	tblCodePage["¦"] = string.char(0xC2,0xA6)
	tblCodePage["§"] = string.char(0xC2,0xA7)
	tblCodePage["¨"] = string.char(0xC2,0xA8)
	tblCodePage["©"] = string.char(0xC2,0xA9)
	tblCodePage["ª"] = string.char(0xC2,0xAA)
	tblCodePage["«"] = string.char(0xC2,0xAB)
	tblCodePage["¬"] = string.char(0xC2,0xAC)
	tblCodePage["­"] = string.char(0xC2,0xAD)		-- "&shy;"	Soft Hyphen
	tblCodePage["®"] = string.char(0xC2,0xAE)
	tblCodePage["¯"] = string.char(0xC2,0xAF)
	tblCodePage["°"] = string.char(0xC2,0xB0)
	tblCodePage["±"] = string.char(0xC2,0xB1)
	tblCodePage["²"] = string.char(0xC2,0xB2)
	tblCodePage["³"] = string.char(0xC2,0xB3)
	tblCodePage["´"] = string.char(0xC2,0xB4)
	tblCodePage["µ"] = string.char(0xC2,0xB5)
	tblCodePage["¶"] = string.char(0xC2,0xB6)
	tblCodePage["·"] = string.char(0xC2,0xB7)
	tblCodePage["¸"] = string.char(0xC2,0xB8)
	tblCodePage["¹"] = string.char(0xC2,0xB9)
	tblCodePage["º"] = string.char(0xC2,0xBA)
	tblCodePage["»"] = string.char(0xC2,0xBB)
	tblCodePage["¼"] = string.char(0xC2,0xBC)
	tblCodePage["½"] = string.char(0xC2,0xBD)
	tblCodePage["¾"] = string.char(0xC2,0xBE)
	tblCodePage["¿"] = string.char(0xC2,0xBF)
	tblCodePage["À"] = string.char(0xC3,0x80)
	tblCodePage["Á"] = string.char(0xC3,0x81)
	tblCodePage["Â"] = string.char(0xC3,0x82)
	tblCodePage["Ã"] = string.char(0xC3,0x83)
	tblCodePage["Ä"] = string.char(0xC3,0x84)
	tblCodePage["Å"] = string.char(0xC3,0x85)
	tblCodePage["Æ"] = string.char(0xC3,0x86)
	tblCodePage["Ç"] = string.char(0xC3,0x87)
	tblCodePage["È"] = string.char(0xC3,0x88)
	tblCodePage["É"] = string.char(0xC3,0x89)
	tblCodePage["Ê"] = string.char(0xC3,0x8A)
	tblCodePage["Ë"] = string.char(0xC3,0x8B)
	tblCodePage["Ì"] = string.char(0xC3,0x8C)
	tblCodePage["Í"] = string.char(0xC3,0x8D)
	tblCodePage["Î"] = string.char(0xC3,0x8E)
	tblCodePage["Ï"] = string.char(0xC3,0x8F)
	tblCodePage["Ð"] = string.char(0xC3,0x90)
	tblCodePage["Ñ"] = string.char(0xC3,0x91)
	tblCodePage["Ò"] = string.char(0xC3,0x92)
	tblCodePage["Ó"] = string.char(0xC3,0x93)
	tblCodePage["Ô"] = string.char(0xC3,0x94)
	tblCodePage["Õ"] = string.char(0xC3,0x95)
	tblCodePage["Ö"] = string.char(0xC3,0x96)
	tblCodePage["×"] = string.char(0xC3,0x97)
	tblCodePage["Ø"] = string.char(0xC3,0x98)
	tblCodePage["Ù"] = string.char(0xC3,0x99)
	tblCodePage["Ú"] = string.char(0xC3,0x9A)
	tblCodePage["Û"] = string.char(0xC3,0x9B)
	tblCodePage["Ü"] = string.char(0xC3,0x9C)
	tblCodePage["Ý"] = string.char(0xC3,0x9D)
	tblCodePage["Þ"] = string.char(0xC3,0x9E)
	tblCodePage["ß"] = string.char(0xC3,0x9F)
	tblCodePage["à"] = string.char(0xC3,0xA0)
	tblCodePage["á"] = string.char(0xC3,0xA1)
	tblCodePage["â"] = string.char(0xC3,0xA2)
	tblCodePage["ã"] = string.char(0xC3,0xA3)
	tblCodePage["ä"] = string.char(0xC3,0xA4)
	tblCodePage["å"] = string.char(0xC3,0xA5)
	tblCodePage["æ"] = string.char(0xC3,0xA6)
	tblCodePage["ç"] = string.char(0xC3,0xA7)
	tblCodePage["è"] = string.char(0xC3,0xA8)
	tblCodePage["é"] = string.char(0xC3,0xA9)
	tblCodePage["ê"] = string.char(0xC3,0xAA)
	tblCodePage["ë"] = string.char(0xC3,0xAB)
	tblCodePage["ì"] = string.char(0xC3,0xAC)
	tblCodePage["í"] = string.char(0xC3,0xAD)
	tblCodePage["î"] = string.char(0xC3,0xAE)
	tblCodePage["ï"] = string.char(0xC3,0xAF)
	tblCodePage["ð"] = string.char(0xC3,0xB0)
	tblCodePage["ñ"] = string.char(0xC3,0xB1)
	tblCodePage["ò"] = string.char(0xC3,0xB2)
	tblCodePage["ó"] = string.char(0xC3,0xB3)
	tblCodePage["ô"] = string.char(0xC3,0xB4)
	tblCodePage["õ"] = string.char(0xC3,0xB5)
	tblCodePage["ö"] = string.char(0xC3,0xB6)
	tblCodePage["÷"] = string.char(0xC3,0xB7)
	tblCodePage["ø"] = string.char(0xC3,0xB8)
	tblCodePage["ù"] = string.char(0xC3,0xB9)
	tblCodePage["ú"] = string.char(0xC3,0xBA)
	tblCodePage["û"] = string.char(0xC3,0xBB)
	tblCodePage["ü"] = string.char(0xC3,0xBC)
	tblCodePage["ý"] = string.char(0xC3,0xBD)
	tblCodePage["þ"] = string.char(0xC3,0xBE)
	tblCodePage["ÿ"] = string.char(0xC3,0xBF)

	-- Set XML/XHTML/HTML "[%c\"&'<>]" Markup encodings: http://en.wikipedia.org/wiki/XML and http://en.wikipedia.org/wiki/HTML
	local function setMarkupEncodings()
		tblCodePage["\t"] = " "		-- HT	"\t" to "\r" are treated as white space in Markup Languages by default
		tblCodePage["\n"] = br_Tag	-- LF	
		tblCodePage["\v"] = br_Tag	-- VT	line break tag	"<br>" or "<br >" or "<br/>" or "<br />" is better
		tblCodePage["\f"] = br_Tag	-- FF
		tblCodePage["\r"] = br_Tag	-- CR
		tblCodePage['"']  = "&quot;"
		tblCodePage["&"]  = "&amp;"
		tblCodePage["'"]  = "&apos;"
		tblCodePage["<"]  = "&lt;"
		tblCodePage[">"]  = "&gt;"
	end -- local function setMarkupEncodings

	-- Set URI/URL/URN "[%s%p]" encodings: http://en.wikipedia.org/wiki/URL and http://en.wikipedia.org/wiki/Percent-encoding
	local function setURIEncodings()
		tblCodePage["\t"] = "+"		-- HT	space
		tblCodePage["\n"] = "%0A" 	-- LF	newline
		tblCodePage["\v"] = "%0A" 	-- VT	newline
		tblCodePage["\f"] = "%0A" 	-- FF	newline
		tblCodePage["\r"] = "%0D" 	-- CR	return
		tblCodePage['"']  = "%22"
		tblCodePage["&"]  = "%26"
		tblCodePage["'"]  = "%27"
		tblCodePage["<"]  = "%3C"
		tblCodePage[">"]  = "%3E"
	end -- local function setURIEncodings

	-- Encode characters according to gsub pattern & lookup table --
	local function strEncode(strText,strPattern,tblPattern)
		return ( (strText or ""):gsub(strPattern,tblPattern) )		-- V3.4
	end -- local function strEncode

	-- Encode CP1252/ANSI characters into UTF-8 codes --
	function fh.StrANSI_UTF8(strText)
		if fhVersion > 5 then
			strText = fhConvertANSItoUTF8(strText)
		else
			strText = strEncode(strText,"[\127-ÿ]",tblCodePage)
		end
		return strText
	end -- function StrANSI_UTF8

	function fh.StrCP_UTF(strText)			-- Legacy
		return fh.StrANSI_UTF8(strText)
	end -- function StrCP1252_UTF8

	function fh.StrCP1252_UTF(strText)		-- Legacy
		return fh.StrANSI_UTF8(strText)
	end -- function StrCP1252_UTF

	-- Encode CP1252/ANSI or UTF-8 characters into UTF-8 --
	function fh.StrEncode_UTF8(strText)
		if stringx.encoding() == "ANSI" then
			return fh.StrANSI_UTF8(strText)
		else
			return strText
		end
	end -- function StrEncode_UTF8

	-- Encode CP1252/ANSI characters into XML/XHTML/HTML/UTF8 codes --
	local strANSI_XML = "[%z\001-\031\"&'<>\127-ÿ]"
	if fhVersion > 6 then											-- Cater for Lua 5.1 %z or Lua 5.3 \0
			strANSI_XML = "[\000-\031\"&'<>\127-ÿ]"
	end

	function fh.StrANSI_XML(strText)
		setMarkupEncodings()
		strText = (strText or ""):gsub(br_Lua,"\n")			-- Convert <br> & <br > & <br/> & <br /> to \n that becomes br_Tag
		strText = strEncode(strText,strANSI_XML,tblCodePage)
		return strText
	end -- function StrANSI_XML

	function StrCP_XML(strText)			-- Legacy
		return fh.StrANSI_XML(strText)
	end -- function StrCP_XML

	function StrCP1252_XML(strText)		-- Legacy
		return fh.StrANSI_XML(strText)
	end -- function StrCP1252_XML

	-- Encode UTF-8 ASCII characters into XML/XHTML/HTML codes --
	local strUTF8_XML = "[%z\001-\031\"&'<>\127]"
	if fhVersion > 6 then											-- Cater for Lua 5.1 %z or Lua 5.3 \0
			strUTF8_XML = "[\000-\031\"&'<>\127]"
	end

	function fh.StrUTF8_XML(strText)
		setMarkupEncodings()
		strText = (strText or ""):gsub(br_Lua,"\n")			-- Convert <br> & <br > & <br/> & <br /> to \n that becomes br_Tag
		strText = strEncode(strText,strUTF8_XML,tblCodePage)
		return strText
	end -- function StrUTF8_XML

	-- Encode CP1252/ANSI or UTF-8 ASCII characters into XML/XHTML/HTML codes --
	function fh.StrEncode_XML(strText)
		if stringx.encoding() == "ANSI" then
			return fh.StrANSI_XML(strText)
		else
			return fh.StrUTF8_XML(strText)
		end
	end -- function StrEncode_XML

	-- Encode Item Text characters into XML/HTML/UTF-8 codes --
	function fh.StrGetItem_XML(ptrItem,strTags)
		return fh.StrEncode_XML(fhGetItemText(ptrItem,strTags))
	end -- function StrGetItem_XML

	-- Encode CP1252/ANSI characters into URI codes --
	function fh.StrANSI_URI(strText)
		setURIEncodings()
		strText = (strText or ""):gsub(br_Lua,"\n")			-- Convert <br> & <br > & <br/> & <br /> to \n that becomes %0A
		strText = strEncode(strText,"[^0-9A-Za-z]",tblCodePage)
		return strText
	end -- function StrANSI_URI

	function fh.StrCP_URI(strText)
		return fh.StrANSI_URI(strText)
	end -- function StrCP_URI

	function fh.StrCP1252_URI(strText)
		return fh.StrANSI_URI(strText)
	end -- function StrCP1252_URI

	-- Encode UTF-8 ASCII characters into URI codes --
	local strUTF8_URI = "[%z\001-\127]"
	if fhVersion > 6 then											-- Cater for Lua 5.1 %z or Lua 5.3 \0
			strUTF8_URI = "[\000-\127]"
	end

	function fh.StrUTF8_URI(strText)
		setURIEncodings()
		strText = (strText or ""):gsub(br_Lua,"\n")			-- Convert <br> & <br > & <br/> & <br /> to \n that becomes br_Tag
		strText = strEncode(strText,strUTF8_URI,tblCodePage)
		return strText
	end -- function StrUTF8_URI

	-- Encode CP1252/ANSI or UTF-8 ASCII characters into URI codes --
	function fh.StrEncode_URI(strText)
		if stringx.encoding() == "ANSI" then
			return fh.StrANSI_URI(strText)
		else
			return fh.StrUTF8_URI(strText)
		end
	end -- function StrEncode_URI

	function fh.StrUTF8_Encode(strText) -- Legacy from V1.0
		return fh.StrUTF8_ANSI(strText)
	end -- function StrUTF8_Encode

	-- Encode UTF-8 bytes into single CP1252/ANSI character V2.0 upvalues --
	local strByteRange = "["..string.char(0xC0).."-"..string.char(0xFF).."]"
	local tblBytePoint = {0xC0;0xE0;0xF0;0xF8;0xFC;}		-- Byte codes for 2-byte, 3-byte, 4-byte, 5-byte, 6-byte UTF-8
	local tblUTF8 = {}
	for strByte = string.byte("€"), string.byte("ÿ") do
		local strChar = string.char(strByte)					-- Use CodePage to UTF-8 table to populate UTF-8 to CodePage table
		local strCode = tblCodePage[strChar]
		tblUTF8[strCode] = strChar
	end

	-- Encode UTF-8 bytes into single CP1252/ANSI character --
	function fh.StrUTF8_ANSI(strText)
		strText = strText or ""
		if fhVersion > 5 then return fhConvertUTF8toANSI(strText) end
		if strText:match(strByteRange) then						-- If text contains characters that need translating then
			local intChar = 0										-- Input character index
			local strChar = ""										-- Current character
			local strCode = ""										-- UTF-8 multi-byte code
			local tblLine = {}										-- Translated output line
			repeat
				intChar = intChar + 1								-- Step through each character in text
				strChar = strText:sub(intChar,intChar)
				if strChar:match(strByteRange) then				-- Convert UTF-8 bytes into CP character
					strCode = strChar								-- First UTF-8 byte code, whose top bits say how many bytes to append
					for intByte, strByte in ipairs(tblBytePoint) do
						if string.byte(strChar) >= strByte then
							intChar = intChar + 1					-- Append next UTF-8 byte code character
							strCode = strCode..strText:sub(intChar,intChar)
						else
							break
						end 
					end
					strChar = tblUTF8[strCode] or "¿"			-- Translate UTF-8 code into CP character
				end
				table.insert(tblLine,strChar)					-- Accumulate output char by char
			until intChar >= #strText
			strText = table.concat(tblLine)
		end
		return strText
	end -- function StrUTF8_ANSI

	function fh.StrUTF_CP(strText)			-- Legacy
		return fh.StrUTF8_ANSI(strText)
	end -- function StrUTF_CP

	function fh.StrUTF_CP1252(strText)		-- Legacy
		return fh.StrUTF8_ANSI(strText)
	end -- function StrUTF_CP1252

	-- Encode CP1252/ANSI or UTF-8 characters into ANSI --
	function fh.StrEncode_ANSI(strText)
		if stringx.encoding() == "ANSI" then
			return strText or ""
		else
			return fh.StrUTF8_ANSI(strText)
		end
	end -- function StrEncode_ANSI

	-- Set ISO-8859-1 "[\127-Ÿ]" encodings: http://en.wikipedia.org/wiki/ISO/IEC_8859-1
	local tblISO8859 = { }
	tblISO8859["\127"]=""  -- DEL
	tblISO8859["€"] = "EUR"	
	tblISO8859["\129"]=""  -- Undefined
	tblISO8859["‚"] = "¸"
	tblISO8859["ƒ"] = "f"
	tblISO8859["„"] = "¸¸"
	tblISO8859["…"] = "..."
	tblISO8859["†"] = "+"
	tblISO8859["‡"] = "±"
	tblISO8859["ˆ"] = "^"
	tblISO8859["‰"] = "%"
	tblISO8859["Š"] = "S"
	tblISO8859["‹"] = "<"
	tblISO8859["Œ"] = "OE"
	tblISO8859["\141"]=""  -- Undefined
	tblISO8859["Ž"] = "Z"
	tblISO8859["\143"]=""  -- Undefined
	tblISO8859["\144"]=""  -- Undefined
	tblISO8859["‘"] = "'"
	tblISO8859["’"] = "'"
	tblISO8859["“"] = '"'
	tblISO8859["”"] = '"'
	tblISO8859["•"] = "º"
	tblISO8859["–"] = "-"
	tblISO8859["—"] = "-"
	tblISO8859["\152"]="~" -- Small Tilde
	tblISO8859["™"] = "TM"
	tblISO8859["š"] = "s"
	tblISO8859["›"] = ">"
	tblISO8859["œ"] = "oe"
	tblISO8859["\157"]=""  -- Undefined
	tblISO8859["ž"] = "z"
	tblISO8859["Ÿ"] = "Y"

	-- Encode CP1252/ANSI characters into ISO-8859-1 codes --
	function fh.StrANSI_ISO(strText)
		return strEncode(strText,"[\127-Ÿ]",tblISO8859)
	end -- function StrANSI_ISO

	function fh.StrCP_ISO(strText)			-- Legacy
		return fh.StrANSI_ISO(strText)
	end -- function StrCP_ISO

	function fh.StrCP1252_ISO(strText)		-- Legacy
		return fh.StrANSI_ISO(strText)
	end -- function StrCP1252_ISO

	function fh.StrUTF8_ISO(strText)
		return fh.StrANSI_ISO(fh.StrUTF8_ANSI(strText))
	end -- function StrUTF8_ISO

	-- Encode CP1252/ANSI or UTF-8 ASCII characters into ISO-8859-1 codes --
	function fh.StrEncode_ISO(strText)
		if stringx.encoding() == "ANSI" then
			return fh.StrANSI_ISO(strText)
		else
			return fh.StrUTF8_ISO(strText)
		end
	end -- function StrEncode_ISO

	-- Convert UTF-8 bytes to a UTF-16 word or pair --
	local tblByte = {}
	local tblLead = { 0x80; 0xC0; 0xE0; 0xF0; 0xF8; 0xFC; }

	function fh.StrUtf8toUtf16(strChar)

		-- Convert any UTF-8 multibytes to UTF-16 --
		local function strUtf8()
			if #tblByte > 0 then
				local intUtf16 = 0
				for intIndex, intByte in ipairs (tblByte) do	-- Convert UTF-8 bytes to UNICODE U+0080 to U+10FFFF
					if intIndex == 1 then
						intUtf16 = intByte - tblLead[#tblByte]
					else
						intUtf16 = intUtf16 * 0x40 + intByte - 0x80
					end
				end
				if intUtf16 > 0xFFFF then							-- U+10000 to U+10FFFF Supplementary Planes -- V2.6
					tblByte = {}
					intUtf16 = intUtf16 - 0x10000
					local intLow10 = 0xDC00 + ( intUtf16 % 0x400 )				-- Low 16-bit Surrogate
					local intTop10 = 0xD800 + math.floor( intUtf16 / 0x400 )	-- High 16-bit Surrogate
					local intChar1 = intTop10 % 0x100
					local intChar2 = math.floor( intTop10 / 0x100 )
					local intChar3 = intLow10 % 0x100
					local intChar4 = math.floor( intLow10 / 0x100 )
					return string.char(intChar1,intChar2,intChar3,intChar4)	-- Surrogate 16-bit Pair
				end
				if intUtf16 < 0xD800								-- U+0080 to U+FFFF (except U+D800 to U+DFFF) -- V2.6
				or intUtf16 > 0xDFFF then							-- Basic Multilingual Plane
					tblByte = {}
					local intChar1 = intUtf16 % 0x100
					local intChar2 = math.floor( intUtf16 / 0x100 )
					return string.char(intChar1,intChar2)		-- BPL 16-bit
				end
				local strUtf8 = ""									-- U+D800 to U+DFFF Reserved Code Points -- V2.6
				for intIndex, intByte in ipairs (tblByte) do
					strUtf8 = strUtf8..string.format("%.2X ",intByte)
				end
				local strUtf16 = string.format("%.4X ",intUtf16)
				fhMessageBox("\n UTF-16 Reserved Code Point U+D800 to U+DFFF \n UTF-16 = "..strUtf16.."  UTF-8 = "..strUtf8.."\n Character will be replaced by a question mark. \n","MB_OK","MB_ICONEXCLAMATION")
				tblByte = {}
				return "?\0"
			end
			return ""
		end -- local function strUtf8

		local intUtf8 = string.byte(strChar)
		if intUtf8 < 0x80 then										-- U+0000 to U+007F (ASCII)
			return strUtf8()..strChar.."\0"						-- Previous UTF-8 multibytes + current ASCII char
		end
		if intUtf8 >= 0xC0 then									-- Next UTF-8 multibyte start
			local strUtf16 = strUtf8()
			table.insert(tblByte,intUtf8)
			return strUtf16											-- Previous UTF-8 multibytes
		end
		table.insert(tblByte,intUtf8)
		return ""
	end -- function StrUtf8toUtf16

	-- Encode UTF-8 bytes into UTF-16 words --
	function fh.StrUTF8_UTF16(strText)
		tblByte = {}													-- (0xFF) flushes last UTF-8 character
		return ( ((strText or "")..string.char(0xFF)):gsub("(.)",fh.StrUtf8toUtf16) )	-- V3.4
	end -- function StrUTF8_UTF16

	-- Encode CP1252/ANSI or UTF-8 characters into UTF-16 words --
	function fh.StrEncode_UTF16(strText)
		if stringx.encoding() == "ANSI" then
			strText = fh.StrANSI_UTF8(strText)
		end
		return fh.StrUTF8_UTF16(strText)
	end -- function StrEncode_UTF16

	local intTop10 = 0

	-- Convert a UTF-16 word or pair to UTF-8 bytes --
	function fh.StrUtf16toUtf8(strChar1,strChar2)
		local intUtf16 = string.byte(strChar2) * 0x100 + string.byte(strChar1)
		if intUtf16 < 0x80 then									-- U+0000 to U+007F (ASCII) 
			return string.char(intUtf16)
		end
		if intUtf16 < 0x800 then									-- U+0080 to U+07FF
			local intByte1 = intUtf16 % 0x40
			intUtf16 = math.floor( intUtf16 / 0x40 )
			local intByte2 = intUtf16
			return string.char( intByte2 + 0xC0, intByte1 + 0x80 )
		end
		if intUtf16 < 0xD800										-- U+0800 to U+FFFF
		or intUtf16 > 0xDFFF then
			local intByte1 = intUtf16 % 0x40
			intUtf16 = math.floor( intUtf16 / 0x40 )
			local intByte2 = intUtf16 % 0x40
			intUtf16 = math.floor( intUtf16 / 0x40 )
			local intByte3 = intUtf16
			return string.char( intByte3 + 0xE0, intByte2 + 0x80, intByte1 + 0x80 )
		end
		if intUtf16 < 0xDC00 then									-- U+10000 to U+10FFFF High 16-bit Surrogate Supplementary Planes -- V2.6
			intTop10 = ( intUtf16 - 0xD800 ) * 0x400 + 0x10000
			return ""
		end
		intUtf16 = intUtf16 - 0xDC00 + intTop10				-- U+10000 to U+10FFFF Low 16-bit Surrogate Supplementary Planes -- V2.6
		local intByte1 = intUtf16 % 0x40
		intUtf16 = math.floor( intUtf16 / 0x40 )
		local intByte2 = intUtf16 % 0x40
		intUtf16 = math.floor( intUtf16 / 0x40 )
		local intByte3 = intUtf16 % 0x40
		intUtf16 = math.floor( intUtf16 / 0x40 )
		local intByte4 = intUtf16
		return string.char( intByte4 + 0xF0, intByte3 + 0x80, intByte2 + 0x80, intByte1 + 0x80 )
	end -- function StrUtf16toUtf8

	-- Encode UTF-16 words into UTF-8 bytes --
	function fh.StrUTF16_UTF8(strText)
		return ( (strText or ""):gsub("(.)(.)",fh.StrUtf16toUtf8) )	-- V3.4
	end -- function StrUTF16_UTF8

	-- Encode UTF-16 words into ANSI characters --
	function fh.StrUTF16_ANSI(strText)
		return fh.StrUTF8_ANSI(fh.StrUTF16_UTF8(strText))
	end -- function StrUTF16_ANSI

	-- Read UTF-16/UTF-8/ANSI file converted to chosen encoding via line iterator --
	local strUtf16 = "^.%z"
	if fhVersion > 6 then											-- Cater for Lua 5.1 %z or Lua 5.3 \0
			strUtf16 = "^.\0"
	end

	function fh.FileLines(strFileName,strEncoding)			-- Derived from http://lua-users.org/wiki/EnhancedFileLines
		local bomUtf16= "^"..string.char(0xFF,0xFE)			-- "ÿþ"
		local bomUtf8 = "^"..string.char(0xEF,0xBB,0xBF)	-- ""
		local fncConv = tostring									-- Function to convert input to current encoding
		local intHead = 1											-- Index to start of current text line
		local intLump = 1024
		local fHandle = general.OpenFile(strFileName,"rb")
		local strText = fHandle:read(1024) or ""				-- Read first lump from file and cater for empty file
		local intBOM = 0
		strEncoding = strEncoding or string.encoding()
		if strText:match(bomUtf16)
		or strText:match(strUtf16) then
			strText,intBOM = strText:gsub(bomUtf16,"") 		-- Strip UTF-16 BOM
			if strEncoding == "ANSI" then						-- Define UTF-16 conversion to current encoding
				fncConv = fh.StrUTF16_ANSI
			else
				fncConv = fh.StrUTF16_UTF8
			end
		elseif strText:match(bomUtf8) then
			strText,intBOM = strText:gsub(bomUtf8,"")			-- Strip UTF-8 BOM
			if strEncoding == "ANSI" then 						-- Define UTF-8 conversion to current encoding
				fncConv = fh.StrUTF8_ANSI
			end
		else
			if strEncoding == "UTF-8" and #strText > 0 then	-- Define ANSI conversion to current encoding and cater for empty file
				fncConv = fh.StrANSI_UTF8
			end
		end
		strText = fncConv(strText)								-- Convert first lump of text

		return function()											-- Iterator function
			local intTail,strTail									-- Index to end of current text line, and terminating characters
			while true do
				intTail, strTail = strText:match("()([\r\n].)",intHead)
				if intTail or not fHandle then
					if intHead > 1 then intLump = 0 end
					break												-- End of line or end of file
				elseif fHandle then
					local strLump = fHandle:read(1024)			-- Read next lump from file
					if strLump then									-- Strip old text and add converted lump
						strText = strText:sub(intHead)..fncConv(strLump)
						intHead = 1
						intLump = 1024
					else
						assert(fHandle:close())					-- End of file
						fHandle = nil
					end
				end
			end
			if not intTail then
				intTail = #strText									-- Last fragment of file
			elseif strTail == "\r\n" then
				intTail = intTail + 1								-- Adjust tail for both \r & \n
			end
			local strLine = strText:sub(intHead,intTail)		-- Extract line from text
			intHead = intTail + 1
			if #strLine > 0 then									-- Return pruned line, tail chars, lump bytes read
				local strBody, strTail = strLine:match("^(.-)([\r\n]+)$")
				return strBody, strTail, intLump
			end    
		end
	end -- function FileLines

	-- Set "[€-ÿ]" ASCII encodings same as Unidecode below
	local tblASCII = { }
	tblASCII["€"] = "=E"
	tblASCII["\129"]="" -- Undefined
	tblASCII["‚"] = ","
	tblASCII["ƒ"] = "f"
	tblASCII["„"] = ",,"
	tblASCII["…"] = "..."
	tblASCII["†"] = "|+"
	tblASCII["‡"] = "|++"
	tblASCII["ˆ"] = "^"
	tblASCII["‰"] = "%0"
	tblASCII["Š"] = "S"
	tblASCII["‹"] = "<"
	tblASCII["Œ"] = "OE"
	tblASCII["\141"]="" -- Undefined
	tblASCII["Ž"] = "Z"
	tblASCII["\143"]="" -- Undefined
	tblASCII["\144"]="" -- Undefined
	tblASCII["‘"] = "'"
	tblASCII["’"] = "'"
	tblASCII["“"] = "\""
	tblASCII["”"] = "\""
	tblASCII["•"] = "*"
	tblASCII["–"] = "-"
	tblASCII["—"] = "--"
	tblASCII["\152"]="~" -- Small Tilde
	tblASCII["™"] = "TM"
	tblASCII["š"] = "s"
	tblASCII["›"] = ">"
	tblASCII["œ"] = "oe"
	tblASCII["\157"]="" -- Undefined
	tblASCII["ž"] = "z"
	tblASCII["Ÿ"] = "Y"
	tblASCII["\160"]=" " -- "&nbsp;" No Break Space
	tblASCII["¡"] = "!"  -- "&iexcl;"
	tblASCII["¢"] = "=c" -- "&cent;"
	tblASCII["£"] = "=L" -- "&pound;"
	tblASCII["¤"] = "=$" -- "&curren;"
	tblASCII["¥"] = "=Y" -- "&yen;"
	tblASCII["¦"] = "|"
	tblASCII["§"] = "=SS"
	tblASCII["¨"] = "\""
	tblASCII["©"] = "(C)"
	tblASCII["ª"] = "a"
	tblASCII["«"] = "<<"
	tblASCII["¬"] = "-"
	tblASCII["­"] = "-"  -- "&shy;" Soft Hyphen
	tblASCII["®"] = "(R)"
	tblASCII["¯"] = "-"
	tblASCII["°"] = "=o"
	tblASCII["±"] = "+-"
	tblASCII["²"] = "2"
	tblASCII["³"] = "3"
	tblASCII["´"] = "'"
	tblASCII["µ"] = "=u"
	tblASCII["¶"] = "=p"
	tblASCII["·"] = "*"
	tblASCII["¸"] = ","
	tblASCII["¹"] = "1"
	tblASCII["º"] = "o"
	tblASCII["»"] = ">>"
	tblASCII["¼"] = "1/4"
	tblASCII["½"] = "1/2"
	tblASCII["¾"] = "3/4"
	tblASCII["¿"] = "?"
	tblASCII["À"] = "A"
	tblASCII["Á"] = "A"
	tblASCII["Â"] = "A"
	tblASCII["Ã"] = "A"
	tblASCII["Ä"] = "A"
	tblASCII["Å"] = "A"
	tblASCII["Æ"] = "AE"
	tblASCII["Ç"] = "C"
	tblASCII["È"] = "E"
	tblASCII["É"] = "E"
	tblASCII["Ê"] = "E"
	tblASCII["Ë"] = "E"
	tblASCII["Ì"] = "I"
	tblASCII["Í"] = "I"
	tblASCII["Î"] = "I"
	tblASCII["Ï"] = "I"
	tblASCII["Ð"] = "D"
	tblASCII["Ñ"] = "N"
	tblASCII["Ò"] = "O"
	tblASCII["Ó"] = "O"
	tblASCII["Ô"] = "O"
	tblASCII["Õ"] = "O"
	tblASCII["Ö"] = "O"
	tblASCII["×"] = "*"
	tblASCII["Ø"] = "O"
	tblASCII["Ù"] = "U"
	tblASCII["Ú"] = "U"
	tblASCII["Û"] = "U"
	tblASCII["Ü"] = "U"
	tblASCII["Ý"] = "Y"
	tblASCII["Þ"] = "TH"
	tblASCII["ß"] = "ss"
	tblASCII["à"] = "a"
	tblASCII["á"] = "a"
	tblASCII["â"] = "a"
	tblASCII["ã"] = "a"
	tblASCII["ä"] = "a"
	tblASCII["å"] = "a"
	tblASCII["æ"] = "ae"
	tblASCII["ç"] = "c"
	tblASCII["è"] = "e"
	tblASCII["é"] = "e"
	tblASCII["ê"] = "e"
	tblASCII["ë"] = "e"
	tblASCII["ì"] = "i"
	tblASCII["í"] = "i"
	tblASCII["î"] = "i"
	tblASCII["ï"] = "i"
	tblASCII["ð"] = "d"
	tblASCII["ñ"] = "n"
	tblASCII["ò"] = "o"
	tblASCII["ó"] = "o"
	tblASCII["ô"] = "o"
	tblASCII["õ"] = "o"
	tblASCII["ö"] = "o"
	tblASCII["÷"] = "/"
	tblASCII["ø"] = "o"
	tblASCII["ù"] = "u"
	tblASCII["ú"] = "u"
	tblASCII["û"] = "u"
	tblASCII["ü"] = "u"
	tblASCII["ý"] = "y"
	tblASCII["þ"] = "th"
	tblASCII["ÿ"] = "y"

	-- Encode CP1252/ANSI characters into ASCII codes [\000-\127] --
	function fh.StrANSI_ASCII(strText)
		return strEncode(strText,"[€-ÿ]",tblASCII)
	end -- function StrANSI_ASCII

	--[=[
		Unidecode converts each codepoint into a few ASCII characters.
		Lookup table indexed by codepoint [0x0000]-[0xFFFF] gives an ASCII string.
		i.e. strASCII = Unidecode[intByte2][intByte1] or "=?" allowing for partially populated table.
		See http://search.cpan.org/dist/Text-Unidecode/ and follow Browse to:
		See http://cpansearch.perl.org/src/SBURKE/Text-Unidecode-1.22/lib/Text/Unidecode/
		where each x??.pm gives 256 ASCII conversions.
		Start with the first few European accented characters, and add the others later.
	--]=]

	local Unidecode = { }

	function fh.StrUnidecode(strChar1,strChar2)				-- Decode UTF-16 byte pair into ASCII characters
		return Unidecode[string.byte(strChar2)][string.byte(strChar1)] or "=?"
	end  -- function StrUnidecode

	-- Encode UTF-8 characters into ASCII codes [\000-\126] --
	function fh.StrUTF8_ASCII(strText)
		strText = fh.StrUTF8_UTF16(strText)						-- Convert to UTF-16 Unicode and then to ASCII
		return ( strText:gsub("(.)(.)",fh.StrUnidecode) )
	end -- function StrUTF8_ASCII

	-- Encode CP1252/ANSI or UTF-8 into ASCII codes [\000-\126] --
	function fh.StrEncode_ASCII(strText)
		if stringx.encoding() == "ANSI" then
			return fh.StrANSI_ASCII(strText)
		else
			return fh.StrUTF8_ASCII(strText)
		end
	end -- function StrEncode_ASCII

	-- Set markup language break tag --
	function fh.SetBreakTag(br_New)
		if not (br_New or ""):match(br_Lua) then				-- Ensure new break tag is "<br>" or "<br >" or "<br/>" or "<br />"
			br_New = "<br />"
		end
		br_Tag = br_New
	end -- function SetBreakTag

	for intByte = 0x00, 0xFF do Unidecode[intByte] = { } end

	Unidecode[0x00] =
	{[0]="\00";"\01";"\02";"\03";"\04";"\05";"\06";"\a";"\b";"\t";"\n";"\v";"\f";"\r";"\14";"\15";"\16";"\17";"\18";"\19";"\20";"\21";"\22";"\23";"\24";"\25";"\26";"\27";"\28";"\29";"\30";"\31";
		" ";"!";'"';"#";"$";"%";"&";"'";"(";")";"*";"+";",";"-";".";"/";"0";"1";"2";"3";"4";"5";"6";"7";"8";"9";":";";";"<";"=";">";"?";							-- 0x20 to 0x3F
		"@";"A";"B";"C";"D";"E";"F";"G";"H";"I";"J";"K";"L";"M";"N";"O";"P";"Q";"R";"S";"T";"U";"V";"W";"X";"Y";"Z";"[";"\\";"]";"^";"_";							-- 0x40 to 0x5F
		"`";"a";"b";"c";"d";"e";"f";"g";"h";"i";"j";"k";"l";"m";"n";"o";"p";"q";"r";"s";"t";"u";"v";"w";"x";"y";"z";"{";"|";"}";"~";"\127";							-- 0x60 to 0x7F
		""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; ""; "";								-- 0x80 to 0x9F
		" ";"!";"=c";"=L";"=$";"=Y";"|";"=SS";'"';"(C)";"a";"<<";"-";"-";"(R)";"-";"=o";"+-";"2";"3";"'";"=u";"=P";"*";",";"1";"o";">>";"1/4";"1/2";"3/4";"?";	-- 0xA0 to 0xBF
		"A";"A";"A";"A";"A";"A";"AE";"C";"E";"E";"E";"E";"I";"I";"I";"I";"D";"N";"O";"O";"O";"O";"O";"*";"O";"U";"U";"U";"U";"Y";"TH";"ss"; 						-- 0xC0 to 0xDF
		"a";"a";"a";"a";"a";"a";"ae";"c";"e";"e";"e";"e";"i";"i";"i";"i";"d";"n";"o";"o";"o";"o";"o";"/";"o";"u";"u";"u";"u";"y";"th";"y";							-- 0xE0 to 0xFF
	}
	Unidecode[0x01] =
	{[0]="A";"a";"A";"a";"A";"a";"C";"c";"C";"c";"C";"c";"C";"c";"D";"d";"D";"d";"E";"e";"E";"e";"E";"e";"E";"e";"E";"e";"G";"g";"G";"g";							-- 0x00 to 0x1F
		"G";"g";"G";"g";"H";"h";"H";"h";"I";"i";"I";"i";"I";"i";"I";"i";"I";"i";"IJ";"ij";"J";"j";"K";"k";"k";"L";"l";"L";"l";"L";"l";"L";							-- 0x20 to 0x3F
		"l";"L";"l";"N";"n";"N";"n";"N";"n";"'n";"ng";"NG";"O";"o";"O";"o";"O";"o";"OE";"oe";"R";"r";"R";"r";"R";"r";"S";"s";"S";"s";"S";"s";						-- 0x40 to 0x5F
		"S";"s";"T";"t";"T";"t";"T";"t";"U";"u";"U";"u";"U";"u";"U";"u";"U";"u";"U";"u";"W";"w";"Y";"y";"Y";"Z";"z";"Z";"z";"Z";"z";"s";							-- 0x60 to 0x7F
		"b";"B";"B";"b";"6";"6";"O";"C";"c";"D";"D";"D";"d";"d";"3";"@";"E";"F";"f";"G";"G";"hv";"I";"I";"K";"k";"l";"l";"W";"N";"n";"O";							-- 0x80 to 0x9F
		"O";"o";"OI";"oi";"P";"p";"YR";"2";"2";"SH";"sh";"t";"T";"t";"T";"U";"u";"Y";"V";"Y";"y";"Z";"z";"ZH";"ZH";"zh";"zh";"2";"5";"5";"ts";"w";				-- 0xA0 to 0xBF
		"|";"||";"|=";"!";"DZ";"Dz";"dz";"LJ";"Lj";"lj";"NJ";"Nj";"nj";"A";"a";"I";"i";"O";"o";"U";"u";"U";"u";"U";"u";"U";"u";"U";"u";"@";"A";"a";				-- 0xC0 to 0xDF
		"A";"a";"AE";"ae";"G";"g";"G";"g";"K";"k";"O";"o";"O";"o";"ZH";"zh";"j";"DZ";"Dz";"dz";"G";"g";"HV";"W";"N";"n";"A";"a";"AE";"ae";"O";"o";				-- 0xE0 to 0xFF
	}
	Unidecode[0x02] =
	{[0]="A";"a";"A";"a";"E";"e";"E";"e";"I";"i";"I";"i";"O";"o";"O";"o";"R";"r";"R";"r";"U";"u";"U";"u";"S";"s";"T";"t";"Y";"y";"H";"h";							-- 0x00 to 0x1F
		"N";"d";"OU";"ou";"Z";"z";"A";"a";"E";"e";"O";"o";"O";"o";"O";"o";"O";"o";"Y";"y";"l";"n";"t";"j";"db";"qp";"A";"C";"c";"L";"T";"s";						-- 0x20 to 0x3F
		"z";"[?]";"[?]";"B";"U";"^";"E";"e";"J";"j";"q";"q";"R";"r";"Y";"y";"a";"a";"a";"b";"o";"c";"d";"d";"e";"@";"@";"e";"e";"e";"e";"j";						-- 0x40 to 0x5F
		"g";"g";"g";"g";"u";"Y";"h";"h";"i";"i";"I";"l";"l";"l";"lZ";"W";"W";"m";"n";"n";"n";"o";"OE";"O";"F";"r";"r";"r";"r";"r";"r";"r";							-- 0x60 to 0x7F
		"R";"R";"s";"S";"j";"S";"S";"t";"t";"u";"U";"v";"^";"w";"y";"Y";"z";"z";"Z";"Z";"?";"?";"?";"C";"@";"B";"E";"G";"H";"j";"k";"L";							-- 0x80 to 0x9F
		"q";"?";"?";"dz";"dZ";"dz";"ts";"tS";"tC";"fN";"ls";"lz";"WW";"]]";"h";"h";"h";"h";"j";"r";"r";"r";"r";"w";"y";"'";'"';"`";"'";"`";"`";"'";				-- 0xA0 to 0xBF
		"?";"?";"<";">";"^";"V";"^";"V";"'";"-";"/";"\\";",";"_";"\\";"/";":";".";"`";"'";"^";"V";"+";"-";"V";".";"@";",";"~";'"';"R";"X";							-- 0xC0 to 0xDF
		"G";"l";"s";"x";"?";"";"";"";"";"";"";"";"V";"=";'"';"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";	-- 0xE0 to 0xFF
	}
	Unidecode[0x03] =
	{
	}
	Unidecode[0x04] =
	{
	}
	Unidecode[0x20] =
	{[0]=" ";" ";" ";" ";" ";" ";" ";" ";" ";" ";" ";" ";"";"";"";"";"-";"-";"-";"-";"--";"--";"||";"_";"'";"'";",";"'";'"';'"';",,";'"';																-- 0x00 to 0x1F
		"|+";"|++";"*";"*>";".";"..";"...";".";"\n";"\n\n";"";"";"";"";"";" ";"%0";"%00";"'";"''";"'''";"`";"``";"```";"^";"<";">";"*";"!!";"!?";"-";"_";											-- 0x20 to 0x3F
		"-";"^";"***";"--";"/";"-[";"]-";"[?]";"?!";"!?";"7";"PP";"(]";"[)";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";	-- 0x40 to 0x5F
		"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"";"";"";"";"";"";"0";"";"";"";"4";"5";"6";"7";"8";"9";"+";"-";"=";"(";")";"n";													-- 0x60 to 0x7F
		"0";"1";"2";"3";"4";"5";"6";"7";"8";"9";"+";"-";"=";"(";")";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";						-- 0x80 to 0x9F
		"ECU";"CL";"Cr";"FF";"L";"mil";"N";"Pts";"Rs";"W";"NS";"D";"=E";"K";"T";"Dr";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";		-- 0xA0 to 0xBF
		"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"";"";"";"";"";"";"";"";"";"";"";"";"";"";"";"";											-- 0xC0 to 0xDF
		"";"";"";"";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";"[?]";		-- 0xE0 to 0xFF
	}
	Unidecode[0x21] =
	{[34]="TM";
	}

	return fh

end -- local function encoder_v3

local encoder = encoder_v3()										-- To access FH encoder chars module

--[[
@Module:			+fh+progbar_v3
@Author:			Mike Tate
@Version:			3.1
@LastUpdated:		23 Jan 2026
@Description:		Progress Bar library module.
@V3.1:				Use NATIVEPARENT amd CENTERPARENT.
@V3.0:				Function Prototype Closure version.
@V1.0:				Initial version.
]]

local function progbar_v3()

	local fh = {}														-- Local environment table

	require "iuplua"													-- To access GUI window builder

	iup.SetGlobal("CUSTOMQUITMESSAGE","YES")					-- Needed for IUP 3.28

	local tblBars = {}												-- Table for optional external attributes
	local strBack = "255 255 255"									-- Background colour default is white
	local strBody = "0 0 0"											-- Body text colour default is black
	local strFont = nil												-- Font dialogue default is current font
	local strStop = "255 0 0"										-- Stop button colour default is red
	local intPosX = iup.CENTERPARENT								-- Show window default position is central	-- V3.1
	local intPosY = iup.CENTERPARENT
	local intMax, intVal, intPercent, intStart, intDelta, intScale, strClock, isBarStop
	local lblText, barGauge, lblDelta, btnStop, dlgGauge

	local function doFocus()										-- Bring the Progress Bar window into Focus
		dlgGauge.BringFront="YES"									-- If used too often, inhibits other windows scroll bars, etc
	end -- local function doFocus

	local function doUpdate()										-- Update the Progress Gauge and the Delta % with clock
		barGauge.Value = intVal
		lblDelta.Title = string.format("%4d %%      %s ",math.floor(intPercent),strClock)
	end -- local function doUpdate

	local function doReset()										-- Reset all dialogue variables and Update display
		intVal		= 0													-- Current value of Progress Bar
		intPercent= 0.01												-- Percentage of progress
		intStart	= os.time()										-- Start time of progress
		intDelta	= 0													-- Delta time of progress
		intScale	= math.ceil( intMax / 1000 )					-- Scale of percentage per second of progress (initial guess is corrected in Step function)
		strClock	= "00 : 00 : 00"									-- Clock delta time display
		isBarStop	= false											-- Stop button pressed signal
		doUpdate()
		doFocus()
	end -- local function doReset

	function fh.Start(strTitle,intMaximum)						-- Create & start Progress Bar window
		if not dlgGauge then
			strTitle	= strTitle or ""								-- Dialogue and button title
			intMax		= intMaximum or 100							-- Maximun range of Progress Bar, default is 100
			local strSize = tostring( math.max( 100, string.len(" Stop "..strTitle) * 8 ) ).."x30"			-- Adjust Stop button size to Title
			lblText	= iup.label	{ Title=" "; Expand="YES"; Alignment="ACENTER"; Tip="Progress Message"; }
			barGauge	= iup.progressbar { RasterSize="400x30"; Value=0; Max=intMax; Tip="Progress Bar"; }
			lblDelta	= iup.label	{ Title=" "; Expand="YES"; Alignment="ACENTER"; Tip="Percentage and Elapsed Time"; }
			btnStop	= iup.button	{ Title=" Stop "..strTitle; RasterSize=strSize; FgColor=strStop; Tip="Stop Progress Button"; action=function() isBarStop = true end; }	-- Signal Stop button pressed	return iup.CLOSE -- Often caused main GUI to close !!!
			dlgGauge	= iup.dialog	{ Title=strTitle.." Progress "; Font=strFont; FgColor=strBody; Background=strBack; DialogFrame="YES";	-- Remove Windows minimize/maximize menu
								iup.vbox{ Alignment="ACENTER"; Gap="10"; Margin="10x10";
									lblText;
									barGauge;
									lblDelta;
									btnStop;
								};
								move_cb	= function(self,x,y) tblBars.X = x tblBars.Y = y end;
								close_cb	= btnStop.action;		-- Windows Close button = Stop button
							}
			if type(tblBars.GUI) == "table"
			and type(tblBars.GUI.ShowDialogue) == "function" then
				dlgGauge.move_cb = nil								-- Use GUI library to show & move window
				tblBars.GUI.ShowDialogue("Bars",dlgGauge,btnStop,"showxy")
			else
				if fhGetAppVersion() > 6 then 					-- Window centres on FH parent	-- V3.1
					iup.SetAttribute(dlgGauge,"NATIVEPARENT",fhGetContextInfo("CI_PARENT_HWND"))
				end
				dlgGauge:showxy(intPosX,intPosY)					-- Show the Progress Bar window
			end
			doReset()													-- Reset the Progress Bar display
		end
	end -- function Start

	function fh.Message(strText)									-- Show the Progress Bar message
		if dlgGauge then lblText.Title = strText end
	end -- function Message

	function fh.Step(intStep)										-- Step the Progress Bar forward
		if dlgGauge then
			intVal = intVal + ( intStep or 1 )					-- Default step is 1
			local intNew = math.ceil( intVal / intMax * 100 * intScale ) / intScale
			if intPercent ~= intNew then							-- Update progress once per percent or per second, whichever is smaller
				intPercent = math.max( 0.1, intNew )				-- Ensure percentage is greater than zero
				if intVal > intMax then intVal = intMax intPercent = 100 end		-- Ensure values do not exceed maximum
				intNew = os.difftime(os.time(),intStart)
				if intDelta < intNew then							-- Update clock of elapsed time
					intDelta = intNew
					intScale = math.ceil( intDelta / intPercent )	-- Scale of seconds per percentage step
					local intHour = math.floor( intDelta / 3600 )
					local intMins = math.floor( intDelta / 60 - intHour * 60 )
					local intSecs = intDelta - intMins * 60 - intHour * 3600
					strClock = string.format("%02d : %02d : %02d",intHour,intMins,intSecs)
				end
				doUpdate()											-- Update the Progress Bar display
			end
			iup.LoopStep()
		end
	end -- function Step

	function fh.Focus()												-- Bring the Progress Bar window to front
		if dlgGauge then doFocus() end
	end -- function Focus

	function fh.Reset()												-- Reset the Progress Bar display
		if dlgGauge then doReset() end
	end -- function Reset

	function fh.Stop()												-- Check if Stop button pressed
		iup.LoopStep()
		return isBarStop
	end -- function Stop

	function fh.Close()												-- Close the Progress Bar window
		isBarStop = false
		if dlgGauge then dlgGauge:destroy() dlgGauge = nil end
	end -- function Close

	function fh.Setup(tblSetup)									-- Setup optional table of external attributes
		if tblSetup then
			tblBars = tblSetup
			strBack = tblBars.Back or strBack						-- Background colour
			strBody = tblBars.Body or strBody						-- Body text colour
			strFont = tblBars.Font or strFont						-- Font dialogue
			strStop = tblBars.Stop or strStop						-- Stop button colour
			intPosX = tblBars.X or intPosX						-- Window position
			intPosY = tblBars.Y or intPosY
		end
	end -- function Setup

	return fh

end -- local function progbar_v3

local progbar = progbar_v3()										-- To access FH progress bars module

--[[
@Module:			+fh+iup_gui_v3
@Author:			Mike Tate
@Version:			4.6
@LastUpdated:		18 Feb 2026
@Description:		Graphical User Interface Library Module
@V4.6:				Improve handling of centred window RasterSize in fh.ShowDialogue(...);
@V4.5:				Adjust CheckVersionInStore() for dedicated button use;
@V4.4:				Introduce use of NATIVEPARENT and CENTERPARENT to centre on parent window by default; Ensure not off screen; Monitors with -ve X;
@V4.3:				Added memo options to CheckVersionInStore;
@V4.2:				Skip if standalone GEDCOM in fh.SaveSettings() and getDataFiles();
@V4.1:				CheckVersionInStore() save & retrieve latest version in file; Remove old wiki Help features;
@V4.0:				Cater for full UTF-8 filenames;
@V3.9:				ShowDialogue() popup closure fhSleep() added; CheckVersionInStore() at monthly intervals;
@V3.8:				Function Prototype Closure version.
@V3.7:				AssignAttributes(tblControls) now allows any string attribute to invoke a function.
@V3.6:				anyMemoDialogue() sets TopMost attribute.
@V3.5:				Replace IsNormalWindow(iupDialog) with SetWindowCoord(tblName) and update CheckWindowPosition(tblName) to prevent negative values freezing main dialog.
@V3.4:				Use general.MakeFolder() to ensure key folders exist, add Get/PutRegKey(), check Registry IE Shell Version in HelpDialogue(), better error handling in LoadSettings().
@V3.3:				LoadFolder() and SaveFolder() use global folder as default for local folder to improve synch across PC.
@V3.2:				Load & Save settings now use a single clipboard so Local PC settings are preserved across synchronised PC.
@V3.1:				IUP 3.11.2 iup.GetGlobal("VERSION") to top, HelpDialogue conditional ExpandChildren="YES/NO", RefreshDialogue uses NaturalSize, SetUtf8Mode(), Load/SaveFolder(), etc
@V3.0:				ShowDialogue "dialog" mode for Memo, new DestroyDialogue, NewHelpDialogue tblAttr for Font, AssignAttributes intSkip, CustomDialogue iup.CENTERPARENT+, IUP Workaround, BalloonToggle, Initialise test Plugin file exists.
@V2.0:				Support for Plugin Data scope, new FontDialogue, RefreshDialogue, AssignAttributes, httpRequest handler, keep "dialog" mode.
@V1.0:				Initial version.
]]

local function iup_gui_v3()

	local fh = {}																			-- Local environment table

	require "iuplua"																		-- To access GUI window builder
	require "iupluacontrols"															-- To access GUI window controls
	require "lfs"																			-- To access LUA filing system
	require "iupluaole"																	-- To access OLE subsystem
	require "luacom"																		-- To access COM subsystem

	iup.SetGlobal("CUSTOMQUITMESSAGE","YES")										-- Needed for IUP 3.28

	local iupVersion = iup.GetGlobal("VERSION")										-- Obtain IUP module version

	-- "iuplua" Omitted Constants Workaround --

	iup.TOP    = iup.LEFT
	iup.BOTTOM = iup.RIGHT
	iup.RED    = iup.RGB(1,0,0)
	iup.GREEN  = iup.RGB(0,1,0)
	iup.BLUE   = iup.RGB(0,0,1)
	iup.BLACK  = iup.RGB(0,0,0)
	iup.WHITE  = iup.RGB(1,1,1)
	iup.YELLOW = iup.RGB(1,1,0)

	-- Shared Interface Attributes & Functions --

	fh.Version	= " "																	-- Plugin Version
	fh.History	= fh.Version															-- Version History

	fh.Red			= "255 0 0"															-- Color attributes (must exclude leading zeros & spaces to allow value comparisons)
	fh.Maroon		= "128 0 0"
	fh.Amber		= "250 160 0"
	fh.Orange		= "255 165 0"
	fh.Yellow		= "255 255 0"
	fh.Olive		= "128 128 0"
	fh.Lime		= "0 255 0"
	fh.Green		= "0 128 0"
	fh.Cyan		= "0 255 255"
	fh.Teal		= "0 128 128"
	fh.Blue		= "0 0 255"
	fh.Navy		= "0 0 128"
	fh.Magenta	= "255 0 255"
	fh.Purple		= "128 0 128"
	fh.Black		= "0 0 0"
	fh.Gray		= "128 128 128"
	fh.Silver		= "192 192 192"
	fh.Smoke		= "240 240 240"
	fh.White		= "255 255 255"
	fh.Risk		= fh.Red																-- Risk colour for hazardous controls such as Close/Delete buttons
	fh.Warn		= fh.Magenta															-- Warn colour for caution controls and warnings
	fh.Safe		= fh.Green															-- Safe colour for active controls such as most buttons
	fh.Info		= fh.Black															-- Info colour for text controls such as labels/tabs
	fh.Head		= fh.Black															-- Head colour for headings
	fh.Body		= fh.Black															-- Body colour for body text
	fh.Back		= fh.White															-- Background colour for all windows

	fh.Gap			= "8"																	-- Layout attributes Gap was "10"
	fh.Border		= "8x8"																-- 	was BigMargin="10x10"
	fh.Margin		= "1x1"																--	was MinMargin
	fh.Balloon	= "NO"																	-- Tooltip balloon mode

	fh.FontSet	=	0																	-- Legacy GUI font set assigned by FontAssignment but used globally
	fh.FontHead	= ""
	fh.FontBody	= ""

	local GUI = { }																		-- Sub-table for GUI Dialogue attributes to allow any "Name"

	--[[
	GUI.Name			table of dialogue attributes, where Name is Font, Main, Memo, Bars, etc
	GUI.Name.CoordX	x co-ordinate	( Loaded & Saved by default )
	GUI.Name.CoordY	y co-ordinate	( Loaded & Saved by default )
	GUI.Name.Dialog	dialogue handle
	GUI.Name.Focus	focus button handle
	GUI.Name.Frame	dialogframe mode, "normal" = dialogframe="NO" else "YES", "showxy" = showxy(), "popup" or "keep" = popup(), default is "normal & showxy"
	GUI.Name.Height	height
	GUI.Name.Raster	rastersize		( Loaded & Saved by default )
	GUI.Name.Width	width
	GUI.Name.Back		ProgressBar background colour
	GUI.Name.Body		ProgressBar body text colour
	GUI.Name.Font		ProgressBar font style
	GUI.Name.Stop		ProgressBar Stop button colour
	GUI.Name.GUI		Module table usable by other modules e.g. progbar.Setup
	--]]

	-- tblScrn[1] = origin x, tblScrn[2] = origin y, tblScrn[3] = width, tblScrn[4] = height
	local tblScrn = stringx.splitnumbers(iup.GetGlobal("VIRTUALSCREEN"))		-- Used by CustomDialogue() and CheckWindowPosition() and ShowDialogue() below
	local intMinX = tblScrn[1]
	local intMinY = tblScrn[2]															-- V4.4
	local intMaxW = tblScrn[3]
	local intMaxH = tblScrn[4]

	function fh.BalloonToggle()														-- Toggle tooltips Balloon mode
		local tblToggle = { YES="NO"; NO="YES"; }
		fh.Balloon = tblToggle[fh.Balloon]
		fh.SaveSettings()
	end -- function BalloonToggle

	iup.SetGlobal("UTF8MODE","NO")
	iup.SetGlobal("UTF8MODE_FILE","NO") 												-- V4.0
	function fh.SetUtf8Mode()															-- Set IUP into UTF-8 mode
		if iupVersion == "3.5" or stringx.encoding() == "ANSI" then return false end
		iup.SetGlobal("UTF8MODE","YES")
		iup.SetGlobal("UTF8MODE_FILE","YES")											-- V4.0
		return true
	end -- function SetUtf8Mode

	local function tblOfNames(...)													-- Get table of dialogue Names including "Font","Main" by default	-- V4.4
		local arg = {...}
		local tblNames = {"Font";"Main";}
		for intName, strName in ipairs(arg) do
			if type(strName) == "string"
			and strName ~= "Font"
			and strName ~= "Main" then
				table.insert(tblNames,strName)
			end
		end
		return tblNames
	end -- local function tblOfNames

	local function tblNameFor(strName)												-- Get table of parameters for chosen dialogue Name
		strName = tostring(strName)
		if not GUI[strName] then														-- Need new table with default minimum & raster size, and X & Y co-ordinates
			GUI[strName] = { }
			local tblName = GUI[strName]
			tblName.Raster = "x"
			tblName.CoordX = iup.CENTERPARENT
			tblName.CoordY = iup.CENTERPARENT
		end
		return GUI[strName]
	end -- local function tblNameFor

	local function intDimension(intMin,intVal,intMax)								-- Return a number bounded by intMin and intMax
		if not intVal then return 0 end												-- Except if no value then return 0
		intVal = tonumber(intVal) or (intMin+intMax)/2
		return math.max(intMin,math.min(intVal,intMax))
	end -- local function intDimension

	function fh.CustomDialogue(strName,strRas,intX,intY)							-- GUI custom window raster size, and X & Y co-ordinates
		-- strRas  nil = old size, "x" or "0x0" = min size, "999x999" = new size
		-- intX/Y  nil = central, "99" = co-ordinate position
		local tblName = tblNameFor(strName)
		local tblSize = {}
		local intWide = 0
		local intHigh = 0
		strRas = strRas or tblName.Raster
		if strRas then																	-- Ensure raster size is between minimum and screen size
			tblSize = stringx.splitnumbers(strRas)
			intWide = intDimension(intWide,tblSize[1],intMaxW)
			intHigh = intDimension(intHigh,tblSize[2],intMaxH)
			strRas = tostring(intWide.."x"..intHigh)
		end
		if intX and intX < iup.CENTERPARENT then
			intX = intDimension(intMinX,intX,intMaxW-intWide)						-- Ensure X co-ordinate positions window on screen	-- V4.4
		end
		if intY and intY < iup.CENTERPARENT then
			intY = intDimension(intMinY,intY,intMaxH-intHigh)						-- Ensure Y co-ordinate positions window on screen	-- V4.4
		end
		tblName.Raster  = strRas or "x"
		tblName.CoordX  = tonumber(intX) or iup.CENTERPARENT						-- V4.4
		tblName.CoordY  = tonumber(intY) or iup.CENTERPARENT						-- V4.4
	end -- function CustomDialogue

	function fh.DefaultDialogue(...)													-- GUI default window minimum & raster size, and X & Y co-ordinates
		for intName, strName in ipairs(tblOfNames(...)) do
			fh.CustomDialogue(strName)
		end
	end -- function DefaultDialogue

	function fh.DialogueAttributes(strName)											-- Provide named Dialogue Attributes
		local tblName = tblNameFor(strName)											-- tblName.Dialog = dialog handle, so any other attributes could be retrieved
		local tblSize = stringx.splitnumbers(tblName.Raster or "x")				-- Split Raster Size into width=tblSize[1] and height=tblSize[2]
		tblName.Width = tblSize[1]
		tblName.Height= tblSize[2]
		tblName.Back = fh.Back															-- Following only needed for NewProgressBar
		tblName.Body = fh.Body
		tblName.Font = fh.FontBody
		tblName.Stop = fh.Risk
		tblName.GUI  = fh																-- Module table
		return tblName
	end -- function DialogueAttributes

	local strDefaultScope = "Project"													-- Default scope for Load/Save data is per Project/User/Machine as set by PluginDataScope()

	local tblClipProj = { }
	local tblClipUser = { }																-- Clipboards of sticky data for each Plugin Data scope -- V3.2
	local tblClipMach = { }

	local function doLoadData(strParam,strDefault,strScope)						-- Load sticky data for Plugin Data scope
		strScope = tostring(strScope or strDefaultScope):lower()
		local tblClipData = tblClipProj
		if     strScope:match("user") then tblClipData = tblClipUser
		elseif strScope:match("mach") then tblClipData = tblClipMach
		end
		return tblClipData[strParam] or strDefault
	end -- local function doLoadData

	function fh.LoadGlobal(strParam,strDefault,strScope)							-- Load Global Parameter for all PC
		return doLoadData(strParam,strDefault,strScope)
	end -- function LoadGlobal

	function fh.LoadLocal(strParam,strDefault,strScope) 							-- Load Local Parameter for this PC
		return doLoadData(fh.ComputerName.."-"..strParam,strDefault,strScope)
	end -- function LoadLocal

	local function doLoadFolder(strFolder)											-- Use relative paths to let Paths change -- V3.3
		strFolder = strFolder:gsub("^FhDataPath",function() return fh.FhDataPath end)	-- Full path to .fh_data folder
		strFolder = strFolder:gsub("^PublicPath",function() return fh.PublicPath end)	-- Full path to Public folder
		strFolder = strFolder:gsub("^FhProjPath",function() return fh.FhProjPath end)	-- Full path to project folder
		return strFolder
	end -- local function doLoadFolder

	function fh.LoadFolder(strParam,strDefault,strScope)							-- Load Folder Parameter for this PC -- V3.3
		local strFolder = doLoadFolder(fh.LoadLocal(strParam,"",strScope))
		if not general.FlgFolderExists(strFolder) then								-- If no local folder try global folder
			strFolder = doLoadFolder(fh.LoadGlobal(strParam,strDefault,strScope))
		end
		return strFolder
	end -- function LoadFolder

	function fh.LoadDialogue(...)														-- Load Dialogue Parameters for "Font","Main" by default
		for intName, strName in ipairs(tblOfNames(...)) do
			local tblName = tblNameFor(strName)
			tblName.Raster = tostring(fh.LoadLocal(strName.."R",tblName.Raster))
			tblName.CoordX = tonumber(fh.LoadLocal(strName.."X",tblName.CoordX))
			tblName.CoordY = tonumber(fh.LoadLocal(strName.."Y",tblName.CoordY))
			fh.CheckWindowPosition(tblName)
		end
	end -- function LoadDialogue

	function fh.LoadSettings(...)														-- Load Sticky Settings from File
		for strFileName, tblClipData in pairs ({ ProjectFile=tblClipProj; PerUserFile=tblClipUser; MachineFile=tblClipMach; }) do
			strFileName = fh[strFileName]
			if general.FlgFileExists(strFileName) then								-- Load Settings File in table lines with key & val fields
				local tblField = {}
				local strClip = general.StrLoadFromFile(strFileName)				-- V4.0
				for strLine in strClip:gmatch("[^\r\n]+") do						-- V4.0
					if #tblField == 0
					and strLine:match("^return {")									-- Unless entire Sticky Data table was saved
					and type(table.load) == "function" then
						local tblClip, strErr = table.load(strFileName)			-- Load Settings File table
						if strErr then error(strErr.."\n\nMay need to Delete the following Plugin Data .dat file:\n\n"..strFileName.."\n\nError detected.") end
						for i,j in pairs (tblClip) do
							tblClipData[i] = tblClip[i]
						end
						break
					end
					tblField = stringx.split(strLine,"=")
					if tblField[1] then tblClipData[tblField[1]] = tblField[2] end
				end
			else
				for i,j in pairs (tblClipData) do
					tblClipData[i] = nil												-- Restore defaults and clear any junk		-- V4.0
				end
			end
		end
		fh.Safe    = tostring(fh.LoadGlobal("SafeColor",fh.Safe))
		fh.Warn    = tostring(fh.LoadGlobal("WarnColor",fh.Warn))
		fh.Risk    = tostring(fh.LoadGlobal("RiskColor",fh.Risk))
		fh.Head    = tostring(fh.LoadGlobal("HeadColor",fh.Head))
		fh.Body    = tostring(fh.LoadGlobal("BodyColor",fh.Body))
		fh.FontHead= tostring(fh.LoadGlobal("FontHead" ,fh.FontHead))
		fh.FontBody= tostring(fh.LoadGlobal("FontBody" ,fh.FontBody))
		fh.History = tostring(fh.LoadGlobal("History"  ,fh.History))
		fh.Balloon = tostring(fh.LoadGlobal("Balloon"  ,fh.Balloon, "Machine"))
		fh.LoadDialogue(...)
	end -- function LoadSettings

	local function doSaveData(strParam,anyValue,strScope)							-- Save sticky data for Plugin Data scope
		strScope = tostring(strScope or strDefaultScope):lower()
		local tblClipData = tblClipProj
		if     strScope:match("user") then tblClipData = tblClipUser
		elseif strScope:match("mach") then tblClipData = tblClipMach
		end
		tblClipData[strParam] = anyValue
	end -- local function doSaveData

	function fh.SaveGlobal(strParam,anyValue,strScope)							-- Save Global Parameter for all PC
		doSaveData(strParam,anyValue,strScope)
	end -- function SaveGlobal

	function fh.SaveLocal(strParam,anyValue,strScope)								-- Save Local Parameter for this PC
		doSaveData(fh.ComputerName.."-"..strParam,anyValue,strScope)
	end -- function SaveLocal

	function fh.SaveFolder(strParam,strFolder,strScope)							-- Save Folder Parameter for this PC
		strFolder = stringx.replace(strFolder,fh.FhDataPath,"FhDataPath")		-- Full path to .fh_data folder
		strFolder = stringx.replace(strFolder,fh.PublicPath,"PublicPath")		-- Full path to Public folder
		strFolder = stringx.replace(strFolder,fh.FhProjPath,"FhProjPath")		-- Full path to project folder
		fh.SaveGlobal(strParam,strFolder,strScope)									-- V3.3
		fh.SaveLocal(strParam,strFolder,strScope)									-- Uses relative paths to let Paths change
	end -- function SaveFolder

	function fh.SaveDialogue(...)														-- Save Dialogue Parameters for "Font","Main" by default
		for intName, strName in ipairs(tblOfNames(...)) do
			local tblName = tblNameFor(strName)
			fh.SaveLocal(strName.."R",tblName.Raster)
			fh.SaveLocal(strName.."X",tblName.CoordX)
			fh.SaveLocal(strName.."Y",tblName.CoordY)
		end
	end -- function SaveDialogue

	function fh.SaveSettings(...)														-- Save Sticky Settings to File
		fh.SaveDialogue(...)
		fh.SaveGlobal("SafeColor",fh.Safe)
		fh.SaveGlobal("WarnColor",fh.Warn)
		fh.SaveGlobal("RiskColor",fh.Risk)
		fh.SaveGlobal("HeadColor",fh.Head)
		fh.SaveGlobal("BodyColor",fh.Body)
		fh.SaveGlobal("FontHead" ,fh.FontHead)
		fh.SaveGlobal("FontBody" ,fh.FontBody)
		fh.SaveGlobal("History"  ,fh.History)
		fh.SaveGlobal("Balloon"  ,fh.Balloon, "Machine")
		for strFileName, tblClipData in pairs ({ ProjectFile=tblClipProj; PerUserFile=tblClipUser; MachineFile=tblClipMach; }) do
			for i,j in pairs (tblClipData) do											-- Check if table has any entries
				strFileName = fh[strFileName]
				if #strFileName > 0 then												-- Skip for standalone GEDCOM -- V4.2
					if type(table.save) == "function" then							-- Save entire Settings File table per Project/User/Machine
						table.save(tblClipData,strFileName)
					else
						local tblClip = {}
						for strKey,strVal in pairs(tblClipData) do					-- Else save Settings File lines with key & val fields		-- V4.0
							table.insert(tblClip,strKey.."="..strVal.."\n")		-- V4.0
						end
						local strClip = table.concat(tblClip,"\n")					-- V4.0
						if not general.SaveStringToFile(strClip,strFileName) then
							error("\nSettings file not saved successfully.\n\nMay need to Delete the following Plugin Data .dat file:\n\n"..strFileName.."\n\nError detected.")
						end	
					end
				end
				break
			end
		end
	end -- function SaveSettings

	function fh.CheckWindowPosition(tblName)										-- Ensure dialogue window coordinates are on Screen
		local tblSize = stringx.splitnumbers(tblName.Raster or "600x400","x")	-- Get window dimensions from the previous use of plugin -- V4.4
		local intWinW = tblSize[1]
		local intWinH = tblSize[2]
		if tonumber(tblName.CoordX) == nil
		or tonumber(tblName.CoordY) == nil
		or tonumber(tblName.CoordX) < intMinX										-- V4.4 -- V3.5
		or tonumber(tblName.CoordY) < intMinY										-- V4.4 -- V3.5
		or tonumber(tblName.CoordX) + intWinW > intMaxW							-- V4.4
		or tonumber(tblName.CoordY) + intWinH > intMaxH then						-- V4.4
			tblName.CoordX = iup.CENTERPARENT											-- V4.4
			tblName.CoordY = iup.CENTERPARENT											-- V4.4
		end
	end -- function CheckWindowPosition

	function fh.SetWindowCoord(tblName)												-- Set the Window coordinates if not Maximised or Minimised -- V3.5
		-- tblPosn[1] = origin x, tblPosn[2] = origin y, tblPosn[3] = width, tblPosn[4] = height
		local tblPosn = stringx.splitnumbers(tblName.Dialog.ScreenPosition)
		local intPosX = tblPosn[1] or -1
		local intPosY = tblPosn[2] or -1
		if intPosX < 0 and intPosY < 0 then											-- If origin is negative (-8, -8 = Maximised, -3200, -3200 = Minimised)
			return false																	-- then is Maximised or Minimised
		end
		tblName.CoordX = intPosX														-- Otherwise set the Window coordinates
		tblName.CoordY = intPosY
		return true
	end -- function SetWindowCoord

	function fh.ShowDialogue(strName,iupDialog,btnFocus,strFrame)				-- Set standard frame attributes and display dialogue window
		local tblName = tblNameFor(strName)
		iupDialog = iupDialog or tblName.Dialog										-- Retrieve previous parameters if needed
		btnFocus  = btnFocus  or tblName.Focus
		strFrame  = strFrame  or tblName.Frame
		strFrame  = strFrame  or "show norm"											-- Default frame mode is dialog:showxy(X,Y) with DialogFrame="NO" ("normal" to vary size, otherwise fixed size)
		strFrame  = strFrame:lower()													-- Other modes are "show", "popup" & "keep" with DialogFrame="YES", or with "normal" for DialogFrame="NO" ("show" for active windows, "popup"/"keep" for modal windows)
		if strFrame:gsub("%s-%a-map%a*[%s%p]*","") == "" then						-- May be prefixed with "map" mode to just map dialogue initially, also may be suffixed with "dialog" to inhibit iup.MainLoop() to allow progress messages
			strFrame = "map show norm"													-- If only "map" mode then default to "map show norm"
		end
		if type(iupDialog) == "userdata" then
			tblName.Dialog = iupDialog
			tblName.Focus  = btnFocus													-- Preserve parameters
			tblName.Frame  = strFrame
			iupDialog.Background = fh.Back											-- Background colour
			iupDialog.Shrink = "YES"													-- Sometimes needed to shrink controls to raster size
			if type(btnFocus) == "userdata" then										-- Set button as focus for Esc and Enter keys
				iupDialog.StartFocus   = iupDialog.StartFocus   or btnFocus
				iupDialog.DefaultEsc   = iupDialog.DefaultEsc   or btnFocus
				iupDialog.DefaultEnter = iupDialog.DefaultEnter or btnFocus
			end
			if tblName.CoordX == iup.CENTERPARENT
			or tblName.CoordY == iup.CENTERPARENT then								-- When centred on parent refresh this window size	-- V4.4 -- V4.6
				fh.RefreshDialogue(strName)
			else
				iupDialog.MinSize = "x"													-- Minimum size (default "x" becomes nil)
				iupDialog.RasterSize = tblName.Raster or "x"						-- Raster size  (default "x" becomes nil)
			end
			iupDialog.MaxSize = intMaxW.."x"..intMaxH								-- Maximum size is virtual screen size
			if strFrame:match("norm") then											-- DialogFrame mode is "NO" by default for variable size window
				if strFrame:match("pop") or strFrame:match("keep") then
					iupDialog.MinBox = "NO"												-- For "popup" and "keep" hide Minimize and Maximize icons
					iupDialog.MaxBox = "NO"
				else 
					strFrame = strFrame.." show"										-- If not "popup" nor "keep" then use "showxy" mode
				end
			else
				iupDialog.DialogFrame = "YES"											-- Define DialogFrame mode for fixed size window
			end
			iupDialog.close_cb  = iupDialog.close_cb  or function() return iup.CLOSE end	-- Define default window X close, move, and resize actions
			iupDialog.move_cb   = iupDialog.move_cb   or function(self) if iup.MainLoopLevel() > 0 then fh.SetWindowCoord(tblName) end end			-- V3.5 -- V4.4
			iupDialog.resize_cb = iupDialog.resize_cb or function(self) if iup.MainLoopLevel() > 0 then tblName.Raster=self.RasterSize end end	-- V3.5 -- V4.4 -- V4.6
			fh.RefreshDialogue(strName)												-- Refresh to set Natural Size as Minimum Size
			if strName == "Main" then
				if fhGetAppVersion() > 6 then 										-- Main window centres on FH parent	-- V4.4
					iup.SetAttribute(iupDialog,"NATIVEPARENT",fhGetContextInfo("CI_PARENT_HWND"))
				end
			else
				local tblMain = tblNameFor("Main")									-- Others popup centrally in Main 	-- V4.4
				local iupMain = tblMain.Dialog
				if iupMain then															-- Centre based on size of windows	-- V1.4
					local arrName = stringx.splitnumbers(tblName.Raster or "0x0")
					local arrMain = stringx.splitnumbers(tblMain.Raster or arrName[1].."x"..arrName[2])
					tblName.CoordX = iupMain.X + math.floor( (arrMain[1] - arrName[1]) / 2 )
					tblName.CoordY = iupMain.Y + math.floor( (arrMain[2] - arrName[2]) / 2 )
				elseif fhGetAppVersion() > 6 then 									-- This window centres on FH parent	-- V4.4
					iup.SetAttribute(iupDialog,"NATIVEPARENT",fhGetContextInfo("CI_PARENT_HWND"))
				end
			end
			if strFrame:match("map") then												-- Only dialogue mapping is required
				iupDialog:map()
				tblName.Frame = strFrame:gsub("%s-%a-map%a*[%s%p]*","")			-- Remove "map" from frame mode ready for subsequent call
				return
			end
			if iup.MainLoopLevel() == 0												-- Called from outside Main GUI, so must use showxy() and not popup()
			or strFrame:match("dialog")
			or strFrame:match("sho") then												-- Use showxy() to dispay dialogue window for "showxy" or "dialog" mode
				iupDialog:showxy(tblName.CoordX,tblName.CoordY)
				if not strFrame:match("dialog")										-- Inhibit MainLoop if "dialog" mode	-- V4.1
				and iup.MainLoopLevel() == 0 then iup.MainLoop() end
			else
				iupDialog:popup(tblName.CoordX,tblName.CoordY)						-- Use popup() to display dialogue window for "popup" or "keep" modes
				fhSleep(200,150)															-- Sometimes needed to prevent MainLoop() closure!	-- V3.9
			end
			if not strFrame:match("dialog") and strFrame:match("pop") then
				tblName.Dialog = nil													-- When popup closed, clear key parameters, but not for "keep" mode
				tblName.Raster = nil
				tblName.CoordX = nil -- iup.CENTERPARENT
				tblName.CoordY = nil -- iup.CENTERPARENT
			elseif tblName.CoordX ~= iup.CENTERPARENT								-- V4.4
			   and tblName.CoordY ~= iup.CENTERPARENT then
				fh.SetWindowCoord(tblName)												-- Set Window coordinate pixel values -- V3.5
			end
		end
	end -- function ShowDialogue

	function fh.DestroyDialogue(strName)												-- Destroy existing dialogue
		local tblName = tblNameFor(strName)
		if tblName then
			local iupDialog = tblName.Dialog
			if type(iupDialog) == "userdata" then
				iupDialog:destroy()
				tblName.Dialog = nil													-- Prevent future misuse of handle -- 22 Jul 2014
			end
		end
	end -- function DestroyDialogue

	local function strDialogueArgs(strArgA,strArgB,comp)							-- Compare two argument pairs and return matching pair
		local tblArgA = stringx.splitnumbers(strArgA)
		local tblArgB = stringx.splitnumbers(strArgB)
		local strArgX = tostring(comp(tblArgA[1] or 100,tblArgB[1] or 100))
		local strArgY = tostring(comp(tblArgA[2] or 100,tblArgB[2] or 100))
		return strArgX.."x"..strArgY
	end -- local function strDialogueArgs

	function fh.RefreshDialogue(strName)												-- Refresh dialogue window size after Font change, etc
		local tblName = tblNameFor(strName)
		local iupDialog = tblName.Dialog												-- Retrieve the dialogue handle
		if type(iupDialog) == "userdata" then
			iupDialog.Size = iup.NULL
			iupDialog.MinSize = iup.NULL												-- V3.1
			iup.Refresh(iupDialog)														-- Refresh window to Natural Size and set as Minimum Size
			if not iupDialog.RasterSize then
				iupDialog:map()
				iup.Refresh(iupDialog)
			end
			local strSize = iupDialog.NaturalSize or iupDialog.RasterSize				-- IUP 3.5 NaturalSize = nil, IUP 3.11 needs NaturalSize					-- V3.1
			iupDialog.MinSize = strDialogueArgs(iupDialog.MaxSize,strSize,math.min)	-- Set Minimum Size to smaller of Maximm Size or Natural/Raster Size	-- V3.1
			iupDialog.RasterSize = strDialogueArgs(tblName.Raster,strSize,math.max)	-- Set Current Size to larger of Current Size or Natural/Raster Size	-- V3.1
			iup.Refresh(iupDialog)
			tblName.Raster = iupDialog.RasterSize
			if iupDialog.Visible == "YES" then										-- Ensure visible dialogue origin is on screen
				tblName.CoordX = math.max(tblName.CoordX,10)
				tblName.CoordY = math.max(tblName.CoordY,10)						-- Set both coordinates to larger of current value or 10 pixels
				if iupDialog.Modal then													--	V3.8
					if iupDialog.Modal == "NO" then
						iupDialog.ZOrder = "BOTTOM"									-- Ensure dialogue is subservient to any popup
						iupDialog:showxy(tblName.CoordX,tblName.CoordY)			-- Use showxy() to reposition main window
					else
						iupDialog:popup(tblName.CoordX,tblName.CoordY)				-- Use popup() to reposition modal window
					end
				end
			else
				iupDialog.BringFront="YES"
			end
		end
	end -- function RefreshDialogue

	function fh.AssignAttributes(tblControls)										-- Assign the attributes of all controls supplied
		local anyFunction = nil
		for iupName, tblAttr in pairs ( tblControls or {} ) do
			if type(iupName) == "userdata" and type(tblAttr) == "table" then	-- Loop through each iup control
				local intSkip = 0														-- Skip counter for attributes same for all controls
				for intAttr, anyName in ipairs ( tblControls[1] or {} ) do		-- Loop through each iup attribute
					local strName = nil
					local strAttr = nil
					local strType = type(anyName)
					if strType == "string" then										-- Attribute is different for each control in tblControls
						strName = anyName
						strAttr = tblAttr[intAttr-intSkip]
					elseif strType == "table" then									-- Attribute is same for all controls as per tblControls[1] 
						intSkip = intSkip + 1
						strName = anyName[1]
						strAttr = anyName[2]
					elseif strType == "function" then
						intSkip = intSkip + 1
						anyFunction = anyName
						break
					end
					if type(strName) == "string" and ( type(strAttr) == "string" or type(strAttr) == "function" ) then
						local anyRawGet = rawget(fh,strAttr)							-- Use rawget() to stop require("pl.strict") complaining
						if type(anyRawGet) == "string" then
							strAttr = anyRawGet											-- Use internal module attribute such as Head or FontBody
						elseif type(iupName[strName]) == "string"
						   and type(strAttr) == "function" then						-- Allow string attributes to invoke a function	-- V3.7
							strAttr = strAttr()
						end
						iupName[strName] = strAttr										-- Assign attribute to control
					end
				end
			end
		end
		if anyFunction then anyFunction() end										-- Perform any control assignment function
	end -- function AssignAttributes

	-- Font Dialogue Attributes and Functions --

	fh.FontBody = iup.GetGlobal("DEFAULTFONT")										-- Set default font for Body and Head text
	fh.FontHead = fh.FontBody:gsub(", B?o?l?d?",", Bold ")

	function fh.FontDialogue(tblAttr,strName)										-- GUI Font Face & Style Dialogue
		tblAttr = tblAttr or {}
		strName = strName or "Main"
		local isFontChosen = false
		local btnFontHead = iup.button { Title="Choose Headings Font and default Colour"; }
		local btnFontBody = iup.button { Title="Choose Body text Font and default Colour"; }
		local btnCol_Safe = iup.button { Title=" Safe Colour "; }
		local btnCol_Warn = iup.button { Title=" Warning Colour "; }
		local btnCol_Risk = iup.button { Title=" Risky Colour "; }
		local btnDefault  = iup.button { Title=" Default Fonts "; }
		local btnMinimum  = iup.button { Title=" Minimum Size "; }
		local btnDestroy  = iup.button { Title=" Close Dialogue "; }
		local frmSetFonts = iup.frame  { Title=" Set Window Fonts & Colours ";
									iup.vbox { Alignment="ACENTER"; Margin=fh.Margin; Homogeneous="YES";
										btnFontHead;
										btnFontBody;
										iup.hbox { btnCol_Safe; btnCol_Warn; btnCol_Risk; Homogeneous="YES"; };
										iup.hbox { btnDefault ; btnMinimum ; btnDestroy ; Homogeneous="YES"; };
									} -- iup.vbox end
								} -- iup.frame end

		-- Create dialogue and turn off resize, maximize, minimize, and menubox except Close button
		local dialogFont  = iup.dialog { Title=" Set Window Fonts & Colours "; Gap=fh.Gap; Margin=fh.Border; frmSetFonts; }

		local tblButtons = { }

		local function setDialogues()													-- Refresh the Main dialogue	-- V4.4
			fh.AssignAttributes(tblAttr)												-- Assign parent dialogue attributes
			fh.RefreshDialogue(strName)												-- Refresh parent window size & position and bring infront of other window
			fh.RefreshDialogue("Font")													-- Refresh Font window size & position and bring infront of parent window
		end -- local function setDialogues

		local function getFont(strColor)												-- Set font button function
			local strTitle = " Choose font style & default colour for "..strColor:gsub("Head","Heading").." text "
			local strValue = "Font"..strColor											-- The font codes below are not recognised by iupFontDlg and result in empty font face!
			local strFont  = rawget(fh,strValue):gsub(" Black,",","):gsub(" Light, Bold",","):gsub(" Extra Bold,",","):gsub(" Semibold,",",")
			local iupFontDlg = iup.fontdlg { Title=strTitle; Color=rawget(fh,strColor); Value=strFont; }
			iupFontDlg:popup()															-- Popup predefined font dialogue
			if iupFontDlg.Status == "1" then
				if iupFontDlg.Value:match("^,") then									-- Font face missing so revert to original font
					iupFontDlg.Value = rawget(fh,strValue)
				end
				fh[strColor] = iupFontDlg.Color										-- Set Head or Body color attribute
				fh[strValue] = iupFontDlg.Value										-- Set FontHead or FontBody font style
				fh.AssignAttributes(tblButtons)										-- Assign the button & frame attributes
				setDialogues()
				isFontChosen = true
			end
		end -- local function getFont

		local function getColor(strColor)												-- Set colour button function
			local strTitle = " Choose colour for "..strColor:gsub("Warn","Warning"):gsub("Risk","Risky").." button & message text "
			local iupColorDlg = iup.colordlg { Title=strTitle; Value=rawget(fh,strColor); ShowColorTable="YES"; }
			iupColorDlg.DialogFrame="YES"
			iupColorDlg:popup()															-- Popup predefined color dialogue fixed size window
			if iupColorDlg.Status == "1" then
				fh[strColor] = iupColorDlg.Value										-- Set Safe or Warn or Risk color attribute
				fh.AssignAttributes(tblButtons)										-- Assign the button & frame attributes
				setDialogues()
				isFontChosen = true
			end
		end -- local function getColor

		local function setDefault()													-- Action for Default Fonts button
			fh.Safe = fh.Green
			fh.Warn = fh.Magenta
			fh.Risk = fh.Red																-- Set default colours
			fh.Body = fh.Black
			fh.Head = fh.Black
			fh.FontBody = iup.GetGlobal("DEFAULTFONT")								-- Set default fonts for Body and Head text
			fh.FontHead = fh.FontBody:gsub(", B?o?l?d?",", Bold")
			fh.AssignAttributes(tblButtons)											-- Assign the button & frame attributes
			setDialogues()
			isFontChosen = true
		end -- local function setDefault

		local function setMinimum()													-- Action for Minimum Size button
			local tblName = tblNameFor(strName)
			local iupDialog = tblName.Dialog											-- Retrieve the parent dialogue handle
			if type(iupDialog) == "userdata" then
				tblName.Raster = "10x10"												-- Refresh parent window to Minimum Size & adjust position
				fh.RefreshDialogue(strName)
			end
			local tblFont = tblNameFor("Font")
			tblFont.Raster = "10x10"													-- Refresh Font window to Minimum Size & adjust position
			fh.RefreshDialogue("Font")
		end -- local function setMinimum

		tblButtons  =  {  {  "Font" ; "FgColor" ; "Tip" ; "action" ; {"TipBalloon";"Balloon";} ; {"Expand";"YES";} ; };
			[btnFontHead] = { "FontHead"; "Head"; "Choose the Heading text Font Face, Style, Size, Effects, and default Colour"; function() getFont("Head") end; };
			[btnFontBody] = { "FontBody"; "Body"; "Choose the Body text Font Face, Style, Size, Effects, and default Colour"   ; function() getFont("Body") end; };
			[btnCol_Safe] = { "FontBody"; "Safe"; "Choose the colour for Safe operations"   ; function() getColor("Safe") end; };
			[btnCol_Warn] = { "FontBody"; "Warn"; "Choose the colour for Warning operations"; function() getColor("Warn") end; };
			[btnCol_Risk] = { "FontBody"; "Risk"; "Choose the colour for Risky operations"  ; function() getColor("Risk") end; };
			[btnDefault ] = { "FontBody"; "Safe"; "Restore default Fonts and Colours"; function() setDefault() end; };
			[btnMinimum ] = { "FontBody"; "Safe"; "Reduce window to its minimum size"; function() setMinimum() end; };
			[btnDestroy ] = { "FontBody"; "Risk"; "Close this dialogue "; function() return iup.CLOSE end; };
			[frmSetFonts] = { "FontHead"; "Head"; };
			} 

		fh.AssignAttributes(tblButtons)												-- Assign the button & frame attributes
		fh.ShowDialogue("Font",dialogFont,btnDestroy,"keep normal")				-- Popup the Set Window Fonts dialogue: "keep normal"  : vary size & posn, and remember size & posn
	--	fh.ShowDialogue("Font",dialogFont,btnDestroy,"popup normal")			-- Popup the Set Window Fonts dialogue: "popup normal" : vary size & posn, but redisplayed centred
	--	fh.ShowDialogue("Font",dialogFont,btnDestroy,"keep")						-- Popup the Set Window Fonts dialogue: "keep" : fixed size, vary posn, and only remember posn
	--	fh.ShowDialogue("Font",dialogFont,btnDestroy,"popup")						-- Popup the Set Window Fonts dialogue: "popup": fixed size, vary posn, but redisplayed centred
		dialogFont:destroy()
		return isFontChosen

	end -- function FontDialogue

	local function anyMemoControl(anyName,fgColor)									-- Compose any control Title and FgColor
		local strName = tostring(anyName)												-- anyName may be a string, and fgColor is default FgColor
		local tipText = nil
		if type(anyName) == "table" then												-- anyName may be a table = { Title string ; FgColor string ; ToolTip string (optional); }
			strName = anyName[1]
			fgColor = anyName[2]:match("%d* %d* %d*") or fgColor
			tipText = anyName[3]
		end
		return strName, fgColor, tipText
	end -- local function anyMemoControl

	local function anyMemoDialogue(strHead,anyHead,strMemo,anyMemo,...)		-- Display framed memo dialogue with buttons
		local arg = {...}																-- Fix for Lua 5.2+
		local intButt = 0																-- Returned value if "X Close" button is used
		local tblButt = { [0]="X Close"; }											-- Button names lookup table
		local strHead, fgcHead, tipHead = anyMemoControl(anyHead or "",strHead)
		local strMemo, fgcMemo, tipMemo = anyMemoControl(anyMemo or "",strMemo)
		-- Create the GUI labels and buttons
		local lblMemo = iup.label { Title=strMemo; FgColor=fgcMemo; Tip=tipMemo; TipBalloon=fh.Balloon; Alignment="ACENTER"; Padding=fh.Margin; Expand="YES"; WordWrap="YES"; }
		local lblLine = iup.label { Separator="HORIZONTAL"; }
		local iupHbox = iup.hbox  { Homogeneous="YES"; }
		local btnButt = iup.button { }
		local strTop  = "YES"															-- Make dialogue TopMost -- V3.6
		local strMode = "popup"
		if arg[1] == "Keep Dialogue" then												-- Keep dialogue open for a progress message
			strMode = "keep dialogue"
			lblLine = iup.label { }
			if not arg[2] then strTop = "NO" end										-- User chooses TopMost -- V3.6
		else
			if #arg == 0 then arg[1] = "OK" end 										-- If no buttons listed then default to an "OK" button
			for intArg, anyButt in ipairs(arg) do
				local strButt, fgcButt, tipButt = anyMemoControl(anyButt,fh.Safe)
				tblButt[intArg] = strButt
				btnButt = iup.button { Title=strButt; FgColor=fgcButt; Tip=tipButt; TipBalloon=fh.Balloon; Expand="NO"; MinSize="80"; Padding=fh.Margin; action=function() intButt=intArg return iup.CLOSE end; }
				iup.Append( iupHbox, btnButt )
			end
		end
		-- Create dialogue and turn off resize, maximize, minimize, and menubox except Close button
		local iupMemo = iup.dialog { Title=fh.Plugin..fh.Version..strHead; TopMost=strTop;	-- TopMost added -- V3.6
								iup.vbox { Alignment="ACENTER"; Gap=fh.Gap; Margin=fh.Margin;
									iup.frame { Title=strHead; FgColor=fgcHead; Font=fh.FontHead;
										iup.vbox { Alignment="ACENTER"; Font=fh.FontBody; lblMemo; lblLine; iupHbox; };
									};
								};
							}
		fh.ShowDialogue("Memo",iupMemo,btnButt,strMode)							-- Show popup Memo dialogue window with righthand button in focus (if any)
		if strMode == "keep dialogue" then return lblMemo, iupMemo end			-- Return label & dialogue controls so message can be changed and dialogue destroyed
		iupMemo:destroy()
		return intButt, tblButt[intButt]												-- Return button number & title that was pressed
	end -- local function anyMemoDialogue

	function fh.MemoDialogue(anyMemo,...)											-- Multi-Button GUI like iup.Alarm and fhMessageBox, with "Memo" in frame
		return anyMemoDialogue(fh.Head,"Memo",fh.Body,anyMemo,...)
	end -- function MemoDialogue

	function fh.WarnDialogue(anyHead,anyMemo,...)									-- Multi-Button GUI like iup.Alarm and fhMessageBox, with heading in frame
		return anyMemoDialogue(fh.Warn,anyHead,fh.Warn,anyMemo,...)
	end -- function WarnDialogue

	function fh.GetRegKey(strKey)														-- Read Windows Registry Key Value
		local luaShell = luacom.CreateObject("WScript.Shell")
		local anyValue = nil
		if pcall( function() anyValue = luaShell:RegRead(strKey) end ) then
			return anyValue																-- Return Key Value if found
		end
		return nil
	end -- function GetRegKey

	function fh.PutRegKey(strKey,anyValue,strType)									-- Write Windows Registry Key Value
		local luaShell = luacom.CreateObject("WScript.Shell")
		local strAns = nil
		if pcall( function() strAns = luaShell:RegWrite(strKey,anyValue,strType) end ) then
			return true
		end
		return nil
	end -- function PutRegKey

	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

	function fh.VersionInStore(strPlugin)											-- Obtain the Version in Plugin Store by Name only	-- V4.5
		local strVersion = "0"
		if strPlugin then
			local strFile = fh.MachinePath.."\\VersionInStore "..strPlugin..".dat"
			general.DeleteFile(strFile)
			local lblMemo, iupMemo = fh.MemoDialogue("Checking for updated version in the Family Historian 'Plugin Store'.","Keep Dialogue")
			local strRequest ="http://www.family-historian.co.uk/lnk/checkpluginversion.php?name="..strPlugin
			local isOK, strReturn = pcall(httpRequest,strRequest)
			iupMemo:destroy()
			if not isOK then																-- Problem with Internet access
				fhMessageBox(strReturn.."\n The Internet appears to be inaccessible. ","MB_OK","MB_ICONEXCLAMATION")
			elseif strReturn ~= nil then
				strVersion = strReturn:match("([%d%.]*),%d*")						-- Version digits & dots then comma and Id digits 
			end
		end
		return strVersion or "0"
	end -- function VersionInStore

	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)	-- V4.1
		for i=1,5 do
			intVersion = intVersion * 100 + tonumber(arrNumbers[i] or 0)
		end
		return intVersion
	end -- local function intVersion

	function fh.CheckVersionInStore()													-- Check if later Version available in Plugin Store	-- V4.5
		local strNewVer = fh.VersionInStore(fh.Plugin:gsub(" %- .*",""))
		local strOldVer = fh.Version
		if intVersion(strNewVer) > intVersion(strOldVer:match("%D*([%d%.]*)")) then
			fh.MemoDialogue("Later Version "..strNewVer.." of this Plugin is available from the 'Plugin Store'.")
		else
			fh.MemoDialogue("No later Version of this Plugin is available from the 'Plugin Store'.")
		end
	end -- function CheckVersionInStore

	function fh.PluginDataScope(strScope)											-- Set default Plugin Data scope to per-Project, or per-User, or per-Machine
		strScope = tostring(strScope):lower()
		if strScope:match("mach") then												-- Per-Machine
			strDefaultScope = "Machine"
		elseif strScope:match("user") then											-- Per-User
			strDefaultScope = "User"
		end																					-- Per-Project is default
	end -- function PluginDataScope

	local function getPluginDataFileName(strScope)									-- Get plugin data filename for chosen scope
		local isOK, strDataFile = pcall(fhGetPluginDataFileName,strScope)
		if not isOK then strDataFile = fhGetPluginDataFileName() end			-- Before V5.0.8 parameter is disallowed and default = CURRENT_PROJECT
		return strDataFile
	end -- local function getPluginDataFileName

	local function getDataFiles(strScope)											-- Compose the Plugin Data file & path & root names
		local strPluginName = fh.Plugin
		local strPluginPlain = stringx.plain(strPluginName)
		local strDataRoot = ""															-- Plugin data file root name -- V4.2
		local strDataPath = ""															-- Plugin data folder path name
		local strDataFile = getPluginDataFileName(strScope)						-- Allow plugins with variant filenames to use same plugin data files
		strDataFile = strDataFile:gsub("\\"..strPluginPlain:gsub(" ","_"):lower(),"\\"..strPluginName)
		strDataFile = strDataFile:gsub("\\"..strPluginPlain..".+%.[D,d][A,a][T,t]$","\\"..strPluginName..".dat")
		if #strDataFile > 0 then														-- Standalone GEDCOM path is ""
			strDataPath = strDataFile:gsub("\\"..strPluginPlain.."%.[D,d][A,a][T,t]$","")
			strDataRoot = strDataPath.."\\"..strPluginName
			general.MakeFolder(strDataPath)											-- V3.4
		end
		return strDataFile, strDataPath, strDataRoot
	end -- local function getDataFiles

	function fh.Initialise(strVersion,strPlugin)									-- Initialise the GUI module with optional Version & Plugin name

		local strAppData = fhGetContextInfo("CI_APP_DATA_FOLDER")
		fh.Plugin = fhGetContextInfo("CI_PLUGIN_NAME")								-- Plugin Name from file
		fh.Version = strVersion or " "												-- Plugin Version
		if fh.Version == " " then
			local strTitle = "\n@Title is missing"
			local strAuthor = "\n@Author is missing"
			local strVersion = "\n@Version is missing"
			local strPlugin = strAppData.."\\Plugins\\"..fh.Plugin..".fh_lua"
			if general.FlgFileExists(strPlugin) then
				for strLine in io.lines(strPlugin) do								-- Read each line from the Plugin file
					strPlugin = strLine:match("^@Title:[\t-\r ]*(.*)")
					if strPlugin then
						strPlugin = strPlugin:gsub("&&","&")
	--?					if fh.Plugin:match("^"..strPlugin:gsub("(%W)","%%%1")) then
						if fh.Plugin:match("^"..stringx.plain(strPlugin)) then
							fh.Plugin = strPlugin										-- Prefer Title to Filename if it matches
							strTitle = nil
						else
							strTitle = "\n@Title differs from Filename"				-- Report abnormality
						end
					end
					if strLine:match("^@Author:%s*(.*)") then						-- Check @Author exists
						strAuthor = nil
					end 
					fh.Version = strLine:gsub("^@Version:%D*([%d%.]*)%D*"," %1 ")
					if fh.Version ~= strLine then										-- Obtain the @Version from Plugin file
						strVersion = nil
						break
					end
				end
				if strTitle or strAuthor or strVersion then							-- Report any header abnormalities
					fhMessageBox("\nScript Header: "..fh.Plugin..(strTitle or "")..(strAuthor or "")..(strVersion or ""),"MB_OK","MB_ICONEXCLAMATION")
				end
			else
				fhMessageBox("\nPlugin has not been saved!","MB_OK","MB_ICONEXCLAMATION")
			end
		end
		fh.History = fh.Version															-- Version History
		fh.Plugin  = strPlugin or fh.Plugin											-- Plugin Name from argument or default from file

		fh.DefaultDialogue()															-- Default "Font","Main" dialogues

		fh.MachineFile,fh.MachinePath,fh.MachineRoot = getDataFiles("LOCAL_MACHINE")		-- Plugin data names per machine
		fh.PerUserFile,fh.PerUserPath,fh.PerUserRoot = getDataFiles("CURRENT_USER")		-- Plugin data names per user
		fh.ProjectFile,fh.ProjectPath,fh.ProjectRoot = getDataFiles("CURRENT_PROJECT")	-- Plugin data names per project

		fh.FhDataPath = fhGetContextInfo("CI_PROJECT_DATA_FOLDER")							-- Paths used by Load/SaveFolder for relative folders		-- V4.0
		fh.PublicPath = fhGetContextInfo("CI_PROJECT_PUBLIC_FOLDER")						-- Public data folder path name		-- V4.0
		if fh.FhDataPath == "" then
			fh.FhDataPath = fh.ProjectPath:gsub("\\Plugin Data$","")
		end
		if fh.PublicPath == "" then
			fh.PublicPath = fh.ProjectPath
			fh.FhProjPath = fh.PublicPath:gsub("^(.+)\\.-\\Plugin Data$","%1")
		else
			general.MakeFolder(fh.PublicPath)											-- V3.4
			fh.FhProjPath = fh.PublicPath:gsub("^(.+)\\.-\\Public$","%1")
		end
		fh.CalicoPie = strAppData:gsub("\\Calico Pie\\.*","\\Calico Pie")		-- Program Data Calico Pie path name

		fh.ComputerName = os.getenv("COMPUTERNAME")									-- Local PC Computer Name

	end -- function Initialise

	fh.Initialise()																		-- Initialise module with default values

	return fh

end -- local function iup_gui_v3

local iup_gui = iup_gui_v3()															-- To access FH IUP GUI build module

-- Preset Global Data Definitions --
function PresetGlobalData()
	iup_gui.Gap = "2"																	-- GUI defaults
	iup_gui.Balloon = "NO" 																-- Needed for PlayOnLinux/Mac	-- V3.8
	iup_gui.SetUtf8Mode()
	IntFhVersion = fhGetAppVersion()
	StrPlusMinus = "±"
	if IntFhVersion > 5 then
		StrPlusMinus = fhConvertANSItoUTF8(StrPlusMinus)							-- Fix "±"	-- V3.7
	end
	StrC = "[%z-\031\127]"																-- LUA control chars pattern becasue "%c" mishandles UTF-8 codes \129 \141 \143 \144 \157 \173
	StrP = "[!-/:-@%[\\%]^_`%]{-~]"													-- LUA punction chars pattern because "%p" mishandles UTF-8 codes
--	i.e. = "[\033-\047\058-\064\091\092\093\094\095\096\123-\126]"
	StrS = "[\t-\r ]"						-- i.e. = "[\009-\013\032]"			-- LUA space chars pattern because "%s" mishandles UTF-8 code (ANSI nbsp=\160)
	StrSP = "[\t-\r !-/:-@%[\\%]^_`%]{-~]"

	StrNonDupsFile = iup_gui.ProjectRoot..".nondups"								-- File names for saved data always ANSI
	StrResultsFile = iup_gui.ProjectRoot..".results"
	StrSoundexFile = iup_gui.ProjectRoot..".soundex"

	PresetGlobalDefaults()																-- User preferences defaults
	SetUserInterfaceDefaults()
	SetNamesMatchDefaults()
	SetEventMatchDefaults()
	SetChronologyDefaults()
	SetOtherMatchDefaults()

end -- function PresetGlobalData

-- Global User Defaults Definition --
function PresetGlobalDefaults()

	TblData = {}																			-- Data table per Individual Record Id of key information

	TblNonDups = {}																		-- Non-Duplicate pairs of Record Id to exclude

	-- User Interface Defaults 		-- Default Value								-- Description of Default
	IntIndiScoreMinDef =     12		--		 12 Points							-- Minimum score needed for Individual to assess Relations
	IntLeastResultsDef =      1		--		  1 Point								-- Result Set lowest score to display
	IntLimitResultsDef =    100		--		100 Rows								-- Result Set limit of rows to display
	IntPruneResultsDef =    200		--		200 Entries							-- Results table threshold to avoid exhausting memory about twice IntLimitResults
	IntProgBarStartDef = 400000		--	400000 Compares							-- Threshold of Comparisons at which to start Progress Bar
																							-- Comparisons = R * (R-1) / 2 where R = Total Individual Records

	-- Names Matching Defaults			-- Default Value								-- Description of Default
	IntLastNameRightDef =     7		--		  7 Points							-- Addition for Lastname perfect match
	IntForeNameRightDef =     6		--		  6 Points							-- Addition for Forename perfect match in the right position
	IntForeNameOtherDef =     3		--		  3 Points							-- Addition for Forename perfect match but in other position
	IntNameSoundexDef   =     2		--		  2 Points							-- Addition for any Name Soundex match only
	IntNameLastWrongDef =    -0		--		 -0 Points							-- Deduction for Lastname total mismatch
	IntNameMinimumDef   =     1		--		  1 Point 							-- Minimum needed to avoid entire Names mismatch
	IntNameDeductionDef =    -0		--		 -0 Points							-- Deduction for Relation entire Names mismatch (not used for Individual)
	IntNameMaximumDef   =    20		--		 20 Points							-- Maximum entire Names match to avoid overwhelming result
	IntNameThresholdDef =     6		--		  6 Points							-- Threshold needed to proceed with Event assessments, etc

	IntIndivi = 1																			-- Table index per Relation
	IntFather = 2
	IntMother = 3
	IntSpouse = 4
	IntChild  = 5

	TblLastNameRight = { }																-- Table entry per Relation
	TblForeNameRight = { }
	TblForeNameOther = { }

	TblNameLastRight = { }
	TblNameForeRight = { }
	TblNameForeOther = { }
 	TblNameSoundex   = { }
	TblNameLastWrong = { }
	TblNameMinimum   = { }
	TblNameDeduction = { }
	TblNameMaximum   = { }
	TblNameThreshold = { }

	-- Event Matching Defaults			-- Default Value								-- Description of Default
	IntDatesToleranceDef =   50		--		±50 Days								-- Tolerance to grant a Lower or Upper Date Timespan match
	IntDatesMatchedDef   =    2		--		  2 Points							-- Addition for a tolerant Lower/Upper Date Timespan match
	IntDatesOverlapDef   =    2		--		  2 Points							-- Addition for an overlapping Date Timespan
	IntDatesMinimumDef   =    1		--		  1 Point 							-- Minimum Dates score to avoid entire Dates mismatch
	IntDatesDeductionDef =  -15		--		-15 Points							-- Deduction for entire Dates mismatch
	IntPlacePartRightDef =    3		--		  3 Points							-- Addition for Place Part perfect match in the right position 
	IntPlacePartOtherDef =    2		--		  2 Points							-- Addition for Place Part perfect match but in other position
	IntPlaceSoundexDef   =    1		--		  1 Point								-- Addition for Place Part Soundex match only
	IntEventMaximumDef   =   10		--		 10 Points							-- Maximum for entire Event match to avoid overwhelming result
	IntBoostedBirthDef   =    1		--		  1 Times								-- Boost score multiplier for Birth Events			-- V3.8
	IntBoostedBapChDef   =    1		--		  1 Times								-- Boost score multiplier for Baptism/Christening	-- V3.8
	IntBoostedMarryDef   =    1		--		  1 Times								-- Boost score multiplier for Marriage Events		-- V3.8
	IntBoostedDeathDef   =    1		--		  1 Times								-- Boost score multiplier for Death/Burial/Cremate	-- V3.8

	-- Date Chronology Defaults		-- Default Value								-- Description of Default
	IntDatesTimespanDef  =   50		--		±50 Years								-- Timespan to extend 'After', 'Before', 'From', 'To' Period & Range Dates
	IntDatesVarianceDef  =    5		--		 ±5 Years								-- Variance for 'Approximate', 'Calculated', 'Estimated' Year only Dates
	IntDatesPregnantDef  =    9		--		  9 Months							-- Pregnancy duration for synthesised Dates for Chronology checks
	IntDatesPubertyDef   =   12		--		 12 Years								-- Minimum puberty age for synthesised Dates for Chronology checks
	IntDatesMarriageDef  =   16		--		 16 Years								-- Minimum marriage age for synthesised Dates for Chronology checks
	IntDatesFertileDef   =   50		--		 50 Years								-- Maximum fertile age for synthesised Dates for Chronology checks
	IntDatesLifespanDef  =  100		--		100 Years								-- Maximum lifespan age for synthesised Dates for Chronology checks
	IntChronMagnitudeDef =   12		--		 12 Months							-- Magnitude of Date Chronology mismatch to deduct 1 point
	IntChronToleranceDef =  -20		--		-20 Points							-- Degree of mismatch tolerated before excluding Individuals
	IntDaysPerYear       =  365.242199												-- Days per year taking account of leap years

	--	Generation Gap Defaults			-- Default Value								-- Description of Default
	IntGenGapFamilyDef   =	   2		--		  2 Gen Gap							-- Largest Generations Up + Down that defines immediate Family to exclude ( < IntGenGapRelative )
	IntGenGapRelativeDef =	   6 		--		  6 Gen Gap							-- Largest Generations Up + Down that defines a close Relative
	IntGenGapDeductDef   =	  -5		--		 -5 Points							-- Deduction for a close Relative as defined above

	--	Gender Mismatch Default
	IntGenderDeductDef   =	 -10		--		-10 Points							-- Deduction for Individual gender mismatch and Child gender mismatch

end -- function PresetGlobalDefaults()

function SetUserInterfaceDefaults()													-- Set User Interface Defaults
	IntIndiScoreMin = IntIndiScoreMinDef
	IntLeastResults = IntLeastResultsDef
	IntLimitResults = IntLimitResultsDef
	IntPruneResults = IntPruneResultsDef
	IntProgBarStart = IntProgBarStartDef
end -- function SetUserInterfaceDefaults

function SetNamesMatchDefaults()														-- Set Names Matching Defaults
	for intRelation = IntIndivi, IntChild do
		TblLastNameRight[intRelation] = IntLastNameRightDef
		TblForeNameRight[intRelation] = IntForeNameRightDef
		TblForeNameOther[intRelation] = IntForeNameOtherDef
		TblNameSoundex  [intRelation] = IntNameSoundexDef
		TblNameLastWrong[intRelation] = IntNameLastWrongDef
		TblNameMinimum  [intRelation] = IntNameMinimumDef
		TblNameDeduction[intRelation] = IntNameDeductionDef
		TblNameMaximum  [intRelation] = IntNameMaximumDef
		TblNameThreshold[intRelation] = IntNameThresholdDef
		SetNamesPoints(intRelation)
	end
end -- function SetNamesMatchDefaults

function SetEventMatchDefaults()														-- Set Event Matching Defaults
	IntDatesTolerance = IntDatesToleranceDef
	IntDatesMatched   = IntDatesMatchedDef
	IntDatesOverlap   = IntDatesOverlapDef
	IntDatesMinimum   = IntDatesMinimumDef
	IntDatesDeduction = IntDatesDeductionDef
	IntPlacePartRight = IntPlacePartRightDef
	IntPlacePartOther = IntPlacePartOtherDef
	IntPlaceSoundex   = IntPlaceSoundexDef
	IntEventMaximum   = IntEventMaximumDef
	IntBoostedBirth   = IntBoostedBirthDef											-- V3.8
	IntBoostedBapCh   = IntBoostedBapChDef											-- V3.8
	IntBoostedMarry   = IntBoostedMarryDef											-- V3.8
	IntBoostedDeath   = IntBoostedDeathDef											-- V3.8
end -- function SetEventMatchDefaults

function SetChronologyDefaults()														-- Set Date Chronology Defaults
	IntDatesTimespan  = IntDatesTimespanDef
	IntDatesVariance  = IntDatesVarianceDef
	IntDatesPregnant  = IntDatesPregnantDef
	IntDatesPuberty   = IntDatesPubertyDef
	IntDatesMarriage  = IntDatesMarriageDef
	IntDatesFertile   = IntDatesFertileDef
	IntDatesLifespan  = IntDatesLifespanDef
	IntChronMagnitude = IntChronMagnitudeDef
	IntChronTolerance = IntChronToleranceDef
end -- function SetChronologyDefaults

function SetOtherMatchDefaults()														-- Set Generation Gap & Gender Mismatch Defaults
	IntGenGapFamily   =	 IntGenGapFamilyDef
	IntGenGapRelative =	 IntGenGapRelativeDef
	IntGenGapDeduct   =	 IntGenGapDeductDef
	IntGenderDeduct   = IntGenderDeductDef
end -- function SetOtherMatchDefaults

function SetNamesPoints(intRelation)													-- Set other Names values from sticky preference values
	TblNameForeOther[intRelation] = TblForeNameOther[intRelation] - TblNameSoundex[intRelation]
	TblNameForeRight[intRelation] = TblForeNameRight[intRelation] - TblNameSoundex[intRelation] - TblNameForeOther[intRelation]
	TblNameLastRight[intRelation] = TblLastNameRight[intRelation] - TblNameSoundex[intRelation]
end -- function SetNamesPoints

function SetEventPoints(flag)															-- Set other Event values from sticky preference values
	IntPartOther = IntPlacePartOther - IntPlaceSoundex
	IntPartRight = IntPlacePartRight - IntPlaceSoundex - IntPartOther
	if flag == nil then SaveSettings() end
end -- function SetEventPoints

function SetChronology(flag)															-- Set other Chronology values from sticky preference values
	IntTimespanDays = math.floor( IntDatesTimespan * IntDaysPerYear )
	IntVarianceDays = math.floor( IntDatesVariance * IntDaysPerYear )
	IntPregnantDays = math.floor( IntDatesPregnant * IntDaysPerYear / 12 )
	IntPubertyDays  = math.floor( IntDatesPuberty  * IntDaysPerYear )
	IntMarriageDays = math.floor( IntDatesMarriage * IntDaysPerYear )
	IntFertileDays  = math.floor( IntDatesFertile  * IntDaysPerYear )
	IntLifespanDays = math.floor( IntDatesLifespan * IntDaysPerYear )
	IntChronMagDays = math.floor( IntChronMagnitude* IntDaysPerYear / 12 )
	if flag == nil then SaveSettings() end
end -- function SetChronology

function SetGenerations(flag)															-- Set other Generations values from sticky preference values
	IntGenGapDeduction = math.abs(IntGenGapDeduct)									-- The 0.01 offset below is to cater for IntGenGapDeduction = 0
	IntFamGenGapMax = -math.floor( ( IntGenGapRelative - IntGenGapFamily - 0.5 ) * IntGenGapDeduction ) - 0.01
	if flag == nil then SaveSettings() end
end -- function SetGenerations

function ResetDefaultSettings()														-- Reset GUI Sticky Settings to Default Values
	iup_gui.CustomDialogue("Main","0x0")												-- Custom "Main" dialogue minimum size & centralisation
	iup_gui.CustomDialogue("Font","0x0")												-- Custom "Font" dialogue minimum size & centralisation
	iup_gui.DefaultDialogue("Bars","Memo")											-- GUI window rastersize and X & Y co-ordinates for "Main","Font","Bars","Memo" dialogues
	StrLast = "Plugin Not Run Yet"													-- Plugin last successful run Date
	StrDate = "1 January 1900"															-- Individual Date Updated threshold
	StrTick = "OFF"																		-- Toggle tick for Last = "OFF" versus Date = "ON"
	StrDiag = "OFF"																		-- Diagnostic Mode toggle
	StrSpan = "OFF"																		-- Timespan Dates toggle
end -- function ResetDefaultSettings

function LoadSettings(strFileName)													-- Load Sticky Settings from File
	iup_gui.LoadSettings()																-- Includes "Main","Font" dialogues and "FontSet" & "History"
	StrDate           = iup_gui.LoadGlobal("Dated",StrDate)						-- Legacy for before V3.0
	StrLast           = iup_gui.LoadGlobal("Last" ,StrLast)
	StrDate           = iup_gui.LoadGlobal("Date" ,StrDate)
	StrTick           = iup_gui.LoadGlobal("Tick" ,StrTick)
--?	StrDiag           = iup_gui.LoadGlobal("Diag" ,StrDiag)
--?	StrSpan           = iup_gui.LoadGlobal("Span" ,StrSpan)
	if StrDate == StrLast then															-- Prior to V3.4 the StrLast date was moved to StrDate to use it as threshold
		StrDate = "1 January 1900"														-- Adjust to V3.4 arrangement keeping the dates separate selected by toggles
		StrTick = "ON"
	end

	IntIndiScoreMin   = iup_gui.LoadGlobal("IndiScoreMin"  , IntIndiScoreMin)
	IntLeastResults   = iup_gui.LoadGlobal("LeastResults"  , IntLeastResults)
	IntLimitResults   = iup_gui.LoadGlobal("LimitResults"  , IntLimitResults)
	IntPruneResults   = iup_gui.LoadGlobal("PruneResults"  , IntPruneResults)

	TblLastNameRight  = iup_gui.LoadGlobal("LastNameRight" , TblLastNameRight)
	TblForeNameRight  = iup_gui.LoadGlobal("ForeNameRight" , TblForeNameRight)
	TblForeNameOther  = iup_gui.LoadGlobal("ForeNameOther" , TblForeNameOther)
	TblNameSoundex    = iup_gui.LoadGlobal("NameSoundex"   , TblNameSoundex)
	TblNameLastWrong  = iup_gui.LoadGlobal("NameLastWrong" , TblNameLastWrong)
	TblNameMinimum    = iup_gui.LoadGlobal("NameMinimum"   , TblNameMinimum)
	TblNameDeduction  = iup_gui.LoadGlobal("NameDeduction" , TblNameDeduction)
	TblNameMaximum    = iup_gui.LoadGlobal("NameMaximum"   , TblNameMaximum)
	TblNameThreshold  = iup_gui.LoadGlobal("NameThreshold" , TblNameThreshold)

	IntDatesTolerance = iup_gui.LoadGlobal("DatesTolerance", IntDatesTolerance)
	IntDatesMatched   = iup_gui.LoadGlobal("DatesMatched"  , IntDatesMatched)
	IntDatesOverlap   = iup_gui.LoadGlobal("DatesOverlap"  , IntDatesOverlap)
	IntDatesMinimum   = iup_gui.LoadGlobal("DatesMinimum"  , IntDatesMinimum)
	IntDatesDeduction = iup_gui.LoadGlobal("DatesDeduction", IntDatesDeduction)
	IntPlacePartRight = iup_gui.LoadGlobal("PlacePartRight", IntPlacePartRight)
	IntPlacePartOther = iup_gui.LoadGlobal("PlacePartOther", IntPlacePartOther)
	IntPlaceSoundex   = iup_gui.LoadGlobal("PlaceSoundex"  , IntPlaceSoundex)
	IntEventMaximum   = iup_gui.LoadGlobal("EventMaximum"  , IntEventMaximum)
	IntBoostedBirth   = iup_gui.LoadGlobal("BoostedBirth"  , IntBoostedBirth)	-- V3.8
	IntBoostedBapCh   = iup_gui.LoadGlobal("BoostedBapCh"  , IntBoostedBapCh)	-- V3.8
	IntBoostedMarry   = iup_gui.LoadGlobal("BoostedMarry"  , IntBoostedMarry)	-- V3.8
	IntBoostedDeath   = iup_gui.LoadGlobal("BoostedDeath"  , IntBoostedDeath)	-- V3.8

	IntDatesTimespan  = iup_gui.LoadGlobal("DatesTimespan" , IntDatesTimespan)
	IntDatesVariance  = iup_gui.LoadGlobal("DatesVariance" , IntDatesVariance)
	IntDatesPregnant  = iup_gui.LoadGlobal("DatesPregnant" , IntDatesPregnant)
	IntDatesPuberty   = iup_gui.LoadGlobal("DatesPuberty"  , IntDatesPuberty)
	IntDatesMarriage  = iup_gui.LoadGlobal("DatesMarriage" , IntDatesMarriage)
	IntDatesFertile   = iup_gui.LoadGlobal("DatesFertile"  , IntDatesFertile)
	IntDatesLifespan  = iup_gui.LoadGlobal("DatesLifespan" , IntDatesLifespan)
	IntChronMagnitude = iup_gui.LoadGlobal("ChronMagnitude", IntChronMagnitude)
	IntChronTolerance = iup_gui.LoadGlobal("ChronTolerance", IntChronTolerance)

	IntGenGapFamily   = iup_gui.LoadGlobal("GenGapFamily"  , IntGenGapFamily)
	IntGenGapRelative = iup_gui.LoadGlobal("GenGapRelative", IntGenGapRelative)
	IntGenGapDeduct   = iup_gui.LoadGlobal("GenGapDeduct"  , IntGenGapDeduct)
	IntGenderDeduct   = iup_gui.LoadGlobal("GenderDeduct"  , IntGenderDeduct)

	for intRelation = IntIndivi, IntChild do
		SetNamesPoints(intRelation)													-- Assign derived User Preference settings
	end
	SetEventPoints("No SaveSettings")
	SetChronology ("No SaveSettings")
	SetGenerations("No SaveSettings")
	if general.FlgFileExists(StrNonDupsFile) then
		TblNonDups, StrErr = table.load(StrNonDupsFile)							-- Load Non-Duplicates table
	end
	SaveSettings()																		-- Save Sticky Data settings (must be last)
end -- function LoadSettings

function SaveSettings(strFileName)													-- Save Sticky Settings to File

	iup_gui.SaveGlobal("Last",StrLast)
	iup_gui.SaveGlobal("Date",StrDate)
	iup_gui.SaveGlobal("Tick",StrTick)
--?	iup_gui.SaveGlobal("Diag",StrDiag)
--?	iup_gui.SaveGlobal("Span",StrSpan)

	iup_gui.SaveGlobal("IndiScoreMin"  , IntIndiScoreMin)
	iup_gui.SaveGlobal("LeastResults"  , IntLeastResults)
	iup_gui.SaveGlobal("LimitResults"  , IntLimitResults)
	iup_gui.SaveGlobal("PruneResults"  , IntPruneResults)

	iup_gui.SaveGlobal("LastNameRight" , TblLastNameRight)
	iup_gui.SaveGlobal("ForeNameRight" , TblForeNameRight)
	iup_gui.SaveGlobal("ForeNameOther" , TblForeNameOther)
	iup_gui.SaveGlobal("NameSoundex"   , TblNameSoundex)
	iup_gui.SaveGlobal("NameLastWrong" , TblNameLastWrong)
	iup_gui.SaveGlobal("NameMinimum"   , TblNameMinimum)
	iup_gui.SaveGlobal("NameDeduction" , TblNameDeduction)
	iup_gui.SaveGlobal("NameMaximum"   , TblNameMaximum)
	iup_gui.SaveGlobal("NameThreshold" , TblNameThreshold)

	iup_gui.SaveGlobal("DatesTolerance", IntDatesTolerance)
	iup_gui.SaveGlobal("DatesMatched"  , IntDatesMatched)
	iup_gui.SaveGlobal("DatesOverlap"  , IntDatesOverlap)
	iup_gui.SaveGlobal("DatesMinimum"  , IntDatesMinimum)
	iup_gui.SaveGlobal("DatesDeduction", IntDatesDeduction)
	iup_gui.SaveGlobal("PlacePartRight", IntPlacePartRight)
	iup_gui.SaveGlobal("PlacePartOther", IntPlacePartOther)
	iup_gui.SaveGlobal("PlaceSoundex"  , IntPlaceSoundex)
	iup_gui.SaveGlobal("EventMaximum"  , IntEventMaximum)
	iup_gui.SaveGlobal("BoostedBirth"  , IntBoostedBirth)							-- V3.8
	iup_gui.SaveGlobal("BoostedBapCh"  , IntBoostedBapCh)							-- V3.8
	iup_gui.SaveGlobal("BoostedMarry"  , IntBoostedMarry)							-- V3.8
	iup_gui.SaveGlobal("BoostedDeath"  , IntBoostedDeath)							-- V3.8

	iup_gui.SaveGlobal("DatesTimespan" , IntDatesTimespan)
	iup_gui.SaveGlobal("DatesVariance" , IntDatesVariance)
	iup_gui.SaveGlobal("DatesPregnant" , IntDatesPregnant)
	iup_gui.SaveGlobal("DatesPuberty"  , IntDatesPuberty)
	iup_gui.SaveGlobal("DatesMarriage" , IntDatesMarriage)
	iup_gui.SaveGlobal("DatesFertile"  , IntDatesFertile)
	iup_gui.SaveGlobal("DatesLifespan" , IntDatesLifespan)
	iup_gui.SaveGlobal("ChronMagnitude", IntChronMagnitude)
	iup_gui.SaveGlobal("ChronTolerance", IntChronTolerance)

	iup_gui.SaveGlobal("GenGapFamily"  , IntGenGapFamily)
	iup_gui.SaveGlobal("GenGapRelative", IntGenGapRelative)
	iup_gui.SaveGlobal("GenGapDeduct"  , IntGenGapDeduct)
	iup_gui.SaveGlobal("GenderDeduct"  , IntGenderDeduct)

	iup_gui.SaveSettings()																-- Includes "Main","Font" dialogues and "FontSet" & "History"
	table.save(TblNonDups,StrNonDupsFile)											-- Save Non-Duplicates table

end -- function SaveSettings

function GUI_MainDialogue()															-- Graphical User Interface

	local function setNameItem(tblName,intRelation,intItem)						-- Set Name for Relation spin value item
		tblName[intRelation] = intItem
		SetNamesPoints(intRelation)
		SaveSettings()
	end -- local function setNameItem

	-- Create the Find Duplicates controls with title/value
	local tglLast   = iup.toggle { Title="Tick to include only Individuals updated after Plugin last run Date :"; RightButton="YES"; }
	local lblLast   = iup.label  { Title=StrLast; Size="80"; }
	local tglDate   = iup.toggle { Title="Tick to include only Individuals updated after this adjustable Date :"; RightButton="YES"; }
	local lblDate   = iup.label  { Title=StrDate; Size="80"; }
	local btnPick   = iup.button { Title=" Click here to select any subset of Individuals to be included "; }
	local lblPick   = iup.label  { Title="0 Records"; }
	local lblLine   = iup.label  { Separator="HORIZONTAL"; }
	local btnFind   = iup.button { Expand="YES"; Title="Find any Duplicates constrained by the Included subset of Individuals chosen above"; }
	local lblTime   = iup.label  { Title="Estimated run time to check Individuals for Duplicates is 99 min 99 sec"; }
	local btnShow   = iup.button { Title="Show the previous Result Set of Duplicates in Family Historian"; }
	local tglDiag   = iup.toggle { Title="Enable Diagnostic Mode :"; Value=StrDiag; RightButton="YES"; }
	local lblNull   = iup.label  { Title=" "; }
	local tglSpan   = iup.toggle { Title="Including Timespan Dates :"; Value=StrSpan; RightButton="YES"; Active="NO"; }

	-- Create the Omit Non-Duplicates controls with title/value
	local lblResult = iup.label  { Title="Result Set Entries"; }
	local lstResult = iup.list   { Value=""; VisibleLines=9; Multiple="YES"; }
	local btnSetAll = iup.button { Title="Select All"; }
	local btnSetNil = iup.button { Title="Select None"; }
	local btnMovAll = iup.button { Title="Move All"; }
	local btnMovSet = iup.button { Title="Move Selected"; }
	local lblNonDup = iup.label  { Title="Non-Duplicates List"; }
	local lstNonDup = iup.list   { Value=""; VisibleLines=9; Multiple="YES"; }
	local btnSelAll = iup.button { Title="Select All"; }
	local btnSelNil = iup.button { Title="Select None"; }
	local btnDelAll = iup.button { Title="Erase List"; }
	local btnDelSel = iup.button { Title="Erase Selected"; }

	-- Create the Dialogue Common controls with title/value
	local btnUpdates = iup.button	{ Title="Check for Updates"; }				-- V3.9
	local btnGetHelp = iup.button { Title="Help && Advice"; }
	local btnDestroy = iup.button { Title="Close Plugin"; }

	-- Create the Set Preferences controls with title/value
	local tblSet = {}																	-- Table to hold all the Set Preference tab controls
	local intTab = 0																		-- Index to Tab number on Set Preference tab
	local tblTab = {}																	-- Table of current Preference Tab number 
	local intRow = 0																		-- Index to Row of controls on each Tab
	local tblRow = {}																	-- Table of current Row of controls
	for intTab = 1, 5 do																-- Set the default attributes for up to 5 Tabs and 15 Rows of controls
		tblSet[intTab] = {}
		tblTab = tblSet[intTab]
		for intRow = 1, 15 do															-- Local function setControls() sets Font & FgColor & BgColor & Padding
			tblTab[intRow] = {}
			tblRow = tblTab[intRow]
			tblRow.Heading = iup.label { Title=" "; Expand="HORIZONTAL"; Padding="x1"; }
			if intTab == 2 then															-- Defaults for Names Matching 2nd tab
				if intRow == 1 then														-- 1st Row has Relations titles, and Defaults button
					local tblTitle = { "Individual "; "Father "; "Mother "; "Spouse "; "Child "; }
					for intRel = IntIndivi, IntChild do
						tblRow[intRel]=iup.label{ Title=tblTitle[intRel]; Alignment="ACENTER:ACENTER"; }
					end
					tblRow.Default = iup.button{ Title="Defaults "; Tip="Restore defaults below"; }
				else																		-- Other Rows have Relations spin controls, and default integers & measurements
					for intRel = IntIndivi, IntChild do
					 tblRow[intRel] = iup.text { Spin="YES"; Border="NO"; Alignment="ARIGHT"; RasterSize=90; ReadOnly="YES"; SpinAlign="RIGHT"; SpinValue=0; SpinInc=1; SpinMin=0; SpinMax=100; }
					end
					tblRow.Integer = iup.label { Title="9"; Expand="YES"; Alignment="ARIGHT:ATOP"; }	-- V3.8
					tblRow.Measure = iup.label { Title="Points"; }
					tblRow.Default = iup.hbox  { Homogeneous="YES"; tblRow.Integer; tblRow.Measure; Margin=0; }
				end
				tblRow.Overall = iup.hbox { Homogeneous="YES"; tblRow.Heading; tblRow[IntIndivi]; tblRow[IntFather]; tblRow[IntMother]; tblRow[IntSpouse]; tblRow[IntChild]; tblRow.Default; }
			else																			-- Defaults for all except Names Matching 2nd tab
				if intRow == 1 then														-- 1st Row has bold title, Current Settings title, and Default Settings button
					tblRow.Current = iup.label { Title=" Current Settings "; }
					tblRow.Default = iup.button{ Title=" Default Settings "; Tip="Restore defaults below"; }
				else																		-- Other Rows have Settings spin control, and default integers & measurements
					tblRow.Current = iup.text  { Spin="YES"; Border="NO"; Alignment="ARIGHT"; RasterSize=110; ReadOnly="YES"; SpinAlign="RIGHT"; SpinValue=0; SpinInc=1; SpinMin=0; SpinMax=100; }
					tblRow.Integer = iup.label { Title="9"; Expand="YES"; Alignment="ARIGHT:ATOP"; }	-- V3.8
					tblRow.Measure = iup.label { Title=" Points "; }
					tblRow.Default = iup.hbox  { Homogeneous="YES"; tblRow.Integer; tblRow.Measure; Margin=0; }
				end
				tblRow.Overall = iup.hbox { Homogeneous="YES"; tblRow.Heading; tblRow.Current; tblRow.Default; }
			end
		end
	end

	intTab = intTab + 1 																-- User Interface is 1st tab
	tblTab = tblSet[intTab]
	intRow = 1
	local intInter = intTab																-- Save tab number for Preferences tab control
	local tblResultSetLim = tblTab[intRow]
	tblResultSetLim.Heading.Title    = "Result Set Limits"						-- 1st row with Default Settings button
	intRow = intRow + 1
	local tblIndiScoreMin = tblTab[intRow]											-- 2nd row
	tblIndiScoreMin.Heading.Title    = "Individual Threshold"
	tblIndiScoreMin.Current.SpinMin  = -100
	tblIndiScoreMin.Current.SpinMax  = 100
	tblIndiScoreMin.Current.spin_cb  = function(self,intItem) IntIndiScoreMin=intItem SaveSettings() end
	tblIndiScoreMin.Integer.Title    = tostring(IntIndiScoreMinDef)
	intRow = intRow + 1
	local tblLeastResults = tblTab[intRow]											-- 3rd row
	tblLeastResults.Heading.Title    = "Results Minimum Score"
	tblLeastResults.Current.SpinMin  = -100
	tblLeastResults.Current.SpinMax  = 100
	tblLeastResults.Current.spin_cb  = function(self,intItem) IntLeastResults=intItem SaveSettings() end
	tblLeastResults.Integer.Title    = tostring(IntLeastResultsDef)
	tblLeastResults.Measure.Title    = " Point "
	intRow = intRow + 1
	local tblLimitResults = tblTab[intRow]											-- 4th row
	tblLimitResults.Heading.Title    = "Results Maximum Rows"
	tblLimitResults.Current.SpinMin  = 20
	tblLimitResults.Current.SpinMax  = 500
	tblLimitResults.Integer.Title    = tostring(IntLimitResultsDef)
	tblLimitResults.Measure.Title    = " Rows "
	intRow = intRow + 1
	local tblPruneResults = tblTab[intRow]											-- 5th row
	tblPruneResults.Heading.Title    = "Memory Conservation"
	tblPruneResults.Current.SpinMin  = 20
	tblPruneResults.Current.SpinMax  = 1000
	tblPruneResults.Current.spin_cb  = function(self,intItem) IntPruneResults=intItem SetEventPoints() end
	tblPruneResults.Integer.Title    = tostring(IntPruneResultsDef)
	tblPruneResults.Measure.Title    = " Entries "
	intRow = intRow + 1
	iup.Destroy(tblTab[intRow].Overall)												-- 6th row replaces defaults with a horizontal separator
	tblTab[intRow].Overall   = iup.vbox { iup.hbox { Margin="8x15"; }; iup.label { Separator="HORIZONTAL"; }; Margin="1x1"; }
	intRow = intRow + 1
	local btnDefault = iup.button { Title="Restore GUI Defaults"; }
	local btnSoundex = iup.button { Title="Erase Soundex Cache"; }				-- 7th row replaces defaults with three buttons
	local btnSetFont = iup.button { Title="Set Window Fonts"; }
	iup.Destroy(tblTab[intRow].Overall)
	tblTab[intRow].Overall = iup.hbox { btnDefault; btnSoundex; btnSetFont; Homogeneous="YES"; Margin="30x50"; Gap="10"; }
	intRow = intRow + 1
	tblTab[intRow] = nil

	intTab = intTab + 1 																-- Names Matching 2nd tab --
	tblTab = tblSet[intTab]
	intRow = 1
	local intNames = intTab																-- Save tab number for Preferences tab control
	local tblNamesDefaults = tblTab[intRow]
	tblNamesDefaults.Heading.Title    = "Names"										-- 1st row with Defaults button
	intRow = intRow + 1
	local tblLastNameRight = tblTab[intRow]											-- 2nd row
	tblLastNameRight.Heading.Title    = "Last Right"
	for intRel = IntIndivi, IntChild do
	 tblLastNameRight[intRel].spin_cb = function(self,intItem) setNameItem(TblLastNameRight,intRel,intItem) end
	end
	tblLastNameRight.Integer.Title    = tostring(IntLastNameRightDef)
	intRow = intRow + 1
	local tblForeNameRight = tblTab[intRow]											-- 3rd row
	tblForeNameRight.Heading.Title    = "Fore Right"
	for intRel = IntIndivi, IntChild do
	 tblForeNameRight[intRel].spin_cb = function(self,intItem) setNameItem(TblForeNameRight,intRel,intItem) end
	end
	tblForeNameRight.Integer.Title    = tostring(IntForeNameRightDef)
	intRow = intRow + 1
	local tblForeNameOther = tblTab[intRow]											-- 4th row
	tblForeNameOther.Heading.Title    = "Fore Other"
	for intRel = IntIndivi, IntChild do
	 tblForeNameOther[intRel].SpinMin = -100
	 tblForeNameOther[intRel].spin_cb = function(self,intItem) setNameItem(TblForeNameOther,intRel,intItem) end
	end
	tblForeNameOther.Integer.Title    = tostring(IntForeNameOtherDef)
	intRow = intRow + 1
	local tblNameSoundex = tblTab[intRow]											-- 5th row
	tblNameSoundex.Heading.Title      = "Soundex"
	for intRel = IntIndivi, IntChild do
	 tblNameSoundex[intRel].spin_cb   = function(self,intItem) setNameItem(TblNameSoundex,intRel,intItem) end
	end
	tblNameSoundex.Integer.Title      = tostring(IntNameSoundexDef)
	intRow = intRow + 1
	local tblNameLastWrong = tblTab[intRow]											-- 6th row
	tblNameLastWrong.Heading.Title    = "Last Wrong"
	for intRel = IntIndivi, IntChild do
	 tblNameLastWrong[intRel].SpinMin = -100
	 tblNameLastWrong[intRel].SpinMax = 0
	 tblNameLastWrong[intRel].spin_cb = function(self,intItem) setNameItem(TblNameLastWrong,intRel,intItem) end
	end
	tblNameLastWrong.Integer.Title    = tostring(IntNameLastWrongDef)
	intRow = intRow + 1
	local tblNameMinimum = tblTab[intRow]											-- 7th row
	tblNameMinimum.Heading.Title      = "Minimum"
	tblNameMinimum[IntIndivi].visible = "NO"
	for intRel = IntIndivi, IntChild do
	 tblNameMinimum[intRel].spin_cb   = function(self,intItem) setNameItem(TblNameMinimum,intRel,intItem) end
	end
	tblNameMinimum.Integer.Title      = tostring(IntNameMinimumDef)
	tblNameMinimum.Measure.Title      = "Point "
	intRow = intRow + 1
	local tblNameDeduction = tblTab[intRow]											-- 8th row
	tblNameDeduction.Heading.Title    = "Deduction"
	tblNameDeduction[IntIndivi].visible= "NO"
	for intRel = IntFather, IntChild do
	 tblNameDeduction[intRel].SpinMin = -100
	 tblNameDeduction[intRel].SpinMax = 0
	 tblNameDeduction[intRel].spin_cb = function(self,intItem) setNameItem(TblNameDeduction,intRel,intItem) end
	end
	tblNameDeduction.Integer.Title    = tostring(IntNameDeductionDef)
	intRow = intRow + 1
	local tblNameMaximum = tblTab[intRow]											-- 9th row
	tblNameMaximum.Heading.Title      = "Maximum"
	for intRel = IntIndivi, IntChild do
	 tblNameMaximum[intRel].spin_cb   = function(self,intItem) setNameItem(TblNameMaximum,intRel,intItem) end
	end
	tblNameMaximum.Integer.Title      = tostring(IntNameMaximumDef)
	intRow = intRow + 1
	local tblNameThreshold = tblTab[intRow]											-- 10th row
	tblNameThreshold.Heading.Title    = "Threshold"
	for intRel = IntIndivi, IntChild do
	 tblNameThreshold[intRel].spin_cb = function(self,intItem) setNameItem(TblNameThreshold,intRel,intItem) end
	end
	tblNameThreshold.Integer.Title    = tostring(IntNameThresholdDef)
	intRow = intRow + 1
	tblTab[intRow] = nil

	intTab = intTab + 1 																-- Event Matching 3rd tab --
	tblTab = tblSet[intTab]
	intRow = 1
	local intEvent = intTab																-- Save tab number for Preferences tab control
	local tblEventDefault = tblTab[intRow]
	tblEventDefault.Heading.Title     = "Events"									-- 1st row with Default Settings button
	intRow = intRow + 1
	local tblDatesTolerance = tblTab[intRow]										-- 2nd row
	tblDatesTolerance.Heading.Title   = "Dates Tolerance"
	tblDatesTolerance.Current.SpinMax = 200
	tblDatesTolerance.Current.spin_cb = function(self,intItem) IntDatesTolerance=intItem SetEventPoints() end
	tblDatesTolerance.Integer.Title   = StrPlusMinus..tostring(IntDatesToleranceDef)
	tblDatesTolerance.Measure.Title   = " Days "
	intRow = intRow + 1
	local tblDatesMatched = tblTab[intRow]											-- 3rd row
	tblDatesMatched.Heading.Title     = "Dates Matched"
	tblDatesMatched.Current.spin_cb   = function(self,intItem) IntDatesMatched=intItem SetEventPoints() end
	tblDatesMatched.Integer.Title     = tostring(IntDatesMatchedDef)
	intRow = intRow + 1
	local tblDatesOverlap = tblTab[intRow]											-- 4th row
	tblDatesOverlap.Heading.Title     = "Dates Overlap"
	tblDatesOverlap.Current.spin_cb   = function(self,intItem) IntDatesOverlap=intItem SetEventPoints() end
	tblDatesOverlap.Integer.Title     = tostring(IntDatesOverlapDef)
	intRow = intRow + 1
	local tblDatesMinimum = tblTab[intRow]											-- 5th row
	tblDatesMinimum.Heading.Title     = "Dates Minimum"
	tblDatesMinimum.Current.spin_cb   = function(self,intItem) IntDatesMinimum=intItem SetEventPoints() end
	tblDatesMinimum.Integer.Title     = tostring(IntDatesMinimumDef)
	tblDatesMinimum.Measure.Title     = " Point "
	intRow = intRow + 1
	local tblDatesDeduction = tblTab[intRow]										-- 6th row
	tblDatesDeduction.Heading.Title   = "Dates Deduction"
	tblDatesDeduction.Current.SpinMin = -100
	tblDatesDeduction.Current.SpinMax = 0
	tblDatesDeduction.Current.spin_cb = function(self,intItem) IntDatesDeduction=intItem SetEventPoints() end
	tblDatesDeduction.Integer.Title   = tostring(IntDatesDeductionDef)
	intRow = intRow + 1
	local tblPlacePartRight = tblTab[intRow]										-- 7th row
	tblPlacePartRight.Heading.Title   = "Place Part Right"
	tblPlacePartRight.Current.spin_cb = function(self,intItem) IntPlacePartRight=intItem SetEventPoints() end
	tblPlacePartRight.Integer.Title   = tostring(IntPlacePartRightDef)
	intRow = intRow + 1
	local tblPlacePartOther = tblTab[intRow]										-- 8th row
	tblPlacePartOther.Heading.Title   = "Place Part Other"
	tblPlacePartOther.Current.spin_cb = function(self,intItem) IntPlacePartOther=intItem SetEventPoints() end
	tblPlacePartOther.Integer.Title   = tostring(IntPlacePartOtherDef)
	intRow = intRow + 1
	local tblPlaceSoundex = tblTab[intRow]											-- 9th row
	tblPlaceSoundex.Heading.Title     = "Place Part Soundex"
	tblPlaceSoundex.Current.spin_cb   = function(self,intItem) IntPlaceSoundex=intItem SetEventPoints() end
	tblPlaceSoundex.Integer.Title     = tostring(IntPlaceSoundexDef)
	tblPlaceSoundex.Measure.Title     = " Point "
	intRow = intRow + 1
	local tblEventMaximum = tblTab[intRow]											-- 10th row
	tblEventMaximum.Heading.Title     = "Event Maximum"
	tblEventMaximum.Current.spin_cb   = function(self,intItem) IntEventMaximum=intItem SetEventPoints() end
	tblEventMaximum.Integer.Title     = tostring(IntEventMaximumDef)
	intRow = intRow + 1
	local tblBoostedBirth = tblTab[intRow]											-- 11th row		-- V3.8
	tblBoostedBirth.Heading.Title     = "Boost Birth Events"
	tblBoostedBirth.Current.spin_cb   = function(self,intItem) IntBoostedBirth=intItem SetEventPoints() end
	tblBoostedBirth.Integer.Title     = tostring(IntBoostedBirthDef)
	tblBoostedBirth.Measure.Title     = " Times "
	intRow = intRow + 1
	local tblBoostedBapCh = tblTab[intRow]											-- 12th row		-- V3.8
	tblBoostedBapCh.Heading.Title     = "Boost Baptism/Christening"
	tblBoostedBapCh.Current.spin_cb   = function(self,intItem) IntBoostedBapCh=intItem SetEventPoints() end
	tblBoostedBapCh.Integer.Title     = tostring(IntBoostedBapChDef)
	tblBoostedBapCh.Measure.Title     = " Times "
	intRow = intRow + 1
	local tblBoostedMarry = tblTab[intRow]											-- 13th row		-- V3.8
	tblBoostedMarry.Heading.Title     = "Boost Marriage Events"
	tblBoostedMarry.Current.spin_cb   = function(self,intItem) IntBoostedMarry=intItem SetEventPoints() end
	tblBoostedMarry.Integer.Title     = tostring(IntBoostedMarryDef)
	tblBoostedMarry.Measure.Title     = " Times "
	intRow = intRow + 1
	local tblBoostedDeath = tblTab[intRow]											-- 14th row		-- V3.8
	tblBoostedDeath.Heading.Title     = "Boost Death/Burial/Cremate"
	tblBoostedDeath.Current.spin_cb   = function(self,intItem) IntBoostedDeath=intItem SetEventPoints() end
	tblBoostedDeath.Integer.Title     = tostring(IntBoostedDeathDef)
	tblBoostedDeath.Measure.Title     = " Times "
	intRow = intRow + 1
	tblTab[intRow] = nil

	intTab = intTab + 1 																-- Date Chronology 4th tab --
	tblTab = tblSet[intTab]
	intRow = 1
	local intChron = intTab																-- Save tab number for Preferences tab control
	local tblChronDefault = tblTab[intRow]
	tblChronDefault.Heading.Title     = "Dates"										-- 1st row with Default Settings button
	intRow = intRow + 1
	local tblDatesTimespan = tblTab[intRow]											-- 2nd row
	tblDatesTimespan.Heading.Title    = "Dates Timespan"
	tblDatesTimespan.Current.SpinMax  = 200
	tblDatesTimespan.Current.spin_cb  = function(self,intItem) IntDatesTimespan=intItem SetChronology() end
	tblDatesTimespan.Integer.Title    = StrPlusMinus..tostring(IntDatesTimespanDef)
	tblDatesTimespan.Measure.Title    = " Years "
	intRow = intRow + 1
	local tblDatesVariance = tblTab[intRow]											-- 3rd row
	tblDatesVariance.Heading.Title    = "Dates Variance"
	tblDatesVariance.Current.spin_cb  = function(self,intItem) IntDatesVariance=intItem SetChronology() end
	tblDatesVariance.Integer.Title    = StrPlusMinus..tostring(IntDatesVarianceDef)
	tblDatesVariance.Measure.Title    = " Years "
	intRow = intRow + 1
	local tblDatesPregnant = tblTab[intRow]											-- 4th row
	tblDatesPregnant.Heading.Title    = "Pregnancy Duration"
	tblDatesPregnant.Current.SpinMin  = 6
	tblDatesPregnant.Current.SpinMax  = 12
	tblDatesPregnant.Current.spin_cb  = function(self,intItem) IntDatesPregnant=intItem SetChronology() end
	tblDatesPregnant.Integer.Title    = tostring(IntDatesPregnantDef)
	tblDatesPregnant.Measure.Title    = " Months "
	intRow = intRow + 1
	local tblDatesPuberty = tblTab[intRow]											-- 5th row
	tblDatesPuberty.Heading.Title     = "Min Puberty Age"
	tblDatesPuberty.Current.SpinMin   = 10
	tblDatesPuberty.Current.SpinMax   = 20
	tblDatesPuberty.Current.spin_cb   = function(self,intItem) IntDatesPuberty=intItem SetChronology() end
	tblDatesPuberty.Integer.Title     = tostring(IntDatesPubertyDef)
	tblDatesPuberty.Measure.Title     = " Years "
	intRow = intRow + 1
	local tblDatesMarriage = tblTab[intRow]											-- 6th row
	tblDatesMarriage.Heading.Title    = "Min Marriage Age"
	tblDatesMarriage.Current.SpinMin  = 10
	tblDatesMarriage.Current.SpinMax  = 20
	tblDatesMarriage.Current.spin_cb  = function(self,intItem) IntDatesMarriage=intItem SetChronology() end
	tblDatesMarriage.Integer.Title    = tostring(IntDatesMarriageDef)
	tblDatesMarriage.Measure.Title    = " Years "
	intRow = intRow + 1
	local tblDatesFertile = tblTab[intRow]											-- 7th row
	tblDatesFertile.Heading.Title     = "Max Fertile Age"
	tblDatesFertile.Current.SpinMin   = 40
	tblDatesFertile.Current.SpinMax   = 80
	tblDatesFertile.Current.spin_cb   = function(self,intItem) IntDatesFertile=intItem SetChronology() end
	tblDatesFertile.Integer.Title     = tostring(IntDatesFertileDef)
	tblDatesFertile.Measure.Title     = " Years "
	intRow = intRow + 1
	local tblDatesLifespan = tblTab[intRow]											-- 8th row
	tblDatesLifespan.Heading.Title    = "Max Lifespan Age"
	tblDatesLifespan.Current.SpinMin  = 60
	tblDatesLifespan.Current.SpinMax  = 140
	tblDatesLifespan.Current.spin_cb  = function(self,intItem) IntDatesLifespan=intItem SetChronology() end
	tblDatesLifespan.Integer.Title    = tostring(IntDatesLifespanDef)
	tblDatesLifespan.Measure.Title    = " Years "
	intRow = intRow + 1
	local tblChronMagnitude = tblTab[intRow]										-- 9th row
	tblChronMagnitude.Heading.Title   = "Chron Magnitude"
	tblChronMagnitude.Current.SpinMin = 1
	tblChronMagnitude.Current.SpinMax = 120
	local txtChMag = tblChronMagnitude.Current
	tblChronMagnitude.Integer.Title   = tostring(IntChronMagnitudeDef)
	tblChronMagnitude.Measure.Title   = " Months "
	intRow = intRow + 1
	local tblChronTolerance = tblTab[intRow]										-- 10th row
	tblChronTolerance.Heading.Title   = "Chron Tolerance"
	tblChronTolerance.Current.SpinMin = -100
	tblChronTolerance.Current.SpinMax = 0
	tblChronTolerance.Current.spin_cb = function(self,intItem) IntChronTolerance=intItem SetChronology() end
	tblChronTolerance.Integer.Title   = tostring(IntChronToleranceDef)
	intRow = intRow + 1
	tblTab[intRow] = nil

	intTab = intTab + 1 																-- Family & Gender 5th tab --
	tblTab = tblSet[intTab]
	intRow = 1
	local intOther = intTab																-- Save tab number for Preferences tab control
	local tblOtherDefault = tblTab[intRow]
	tblOtherDefault.Heading.Title     = "Generation Gap"							-- 1st row with Default Settings button
	intRow = intRow + 1
	local tblGenGapFamily = tblTab[intRow]											-- 2nd row
	tblGenGapFamily.Heading.Title     = "Family Generations"
	tblGenGapFamily.Current.SpinMax   = 10
	tblGenGapFamily.Current.spin_cb   = function(self,intItem) IntGenGapFamily=intItem SetGenerations() end
	tblGenGapFamily.Integer.Title     = tostring(IntGenGapFamilyDef)
	tblGenGapFamily.Measure.Title     = " Gen Gap "
	intRow = intRow + 1
	local tblGenGapRelative = tblTab[intRow]										-- 3rd row
	tblGenGapRelative.Heading.Title   = "Relatives Generations"
	tblGenGapRelative.Current.SpinMax = 10
	tblGenGapRelative.Current.spin_cb = function(self,intItem) IntGenGapRelative=intItem SetGenerations() end
	tblGenGapRelative.Integer.Title   = tostring(IntGenGapRelativeDef)
	tblGenGapRelative.Measure.Title   = " Gen Gap "
	intRow = intRow + 1
	local tblGenGapDeduct = tblTab[intRow]											-- 4th row
	tblGenGapDeduct.Heading.Title     = "Relatives Deduction"
	tblGenGapDeduct.Current.SpinMin   = -100
	tblGenGapDeduct.Current.SpinMax   = 0
	tblGenGapDeduct.Current.spin_cb   = function(self,intItem) IntGenGapDeduct=intItem SetGenerations() end
	tblGenGapDeduct.Integer.Title     = tostring(IntGenGapDeductDef)
	intRow = intRow + 1
	iup.Destroy(tblTab[intRow].Overall)												-- 5th row replaces defaults with horizontal separator
	tblTab[intRow].Overall    = iup.vbox { iup.hbox { Margin="8x8"; }; iup.label { Separator="HORIZONTAL"; }; iup.hbox { Margin="8x8" }; Margin="1x1"; }
	intRow = intRow + 1
	local tblGenderMismatch = tblTab[intRow]										-- 6th row
	iup.Destroy(tblGenderMismatch.Heading)
	iup.Destroy(tblGenderMismatch.Current)
	iup.Destroy(tblGenderMismatch.Default)
	tblGenderMismatch.Heading         = iup.label { Title="Gender Mismatch"; }
	tblGenderMismatch.Overall         = iup.hbox { Homogeneous="YES"; tblGenderMismatch.Heading; iup.label { Title=" Current Settings "; Expand="YES"; }; iup.label { Title=" " }; }
	intRow = intRow + 1
	local tblGenderDeduct = tblTab[intRow]											-- 7th row
	tblGenderDeduct.Heading.Title     = "Gender Deduction"
	tblGenderDeduct.Current.SpinMin   = -100
	tblGenderDeduct.Current.SpinMax   = 0
	tblGenderDeduct.Current.spin_cb   = function(self,intItem) IntGenderDeduct=intItem SaveSettings() end
	tblGenderDeduct.Integer.Title     = tostring(IntGenderDeductDef)
	intRow = intRow + 1
	tblTab[intRow] = nil

	intTab = intTab + 1																	-- Signal end of table of Preferences tab controls
	tblSet[intTab] = nil

	-- Create the Find Duplicates tab layout
	local vboxFind = iup.vbox { Gap="8"; Margin="8x8";
								iup.hbox { Margin="10x0"; iup.label{Expand="YES";}; tglLast; lblLast; };
								iup.hbox { Margin="10x0"; iup.label{Expand="YES";}; tglDate; lblDate; };
								iup.hbox { Margin="10x0"; btnPick; lblPick; };
								iup.hbox { Margin="10x20";lblLine; };
								iup.hbox { Margin="10x0"; btnFind; };
								iup.hbox { Margin="10x0"; lblTime; };
								iup.hbox { Margin="10x0"; btnShow; };
								iup.hbox { Margin="10x0"; iup.label{Expand="YES";}; tglDiag; iup.label{Expand="YES";}; tglSpan; iup.label {Expand="YES";}; };
							}

	-- Create the Omit Non-Duplicates tab layout
	local vboxOmit = iup.vbox { Gap="4"; Margin="4x4";
								iup.vbox { Margin="10x0";
											 lblResult; lstResult;
											 iup.hbox { Margin="0x0"; btnSetAll; btnSetNil; btnMovAll; btnMovSet; Homogeneous="YES"; Expand="HORIZONTAL"; };
											 lblNonDup; lstNonDup;
											 iup.hbox { Margin="0x0"; btnSelAll; btnSelNil; btnDelAll; btnDelSel; Homogeneous="YES"; Expand="HORIZONTAL"; };
									 };
							}

	-- Create the Set Preferences tab layout
	local tblPref = {}
	for intTab = 1, #tblSet do
		tblTab = tblSet[intTab]
		if tblTab == nil then break end
		local strMargin = "40x1"
		if intTab == intNames then strMargin = "2x1" end
		tblPref[intTab] = iup.vbox { Gap="8"; Margin=strMargin; }
		for intRow = 1, #tblTab do
			if tblTab[intRow] == nil then break end
			iup.Append( tblPref[intTab], tblTab[intRow].Overall )
		end
	end

	local tabPref = iup.tabs {
								tblPref[intInter]; TabTitle0=" User Interface ";
								tblPref[intNames]; TabTitle1=" Names Matching ";
								tblPref[intEvent]; TabTitle2=" Event Matching ";
								tblPref[intChron]; TabTitle3=" Date Chronology ";
								tblPref[intOther]; TabTitle4=" Family && Gender ";
							}
	local vboxPref = iup.vbox { Gap="10";
								tabPref;
							}

	-- Create the Tab controls layout
	local tabCont = iup.tabs {
								vboxFind; TabTitle0="  Find Duplicates  ";
								vboxOmit; TabTitle1="Omit Non-Duplicates";
								vboxPref; TabTitle2="  Set Preferences  ";
							}

	-- Combine all the above controls
	local allCont = iup.vbox {
								tabCont;
								iup.hbox { btnUpdates; btnGetHelp; btnDestroy; Homogeneous="YES"; Margin="90x10"; Gap="10"; Expand="HORIZONTAL"; }	-- V3.9
 							}

	-- Create dialogue and turn off resize, maximize, minimize, and menubox except Close button
	local dialogMain = iup.dialog { Title=iup_gui.Plugin..iup_gui.Version;
								allCont;
							}

	-- Local Variables and Functions
	local intTotal = 0																	-- Total number of Idividual Records
	local intPick  = 0																	-- Picked number of Individual Records
	local tblIndi  = { }																-- User selection of Individual Records
	local intDate  = 0																	-- Date threshold for last Updated value
	local tblResults = { }																-- Results List for Omit Non-Duplicates tab, and Show previous Result Set button
	local intTabPosn = 0
	local tblControls = { }																-- GUI control attributes used by doSetFont

	local function setMonospacedFont()												-- Monospaced font "Consolas, " or "Lucida Console, " or "Lucida Sans Typewriter, " or "DejaVu Sans Mono, "
		local strFont = iup_gui.FontBody:gsub(".-, ","Lucida Console, ")
		lstResult.Font = strFont
		lstNonDup.Font = strFont
	end -- local function setMonospacedFont

	local function setControls()														-- Set control attributes mainly for Preference tab Font & FgColor & BgColor & Padding

		local function setAttribs(iupControl,intRow)								-- pcall(setAttribs,iupControl,intRow) prevents missing control handle errors propagating
			if intRow == 1 then
				if iupControl.Title:match("Default") then
					iupControl.FgColor = iup_gui.Safe									-- Set all Default button attributes
					iupControl.Expand  = "HORIZONTAL"
					iupControl.Padding = "10x2"
				else
					iupControl.Expand  = "YES"											-- Set all Spin heading attributes
					iupControl.Padding = "x3"
				end
			elseif iupControl.Spin == "YES" then
				iupControl.Font = iup_gui.FontHead									-- Set all Spin control attributes
				iupControl.FgColor = iup_gui.Safe
				iupControl.BgColor = iup_gui.Smoke
				iupControl.Padding = "20"
				iupControl.Size = "42"
			end
		end -- local function setAttribs()

		if fhGetAppVersion() > 6 then													-- FH V7 IUP 3.28	-- V3.8
			tabPref.TabPadding = "8x4"
			tabCont.TabPadding = "8x4"
		else																				-- FH V6 IUP 3.11	-- V3.8
			tabPref.Padding = "8x8"
			tabCont.Padding = "8x8"
		end

		for i, iupControl in ipairs({ tblResultSetLim.Heading; tblNamesDefaults.Heading; tblEventDefault.Heading; tblChronDefault.Heading; tblOtherDefault.Heading; tblGenderMismatch.Heading; }) do
			iupControl.Font = iup_gui.FontHead
			iupControl.FgColor = iup_gui.Head											-- Set top left Header attributes
			iupControl.Expand  = "YES"
			iupControl.Padding = "x4"
		end
		for i, iupControl in ipairs({ tblPref[intInter]; tblPref[intNames]; tblPref[intEvent]; tblPref[intChron]; tblPref[intOther]; }) do
			iupControl.Font = iup_gui.FontBody
			iupControl.FgColor = iup_gui.Body											-- Set each Preference tab default attributes
		end
		for intTab = 1, #tblSet do														-- Reset the attributes of Defaults buttons and Spin headings/controls
			tblTab = tblSet[intTab]
			if tblTab == nil then break end
			for intRow = 1, #tblTab do
				tblRow = tblTab[intRow]
				if tblRow == nil then break end
				if intRow == 1 then
					pcall(setAttribs,tblRow.Default,intRow)							-- pcall for Defaults button prevents missing handle errors propagating
				end
				if intTab == intNames then
					for intRel = IntIndivi, IntChild do
						pcall(setAttribs,tblRow[intRel],intRow)						-- pcall for Spin heading/control prevents missing handle errors propagating
					end
				else
					pcall(setAttribs,tblRow.Current,intRow)							-- pcall for Spin heading/control prevents missing handle errors propagating
				end
			end
		end
		setMonospacedFont()
	end -- local function setControls

	local function doResetRecords()													-- Reset all Records to excluded
		intTotal = 0
		local ptrIndi = fhNewItemPtr()
		ptrIndi:MoveToFirstRecord("INDI")
		while ptrIndi:IsNotNull() do
			local intRecId = fhGetRecordId(ptrIndi)
			if not TblData[intRecId] then TblData[intRecId] = {} end				-- Create table of Data entries per Record Id
			TblData[intRecId].Chosen = false											-- Flag all Records as excluded
			intTotal = intTotal + 1														-- Count the Total number of Records
			ptrIndi:MoveNext()
		end
	end -- local function doResetRecords

	local function intChosenRecord(ptrIndi)											-- Check if Record last Updated after chosen Date
		local intRecId = fhGetRecordId(ptrIndi)
		if ( fhCallBuiltInFunction("DayNumber",fhCallBuiltInFunction("LastUpdated",ptrIndi)) or 999999 ) >= intDate then	-- 0 => 999999 to allow undated records	-- V3.7
			TblData[intRecId].Chosen = true											-- Flag the chosen Records to include
			return 1
		end
		TblData[intRecId].Chosen = false												-- Flag the other Records to exclude
		return 0
	end -- local function intChosenRecord

	local function setEstimatedTime()													-- Set estimated run time based on chosen & total records
		local strTime = "less than a few minutes"
		local intScale = 60000000
		if tglDiag.Value == "ON" then intScale = intScale / 20 end				-- Lengthen estimate in Diagnostic Mode
		local intMins = math.floor( intPick * intTotal / intScale )
		if intMins <= 0 then strTime = "only a few seconds" end
		if intMins >= 2 then strTime = "from "..intMins.." minutes to "..(intMins*4).." minutes" end
		lblTime.Title = "Estimated run time to check for Duplicates is "..strTime
	end -- local function setEstimatedTime

	local function doPickRecords()													-- Pick chosen Individual Records
		intPick = 0
		if #tblIndi == 0 then															-- No selection
			local ptrIndi = fhNewItemPtr()
			ptrIndi:MoveToFirstRecord("INDI")
			while ptrIndi:IsNotNull() do
				intPick = intPick + intChosenRecord(ptrIndi)						-- So check all the Records
				ptrIndi:MoveNext()
			end
		else
			for intIndi = 1, #tblIndi do
				intPick = intPick + intChosenRecord(tblIndi[intIndi])				-- Check just selected Records
			end
		end
		local strRecords = " Records Chosen"
		if intPick == 1 then strRecords = strRecords:gsub("s "," ") end
		lblPick.Title = intPick..strRecords	
		setEstimatedTime()
		if intPick == 0 then btnFind.Active = "NO" else btnFind.Active = "YES" end
		return intPick
	end -- local function doPickRecords

	local function setDateValue()														-- Set Date Value and toggle ticks
		local datDate = fhNewDate(9999)
		if StrTick == "ON" and StrLast:match("^%d") then							-- Use the Plugin Last run Date if it exists
			tglLast.Value = "ON"
			tglDate.Value = "OFF"
			lblLast.Active = "YES"														-- Set mode of toggles & Dates
			lblDate.Active = "NO"
			datDate:SetValueAsText(StrLast)
			intDate = fhCallBuiltInFunction("DayNumber",datDate:GetDatePt1()) or 999999
		else
			StrTick = "OFF"																-- Use the adjustable Date
			tglLast.Value = "OFF"
			tglDate.Value = "ON"
			lblLast.Active = "NO"														-- Set mode of toggles & Dates
			lblDate.Active = "YES"
			local strDate = lblDate.Title
			datDate:SetValueAsText(strDate)											-- Check that Date has valid format
			intDate = general.GetDayNumber(datDate:GetDatePt1())
			if intDate == 0 then
	 			iup_gui.MemoDialogue("\n Unrecognised Date \n "..strDate.." \n")
				intDate = 999999
			else
				StrDate = strDate
				SaveSettings()															-- Save the adjustable Date
			end
--[==[
			if datDate:SetValueAsText(strDate) then									-- Check that Date has valid format
				intDate = fhCallBuiltInFunction("DayNumber",datDate:GetDatePt1())
			else
				intDate = nil
			end
			if intDate then
				StrDate = strDate
				SaveSettings()															-- Save the adjustable Date
			else
	 			iup_gui.MemoDialogue("\n Unrecognised Date \n "..strDate.." \n")
				intDate = 999999
			end
--]==]
		end
	end -- local function setDateValue

	local function getEntry(intRecId)													-- From Rec Id get Name & Format Size for Entry
		local ptrName = fhNewItemPtr()
		ptrName:MoveToRecordById("INDI",intRecId or 0)
		local strName = fhGetDisplayText(ptrName)
		local intSize = strName:length()												-- Name length in characters
		if string.encoding() == "UTF-8" then
			while intSize > 30 do														-- Remove trailing UTF-8 chars beyond 30th
				intSize = intSize - 1
				strName = strName:gsub("[%z\1-\127\194-\244][\128-\191]*$","")
			end
		end
		return intRecId,strName,( 30 + strName:len() - intSize )					-- Format size adjusted for UTF-8 chars
	end -- local function getEntry

	local function strFormatResult(tblEntry)										-- Format a pair of Duplicate Records
		if not tblEntry then return " " end
		local intRecIdA,strNameA,intSizeA = getEntry(tblEntry.RecordIdA)
		local intRecIdB,strNameB,intSizeB = getEntry(tblEntry.RecordIdB)
		local strFormat = ("%6d %-{A}.{A}s%6d %-.{B}s"):gsub("{A}",intSizeA):gsub("{B}",intSizeB)
		return string.format(strFormat,intRecIdA,strNameA,intRecIdB,strNameB)
	end -- local function strFormatResult

	local function doDisplayTables()													-- Display both Results List and Non-Duplicates tables in Omit Non-Duplicates tab
		setMonospacedFont()
		local intValue = lstResult.Value
		lstResult.removeitem = nil
		for intEntry = 1, #tblResults do
			lstResult[intEntry] = strFormatResult(tblResults[intEntry])			-- Results List candidate pairs
		end
		lstResult.Value = intValue
		local intValue = lstNonDup.Value
		lstNonDup.removeitem = nil
		for intEntry = 1, #TblNonDups do
			lstNonDup[intEntry] = strFormatResult(TblNonDups[intEntry])			-- Non-Duplicates list of pairs
		end
		lstNonDup.Value = intValue
	end -- local function doDisplayTables

	local function doLoadLists()														-- Load the Result List excluding missing Records and Non-Duplicate pairs
		if general.FlgFileExists(StrResultsFile) then
			local tblResultsFile, strErr = table.load(StrResultsFile)			-- Retrieve saved previous Result Set file
			local intEntry = 1
			for i, tblResultsFile in ipairs( tblResultsFile ) do
				local intDataA = tblResultsFile.RecordIdA							-- Get RecordIds of each Result Set pair in turn
				local intDataB = tblResultsFile.RecordIdB
				local ptrIndiA = fhNewItemPtr()
				local ptrIndiB = fhNewItemPtr()
				ptrIndiA:MoveToRecordById('INDI',intDataA)							-- Do both Individual Records exist i.e. have not been Merged
				ptrIndiB:MoveToRecordById('INDI',intDataB)
				if ptrIndiA:IsNotNull() and ptrIndiB:IsNotNull() then				-- Both the Individual Records do exist in GEDCOM
					tblResults[intEntry] = tblResultsFile
					for i, tblNonDups in ipairs( TblNonDups ) do					-- Search Non-Duplicates table
						if intDataA == tblNonDups.RecordIdA and intDataB == tblNonDups.RecordIdB then
							tblResults[intEntry] = nil
							intEntry = intEntry - 1										-- Exclude Non-Duplicate pairs
							break
						end
					end
					intEntry = intEntry + 1												-- Step onto next internal Results List entry
				end
			end
			doDisplayTables()
			if #TblNonDups > 0 then btnDelAll.Active = "YES" end
		end
	end -- local function doLoadLists

	local function doTick(tblArg,intMode)											-- Action for Last run Date & adjustable Date toggles
		local intState = tblArg[2]
		if intState == intMode then StrTick = "ON" else StrTick = "OFF" end
		setDateValue()
		doPickRecords()																	-- Pick and count the Records
		SaveSettings()
	end -- local function doTick

	function lblDate:button_cb(iupButton,intPressed,intPosX,intPosY,strStatus)
		if iupButton == iup.BUTTON1 and intPressed == 1 then						-- Left mouse button is pressed within Date label
			local datDate = fhNewDate(0000)
			local isOK = datDate:SetValueAsText(StrDate)
			datDate = fhPromptUserForDate(datDate)									-- Prompt user with adjustable Date as default
			if  datDate
			and datDate:GetType() == "Simple"
			and datDate:GetSubtype() == "" then
				lblDate.Title = datDate:GetDisplayText()							-- New adjusted Date is plain simple
				setDateValue()
				doPickRecords()															-- Pick and count the Records
			else
	 			iup_gui.MemoDialogue("\n The adjustable Date must be a Simple date and \n not a Period, Phrase, Range, or Quarter Date. \n")
			end
		end
	end -- function lblDate:button_cb

	local function doPick()																-- Action for Pick button
		dialogMain.Active = "NO"
		tblIndi = fhPromptUserForRecordSel('INDI')
		if #tblIndi > 0 then doResetRecords() end
		doPickRecords()																	-- Pick and count the Records
		dialogMain.bringfront = "YES"
		dialogMain.Active = "YES"
	end -- local function doPick

	local function doFind()																-- Action for Find any Duplicates button
		local tglSpanActive = tglSpan.Active
		tglSpan.Active = "NO"
		dialogMain.Active = "NO"
		if intPick > 0 then																-- If any Records chosen, then run Find Duplicates, which returns true if any found and not stopped
			if FindDuplicateRecords(intTotal,tglDiag.Value=="ON",tglSpan.Value=="ON") then
				local dateToday = fhNewDate(0000)
				dateToday:SetSimpleDate(fhCallBuiltInFunction("Today"))
				StrLast = dateToday:GetDisplayText()									-- Set date Today as last run date
				SaveSettings()
				return iup.CLOSE
			else
				local ptrIndi = fhNewItemPtr()
				ptrIndi:MoveToFirstRecord("INDI")
				while ptrIndi:IsNotNull() do											-- Loop through every Individual Record
					TblData[fhGetRecordId(ptrIndi)].Names = nil						-- Clear individual records
					ptrIndi:MoveNext()
				end
			end
		end
		dialogMain.bringfront = "YES"
		dialogMain.Active = "YES"
		tglSpan.Active = tglSpanActive
	end -- local function doFind

	local function doShow()																-- Action for Show previous Result Set button
		local tglSpanActive = tglSpan.Active
		tglSpan.Active = "NO"
		dialogMain.Active = "NO"
		if general.FlgFileExists(StrResultsFile) then
			doLoadLists()																	-- Load the Results List and display as Result Set
			if DisplayResultSet(tblResults,tglDiag.Value=="ON",tglSpan.Value=="ON") then
				return iup.CLOSE
			end
		end
		dialogMain.bringfront = "YES"
		dialogMain.Active = "YES"
		tglSpan.Active = tglSpanActive
	end -- local function doShow

	local function doDiag()																-- Action for Diagnostic toggle
		StrDiag = tglDiag.Value
		if StrDiag == "ON" then tglSpan.Active = "YES" end
		if StrDiag == "OFF" then tglSpan.Active = "NO" end
		setEstimatedTime()																-- Increase run time estimate
--?		SaveSettings()																	-- Enable StrDiag in doDefault/Load/SaveSettings() to make it sticky
	end -- local function doDiag

	local function doSpan()																-- Action for Timespan toggle
		StrSpan = tglSpan.Value
--?		SaveSettings()																	-- Enable StrSpan in doDefault/Load/SaveSettings() to make it sticky
	end -- local function doSpan

	local function setButtons()														-- Set Non-Duplicates tab buttons active or not

		local function setButton(isMatch,btnName)
		 if isMatch then btnName.Active = "YES" else btnName.Active = "NO" end
		end -- local function setButton

		local strMove = lstResult.Value
		setButton(strMove:match("%-"),btnSetAll)									-- Need some unselected to enable Select All
		setButton(strMove:match("%+"),btnSetNil)									-- Need some selected to enable Select None
		setButton(strMove:match("^."),btnMovAll)									-- Need some entries to enable Move All
		setButton(strMove:match("%+"),btnMovSet)									-- Need some selected to enable Move Selected
		local strDrop = lstNonDup.Value
		setButton(strDrop:match("%-"),btnSelAll)									-- Need some unselected to enable Select All
		setButton(strDrop:match("%+"),btnSelNil)									-- Need some selected to enable Select None
		setButton(strDrop:match("^."),btnDelAll)									-- Need some entries to enable Erase List
		setButton(strDrop:match("%+"),btnDelSel)									-- Need some selected to enable Erase Selected
	end -- local function setButtons

	local function doSelect(iupList,strChar)										-- Select All or None of List entries for btnSetAll, btnSetNil, btnSelAll, btnSelNil
		iupList.Value = string.rep(strChar,iupList.Value:len())
		setButtons()
	end -- local function doSelect

	local function doChosen(strMatch)													-- Move chosen Result Set entries to Non-Duplicates for btnMovAll & btnMovSet
		local strMove = lstResult.Value
		local intMove = strMove:len()
		for intEntry = intMove, 1, -1 do
			strChar = strMove:sub(intEntry,intEntry)
			if strChar:match(strMatch) then
				table.insert(TblNonDups,1,tblResults[intEntry])					-- Add to Non-Duplicates and remove from Results Set
				table.remove(tblResults,intEntry)
			end
		end
		doDisplayTables()
		lstResult.Value = string.rep("-",lstResult.Value:len())					-- Clear all selections
		setButtons()
	end -- local function doChosen

	local function doDelete(strMatch)													-- Delete chosen Non-Duplicates entries for btnDelAll & btnDelSel
		local strMove = lstNonDup.Value
		local intMove = strMove:len()
		local intButton = 1
		if strMatch == "^." or strMove == string.rep("+",intMove) then			-- If btnDelAll or all entries selected then prompt for approval
			intButton = iup_gui.MemoDialogue("\n Continue to ERASE the entire Non-Duplicates list ? \n","Yes, Erase","No, Cancel")
		end
		if intButton == 1 then
			for intEntry = intMove, 1, -1 do											-- If approved then delete all matching Non-Duplicates entries
				strChar = strMove:sub(intEntry,intEntry)
				if strChar:match(strMatch) then
					table.remove(TblNonDups,intEntry)
				end
			end
			doLoadLists()
			lstNonDup.Value = string.rep("-",lstNonDup.Value:len())				-- Clear all selections
			setButtons()
		end
	end -- local function doDelete

	local function doDefault()															-- Handle the Restore GUI Defaults button on Set Preferences tab
		ResetDefaultSettings()
--?		tglDiag.Value = StrDiag															-- Reset controls
--?		tglSpan.Value = StrSpan
		lblDate.Title = StrDate
		setDateValue()
		tblIndi = {}
		doPickRecords()																	-- Pick and count the Records
		iup_gui.ShowDialogue("Main")
		iup_gui.DefaultDialogue()														-- V3.9
		SaveSettings()																	-- Save sticky data settings
	end -- local function doDefault

	local function doSoundex()															-- Handle the Erase Soundex Cache button on Set Preferences tab
		TblSoundex = { }																	-- Soundex dictionary codes cache of previously coded Names & Places
		table.save(TblSoundex,StrSoundexFile)
	end -- local function doSoundex

	local function doSetFont()															-- Handle the Set Window Font button on Set Preferences tab
		btnSetFont.Active = "NO"
		iup_gui.FontDialogue(tblControls)
		SaveSettings()																	-- Save sticky data settings
		btnSetFont.Active = "YES"
	end -- local function doSetFont

	function btnSetFont:button_cb(intButton,intPress)								-- Action for mouse right-click on Set Window Fonts button
		if intButton == iup.BUTTON3 and intPress == 0 then
			iup_gui.BalloonToggle()														-- Toggle tooltips Balloon mode
		end
	end -- function btnSetFont:button_cb

	function tblLimitResults.Current:spin_cb(intItem)								-- Call back for Result Set Maximum Rows spin control on Set Preferences tab
		IntLimitResults = intItem
		tblPruneResults.Current.SpinMin = intItem
		intItem = intItem * 2
		if IntPruneResults < intItem then
			IntPruneResults = intItem
			tblPruneResults.Current.SpinValue = intItem
		end
		SaveSettings()																	-- Save sticky data settings
	end -- function tblLimitResults.Current:spin_cb

	local function setResultSet()														-- Set the Result Set spin values
		tblIndiScoreMin.Current.SpinValue = IntIndiScoreMin
		tblLeastResults.Current.SpinValue = IntLeastResults
		tblLimitResults.Current.SpinValue = IntLimitResults
		tblPruneResults.Current.SpinMin   = IntLimitResults
		tblPruneResults.Current.SpinValue = IntPruneResults
	end -- local function setResultSet

	function tblResultSetLim.Default:action()										-- Action for Result Set Limits Default Settings button on Set Preferences tab
		SetUserInterfaceDefaults()
		setResultSet()
		SaveSettings()																	-- Save sticky data settings
	end -- function tblResultSetLim.Default:action

	local function setNamesValues()													-- Set the Names Matching spin Values
		for intRelation = IntIndivi, IntChild do
			tblLastNameRight[intRelation].SpinValue = TblLastNameRight[intRelation]
			tblForeNameRight[intRelation].SpinValue = TblForeNameRight[intRelation]
			tblForeNameOther[intRelation].SpinValue = TblForeNameOther[intRelation]
			tblNameSoundex  [intRelation].SpinValue = TblNameSoundex  [intRelation]
			tblNameLastWrong[intRelation].SpinValue = TblNameLastWrong[intRelation]
			tblNameMinimum  [intRelation].SpinValue = TblNameMinimum  [intRelation]
			tblNameDeduction[intRelation].SpinValue = TblNameDeduction[intRelation]
			tblNameMaximum  [intRelation].SpinValue = TblNameMaximum  [intRelation]
			tblNameThreshold[intRelation].SpinValue = TblNameThreshold[intRelation]
		end
	end -- local function setNamesValues

	function tblNamesDefaults.Default:action()										-- Action for Names Matching Default Settings button on Set Preferences tab
		SetNamesMatchDefaults()
		setNamesValues()
		SaveSettings()																	-- Save sticky data settings
	end -- function tblNamesDefaults.Default:action

	local function setEventValues()													-- Set the Event Matching spin Values
		tblDatesTolerance.Current.SpinValue = IntDatesTolerance
		tblDatesMatched  .Current.SpinValue = IntDatesMatched
		tblDatesOverlap  .Current.SpinValue = IntDatesOverlap
		tblDatesMinimum  .Current.SpinValue = IntDatesMinimum
		tblDatesDeduction.Current.SpinValue = IntDatesDeduction
		tblPlacePartRight.Current.SpinValue = IntPlacePartRight
		tblPlacePartOther.Current.SpinValue = IntPlacePartOther
		tblPlaceSoundex  .Current.SpinValue = IntPlaceSoundex
		tblEventMaximum  .Current.SpinValue = IntEventMaximum
		tblBoostedBirth  .Current.SpinValue = IntBoostedBirth						-- V3.8
		tblBoostedBapCh  .Current.SpinValue = IntBoostedBapCh						-- V3.8
		tblBoostedMarry  .Current.SpinValue = IntBoostedMarry						-- V3.8
		tblBoostedDeath  .Current.SpinValue = IntBoostedDeath						-- V3.8
	end -- local function setEventValues

	function tblEventDefault.Default:action()										-- Action for Event Matching Default Settings button on Set Preferences tab
		SetEventMatchDefaults()
		setEventValues()
		SetEventPoints()																	-- Update and Save sticky data settings
	end -- function tblEventDefault.Default:action

	local intSpinLo = 1
	local intSpinHi = 6

	function tblChronMagnitude.Current:spin_cb(intItem)							-- Call back for Chronology Magnitude spin control on Set Preferences tab
		local intSpininc = tonumber(txtChMag.SpinInc)								-- Magnitudes = 1, 2, 3, 4, 6, 12, 18 and increments of intSpinHi=6 to 120 Months
		if intSpininc > intSpinLo and intItem < 6 then
			txtChMag.SpinInc = intSpinLo												-- When value decreases below 6 set decrement = intSpinLo=1 and adjust value
			intItem = 4
			txtChMag.SpinValue = intItem + intSpininc
		elseif intSpininc < intSpinHi and intItem > 4 then
			txtChMag.SpinInc = intSpinHi												-- When value increases above 4 set increment = intSpinHi=6 and adjust value
			intItem = math.ceil( intItem / intSpinHi ) * intSpinHi
			txtChMag.SpinValue = intItem - intSpininc
		end
		if intItem == 6 then txtChMag.SpinInc = 2 end								-- Needed to allow spin decrement below 6 to reduce to 1
		IntChronMagnitude = intItem
		SetChronology()																	-- Update and Save sticky data settings
	end -- function tblChronMagnitude.Current:spin_cb

	local function setChronValues()													-- Set the Date Chronology spin Values
		tblDatesTimespan .Current.SpinValue = IntDatesTimespan
		tblDatesVariance .Current.SpinValue = IntDatesVariance
		tblDatesPregnant .Current.SpinValue = IntDatesPregnant
		tblDatesPuberty  .Current.SpinValue = IntDatesPuberty
		tblDatesMarriage .Current.SpinValue = IntDatesMarriage
		tblDatesFertile  .Current.SpinValue = IntDatesFertile
		tblDatesLifespan .Current.SpinValue = IntDatesLifespan
		tblChronMagnitude.Current.SpinValue = IntChronMagnitude
		tblChronTolerance.Current.SpinValue = IntChronTolerance
		local intSpininc = 2
		if IntChronMagnitude < 6 then intSpininc = intSpinLo end
		if IntChronMagnitude > 6 then intSpininc = intSpinHi end
		tblChronMagnitude.Current.SpinInc = intSpininc
	end -- local function setChronValues

	function tblChronDefault.Default:action()										-- Action for Date Chronology Default Settings button on Set Preferences tab
		SetChronologyDefaults()
		setChronValues()
		SetChronology()																	-- Update and Save sticky data settings
	end -- function tblChronDefault.Default:action

	local function setOtherValues()													-- Set Family & Gender spin Values
		tblGenGapFamily  .Current.SpinValue = IntGenGapFamily
		tblGenGapRelative.Current.SpinValue = IntGenGapRelative
		tblGenGapDeduct  .Current.SpinValue = IntGenGapDeduct
		tblGenderDeduct  .Current.SpinValue = IntGenderDeduct
	end -- local function setOtherValues

	function tblOtherDefault.Default:action()										-- Action for Date Chronology Default Settings button on Set Preferences tab
		SetOtherMatchDefaults()
		setOtherValues()
		SetGenerations()																	-- Update and Save sticky data settings
	end -- function tblOtherDefault.Default:action

	function btnUpdates:action()														-- Action for Check for Updates button	-- V3.9
		iup_gui.CheckVersionInStore()													-- Notify if later Version
	end -- function btnUpdates:action

	local function doExecute(strExecutable, strParameter)							-- Invoke FH Shell Execute API	-- V3.8
		local function ReportError(strMessage)
			iup_gui.WarnDialogue(	"Shell Execute Error",
									"ERROR: "..strMessage.." :\n"..strExecutable.."\n"..strParameter.."\n\n",
									"OK"	)
		end -- local function ReportError
		return general.DoExecute(strExecutable, strParameter, ReportError)
	end -- local function doExecute

	local strHelp = "https://pluginstore.family-historian.co.uk/page/help/find-duplicate-individuals"
	local arrHelp = { "-find-duplicates-tab"; "-omit-non-duplicates-tab"; "-set-preferences-tab"; }

	function btnGetHelp:action()														-- Action for Help and Advice button according to current tab -- V3.8
		local strPage = arrHelp[intTabPosn] or ""
		doExecute( strHelp..strPage )
		fhSleep(2000,500)
		dialogMain.BringFront="YES"
	end -- function btnGetHelp:action

	function tabCont:tabchangepos_cb(intNew,intOld)								-- Call back when Main tab position is changed  
		intTabPosn = intNew + 1															-- 31 July 2013
		if intNew == 1 then
			doLoadLists()																	-- Load the Omit Non-Duplicates tab
		end
		setButtons()
	end -- function tabCont:tabchangepos_cb

	-- Set other GUI control attributes
	local strMiddle = "ACENTER:ACENTER"
	tblControls =  {  {  "Font"; "FgColor"; "Alignment"; "Padding"; "Tip"; "action"; {"TipBalloon";"Balloon";}; {"Expand";"YES";}; {"help_cb";function() iup_gui.HelpDialogue(intTabPosn) end;}; setControls; };
	-- Find Duplicates tab
		[vboxFind]   = { "FontBody"; "Body"; "ARIGHT" ; };
		[tglLast]    = { "FontBody"; "Safe"; false    ; false; "Include Individuals updated after Plugin last run Date"	; function(...) doTick({...},1) end; };
		[lblLast]    = { "FontBody"; "Body"; strMiddle; "10" ; "Date the Plugn was last run successfully" ; };
		[tglDate]    = { "FontBody"; "Safe"; false    ; false; "Include Individuals updated after this adjustable Date"	; function(...) doTick({...},0) end; };
		[lblDate]    = { "FontBody"; "Safe"; strMiddle; "10" ; "User adjustable Date threshold for Individuals" ; };
		[btnPick]    = { "FontBody"; "Safe"; false    ; "10" ; "Select any subset of Individuals to include"				; function() return doPick() end; };
		[lblPick]    = { "FontBody"; "Body"; strMiddle; "10" ; "Number of Individual records included" ; };
		[btnFind]    = { "FontBody"; "Safe"; false    ; "10" ; "Find Duplicates from Included subset of Individuals"		; function() return doFind() end; };
		[lblTime]    = { "FontBody"; "Body"; strMiddle; "10" ; "Estimated run time to find Duplicates" ; };
		[btnShow]    = { "FontBody"; "Safe"; false    ; "10" ; "Show previous Result Set without using Find again"			; function() return doShow() end; };
		[tglDiag]    = { "FontBody"; "Safe"; false    ; false; "Enable Diagnostic Mode showing details in Result Set"		; function(...) doDiag({...}) end; };
		[tglSpan]    = { "FontBody"; "Safe"; false    ; false; "Include Timespan Dates in the Result Set details"			; function(...) doSpan({...}) end; };
	-- Omit Non-Duplicates tab
		[vboxOmit]   = { "FontBody"; "Body"; "ARIGHT" ; };
		[lblResult]  = { "FontHead"; "Head"; strMiddle;  "0" ; "Result Set of possible duplicates" ; };
		[lstResult]  = { "FontBody"; "Body"; "ALEFT"  ;  "0" ; "Use Leftclick and Shft+Leftclick and Ctrl+Leftclick"		; function() setButtons() end; };
		[btnSetAll]  = { "FontBody"; "Safe"; false    ; "10" ; "Select all of the Result Set entries"							; function() doSelect(lstResult,"+") end; };
		[btnSetNil]  = { "FontBody"; "Safe"; false    ; "10" ; "Select none of the Result Set entries"						; function() doSelect(lstResult,"-") end; };
		[btnMovAll]  = { "FontBody"; "Safe"; false    ; "10" ; "Move all Result Set entries to Non-Duplicates"				; function() doChosen("^.") end; };
		[btnMovSet]  = { "FontBody"; "Safe"; false    ; "10" ; "Move selected Result Set entries to Non-Duplicates"		; function() doChosen("%+") end; };
		[lblNonDup]  = { "FontHead"; "Head"; strMiddle;  "0" ; "List of Non-Duplicates to ignore" ; };
		[lstNonDup]  = { "FontBody"; "Body"; "ALEFT"  ;  "0" ; "Use Leftclick and Shft+Leftclick and Ctrl+Leftclick"		; function() setButtons() end; };
		[btnSelAll]  = { "FontBody"; "Safe"; false    ; "10" ; "Select all of the Non-Duplicates list"						; function() doSelect(lstNonDup,"+") end; };
		[btnSelNil]  = { "FontBody"; "Safe"; false    ; "10" ; "Select none of the Non-Duplicates list"						; function() doSelect(lstNonDup,"-") end; };
		[btnDelAll]  = { "FontBody"; "Safe"; false    ; "10" ; "Erase all of the Non-Duplicates list"							; function() doDelete("^.") end; };
		[btnDelSel]  = { "FontBody"; "Safe"; false    ; "10" ; "Erase selected Non-Duplicates list entries"					; function() doDelete("%+") end; };
	-- Preferences tab
		[tabPref]    = { "FontHead"; "Head"; false    ; false; "Select preference settings to adjust scoring" ; };					-- V3.8
		[vboxPref]   = { "FontBody"; "Body"; "ARIGHT" ; };
		[btnDefault] = { "FontBody"; "Safe"; false    ; "10" ; "Restore GUI defaults and Window sizes and positions"		; function() doDefault() end; };
		[btnSoundex] = { "FontBody"; "Safe"; false    ; "10" ; "Erase the Soundex Cache history file"							; function() doSoundex() end; };
		[btnSetFont] = { "FontBody"; "Safe"; false    ; "10" ; "Choose user interface window font styles"					; function() doSetFont() end; };
	-- Dialogue Common controls
		[tabCont]    = { "FontHead"; "Head"; false    ; false; "Find Duplicates or Omit Non-Duplicates or Set Preferences" ; };	-- V3.8
		[allCont]    = { "FontBody"; "Body"; "ARIGHT" ; };
		[btnUpdates] = { "FontBody"; "Safe"; false    ;  "4" ; "Check for a later plugin version in the Plugin Store"; }; 		-- V3.9
		[btnGetHelp] = { "FontBody"; "Safe"; false    ;  "4" ; "Obtain online Help and Advice from the Plugin Store" ; };
		[btnDestroy] = { "FontBody"; "Risk"; false    ;  "4" ; "Close the plugin and keep all edits"							; function() return iup.CLOSE end; };
		}

	iup_gui.AssignAttributes(tblControls)											-- Assign GUI control attributes

	doResetRecords()
	lblDate.Title = StrDate
	setDateValue()
	doPickRecords()																		-- Pick and count the Records
	if not general.FlgFileExists(StrResultsFile) then btnShow.Active = "NO" end
	setResultSet()
	setNamesValues()
	setEventValues()
	setChronValues()
	setOtherValues()

	iup_gui.ShowDialogue("Main",dialogMain,btnDestroy,"Map")						-- Map Main GUI Dialogue

	iup_gui.RefreshDialogue("Main")													-- Adjust GUI size after adding Preferences

	iup_gui.ShowDialogue("Main")														-- Display Main GUI Dialogue

end -- function GUI_MainDialogue

function NewSoundex(tblSoundex)														-- Prototype for Soundex Calculator

--	See http://en.wikipedia.org/wiki/Soundex and http://creativyst.com/Doc/Articles/SoundEx1/SoundEx1.htm#SoundExAndCensus
--	This Soundex variant converts all characters to unaccented upper case ASCII,
--	and encodes 1st letter into its code number e.g. Gill (G400) & Jill (J400) are now both 2400.

	local tblSoundex = tblSoundex or { }												-- Soundex dictionary cache of previously coded Names
	tblSoundex[""] = "0000"																-- Seed with null string special case

	local tblCodeNum = {																-- Soundex code number table
		A=0;E=0;I=0;O=0;U=0;Y=0;H=0;	-- H=0;W=0;									-- H & W are ignored after first character
		B=1;F=1;P=1;V=1;W=1;			-- H=1;W=1;									-- W treated as B,F,P,V so BILL=WILL=1400
		C=2;G=2;J=2;K=2;Q=2;S=2;X=2;Z=2;
		D=3;T=3;
		L=4;
		M=5;N=5;
		R=6;
	}

	local function getSoundex(strAnyName)											-- Get the Soundex code for any Name
		local strNewName = encoder.StrEncode_ASCII(strAnyName):upper()			-- Convert to ASCII upper case characters
		local strSoundex = tblCodeNum[strNewName:sub(1,1)] or ""					-- Soundex starts with leading letter code number
		local strLastNum = strSoundex													-- Set initial Soundex code number
		tblCodeNum.H = nil																-- Ignore H after first character
		tblCodeNum.W = nil																-- Ignore W after first character
		for i = 2, string.len(strNewName) do
			local strCodeNum = tblCodeNum[strNewName:sub(i,i)]					-- Step through Soundex code of each subsequent letter
			if strCodeNum then
				if strCodeNum > 0 and strCodeNum ~= strLastNum then				-- Not a vowel nor same as Soundex preceeding code
					strSoundex = strSoundex..strCodeNum								-- So append Soundex code until 4 chars long
					if string.len(strSoundex) == 4 then break end
				end
				strLastNum = strCodeNum													-- Save as Soundex preceeding code, unless H or W
			end
		end
		tblCodeNum.H = 0																	-- Reinstate initial H code number
		tblCodeNum.W = 1																	-- Reinstate initial W code number
		strSoundex = string.sub(strSoundex.."0000",1,4)							-- Pad code with zeroes to 4 chars long
		tblSoundex[strAnyName] = strSoundex											-- Save code in cache for future quick lookup
		return strSoundex
	end -- local function getSoundex

	return function(strAnyName)														-- Convert a Name to Soundex
		return tblSoundex[strAnyName] or getSoundex(strAnyName)					-- If already in cache then return previous code else get new code
	end -- anonymous function

end -- function NewSoundex

function NewNamesData()																	-- Prototype to Make Names & Soundex Dictionary per Individual

	local tblNameLastRight = TblNameLastRight										-- Duplicate globals as locals
	local tblNameForeRight = TblNameForeRight
	local tblNameForeOther = TblNameForeOther
	local tblNameSoundex   = TblNameSoundex
	local intIndivi = IntIndivi
	local intChild  = IntChild

	return function(ptrInd)																-- Make Names & Soundex Dictionary per Individual
		local tblName = {}
		local ptrName = fhGetItemPtr(ptrInd,"~.NAME")
		while ptrName:IsNotNull() do													-- Loop through every NAME tag instance replacing punctuation such as - , . [ ] ( ) with a space
			local strSurname = fhGetItemText(ptrName,"~:SURNAME")
			local strSURNAME = " "..strSurname:upper():gsub(StrS,""):gsub(StrP," ").." "			-- Strip spaces in Surnames, so "Van Dyke" becomes "VANDYKE", but "Smith-Jones" becomes "SMITH JONES"
			strSurname = " "..strSurname:lower():gsub(StrP," ").." "
			for intRef, strRef in ipairs({ "~:ADORNED_FULL"; "~.NICK"; "~._USED"; "~.FONE"; "~.ROMN"; }) do		-- V3.8
				local strName = fhGetItemText(ptrName,strRef):lower():gsub(StrP," ").." "			-- Remove unnamed Names, ensure Forenames are lowercase, and Surname is uppercase
--#				strName = strName:gsub("%[unnamed person%]",""):gsub(strSurname,strSURNAME)		--	gsub() is faster than replace() and no magic symbols in surname by now?
				strName = strName:gsub("%[unnamed person%]",""):replace(strSurname,strSURNAME)	--	gsub() is faster than replace() and no magic symbols in surname by now?
				for intName, strName in ipairs(strName:split(" ")) do				-- Extract Names separated by space, and more than 2 chars long
					if string.len(strName) > 2 and not tblName[strName] then		-- Ensure replicated Names are skipped
						if strName:match("[A-Z]") then
							tblName["0"..strName] = tblNameLastRight				-- Lastname gets most points, has "0" upper case	( default 7 total = 5 + 2 for Soundex )
						else
							tblName[intName..strName] = tblNameForeRight			-- Forename in right position, has leading 1 - 9	( default 6 total = 3 + 1 below + 2 for Soundex )
							tblName[strName] = tblNameForeOther						-- Forename in other position, is all lower case	( default 3 total = 1 + 2 for Soundex )
						end
						local strSoundex = StrSoundex(strName)
						if not tblName[strSoundex] then
							tblName[strSoundex] = tblNameSoundex						-- Soundex match only points, has capital + digits	( default 2 total )
						else
							local tblSoundex = {}										-- Different Name part but replicated Soundex, so points must accumulate to achieve correct total
							for intRelation = intIndivi, intChild do
								tblSoundex[intRelation] = tblName[strSoundex][intRelation] + tblNameSoundex[intRelation]
							end
							tblName[strSoundex] = tblSoundex
						end
					end
				end
			end
			ptrName:MoveNext("SAME_TAG")
		end
		return tblName
	end -- anonymous function

end -- function NewNamesData

function NewEventData()																	-- Prototype to Make Event Date Timespan & Place Parts per Individual

	local intPartRight = IntPartRight													-- Duplicate globals as locals
	local intPartOther = IntPartOther
	local intPlaceSoundex = IntPlaceSoundex

	local tblLower = { Before=IntTimespanDays;  To=IntTimespanDays; Approximate0=IntVarianceDays; Calculated0=IntVarianceDays; Estimated0=IntVarianceDays; }
	local tblUpper = { After=IntTimespanDays; From=IntTimespanDays; Approximate0=IntVarianceDays; Calculated0=IntVarianceDays; Estimated0=IntVarianceDays; }

	return function(ptrEvent)															-- Make Event Date Timespan & Place Parts per Individual
		local dateDate = fhGetValueAsDate(fhGetItemPtr(ptrEvent,"~.DATE"))
		if dateDate:IsNull() then return nil end									-- If no Event Date, then return nil
		local pntLower = dateDate:GetDatePt1()										-- Lower Date whether Type is Simple or Period or Range
--#		local intLower = fhCallBuiltInFunction("DayNumber",pntLower) or 0		-- Lower Date with Month missing uses Jan, and with Day missing uses 1st
		local intLower = general.GetDayNumber(pntLower) or 0						-- Lower Date with Month missing uses Jan, and with Day missing uses 1st
		local pntUpper = dateDate:GetDatePt2()
--#		local intUpper = fhCallBuiltInFunction("DayNumber",pntUpper)
		local intUpper = general.GetDayNumber(pntUpper) or 0
--#		if intUpper then																	-- Upper Date for Period(From-To) or Range(Between)=Quarter Date
		if intUpper > 0 then															-- Upper Date for Period(From-To) or Range(Between)=Quarter Date
			if   pntUpper:GetMonth() == 0 then intUpper = intUpper + 364		-- Upper Date with Month missing, so extend to end of Year
			elseif pntUpper:GetDay() == 0 then intUpper = intUpper + 30 end		-- Upper Date with Day missing, so extend to end of Month (could adjust according to Month?)
		else																				-- No Upper Date for Simple(Approximate,Calculated,Extimated) or Period(From,To) or Range(After,Before)
			intUpper = intLower															-- So Upper Date = Lower Date and extend as above
			if   pntLower:GetMonth() == 0 then intUpper = intUpper + 364
			elseif pntLower:GetDay() == 0 then intUpper = intUpper + 30 end
			local strSubType = dateDate:GetSubtype()								-- Subtype is Approximate, Calculated, Estimated, From, To, After, Before, or ""
			if string.len(strSubType) > 7 then
				strSubType = strSubType..pntLower:GetMonth()						-- Simple(Approximate,Calculated,Estimated) Year Date needs Upper/Lower = +/- half Timespan
			end
			intLower = intLower - ( tblLower[strSubType] or 0 )					-- Period(To) or Range(Before) Date needs Lower = Lower - Timespan
			intUpper = intUpper + ( tblUpper[strSubType] or 0 )					-- Period(From) or Range(After) Date needs Upper = Upper + Timespan
		end
		local tblPlace = {}
		for _, strRef in ipairs({ "~.PLAC"; "~.PLAC.FONE"; "~.PLAC.ROMN"; }) do	-- Cater for multiple FONE & ROMN variants	-- V3.8
			local ptrPlace = fhGetItemPtr(ptrEvent,strRef)
			while ptrPlace:IsNotNull() do
				for intPlace, strPlace in ipairs(fhGetValueAsText(ptrPlace):split(",")) do
					strPlace = strPlace:lower():gsub(StrSP,"")							-- Remove all spaces and punctuation from Place part and ensure lowercase
					if string.length(strPlace) > 1 and not tblPlace[strPlace] then	-- Ensure replicated Places & Soundex are eliminated, and part > 1 char
						tblPlace[strPlace] = intPartOther									-- Place part in other position, is all lower case	( default 2 total = 1 + 1 for Soundex )
						tblPlace[intPlace..strPlace] = intPartRight						-- Place part in right position, has leading digit	( default 3 total = 1 + 1 above + 1 for Soundex )
						tblPlace[StrSoundex(strPlace)] = intPlaceSoundex				-- Similar sounding Place part, has capital+digits	( default 1 total )
					end
				end
				ptrPlace:MoveNext("SAME_TAG")
			end
		end
		return TblMakeEvent(intLower,intUpper,tblPlace)							-- Save Lower & Upper Date Timespan and Place & Soundex for each comma separated Place part
	end -- anonymous function

end -- function NewEventData

function NewPersonData()																-- Prototype to Make Person Database for Individual

	local tblFact = { BIRT="Birth"; BAPM="BapCh"; CHR="BapCh"; FAMS="Marry"; DEAT="Death"; BURI="Death"; CREM="Death"; }
	local tblRel = { FAMCHUSB="Father"; FAMCWIFE="Mother"; FAMSHUSB="Spouse"; FAMSWIFE="Spouse"; FAMSCHIL="Child"; }

	return function(ptrInd,intRid)													-- Make Person Database for Individual
		local tblInd = TblData[intRid]
		tblInd.Indiv = ptrInd:Clone()													-- Save Individual Record pointer
		tblInd.Gender= fhGetItemText(ptrInd,"~.SEX")								-- Save Individual Gender
		tblInd.Names = TblNamesData(ptrInd)											-- Save Individual Names data table
		tblInd.Birth = {}
		tblInd.BapCh = {}																-- Save an Event table for each type of BMD Event in tblFact
		tblInd.Marry = {}
		tblInd.Death = {}
		tblInd.Father= {}																-- Save a Record Id table for each type of Relation in tblRel
		tblInd.Mother= {}
		tblInd.Spouse= {}
		tblInd.Child = {}
		local ptrItem = fhNewItemPtr()
		ptrItem:MoveToFirstChildItem(ptrInd)
		while ptrItem:IsNotNull() do													-- Loop through all instances of each Item
			local strTag = fhGetTag(ptrItem)
			local strFact = tblFact[strTag]											-- Lookup the Fact name for Item Tag (if any)
			if strFact then
				local ptrFact = ptrItem:Clone()
				if strFact == "Marry" then ptrFact:MoveTo(fhGetValueAsLink(ptrFact),"~.MARR") end
				table.insert(tblInd[strFact],TblEventData(ptrFact))				-- Save each Event table of Dates & Places
			end
			if strTag:match("FAM") then												-- Family link Item
				local ptrRel = fhNewItemPtr()
				ptrRel:MoveToFirstChildItem(fhGetValueAsLink(ptrItem))
				while ptrRel:IsNotNull() do											-- Loop through all instances of each Item
					local strRel = tblRel[strTag..fhGetTag(ptrRel)]				-- Lookup the Relation name for Tags (if any)
					if strRel then
						local ptrLnk = fhGetValueAsLink(ptrRel)						-- Ensure link pointer has a value
						if ptrLnk:IsNotNull() then
							local intRel = fhGetRecordId(ptrLnk)						-- Obtain the Record Id of Relation
							if intRel ~= intRid then									-- Relation is not original Individual
								table.insert(tblInd[strRel],intRel)					-- Save their Record Id
								TblData[intRel].Indiv = ptrLnk:Clone()				-- Save their Record pointer
							end
						end
					end
					ptrRel:MoveNext("ANY")
				end
			end
			ptrItem:MoveNext("ANY")
		end
	end -- anonymous function

end -- function NewPersonData

function TblMakeEvent(intLower,intUpper,tblPlace)									-- Make Event from Date Timespan & Place Parts
	local tblEvent = {}
	tblEvent.Lower = intLower
	tblEvent.Upper = intUpper
	tblEvent.Place = tblPlace
	return tblEvent
end -- function TblMakeEvent

function NewScoreNamesIndi()															-- Prototype to Score Names of primary Individuals

	local intLastWrongIndi = TblNameLastWrong[IntIndivi]
	local intNameMaxIndi = TblNameMaximum[IntIndivi]

	return function(tblListA,tblListB)												-- Calculate the Score for Comparing two Individual Name Lists
		local intScore = 0
		local intLastWrong = intLastWrongIndi										-- Deduction if no Lastname match (default is 0)
		for strName in pairs(tblListA) do
			local tblName = tblListB[strName]
			if tblName then
				intLastWrong = strName:match("^(0)") or intLastWrong				-- Note if Lastname matches to inhibit deduction
				intScore = intScore + tblName[IntIndivi]							-- Increase score for any Name or Soundex matches
			end
		end
		if tonumber(intLastWrong) < 0 then return intLastWrong end				-- Reduce score if no Lastname match and deduction exists
		return math.min(intScore,intNameMaxIndi)									-- Limit prevents multiple Alternate Name matches overwhelming result
	end -- anonymous function

end -- function NewScoreNamesIndi

function IntScoreNamesData(tblListA,tblListB,intRelation)						-- Calculate the Score for Comparing two Name Lists of Relations
	local intScore = 0
	local intLastWrong = TblNameLastWrong[intRelation]							-- Deduction if no Lastname match (default is 0)
	for strName in pairs(tblListA) do
		local tblName = tblListB[strName]
		if tblName then
			intLastWrong = strName:match("^(0)") or intLastWrong					-- Note if Lastname matches to inhibit deduction
			intScore = intScore + tblName[intRelation]								-- Increase score for any Name or Soundex matches
		end
	end
	if tonumber(intLastWrong) < 0 then return intLastWrong end					-- Reduce score if no Lastname match and deduction exists
	if intScore < TblNameMinimum[intRelation] then return TblNameDeduction[intRelation]	end -- Reduce score for Name matches below minimum threshold
	return math.min(intScore,TblNameMaximum[intRelation])							-- Limit prevents multiple Alternate Name matches overwhelming result
end -- function IntScoreNamesData

function IntScoreEventData(tblEventA,tblEventB)									-- Calculate the Score for Comparing two Events
	if tblEventA.Place and tblEventB.Place then										-- Both the Event Dates exist and are Real not Synthetic
		local intScore = 0
		if math.abs(tblEventA.Lower-tblEventB.Lower) <= IntDatesTolerance then intScore = intScore + IntDatesMatched end
		if math.abs(tblEventA.Upper-tblEventB.Upper) <= IntDatesTolerance then intScore = intScore + IntDatesMatched end
		if math.max(tblEventA.Lower,tblEventB.Lower) <= math.min(tblEventA.Upper,tblEventB.Upper) then intScore = intScore + IntDatesOverlap end
		if intScore < IntDatesMinimum then return IntDatesDeduction end			-- Dates completely different, so reduce score
		for strPlace in pairs(tblEventA.Place) do
			intScore = intScore + ( tblEventB.Place[strPlace] or 0 )				-- Increase score for Place part or Soundex matches
		end
		return math.min(intScore,IntEventMaximum)							 		-- Limit prevents multiple Place part matches overwhelming result
	end
	return 0
end -- function IntScoreEventData

function IntScoreBestEvents(tblEventsA,tblEventsB)								-- Calculate best Score for Comparing all Events
	local intScore = nil
	for intEventA, tblEventA in ipairs(tblEventsA) do
		for intEventB, tblEventB in ipairs(tblEventsB) do
			intScore = math.max(intScore or -999,IntScoreEventData(tblEventA,tblEventB))
		end
	end
	return intScore or 0
end -- function IntScoreBestEvents

function TblScoreRelatives(tblRidA,tblRidB,intRelation)							-- Calculate best Score for Comparing all Relatives
	local tblScore = { 0; 0; 0; 0; 0; 0; }
	tblRidA.Best = tblRidA[1]															-- Record Id of Best matching Relatives
	tblRidB.Best = tblRidB[1]
	for intRelA, intRidA in ipairs(tblRidA) do
		for intRelB, intRidB in ipairs(tblRidB) do
			if intRidA and intRidB then												-- Both the Record Id exist
				if intRidA == intRidB and IntGenGapFamily > 0 then				-- Both the same person and immediate family excluded	-- V3.7
					return { 0; 0; 0; 0; 0; 0; }										-- So return zero, as they are not duplicates to be scored, and negative score upsets close Family preferences
				end
				local tblIndA = TblData[intRidA]
				local tblIndB = TblData[intRidB]										-- Score Names data
				if not tblIndA.Names then GetPersonData(tblIndA.Indiv,intRidA) end
				if not tblIndB.Names then GetPersonData(tblIndB.Indiv,intRidB) end
				local intNames = IntScoreNamesData(tblIndA.Names,tblIndB.Names,intRelation)
				if intNames >= TblNameThreshold[intRelation] then					-- Threshold has been reached, so score Event data
					local intBirth = IntScoreBestEvents(tblIndA.Birth,tblIndB.Birth)
					local intBapCh = IntScoreBestEvents(tblIndA.BapCh,tblIndB.BapCh)
					local intMarry = IntScoreBestEvents(tblIndA.Marry,tblIndB.Marry)
					local intDeath = IntScoreBestEvents(tblIndA.Death,tblIndB.Death)
					local intScore = intNames+intBirth+intBapCh+intMarry+intDeath
					if intScore > tblScore[1] then									-- Save the best Scores and pair of Relatives Id
						tblScore = { intScore; intNames; intBirth; intBapCh; intMarry; intDeath; }
						tblRidA.Best = intRidA
						tblRidB.Best = intRidB
					end
				else
					if intNames > tblScore[1] then
						tblScore = { intNames; intNames; }
						tblRidA.Best = intRidA
						tblRidB.Best = intRidB
					end
				end
			end
		end
	end
	return tblScore
end -- function TblScoreRelatives

function TblScoreGender(tblIndA,tblIndB)											-- Calculate Score for Comparing Gender of Individuals and Best Children
	local intIndivGend = 0
	if tblIndA.Gender ~= tblIndB.Gender then intIndivGend = IntGenderDeduct end
	local intChildGend = 0
	local intChildA = tblIndA.Child.Best												-- Retrieve the Best matching Child Record Id
	local intChildB = tblIndB.Child.Best
	if intChildA and intChildB then
		if TblData[intChildA].Gender ~= TblData[intChildB].Gender then intChildGend = IntGenderDeduct end
	end
	return { intIndivGend+intChildGend; intIndivGend; intChildGend; }
end -- function TblScoreGender

function IntLower(tblEvent)															-- Lower date range value
	if tblEvent then return tblEvent.Lower end
	return -99999
end -- function IntLower

function IntUpper(tblEvent)															-- Upper date range value
	if tblEvent then return tblEvent.Upper end
	return 999999
end -- function IntUpper

function TblDataIndi(intRid)															-- Create Person Database if not yet compiled
	local tblInd = TblData[intRid]
	if not tblInd.Names then GetPersonData(tblInd.Indiv,intRid) end
	return tblInd
end -- function TblDataIndi(intRid)

function SynthesiseDates(tblInd)														-- Synthesise missing Event Dates from other Event Dates
	local tblBirth = tblInd.Birth[1]													-- Only consider 1st Date for each type of Event
	local tblBapCh = tblInd.BapCh[1]
	local tblMarry = tblInd.Marry[1]
	local tblDeath = tblInd.Death[1]
	local intSpouse = tblInd.Spouse[1]												-- Cannot cope with multiple Spouses, so only synthesise Marriage Date when one Spouse
	if #tblInd.Spouse == 1 and not tblMarry then									-- Single Spouse but no Marriage Date, so synthesise from own/spouse BMD events
		local tblSpouse = TblDataIndi(intSpouse)
		local intLower = math.max( IntLower(tblBirth), IntLower(tblBapCh), IntLower(tblSpouse.Birth[1]), IntLower(tblSpouse.BapCh[1]) )
		local intUpper = math.min( IntUpper(tblDeath), IntUpper(tblSpouse.Death[1]) )
		if intLower > 0 or intUpper < 999999 then									-- Synthetic Lower or Upper date exists
			if intLower <= 0		then intLower = intUpper - IntLifespanDays		-- No Lower date so Lower = Upper - Lifespan
										else intLower = intLower + IntMarriageDays end		-- Lower Birth/Baptism date exists so add Age of Consent
			if intUpper >= 999999	then intUpper = intLower + IntLifespanDays end		-- No upper date so Upper = Lower + Lifespan
			tblInd.Marry[1] = TblMakeEvent(intLower,intUpper)						-- Synthesise Event with no Place
			tblMarry = tblInd.Marry[1]
		end
	end
	if not tblBirth then																-- No Birth Date, so synthesise from own/child/parent BMD events
		local intLower = -99999
		local intUpper = math.min( IntUpper(tblDeath), IntUpper(tblMarry) - IntMarriageDays, IntUpper(tblBapCh) )
		local intChild1 = tblInd.Child[1]
		if intChild1 then
			local tblChild1 = TblDataIndi(intChild1)								-- Latest Birth is 1st Child's latest Birth/Baptism/Death - Age of Puberty, or Marriage - Age of Puberty - Age of Consent
			intUpper = math.min( intUpper, IntUpper(tblChild1.Birth[1]) - IntPubertyDays, IntUpper(tblChild1.BapCh[1]) - IntPubertyDays, IntUpper(tblChild1.Death[1]) - IntPubertyDays, IntUpper(tblChild1.Marry[1]) - IntPubertyDays - IntMarriageDays )
		end
		local intMother = tblInd.Mother[1]
		if intMother then
			local tblMother = TblDataIndi(intMother)
			intLower = math.max( intLower, IntLower(tblMother.Birth[1]) + IntPubertyDays )	-- Earliest Birth is 1st Mother's earliest Birth + Age of Puberty
			intUpper = math.min( intUpper, IntUpper(tblMother.Birth[1]) + IntFertileDays, IntUpper(tblMother.Death[1]) )
		end																									-- Latest Birth is earliest of 1st Mother's latest Birth + Age of Fertility, or 1st Mother's latest Death
		local intFather = tblInd.Father[1]
		if intFather then
			local tblFather = TblDataIndi(intFather)
			intLower = math.max( intLower, IntLower(tblFather.Birth[1]) + IntPubertyDays )	-- Earliest Birth is 1st Father's earliest Birth + Age of Puberty
			intUpper = math.min( intUpper, IntUpper(tblFather.Death[1]) + IntPregnantDays )	-- Latest Birth is 1st Father's latest Death + Pregnancy
		end
		if intLower > 0 or intUpper < 999999 then													-- Synthetic Lower or Upper date exists
			if intLower <= 0		then intLower = intUpper - IntLifespanDays end					-- No Lower date so Lower = Upper - Lifespan
			if intUpper >= 999999	then intUpper = intLower + IntLifespanDays end				-- No upper date so Upper = Lower + Lifespan
			tblInd.Birth[1] = TblMakeEvent(intLower,intUpper)										-- Synthesise Event with no Place
			tblBirth = tblInd.Birth[1]
		end
	end
	if tblBirth and not tblDeath then
		tblInd.Death[1] = TblMakeEvent(tblBirth.Lower,tblBirth.Upper+IntLifespanDays)		-- Birth Date but no Death Date, so synthesise from own Birth event
	end
end -- function SynthesiseDates

function IntDateChronCheck(intDateA,intDateB)										-- Check date chronology and return proportional points score
	return math.floor( math.min( ( intDateA - intDateB ), 0 ) / IntChronMagDays )
end -- function IntDateChronCheck

function IntScoreDateChron(tblIndA,tblIndB)											-- Calculate the Score for the Chronology of Event Dates i.e. Is Birth after Baptism after Marriage after Death ?
	local intLowerBirthA = IntLower(tblIndA.Birth[1])								-- Lower Date for each Event for both Individuals
	local intLowerBirthB = IntLower(tblIndB.Birth[1])
	local intLowerBapChA = IntLower(tblIndA.BapCh[1])
	local intLowerBapChB = IntLower(tblIndB.BapCh[1])
	local intLowerMarryA = IntLower(tblIndA.Marry[1])
	local intLowerMarryB = IntLower(tblIndB.Marry[1])
	local intLowerDeathA = IntLower(tblIndA.Death[1])
	local intLowerDeathB = IntLower(tblIndB.Death[1])
	local intUpperBirthA = IntUpper(tblIndA.Birth[#tblIndA.Birth])				-- Upper Date for each Event for both Individuals
	local intUpperBirthB = IntUpper(tblIndB.Birth[#tblIndB.Birth])
	local intUpperBapChA = IntUpper(tblIndA.BapCh[#tblIndA.BapCh])
	local intUpperBapChB = IntUpper(tblIndB.BapCh[#tblIndB.BapCh])
	local intUpperMarryA = IntUpper(tblIndA.Marry[#tblIndA.Marry])
	local intUpperMarryB = IntUpper(tblIndB.Marry[#tblIndB.Marry])
	local intUpperDeathA = IntUpper(tblIndA.Death[#tblIndA.Death])
	local intUpperDeathB = IntUpper(tblIndB.Death[#tblIndB.Death])

	local intScore =
		IntDateChronCheck( intUpperBirthA, intLowerBirthB ) +						-- Individual A latest birth before Individual B earliest birth
		IntDateChronCheck( intUpperBirthB, intLowerBirthA ) +						-- Individual B latest birth before Individual A earliest birth
		IntDateChronCheck( intUpperBapChA, intLowerBirthB ) +						-- Individual A baptised before Individual B born
		IntDateChronCheck( intUpperBapChB, intLowerBirthA ) +						-- Individual B baptised before Individual A born
		IntDateChronCheck( intUpperMarryA, intLowerBirthB ) +						-- Individual A married before Individual B born
		IntDateChronCheck( intUpperMarryB, intLowerBirthA ) +						-- Individual B married before Individual A born
		IntDateChronCheck( intUpperDeathA, intLowerBirthB ) +						-- Individual A died before Individual B born
		IntDateChronCheck( intUpperDeathB, intLowerBirthA ) +						-- Individual B died before Individual A born

		IntDateChronCheck( intUpperBapChA, intLowerBapChB ) +						-- Individual A latest baptised before Individual B earliest baptised
		IntDateChronCheck( intUpperBapChB, intLowerBapChA ) +						-- Individual B latest baptised before Individual A earliest baptised
		IntDateChronCheck( intUpperMarryA, intLowerBapChB ) +						-- Individual A married before Individual B baptised
		IntDateChronCheck( intUpperMarryB, intLowerBapChA ) +						-- Individual B married before Individual A baptised
		IntDateChronCheck( intUpperDeathA, intLowerBapChB ) +						-- Individual A died before Individual B baptised
		IntDateChronCheck( intUpperDeathB, intLowerBapChA ) +						-- Individual B died before Individual A baptised

		IntDateChronCheck( intUpperMarryA, intLowerMarryB ) +						-- Individual A latest marriage before Individual B earliest marriage
		IntDateChronCheck( intUpperMarryB, intLowerMarryA ) +						-- Individual B latest marriage before Individual A earliest marriage
		IntDateChronCheck( intUpperDeathA, intLowerMarryB ) +						-- Individual A died before Individual B married
		IntDateChronCheck( intUpperDeathB, intLowerMarryA ) +						-- Individual B died before Individual A married

		IntDateChronCheck( intUpperDeathA, intLowerDeathB ) +						-- Individual A latest died before Individual B earliest died
		IntDateChronCheck( intUpperDeathB, intLowerDeathA )						-- Individual B latest died before Individual A earliest died

	if intScore > IntChronTolerance then
		local intMother = tblIndB.Mother.Best
		if intMother then
			tblEvent = TblDataIndi(intMother)
			intScore = intScore + 
				IntDateChronCheck( intUpperBirthA, IntLower(tblEvent.Birth[1])+IntPubertyDays ) +					-- Individual A born before Mother of B mature
				IntDateChronCheck( IntUpper(tblEvent.Birth[#tblEvent.Birth])+IntFertileDays, intLowerBirthA ) +	-- Mother of B infertile before Individual A born
				IntDateChronCheck( IntUpper(tblEvent.Death[#tblEvent.Death]), intLowerBirthA )						-- Mother of B died before Individual A born
		end
		local intMother = tblIndA.Mother.Best
		if intMother then
			tblEvent = TblDataIndi(intMother)
			intScore = intScore + 
				IntDateChronCheck( intUpperBirthB, IntLower(tblEvent.Birth[1])+IntPubertyDays ) +					-- Individual B born before Mother of A mature
				IntDateChronCheck( IntUpper(tblEvent.Birth[#tblEvent.Birth])+IntFertileDays, intLowerBirthA ) +	-- Mother of A infertile before Individual B born
				IntDateChronCheck( IntUpper(tblEvent.Death[#tblEvent.Death]), intLowerBirthA )						-- Mother of A died before Individual B born
		end
		local intFather = tblIndB.Father.Best
		if intFather then
			tblEvent = TblDataIndi(intFather)
			intScore = intScore + 
				IntDateChronCheck( intUpperBirthA, IntLower(tblEvent.Birth[1])+IntPubertyDays ) +					-- Individual A born before Father of B mature
				IntDateChronCheck( IntUpper(tblEvent.Death[#tblEvent.Death]), intLowerBirthA-IntPregnantDays )	-- Father of B died before Individual A conceived
		end
		local intFather = tblIndA.Father.Best
		if intFather then
			tblEvent = TblDataIndi(intFather)
			intScore = intScore + 
				IntDateChronCheck( intUpperBirthB, IntLower(tblEvent.Birth[1])+IntPubertyDays ) +					-- Individual B born before Father of A mature
				IntDateChronCheck( IntUpper(tblEvent.Death[#tblEvent.Death]), intLowerBirthB-IntPregnantDays )	-- Father of A died before Individual B conceived
		end
		local intChild = tblIndB.Child.Best
		if intChild then
			tblEvent = TblDataIndi(intChild)
			intScore = intScore + 
				IntDateChronCheck( intUpperDeathA, IntLower(tblEvent.Birth[1])-IntPregnantDays )					-- Individual A died before Child of B conceived
		end
		local intChild = tblIndA.Child.Best
		if intChild then
			tblEvent = TblDataIndi(intChild)
			intScore = intScore + 
				IntDateChronCheck( intUpperDeathB, IntLower(tblEvent.Birth[1])-IntPregnantDays )					-- Individual B died before Child of A conceived
		end
	end
	return intScore
end -- function IntScoreDateChron

function TblScoreGenGap(ptrIndA,ptrIndB)											-- Calculate the Score for Comparing Gender Gap
	local intGensUp = fhCallBuiltInFunction("RelationCode",ptrIndA,ptrIndB,"GENS_UP",1)		-- Always positive or nil if unrelated
	local intGensDn = fhCallBuiltInFunction("RelationCode",ptrIndA,ptrIndB,"GENS_DOWN",1)	-- Always positive or nil if unrelated
	if intGensUp and intGensDn then
		local intGenGap = intGensUp + intGensDn - IntGenGapRelative				-- Spouse = -6, Parent:Child = -5, Sibling/Gparent:Gchild = -4, -3, -2, -1 , 0 , 1, etc
		return { math.min( intGenGap * IntGenGapDeduction, 0 ); intGensUp; intGensDn; }
	end
	return { 0; intGensUp or ""; intGensDn or ""; }
end -- function TblScoreGenGap

function SortResults(tblResults)														-- Sort the Results into Descending Order of Full Score and then by Individual Score
	table.sort( tblResults, function(tblA,tblB) if tblA.FullScore ~= tblB.FullScore then return tblA.FullScore > tblB.FullScore else return tblA.IndiScore > tblB.IndiScore end end )
end -- function SortResults

-- Find Duplicate Records --
function FindDuplicateRecords(intTotal,flgDiag,flgSpan)							-- Total records, Diagnostic mode, with Timespans

	local tblData = TblData																-- Data table of Record Id to include in candidate checks
	local tblRecId = {}																	-- Individual Record Id pointers to Individual Record Data saved for comparisons
	local tblResults = {}																-- Results for Individual Record matches
	local intBestScore = 0
	local intMinimum = IntLeastResults												-- Minimum initial score to retain in Results
	local isResultOK = true																-- Flag if completed run with Result Set
	local intStartTime = os.time()													-- Time search started for Result Set duration Message
	local intComparisons = intTotal * ( intTotal-1 ) / 2							-- Number of Individual versus Individual comparisons
	local intProgressStep = 0															-- Progress count of inner loop Individual comparisons
	local intIndThreshold = TblNameThreshold[IntIndivi]							-- Local copies of Global values
	local intIndiScoreMin = IntIndiScoreMin
	local intLeastResults = IntLeastResults
	local intLimitResults = IntLimitResults
	local intPruneResults = IntPruneResults
	local intProgBarStart = IntProgBarStart
	if flgDiag then
		intIndThreshold = -100 -- points												-- Adjusted values for Diagnostic Mode to retain many more Results
		intIndiScoreMin = -100 -- points
		intLeastResults = -100 -- points
		intLimitResults =  200 -- rows
		intPruneResults = intLimitResults * 2
		intProgBarStart = intProgBarStart / 10
	end

	progbar.Setup(iup_gui.DialogueAttributes("Bars"))								-- Create the Progress Bar functions
	if intComparisons > intProgBarStart then										-- Optionally start Progress Bar for Comparsions
		progbar.Start("Finding Duplicates",intComparisons)
	end

	TblSoundex = { }																		-- Soundex dictionary codes cache of previously coded Names & Places
	if general.FlgFileExists(StrSoundexFile) then
		progbar.Focus()
		progbar.Message("Loading Soundex Cache")
		TblSoundex, StrErr = table.load(StrSoundexFile)							-- Load Soundex dictionary codes cache table
		if TblSoundex[""] == "Z000" then
			TblSoundex = { }																-- Erase old initial letter Soundex cache
		end
	end
	StrSoundex = NewSoundex(TblSoundex)												-- Prototype for Soundex Calculator using Soundex dictionary codes cache

	TblNamesData = NewNamesData()														-- Prototype to Make Names & Soundex Dictionary per Individual

	TblEventData = NewEventData()														-- Prototype to Make Event Date Timespan & Place Parts per Individual

	GetPersonData = NewPersonData()													-- Prototype to Make Personal Profile Database per Individual

	IntScoreNamesIndi = NewScoreNamesIndi()											-- Prototype to Score Names of primary Individuals

	local ptrIndA = fhNewItemPtr()
	ptrIndA:MoveToFirstRecord("INDI")
	while ptrIndA:IsNotNull() do														-- Loop through every Individual Record
		local intRidA = fhGetRecordId(ptrIndA)										-- Obtain the Record Id of Individual
		local tblIndA = tblData[intRidA]
		if not tblIndA.Names then GetPersonData(ptrIndA,intRidA) end			-- Make the Person Database for Individual
		table.insert(tblRecId,intRidA)												-- Save the Data Record Id in consecutively indexed table
		SynthesiseDates(tblIndA)														-- Synthesise missing Event Dates from other Events
		for intIndB = 1, #tblRecId - 1 do												-- Loop through prior Individual Record entries
			local intRidB = tblRecId[intIndB]											-- Record Id from consecutively indexed table
			local tblIndB = tblData[intRidB]											-- Lookup Individual Record Data
			if tblIndA.Chosen or tblIndB.Chosen then								-- Only score Records that were Chosen in GUI
				local intIndiNames = IntScoreNamesIndi(tblIndA.Names,tblIndB.Names)
				if intIndiNames >= intIndThreshold then								-- If enough Individual Names match, assess score for Individual BMD Events, etc
					local intIndiBirth = IntScoreBestEvents(tblIndA.Birth,tblIndB.Birth) * IntBoostedBirth		-- V3.8
					local intIndiBapCh = IntScoreBestEvents(tblIndA.BapCh,tblIndB.BapCh) * IntBoostedBapCh		-- V3.8
					local intIndiMarry = IntScoreBestEvents(tblIndA.Marry,tblIndB.Marry) * IntBoostedMarry		-- V3.8
					local intIndiDeath = IntScoreBestEvents(tblIndA.Death,tblIndB.Death) * IntBoostedDeath		-- V3.8
					local intIndiScore = intIndiNames + intIndiBirth + intIndiBapCh	+ intIndiMarry + intIndiDeath
					if intIndiScore >= intIndiScoreMin then							-- If Individual Score exceeds minimum, assess score for Relatives, Gender, etc
						local tblFather = TblScoreRelatives(tblIndA.Father,tblIndB.Father,IntFather)
						local tblMother = TblScoreRelatives(tblIndA.Mother,tblIndB.Mother,IntMother)
						local tblSpouse = TblScoreRelatives(tblIndA.Spouse,tblIndB.Spouse,IntSpouse)
						local tblChild  = TblScoreRelatives(tblIndA.Child ,tblIndB.Child ,IntChild )
						local tblGender = TblScoreGender(tblIndA,tblIndB)
						local intFullScore = intIndiScore + tblFather[1] + tblMother[1] + tblSpouse[1] + tblChild[1] + tblGender[1]
						if intFullScore >= intMinimum then							-- Continue if score is above lowest retained Results entry
							local ptrIndB = tblIndB.Indiv								-- Get other Individual Record pointer
							local intDateChron = IntScoreDateChron(tblIndA,tblIndB)	-- Check date chronology using Best match Relations
							local tblGenGap = TblScoreGenGap(ptrIndA,ptrIndB)		-- Only check generation gap now as it has a high run time overhead
							if intDateChron > IntChronTolerance						-- Exclude major chronology mismatch
							and tblGenGap[1] > IntFamGenGapMax then					-- Exclude spouse (including spouse's spouses), parent/child, sibling, gparent/gchild
								intFullScore = intFullScore + intDateChron + tblGenGap[1]
								if intFullScore >= intMinimum then
									local isCandidate = true
									for i, tblNonDups in ipairs( TblNonDups ) do
										if intRidA == tblNonDups.RecordIdA and intRidB == tblNonDups.RecordIdB then
											isCandidate = false							-- Exclude Non-Duplicate pairs
											break
										end
									end
									if isCandidate then
										table.insert( tblResults,
											{
											FullScore=intFullScore; RecordIdA=intRidA; RecordIdB=intRidB;
											IndiScore=intIndiScore; IndiNames=intIndiNames; IndiBirth=intIndiBirth; IndiBapCh=intIndiBapCh; IndiMarry=intIndiMarry; IndiDeath=intIndiDeath;
											FathScore=tblFather[1]; FathNames=tblFather[2]; FathBirth=tblFather[3]; FathBapCh=tblFather[4]; FathMarry=tblFather[5]; FathDeath=tblFather[6];
											MothScore=tblMother[1]; MothNames=tblMother[2]; MothBirth=tblMother[3]; MothBapCh=tblMother[4]; MothMarry=tblMother[5]; MothDeath=tblMother[6];
											SpouScore=tblSpouse[1]; SpouNames=tblSpouse[2]; SpouBirth=tblSpouse[3]; SpouBapCh=tblSpouse[4]; SpouMarry=tblSpouse[5]; SpouDeath=tblSpouse[6];
											ChilScore=tblChild [1]; ChilNames=tblChild [2]; ChilBirth=tblChild [3]; ChilBapCh=tblChild [4]; ChilMarry=tblChild [5]; ChilDeath=tblChild [6];
											FamGenGap=tblGenGap[1]; FamGensUp=tblGenGap[2]; FamGensDn=tblGenGap[3]; DateChron=intDateChron;
											GendScore=tblGender[1]; IndivGend=tblGender[2]; ChildGend=tblGender[3];
											} )
										if #tblResults >= intPruneResults then		-- Prune low scores from Results to avoid exceeding memory
											SortResults(tblResults)
											for intEntry = intLimitResults + 1, #tblResults do tblResults[intEntry] = nil end	-- table.remove(tblResults) end
											intMinimum = tblResults[#tblResults].FullScore
										end
									end
								end
							end
						end
					end
				end
			end
		end
		progbar.Message("Individual Record Id "..intRidA)							-- Report progress
		progbar.Step(intProgressStep)
		intProgressStep = intProgressStep + 1										-- Each inner loop performs incrementing number of comparisons
		if progbar.Stop() then															-- Break out of outer loop
			isResultOK = false
			break
		end
		ptrIndA:MoveNext()
	end

	progbar.Focus()
	progbar.Message("Saving Soundex Cache")											-- Save Soundex dictionary codes cache table
	table.save(TblSoundex,StrSoundexFile)

	if isResultOK then																	-- Dislay Result Set unless Progress Bar was Stopped

		progbar.Message("Composing Result Set")
		SortResults(tblResults)															-- Sort the final results

		local dateOrigin = fhNewDate(0001,01,10):GetDatePt1()						-- Date origin for Timespan Dates is 10-Jan-0001 = 1-Jan-0001 + 9 days for Gregorian calendar offset
		local dateTimespan = fhNewDate(0000)											-- Date Timespan pointer for Timespan Date entries

		for intEntry = 1, #tblResults do												-- Extract highest scoring entries & populate table with Timespan Dates
			local tblEntry = tblResults[intEntry]									-- Timespan Dates only added now to avoid slowing down main search loop above 
			if tblEntry then intFullScore = tblEntry.FullScore end
			if intFullScore < intLeastResults
			or intEntry > intLimitResults then
				table.remove(tblResults)												-- When low Score or Results limit reached, purge remainder of results
			else
				intRecordIdA = tblEntry.RecordIdA
				intRecordIdB = tblEntry.RecordIdB
				-- Populate this Entry in Results table with Event Date Timespans
				for strEvent, strPrefix in pairs( { Birth="B_"; BapCh="C_"; Marry="M_"; Death="D_"; } ) do
					for strRecId, intRecId in pairs( { A_=intRecordIdA; B_=intRecordIdB; } ) do
						local strIndex = strPrefix..strRecId.."Span"
						local tblIndi  = tblData[intRecId]
						local tblEvent = tblIndi[strEvent][1]						-- Get earliest Lower/Upper Timespan Dates for each Event for both Records
						if tblEvent then													-- Event Timespan Date exists
							local intLower = tblEvent.Lower
							local intUpper = tblEvent.Upper
							if intLower == intUpper then								-- Lower = Upper so Simple Date
								dateTimespan:SetSimpleDate(fhCallBuiltInFunction("CalcDate",dateOrigin,0,0,intLower))
							else															-- Lower < Upper so Date Range
								dateTimespan:SetRange("between",fhCallBuiltInFunction("CalcDate",dateOrigin,0,0,intLower),fhCallBuiltInFunction("CalcDate",dateOrigin,0,0,intUpper))
							end
							local strType = "  Synth"
							if tblEvent.Place then strType = "  Actual" end		-- Date is Actual and not Synthetic
							tblEntry[strIndex] = dateTimespan:GetDisplayText("COMPACT")..strType
						else
							tblEntry[strIndex] = " "									-- Event Timespan Date missing
						end
					end
				end
				progbar.Step(intProgressStep)
			end
		end
		table.save(tblResults,StrResultsFile)										-- Save results so can show again later

		isResultOK = DisplayResultSet(tblResults,flgDiag,flgSpan,intStartTime)
	end

	progbar.Close()																		-- Close the Progress Bar and retrieve its window position

	return isResultOK
end -- function FindDuplicateRecords

function DisplayResultSet(tblResults,flgDiag,flgSpan,intStartTime)				-- Display the Result Set in FH

	if #tblResults == 0 then
		iup_gui.MemoDialogue("\n No duplicate Individual Records found. \n","OK")
		return false
	end

	local tblColumnKey =
	{	--	 Column Title			;	Index		;	Type	;Width;	Alignment;	-- Result Set Column parameters allow easy changes
		{	"Individual"				; "IndiScore";	};
		{	"I-Names"					; "IndiNames";	};
		{	"I-Birth"					; "IndiBirth";	};
		{	"I-Birth Timespan-A "	; "B_A_Span"	; "text"	;142 ; "align_right"; };	-- Birth Timespan Record A
		{	"I-Birth Timespan-B "	; "B_B_Span"	; "text"	;142 ; "align_right"; };	-- Birth Timespan Record B
		{	"I-BapCh"					; "IndiBapCh";	};
		{	"I-BapCh Timespan-A "	; "C_A_Span"	; "text"	;142 ; "align_right"; };	-- Bap/Chr Timespan Record A
		{	"I-BapCh Timespan-B "	; "C_B_Span"	; "text"	;142 ; "align_right"; };	-- Bap/Chr Timespan Record B
		{	"I-Marry"					; "IndiMarry";	};
		{	"I-Marry Timespan-A "	; "M_A_Span"	; "text"	;142 ; "align_right"; };	-- Marry Timespan Record A
		{	"I-Marry Timespan-B "	; "M_B_Span"	; "text"	;142 ; "align_right"; };	-- Marry Timespan Record B
		{	"I-Death"					; "IndiDeath";	};
		{	"I-Death Timespan-A "	; "D_A_Span"	; "text"	;142 ; "align_right"; };	-- Death Timespan Record A
		{	"I-Death Timespan-B "	; "D_B_Span"	; "text"	;142 ; "align_right"; };	-- Death Timespan Record B
		{	"Father"					; "FathScore";	};
		{	"F-Names"					; "FathNames";	};
		{	"F-Birth"					; "FathBirth";	};
		{	"F-BapCh"					; "FathBapCh";	};
		{	"F-Marry"					; "FathMarry";	};
		{	"F-Death"					; "FathDeath";	};
		{	"Mother"					; "MothScore";	};
		{	"M-Names"					; "MothNames";	};
		{	"M-Birth"					; "MothBirth";	};
		{	"M-BapCh"					; "MothBapCh";	};
		{	"M-Marry"					; "MothMarry";	};
		{	"M-Death"					; "MothDeath";	};
		{	"Spouse"					; "SpouScore";	};
		{	"S-Names"					; "SpouNames";	};
		{	"S-Birth"					; "SpouBirth";	};
		{	"S-BapCh"					; "SpouBapCh";	};
		{	"S-Marry"					; "SpouMarry";	};
		{	"S-Death"					; "SpouDeath";	};
		{	"Child"					; "ChilScore";	};
		{	"C-Names"					; "ChilNames";	};
		{	"C-Birth"					; "ChilBirth";	};
		{	"C-BapCh"					; "ChilBapCh";	};
		{	"C-Marry"					; "ChilMarry";	};
		{	"C-Death"					; "ChilDeath";	};
		{	"Chrono"					; "DateChron";	};
		{	"Gen.Gap"					; "FamGenGap";	};
		{	"Gens-Up"					; "FamGensUp";	};
		{	"Gens-Dn"					; "FamGensDn";	};
		{	"Gender"					; "GendScore";	};
		{	"I-Gend"					; "IndivGend";	};
		{	"C-Gend"					; "ChildGend";	};
	}
	local intFullScore = 0																-- Full Score
	local tblFullScore = {}
	local tblPercentage = {}
	local intRecordIdA = 0																-- Record Id A & Individual A
	local tblRecordIdA = {}
	local tblPossibleA = {}
	local intRecordIdB = 0																-- Record Id B & Individual B
	local tblRecordIdB = {}
	local tblPossibleB = {}
	local tblResultSet = {}																-- Table of Result Set Columns for Diagnostic sub-scores

	local intMaxScore = 0
	local intBoosted = IntBoostedBirth + IntBoostedBapCh + IntBoostedMarry + IntBoostedDeath	-- V3.8
	for intRelation = IntIndivi, IntChild do										-- Max score for Percentage calculation = Event*Boost & Name maxima for each relation
		intMaxScore = intMaxScore + (IntEventMaximum * intBoosted) + TblNameMaximum[intRelation]
		intBoosted = 4																	-- V3.8
	end

	for intEntry = 1, #tblResults do													-- Create Result Set Column tables
		local tblEntry = tblResults[intEntry]
		intRecordIdA = tblEntry.RecordIdA
		local ptrPossibleA = fhNewItemPtr()											-- Record Id and Pointer of Individual A
		ptrPossibleA:MoveToRecordById("INDI",intRecordIdA)
		intRecordIdB = tblEntry.RecordIdB
		local ptrPossibleB = fhNewItemPtr()											-- Record Id and Pointer of Individual B
		ptrPossibleB:MoveToRecordById("INDI",intRecordIdB)
		intFullScore = tblEntry.FullScore
		table.insert(tblPercentage,math.floor(intFullScore*100/intMaxScore))	-- Percentage Full Score
		table.insert(tblRecordIdA,intRecordIdA)										-- Individual Rec Id A
		table.insert(tblPossibleA,ptrPossibleA)										-- Individual Record A
		table.insert(tblRecordIdB,intRecordIdB)										-- Individual Rec Id B
		table.insert(tblPossibleB,ptrPossibleB)										-- Individual Record B
		table.insert(tblFullScore,intFullScore)										-- Points Full Score
		for i, tblColumn in ipairs( tblColumnKey ) do
			local strIndex = tblColumn[2]
			if intEntry==1 then tblResultSet[strIndex] = {} end					-- Create Diagnostic sub-score Column tables
			table.insert(tblResultSet[strIndex],tblEntry[strIndex])
		end
	end
	local intSize = #tblFullScore														-- Output primary Result Set Columns unconditionally
	local strMessage = ""
	if intStartTime then
		local intTime = os.difftime(os.time(),intStartTime)
		strMessage = " in "..intTime.." seconds"
	end
	strMessage = "found "..intSize.." candidates"..strMessage
	if intSize == 1 then strMessage = strMessage:gsub("candidates","candidate") end
	if intTime == 1 then strMessage = strMessage:gsub("seconds","second") end
	fhOutputResultSetTitles(iup_gui.Plugin..iup_gui.Version..strMessage)
	fhOutputResultSetColumn("Percent"		, "integer"	, tblPercentage, intSize,  31, "align_mid" )
	fhOutputResultSetColumn("Rec Id A"	, "integer"	, tblRecordIdA , intSize,  31, "align_mid" )
	fhOutputResultSetColumn("Record A"	, "item"		, tblPossibleA , intSize, 140, "align_left")
	fhOutputResultSetColumn("Rec Id B"	, "integer"	, tblRecordIdB , intSize,  31, "align_mid" )
	fhOutputResultSetColumn("Record B"	, "item"		, tblPossibleB , intSize, 140, "align_left")
	fhOutputResultSetColumn(" Total "		, "integer"	, tblFullScore , intSize,  31, "align_mid" )
	if intSize > 0 then
		for i, tblColumn in ipairs( tblColumnKey ) do								-- Output Diagnostic sub-score Columns only if they exist
			local strTitle = tblColumn[1]
			local strIndex = tblColumn[2]
			local strType = tblColumn[3]
			local intWidth = tblColumn[4]
			local strAlign = tblColumn[5]
			if flgDiag or not strTitle:match("%-") then								-- Only output Diagnostic or Timespan columns if requested
				if flgSpan or not strTitle:match("Timespan") then
					if strType and intWidth and strAlign then
						fhOutputResultSetColumn(strTitle, strType, tblResultSet[strIndex], intSize, intWidth, strAlign )
					else
						fhOutputResultSetColumn(strTitle, "integer", tblResultSet[strIndex], intSize, 31, "align_mid" )
					end
				end
			end 
		end
	end
	return true
end -- function DisplayResultSet

-- Main Code Section Starts Here --

	if fhGetContextInfo("CI_APP_MODE") ~= "Project Mode" then					-- V3.9
		fhMessageBox("\nThis plugin cannot operate on a Standalone GEDCOM File.\n")
		return
	end

	fhInitialise(5,0,8,"save_recommended")

	PresetGlobalData()																	-- Preset global data settings

	ResetDefaultSettings()																-- Preset default sticky settings

	LoadSettings()																		-- Load sticky data settings

	GUI_MainDialogue()																	-- Invoke graphical user interface

	SaveSettings()																		-- Save sticky data settings

	if IntFhVersion > 5 then fhSetConversionLossFlag(false) end					-- Inhibit loss of accents message

--[[
@V3.9:				Library 4.1; Check for Updates button; Centre window on Parent FH window; Exclude Standalone GEDCOM Files;
@V3.8:				Updated library to Functions Prototypes v3.0; iup_gui.Balloon = "NO" for PlayOnLinux/Mac; Boost event scores so for instance duplicate Marriages are given precedence; Add FONE & ROMN to NAME & PLAC; FH V7 Lua 3.5 IUP 3.28;
@V3.7:				Fix "±" character in Set Preferences tab Event Matching & Date Chronology, include immediate family in TblScoreRelatives(...), include undated records in intChosenRecord(...).
@V3.6:				Both ANSI FH V5 & UTF-8 FH V6 IUP 3.11.2 plus new Soundex all numeric coding for Unicode, etc.
@V3.5:				Latest GUI Library, add F1 help_cb, add BalloonToggle().
@V3.4:				Fix close relations problem, improve Find Duplicates date selection using toggles & prompt, allow multiple selection from lists in Omit Non-Duplicate tab.
@V3.4:				Fix stack overflow problem, Version History help pages, and new GUI library, Library modules, iup_gui.Balloon = "YES", auto Plugin Title & Version, GUI attribute mixed case.
@V3.3:				Better Progress Bar message while loading large family Relation Pool.
@V3.2:				More efficient Record Pool Database construction and Individual comparison loop, detects all same sex parents, fix minor ProgressBar bug, include CREMation="Death".
@V3.1:				Includes all multiple Relations and all multiple Events in assessments, and adds some function prototypes.
@V3.0:				Update to Omit Non-Duplicates list, remove Named List option, add Erase Soundex Cache, finish Help & Advice, and first Plugin Store release.
@V2.6:				Minor updates to the Soundex function and its file load/save, plus better Progress Bar presentation.
@V2.5:				Minor updates to Name & Event checks and ProgressBar to improve performance, extra ProgressBar.Messages and moved ProgressBar.Stop() after output of Result Set.
@V2.4:				Minor adjustment to Set Preferences, structured Help & Advice, small corrections to Name & Event checks, plus Soundex and ProgressBar function prototypes.
@V2.3:				Add extra chronology date checks & remove obsolete checks, add CheckVersionInStore, strip all spaces & ignore case in Place parts, better Result Set sort, add complete sticky Set Preferences.
@V2.2:				Bug fix for Non-Duplicates list, and some minor improvements.
@V2.1:				Adjust Soundex scoring for similar Lastname & Forename case, treat space separated Surnames as one Name, ignore case when matching Fornames,
					monospaced font for Non-Duplicates tab, added some Set Preferences sticky options and tabs. 
@V2.0:				Add Non-Duplicates list management tab & simple Set Preferences tab, deduction for Surname mismatch per relative, proportional Chronology in Months.
@V1.9:				Fix bug in scoring values per relative.
@V1.8:				Add name scoring values per relative, separate Period/Range and Approx/Calc/Est timespans, proportional Chronology scores, show previous Result Set.
@V1.7:				Add Forename positional scores, increase score for Surname match, reduce Name mismatch to 0,
					add Place positional scores, drop Child Count, reduce Generation Gap deductions, include Percentage result score,
					new Date timespans for Approx/Calc/Est & Before/After & From/To, check 1st Child Gender, User Preference Settings,
					synthesise missing Event Dates, extra Chronology checks, exclude multiple Chronology errors, exclude close Family,
					sticky Settings and Soundex & NonDups tables, Date Timespans in diagnostic Results, GUI Last Run Date and bug fixes.
@V1.6:				Fix bug with selecting subset, extra Date chronology checks.
@V1.5:				1st Child name check, corrected Gen Gap check, better Date chronology check, tweaked other scores, improved run time estimate, Diagnostic mode.
@V1.4:				Fixed GUI size on Font change, fixed bug in last Update check, corrected run time estimate, added Burial Event, added Date chronology check.
@V1.3:				Proportional Generation gap score, separate Names table, Father & Mother Name & Soundex check, Result Set diagnostic sub-scores, GUI with selection options.
@V1.2:				Soundex checks runtime improvements, limit Name checks score, Event Date +/-50 days check, Place Name & Soundex check, Spouse Name & Soundex check, Generation gap check.
@V1.1:				Added child count comparison. Prune low scores from results to prevent exceeding memory.
@V1.0:				Initial version.
]]

Source: Find-Duplicate-Individuals-2.fh_lua