Format UniqueID.fh_lua

--[[
@Title:       Format UniqueID
@Type:        Standard
@Author:      Mark Draper
@Version:     1.2
@LastUpdated: 13 Jan 2026
@Licence:     This plugin is copyright (c) 2026 Mark Draper and is licensed under the MIT License which
 is hereby incorporated by reference (see https://pluginstore.family-historian.co.uk/fh-plugin-licence)
@Description: Converts UniqueID tags between GEDCOM 5.5.1 and GEDCOM 7.0 format to aid import and 
export with other family history applications that do not understand both formats.  This ensures that
the identifier is preserved in order to enable unambiguous record identification between Family Historian
and other applicaions.
]]

--[[
Version 1.0 (Sep 2021)
	- Initial Store version
Version 1.1 (Nov 2023)
	- Improved user interface, but no change in function
Version 1.2 (Jan 2026)
	- Multi-monitor support added
]]

require('iuplua')
fh = require('fhUtils')
fh.setIupDefaults()
fhInitialise(7,0,15, 'save_recommended')

-- *********************************************************************************** --

function main()

	local pI = fhNewItemPtr()

	-- check for missing UID values
 
	pI:MoveToFirstRecord('INDI')
	while pI:IsNotNull() do
		if fhGetItemPtr(pI, '~._UID'):IsNull() then
			local Msg = 'Not all Individual Records in this Project have a UniqueID. Select "Tools > ' ..
					'Record Identifiers..." from the main menu to calculate missing values before ' ..
					're-running this plugin.'
			fhMessageBox(Msg, 'MB_OK', 'MB_ICONSTOP')
			return
		end
		pI:MoveNext('SAME_TAG')
	end

	-- present main menu for option selection

	ShowMenu()

end

-- *********************************************************************************** --

function ShowMenu()

	local option1 = iup.toggle{title='Hyphenated, no checksum (GEDCOM 7 preferred format)',
			tip='Family Historian format'}
	local option2 = iup.toggle{title='Non-hyphenated, no checksum (32 characters)'}
	local option3 = iup.toggle{title='Non-hyphenated, with checksum (36 characters)',
			tip='e.g. PAF and RootsMagic format'}
	local exclusive = iup.radio{iup.vbox{option1, option2, option3}; value=option1, gap=10}

	local frame = iup.frame{exclusive; title='Select Preferred UID Format', size='250x70', margin='20x20'}

	local btnUpdate = iup.button{title='Update', tip='Process changes', padding='10x3',
			action = function(self) ProcessUIDs(exclusive.Value.Title) end}
	local btnHelp = iup.button{title='Help', tip='Show help',
			action = function(self) fhShellExecute('https://pluginstore.family-historian.co.uk/help/format-uniqueid') end}
	local btnClose = iup.button{title='Close', tip='Close plugin', action = function(self) return iup.CLOSE end}

	local buttons = iup.hbox{iup.fill{}, btnUpdate, btnHelp, btnClose, iup.fill{};
			margin='0x10', normalizesize='BOTH', padding=10, gap=20}

	local vbox = iup.vbox{iup.fill{}, frame, buttons, iup.fill{};
			alignment='Acenter', gap=15}

	local dialog = iup.dialog{vbox; title='Format UniqueID (1.2)', margin='20x20',
			resize='No', minbox='No', maxbox='No'}

	iup.SetAttribute(dialog, 'NATIVEPARENT', fhGetContextInfo('CI_PARENT_HWND'))

	dialog:popup()
end

-- *********************************************************************************** --

function ProcessUIDs(Format)

	local pI = fhNewItemPtr()

	pI:MoveToFirstRecord('INDI')
	while pI:IsNotNull() do
		local pU = fhGetItemPtr(pI, '~._UID')
		local UID = fhGetValueAsText(pU)
		local NewUID = ProcessUID(UID, Format) 
		if NewUID ~= UID then fhSetValueAsText(pU, NewUID) end
		pI:MoveNext('SAME_TAG')
	end
	fhUpdateDisplay()
end

-- *********************************************************************************** --

function ProcessUID(UID, Format)

	-- determines whether passed UID is valid (return same value if not)

	local S = UID:gsub('-', '')

	if not tonumber('0x' .. S) then return UID end				-- not hexadecimal
	if S:len() ~= 32 and S:len() ~= 36 then return UID end		-- invalid length
	if S:len() == 36 then										-- checksum error
		if S:sub(1, 32) .. CalculateCheckSum(S:sub(1, 32)) ~= UID then return UID end
	end

	-- process valid values according to the Format option

	if Format == 'Hyphenated, no checksum (GEDCOM 7 preferred format)' then
		local NewUID = S:sub(1, 8) .. '-' .. S:sub(9, 12) .. '-' .. S:sub(13, 16) ..
				'-' .. S:sub(17, 20) .. '-' .. S:sub(21, 32)
		return NewUID
	elseif Format == 'Non-hyphenated, no checksum (32 characters)' then
		return S:sub(1, 32)
	elseif Format == 'Non-hyphenated, with checksum (36 characters)' then
		if S:len() == 32 then			-- no checksum
			return S .. CalculateCheckSum(S)
		elseif S:len() == 36 then		-- checksum present, so don't recalculate
			return S
		end
	end
end

-- *********************************************************************************** --

function CalculateCheckSum(UID)

	-- calculates checksum using published method

	local a = 0
	local b = 0

	for i = 1, 31, 2 do
		local byte = UID:sub(i, i + 1)
		local value = tonumber('0x' .. byte)
		a = a + value
		b = b + a
	end

	local cs1 = string.format('%x', a)
	local cs2 = string.format('%x', b)

	local checksum = cs1:sub(-2) .. cs2:sub(-2)

	-- use same case for checksum as for main string

	if UID:upper() == UID then checksum = checksum:upper() end

	return checksum
end

-- *********************************************************************************** --

main()

Source:Format-UniqueID-2.fh_lua