Export Public GEDCOM File.fh_lua

--[[
@Title:       Export Public GEDCOM File
@Type:        Standard
@Author:      Mark Draper
@Version:     1.0.1
@LastUpdated: 29 Sep 2025
@Licence:     This plugin is copyright (c) 2025 Mark Draper and is licensed under the MIT License which
              is hereby incorporated by reference (see
              https://pluginstore.family-historian.co.uk/fh-plugin-licence)
@Description: Produces a 'one-click' GEDCOM export where all individuals marked as either Living or
              Private are excluded, along with their associated family records. The focus is on the export of 
              tree facts to current applications, enabling the plugin to dispense with the complex and often
              highly technical configuration options required in alternative export routes that attempt to 
              capture as much tree information as possible with support for legacy destinations.
]]

require 'luacom'
require('iuplua')
require('iupluaimglib')
require 'imlua'
require 'imlua_process'

fhInitialise(7, 0, 0, 'save_recommended')
fh = require('fhUtils')
fh.setIupDefaults()
iup.SetGlobal('UTF8MODE_FILE','YES')

FSO = luacom.CreateObject('Scripting.FileSystemObject')

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

function main()

	-- check for project

	if fhGetContextInfo('CI_APP_MODE') ~= 'Project Mode' then
		MessageBox('This plugin cannot be run from a stand-alone GEDCOM file.', 'OK', 'ERROR')
		return
	end

	-- define options table

	local tblMenuOptions = {
		BMD = 'Birth, Marriage and Death Facts only',
		STD = 'Standard GEDCOM Facts only',
		ALL = 'All Facts in Fact Sets',
		ADDR = 'Combine Address with Place',
		GPS = 'Export Place Geo-tagging data',
		RESI = 'Export Census as Residence (Ancestry)',
		SOUR0 = 'None',
		SOUR1 = 'Titles only',
		SOUR2 = 'Include Outline Citations and Text from Source',
		NOTE = 'Include Local Notes',
		SUBM = 'Include Submitter Record',
		MEDIA0 = 'None',
		MEDIA1 = 'Create new "Frame Link" files in export folder',
		MEDIA2 = 'Copy all Project Media to export folder',
		MEDIAF = 'First Media item only',
		MEDIAS = 'Include Source images',
		MEDIARM = 'Optimise Media export for RootsMagic'}

	-- add form option controls

	for option, FormTitle in pairs(tblMenuOptions) do
		tblMenuOptions[option] = iup.toggle{title = ' ' .. FormTitle, expand = 'HORIZONTAL'}
	end

	-- complete menu generation

	GetOptions(tblMenuOptions)
	Menu(tblMenuOptions)
	SaveOptions(tblMenuOptions)
end

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

function Menu(tblT)

	local function update_media()
		for _, option in ipairs({'MEDIAS', 'MEDIAF', 'MEDIARM'}) do
			if tblT.MEDIA0.value == 'ON' then
				tblT[option].value = 'OFF'
				tblT[option].active = 'NO'
			else
				tblT[option].active = 'YES'
			end
		end
		if tblT.SOUR0.value == 'ON' then
			tblT.MEDIAS.value = 'OFF'
			tblT.MEDIAS.active = 'NO'
		else
			if tblT.MEDIA0.value == 'OFF' then tblT.MEDIAS.active = 'YES' end
		end
	end

	-- add rest of form layout

	local FactOptions = iup.vbox{tblT.BMD, tblT.STD, tblT.ALL; gap = 5, margin = '5x5'}
	local FactRadio = iup.radio{FactOptions}
	local FactFrame = iup.frame{FactRadio, title = 'Fact Options'}

	local SourceRadio = iup.radio{iup.vbox{tblT.SOUR0, tblT.SOUR1, tblT.SOUR2; gap = 5, margin = '5x5'}}
	local SourceSeparator = iup.flatseparator{orientation = 'HORIZONTAL', style = 'LINE', barsize = 1,
			color = '220 220 220'}
	local SourceTick = iup.vbox{tblT.MEDIAS; margin = '5x5'}
	local SourceOptions = iup.vbox{SourceRadio, SourceSeparator, SourceTick; gap = 5, margin = '5x5'}
	local SourceFrame = iup.frame{SourceOptions, title = 'Source Options'}

	local MediaRadio = iup.radio{iup.vbox{tblT.MEDIA0, tblT.MEDIA1, tblT.MEDIA2; gap = 5, margin = '5x5'}}
	local MediaSeparator = iup.flatseparator{orientation = 'HORIZONTAL', style = 'LINE', barsize = 1,
			color = '220 220 220'}
	local MediaTick = iup.vbox{tblT.MEDIAF; margin = '5x5'}
	local MediaOptions = iup.vbox{MediaRadio, MediaSeparator, MediaTick; gap = 5, margin = '5x5'}
	local MediaFrame = iup.frame{MediaOptions, title = 'Media Options'}
	update_media()

	local RecordOptions = iup.vbox{tblT.ADDR, tblT.GPS, tblT.RESI, tblT.MEDIARM, tblT.NOTE, tblT.SUBM;
			gap = 5, margin = '10x10'}
	local btnPrivate = iup.button{title = 'Private Records'; padding = '10x3',
			action = function(self)
					if TabulateExcludedRecords() then return iup.CLOSE end end}
	local btnExport = iup.button{title = 'Export';
			action = function(self)
				if ExportGEDCOM(tblT) == -1 then				-- user cancelled
					if gblProgress then
						gblProgress.dialog:destroy()
						gblProgress = nil
					end
					MessageBox('Export aborted', 'OK', 'WARNING')
				end
			end}
	local btnHelp = iup.button{title = 'Help', active = 'YES',
			action = function(self)
			fhShellExecute('https://pluginstore.family-historian.co.uk/page/help/export-public-gedcom-file') end}
	local btnClose = iup.button{title = 'Close', action = function(self) return iup.CLOSE end}
	local buttons = iup.hbox{btnExport, btnPrivate, btnHelp, btnClose;
			normalizesize = 'BOTH'; gap = 50, margin = '20x20'}
	local tblB = {btnPrivate, btnExport, btnHelp, btnClose}
		for _, control in ipairs(tblB) do
			control.TipBalloon = 'YES'
			control.TipBalloonTitleIcon = 1
		end
	btnPrivate.TipBalloonTitle = 'Private'
	btnPrivate.Tip = 'Tabulate Private/Living Individuals and their families who will be ' ..
			'excluded from the export.'
	btnExport.TipBalloonTitle = 'Export'
	btnExport.Tip = 'Export public GEDCOM file (omitting all Living or Private Individuals and Families)'
	btnHelp.TipBalloonTitle = 'Help'
	btnHelp.Tip = 'Display Plugin Help file'
	btnClose.TipBalloonTitle = 'Close'
	btnClose.Tip = 'Close Plugin'

	local col1 = iup.vbox{FactFrame, MediaFrame; alignment = 'ALEFT', gap = 20}
	local col2 = iup.vbox{SourceFrame, RecordOptions; alignment = 'ALEFT', gap = 20}
	local hbox = iup.hbox{col1, col2; gap = 5}
	local vbox = iup.vbox{hbox, buttons; alignment = 'ACENTER', gap = 5, margin = '10x10'}

	function tblT.BMD:valuechanged_cb()
		if tblT.BMD.value == 'ON' then
			tblT.RESI.value = 'OFF'
			tblT.RESI.active = 'NO'
		else
			tblT.RESI.active = 'YES'
		end
	end

	function tblT.SOUR0:valuechanged_cb()
		update_media()
	end

	function tblT.MEDIA0:valuechanged_cb()
		update_media()
	end

	local p = fhNewItemPtr()
	p:MoveToFirstRecord('SUBM')
	if p:IsNull() then
		tblT.SUBM.value = 'OFF'
		tblT.SUBM.active = 'NO'
	end

	menudialog = iup.dialog{vbox; resize = 'No', minbox = 'No', maxbox = 'No',
			title = 'Export Public GEDCOM File (1.0.1)'}
	iup.SetAttribute(menudialog, 'NATIVEPARENT', fhGetContextInfo('CI_PARENT_HWND'))
	menudialog:popup()
end

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

function GetOptions(tblMenuOptions)

	-- get plugin options from options file

	local OptionsFile = fhGetPluginDataFileName('CURRENT_PROJECT'):sub(1,-4) .. 'ini'
	if not FSO:FileExists(OptionsFile) then
		tblMenuOptions.ALL.value = 'ON'
		tblMenuOptions.MEDIA0.value = 'ON'
		tblMenuOptions.MEDIAS.active = 'NO'
		tblMenuOptions.MEDIARM.active = 'NO'
		return
	end
	for option, control in pairs(tblMenuOptions) do
		if option ~= 'FILE' and fhGetIniFileValue(OptionsFile, 'Options', option, 'bool') then
			control.value = 'ON' else control.value = 'OFF'
		end
	end
	tblMenuOptions.FILE = fhGetIniFileValue(OptionsFile, 'File', 'File', 'text')
	if tblMenuOptions.FILE == '' then tblMenuOptions.FILE = nil end
end

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

function SaveOptions(tblMenuOptions)

	-- create ini file as UTF-16LE

	local OptionsFile = fhGetPluginDataFileName('CURRENT_PROJECT'):sub(1,-4) .. 'ini'
	fhSaveTextFile(OptionsFile, '[Options]', 'UTF-16LE')
	for option, control in pairs(tblMenuOptions) do
		if option ~= 'FILE' then
			fhSetIniFileValue(OptionsFile, 'Options', option, 'bool', control.value == 'ON')
		else
			fhSetIniFileValue(OptionsFile, 'File', 'FILE', 'text', tblMenuOptions.FILE)
		end
	end
end

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

function ExportGEDCOM(tblMenuOptions)

	-- create local options table (syntactic sugar...)

	local tblOptions = {}
	for option, control in pairs(tblMenuOptions) do
		if option ~= 'FILE' and control.value == 'ON' then tblOptions[option] = true end
	end

	local tblFacts = GetFacts()
	local tblGPS = GetGPS()

	-- get temporary ANSI files for use if required

	local ANSIsource = os.getenv('TEMP') .. '\\' .. os.tmpname():match('\\([^\\]+)$')
	local ANSIdest = os.getenv('TEMP') .. '\\' .. os.tmpname():match('\\([^\\]+)$')

	if not FSO:FolderExists('Z:\\bin') and not FSO:FolderExists('Z:\\etc') then		-- not WINE
		local _ = fhConvertUTF8toANSI(ANSIsource)
		if fhIsConversionLossFlagSet() then											-- Unicode user name
			FSO:CopyFile(fhGetContextInfo('CI_GEDCOM_FILE'), ANSIsource)
			ANSIsource = FSO:GetFile(ANSIsource).ShortPath
			FSO:CopyFile(fhGetContextInfo('CI_GEDCOM_FILE'), ANSIdestination)
			ANSIdestination = FSO:GetFile(ANSIdestination).ShortPath
		end
	end

	-- Identify included records and store in table.

	local tblRecords = DefineRecordsTable()
	for _, type in ipairs({'INDI', 'FAM'}) do
		local p = fhNewItemPtr()
		p:MoveToFirstRecord(type)
		while p:IsNotNull() do
			tblRecords[type].total = tblRecords[type].total + 1
			if IsIncluded(p, type == 'FAM') then
				tblRecords[type][fhGetRecordId(p)] = IsIncluded(p, type == 'FAM')
				tblRecords[type].included = tblRecords[type].included + 1
			end
		p:MoveNext()
		end
	end
	if tblRecords.INDI.included + tblRecords.FAM.included == 0 then
		MessageBox('Nothing to save.', 'OK', 'ERROR')
		return
	end

	-- get export file name

	local filedlg = iup.filedlg{dialogtype = 'SAVE', title = 'Export Public GEDCOM File',
			extfilter = 'GEDCOM files (*.ged)|*.ged|All Files (*.*)|*.*|',
			file = fhGetContextInfo('CI_PROJECT_NAME') .. ' Export.ged',
			extdefault = 'ged'}
	filedlg:popup()
	if filedlg.Status == '-1' then return end
	local FileName = filedlg.value
	tblOptions.FILE = FileName
	local tblTimer = {A = os.time()}

	-- check for Submitter Record

	local pU = fhNewItemPtr()
	pU:MoveToFirstRecord('SUBM')

	-- write GEDCOM header

	local tblOutput = {}
	table.insert(tblOutput, '0 HEAD')
	table.insert(tblOutput, '1 SOUR Family_Historian')
	table.insert(tblOutput, '2 NAME Export Public GEDCOM File Plugin')
	local i, j, k = fhGetAppVersion()
	table.insert(tblOutput, '2 VERS ' .. i .. '.' .. j .. '.' .. k .. ' (Plugin version 1.0)')
	table.insert(tblOutput, '2 CORP Calico Pie Limited')
	table.insert(tblOutput, '1 FILE ' .. FileName)
	table.insert(tblOutput, '1 GEDC')
	table.insert(tblOutput, '2 VERS 5.5.1')
	table.insert(tblOutput, '2 FORM LINEAGE-LINKED')
	table.insert(tblOutput, '1 CHAR UTF-8')
	if tblOptions.SUBM and pU:IsNotNull() then
		table.insert(tblOutput, '1 SUBM @U' .. fhGetRecordId(pU) .. '@') end

	-- create progress bar

	ProgressBarReset('Processing Individuals', tblRecords.INDI.included)
	iup.SetAttribute(gblProgress.icon, 'START', 'YES')

	-- process in-scope individuals

	local pI = fhNewItemPtr()
	pI:MoveToFirstRecord('INDI')
	while pI:IsNotNull() do
		if tblRecords.INDI[fhGetRecordId(pI)] then
			ExportIND(pI, tblRecords, tblFacts.INDI, tblGPS, tblOptions, tblOutput)
			if ProgressUpdate() then return -1 end
		end
		pI:MoveNext()
	end

	-- Process in-scope families

	ProgressBarReset('Processing Families', tblRecords.FAM.included)
	local pF = fhNewItemPtr()
	pF:MoveToFirstRecord('FAM')
	while pF:IsNotNull() do
		if tblRecords.FAM[fhGetRecordId(pF)] then
			ExportFAM(pF, tblRecords, tblFacts.FAM, tblGPS, tblOptions, tblOutput)
			if ProgressUpdate() then return -1 end
		end
		pF:MoveNext()
	end

	-- export referenced sources if specified

	if not tblOptions.SOUR0 then
		if ExportSOUR(tblRecords, tblOptions, tblOutput) == -1 then return -1 end	-- user aborted
	end

	-- export referenced media if specified

	if not tblOptions.MEDIA0 and not tblOptions.MEDIARM then
		if ExportOBJE(tblRecords, tblOutput) == -1 then return -1 end	-- user aborted
	end

	-- export first Submitter Record

	if tblOptions.SUBM and pU:IsNotNull() then
		table.insert(tblOutput, '0 @U' .. fhGetRecordId(pU) .. '@ SUBM')
		local p = fhNewItemPtr()
		p:MoveToFirstChildItem(pU)
		while p:IsNotNull() do
			if fhGetValueAsText(p) ~= '' then
				table.insert(tblOutput, '1 ' .. fhGetTag(p) .. ' ' .. fhGetValueAsText(p))
			end
			p:MoveNext()
		end
	end
	table.insert(tblOutput, '0 TRLR\n')
	gblProgress.dialog:destroy()
	gblProgress = nil

	-- save GEDCOM file to disk and clear table memory

	if not fnSaveTextFile(FileName, table.concat(tblOutput, '\n'), ANSIdest) then
		MessageBox('Failed to write output file to ' .. FileName, 'OK', 'ERROR')
		return
	end
	tblOutput = nil
	collectgarbage('collect')
	tblTimer.B = os.time()
	if not tblOptions.MEDIA0 then
		tblTimer.C = CopyMediaFiles(tblRecords, tblOptions, ANSIsource, ANSIdest) end
	if tblTimer.C == -1 then return -1 end											-- user aborted

	-- delete ANSI files if used

	for _, F in ipairs({ANSIsource, ANSIdest}) do
		if FSO:FileExists(F) then
			local b, err = os.remove(F)
			if not b then
				MessageBox('Failed to remove temporary file ' .. F .. '\n' .. err, 'OK', 'ERROR') end
		end
	end

	-- provide confirmation message

	local n = 0
	for _, _ in pairs(tblRecords.OBJE) do n = n + 1 end
	local msg = 'File exported completed.' ..
			'\n\nINDI: \t\t' .. tblRecords.INDI.included .. ' of ' .. tblRecords.INDI.total ..
			'\nFAM: \t\t' .. tblRecords.FAM.included .. ' of ' .. tblRecords.FAM.total ..
			'\nSOUR: \t\t' .. tblRecords.SOUR.included ..
			'\nOBJE: \t\t' .. n ..
			'\n\nGenerate File: \t' .. tblTimer.B - tblTimer.A .. ' s'
	if tblTimer.C then msg = msg .. '\nCopy Media: \t' .. tblTimer.C .. ' s' end
	MessageBox(msg, 'OK', 'INFORMATION', 'Export Confirmation')
end

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

function ExportIND(pI, tblRecords, tblFactsI, tblGPS, tblOptions, tblOutput)

	local p = fhNewItemPtr()
	table.insert(tblOutput, '0 @I' .. fhGetRecordId(pI) .. '@ INDI')
	p:MoveTo(pI,'~.NAME')
	while p:IsNotNull() do
		table.insert(tblOutput, '1 NAME ' .. fhGetValueAsText(p))
		if fhGetItemText(p, '~:SURNAME') ~= '' then
			table.insert(tblOutput, '2 SURN ' .. fhGetItemText(p, '~:SURNAME'))
		end
		if fhGetItemText(p, '~:GIVEN_ALL') ~= '' then
			table.insert(tblOutput, '2 GIVN ' .. fhGetItemText(p, '~:GIVEN_ALL'))
		end
		for _, field in ipairs({'NPFX', 'NSFX', 'NICK'}) do
			if fhGetItemPtr(p, '~.' .. field):IsNotNull() then
				table.insert(tblOutput, '2 ' .. field .. ' ' .. fhGetItemText(p, '~.' .. field))
			end
		end
		if tblOptions.NOTE then ExportNoteFields(p, 2, tblRecords, tblOptions, tblOutput) end
		if not tblOptions.SOUR0 then ExportSourceFields(p, 2, tblRecords, tblOptions, tblOutput) end
		p:MoveNext('SAME_TAG')
	end
	if fhGetItemPtr(pI,'~.SEX'):IsNotNull() then
		table.insert(tblOutput, '1 SEX ' .. fhGetItemText(pI, '~.SEX'):sub(1, 1))
	end
	if fhGetItemPtr(pI,'~.REFN'):IsNotNull() then
		table.insert(tblOutput, '1 REFN ' .. fhGetItemText(pI, '~.REFN'))
	end
	if fhGetItemPtr(pI,'~._UID'):IsNotNull() then
		table.insert(tblOutput, '1 _UID ' .. fhGetItemText(pI, '~._UID'))
	end
	ExportFactFields(pI, tblRecords, tblFactsI, tblGPS, tblOptions, tblOutput)
	if not tblOptions.SOUR0 then ExportSourceFields(pI, 1, tblRecords, tblOptions, tblOutput) end
	if not tblOptions.MEDIA0 then ExportMediaFields(pI, 1, tblRecords, tblOptions, tblOutput) end
	if tblOptions.NOTE then ExportNoteFields(pI, 1, tblRecords, tblOptions, tblOutput) end

	-- export family links

	local pL = fhNewItemPtr()
	pL:MoveTo(pI,'~.FAMC')
	while pL:IsNotNull() do
		p = fhGetValueAsLink(pL)
		if tblRecords.FAM[fhGetRecordId(p)] then						-- exclude private families
			table.insert(tblOutput, '1 FAMC @F' .. fhGetRecordId(p) .. '@')
		end
		pL:MoveNext('SAME_TAG')
	end
	pL:MoveTo(pI,'~.FAMS')
	while pL:IsNotNull() do
		p = fhGetValueAsLink(pL)
		if tblRecords.FAM[fhGetRecordId(p)] then						-- exclude private families
			table.insert(tblOutput, '1 FAMS @F' .. fhGetRecordId(p) .. '@')
		end
		pL:MoveNext('SAME_TAG')
	end
end

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

function ExportFAM(pF, tblRecords, tblFactsF, tblGPS, tblOptions, tblOutput)

	local p = fhNewItemPtr()
	table.insert(tblOutput, '0 @F' .. fhGetRecordId(pF) .. '@ FAM')

	if tblRecords.FAM[fhGetRecordId(pF)] ~= -1 then			-- suppress details when private spouse
		ExportFactFields(pF, tblRecords, tblFactsF, tblGPS, tblOptions, tblOutput)
		if not tblOptions.SOUR0 then ExportSourceFields(pF, 1, tblRecords, tblOptions, tblOutput) end
		if not tblOptions.MEDIA0 then ExportMediaFields(pF, 1, tblRecords, tblOptions, tblOutput) end
		if tblOptions.NOTE then ExportNoteFields(pF, 1, tblRecords, tblOptions, tblOutput) end
	end

	local pL1, pL2 = GetFamilySpouses(pF)
	if pL1:IsNotNull() and IsIncluded(pL1) then
		table.insert(tblOutput, '1 HUSB @I' .. fhGetRecordId(pL1) .. '@')
	end
	if pL2:IsNotNull() and IsIncluded(pL2) then
		table.insert(tblOutput, '1 WIFE @I' .. fhGetRecordId(pL2) .. '@')
	end
	pL1:MoveTo(pF,'~.CHIL')
	while pL1:IsNotNull() do
		p = fhGetValueAsLink(pL1)
		if IsIncluded(p) then										-- exclude private individuals
			table.insert(tblOutput, '1 CHIL @I' .. fhGetRecordId(p) .. '@')
		end
		pL1:MoveNext('SAME_TAG')
	end
end

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

function ExportSOUR(tblRecords, tblOptions, tblOutput)

	local n = 0
	for item, _ in pairs(tblRecords.SOUR) do
		if item then n = n + 1 end
	end
	tblRecords.SOUR.included = n

	if not tblOptions.SOUR0 and tblRecords.SOUR.included > 0 then
		ProgressBarReset('Processing Sources', tblRecords.SOUR.included)
		local pS = fhNewItemPtr()
		pS:MoveToFirstRecord('SOUR')
		while pS:IsNotNull() do
			local ID = fhGetRecordId(pS)
			if tblRecords.SOUR[ID] then
				table.insert(tblOutput, '0 @S' .. ID .. '@ SOUR')
				if fhGetItemPtr(pS, '~.TITL'):IsNotNull() then
					table.insert(tblOutput, '1 TITL ' .. fhGetItemText(pS, '~.TITL'))
				end
				if fhGetItemPtr(pS, '~.PUBL'):IsNotNull() then
					table.insert(tblOutput, '1 PUBL ' .. fhGetItemText(pS, '~.PUBL'))
				end
				if fhGetItemPtr(pS, '~.TEXT'):IsNotNull() and tblOptions.SOUR2 then
					GetNoteText(fhGetItemPtr(pS, '~.TEXT'), 'TEXT', 1, tblOutput)
				end
				if tblOptions.MEDIAS then ExportMediaFields(pS, 1, tblRecords, tblOptions, tblOutput) end
				if tblOptions.NOTE then ExportNoteFields(pS, 1, tblRecords, tblOptions, tblOutput) end
				if ProgressUpdate() then return -1 end
			end
			pS:MoveNext()
		end
	end
end

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

function ExportOBJE(tblRecords, tblOutput)

	local pM = fhNewItemPtr()
	for i = 1, tblRecords.MaxOBJE do
		if tblRecords.OBJE[i] then
			table.insert(tblOutput, '0 @O' .. i .. '@ OBJE')
			table.insert(tblOutput, '1 FILE ' .. tblRecords.OBJE[i].PATH)
			pM:MoveToRecordById('OBJE', tblRecords.OBJE[i].SOURCERIN)
			if fhGetItemText(pM, '~.FILE.FORM') ~= '' then
				table.insert(tblOutput, '2 FORM ' .. fhGetItemText(pM, '~.FILE.FORM'))
			end
			if fhGetItemText(pM, '~.FILE.TITL') ~= '' then
				if tblRecords.OBJE[i].AREA == '' then
					table.insert(tblOutput, '2 TITL ' .. fhGetItemText(pM, '~.FILE.TITL'))
				else
					table.insert(tblOutput, '2 TITL ' .. fhGetItemText(pM, '~.FILE.TITL') .. ' (Cut-out)')
				end
			end
		end
	end
end

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

function ExportFactFields(pR, tblRecords, tblFactsR, tblGPS, tblOptions, tblOutput)

	-- export fact details for individual or family record

	local p = fhNewItemPtr()
	p:MoveToFirstChildItem(pR)
	while p:IsNotNull() do
		local tag = fhGetTag(p)
		if tblFactsR[tag] and fhGetItemText(p, '~._FLGS.__PRIVATE') ~= 'Y' and
				fhGetItemText(p, '~._FLGS.__REJECTED') ~= 'Y' and not
				(tblOptions.BMD and tag ~= 'BIRT' and tag ~= 'MARR' and tag ~= 'DEAT') then
			if tblFactsR[tag] == -1 then										-- standard fact
				if tag == 'CENS' and tblOptions.RESI then			-- Ancestry compatibility
					table.insert(tblOutput, '1 RESI' .. FactValue(p))
				else
					table.insert(tblOutput, '1 ' .. tag .. FactValue(p))
				end
			else									-- custom fact
				table.insert(tblOutput, '1 EVEN' .. FactValue(p))
				table.insert(tblOutput, '2 TYPE ' .. tblFactsR[tag])
			end
			local pD = fhGetItemPtr(p,'~.DATE')
			if pD:IsNotNull() then
				table.insert(tblOutput, '2 DATE ' .. GetGEDCOMDate(pD))
			end
			local place = fhGetItemText(p, '~.PLAC')
			if tblOptions.ADDR then
				local address = fhGetItemText(p, '~.ADDR')
				if address ~= '' and place ~= '' then
					table.insert(tblOutput, '2 PLAC ' .. address .. ', ' .. place)
				elseif address ~= '' or place ~= '' then
					table.insert(tblOutput, '2 PLAC ' .. address .. place)
				end
			else
				if fhGetItemPtr(p, '~.PLAC'):IsNotNull() then
					table.insert(tblOutput, '2 PLAC ' .. place)
				end
			end
			if tblOptions.GPS and tblGPS[place] then
				table.insert(tblOutput, '3 MAP')
				table.insert(tblOutput, '4 LATI ' .. tblGPS[place].LATI)
				table.insert(tblOutput, '4 LONG ' .. tblGPS[place].LONG)
			end
			if not tblOptions.ADDR and fhGetItemText(p, '~.ADDR') ~= '' then
				table.insert(tblOutput, '2 ADDR ' .. fhGetItemText(p, '~.ADDR'))
			end
			if fhGetItemPtr(p, '~.AGE'):IsNotNull() then
				table.insert(tblOutput, '2 AGE ' .. fhGetItemText(p, '~.AGE'))
			end
			if not tblOptions.SOUR0 then ExportSourceFields(p, 2, tblRecords, tblOptions, tblOutput) end
			if not tblOptions.MEDIA0 then ExportMediaFields(p, 2, tblRecords, tblOptions, tblOutput) end
			if tblOptions.NOTE then ExportNoteFields(p, 2, tblRecords, tblOptions, tblOutput) end
		end
		p:MoveNext()
	end
end

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

function ExportSourceFields(p, level, tblRecords, tblOptions, tblOutput)

	local pS = fhGetItemPtr(p, '~.SOUR')
	while pS:IsNotNull() do
		local pL = fhGetValueAsLink(pS)
		local ID = fhGetRecordId(pL)
		tblRecords.SOUR[ID] = true							-- mark source for export
		table.insert(tblOutput, level .. ' SOUR @S' .. ID .. '@')
		if fhGetItemPtr(pS, '~.PAGE'):IsNotNull() then
			table.insert(tblOutput, level+1 .. ' PAGE ' .. fhGetItemText(pS, '~.PAGE'))
		end
		if fhGetItemPtr(pS, '~.DATA.TEXT'):IsNotNull() and tblOptions.SOUR2 then
			GetNoteText(fhGetItemPtr(pS, '~.DATA.TEXT'), 'DATATEXT', level+1, tblOutput)
		end
		if fhGetItemPtr(pS, '~.OBJE'):IsNotNull() and tblOptions.MEDIAS then
			ExportMediaFields(pS, level+1, tblRecords, tblOptions, tblOutput)
		end
		pS:MoveNext('SAME_TAG')
	end
end

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

function ExportMediaFields(p, level, tblRecords, tblOptions, tblOutput)

	local i = tblOptions.FILE:find('\\[^\\]+$')
	local export_folder = tblOptions.FILE:sub(1, i-1)
	local data_folder = fhGetContextInfo('CI_PROJECT_DATA_FOLDER')
	local first = true
	local pM = fhGetItemPtr(p, '~.OBJE')
	while pM:IsNotNull() do
		local tblM = {}
		local pL = fhGetValueAsLink(pM)

		-- does this media have unique ID and AREA?

		local ID = fhGetRecordId(pL)
		local area = fhGetItemText(pM, '~._AREA')
		local status = 0
		for RIN, M in pairs(tblRecords.OBJE) do
			if RIN == ID and M.AREA == area then
				status = 1							-- reuse existing record
				break
			elseif RIN == ID and M.AREA ~= area then
				status = -1							-- need new record
				break
			end
		end

		if status == -1 then
			tblRecords.MaxOBJE = tblRecords.MaxOBJE + 1
			ID = tblRecords.MaxOBJE
		end

		-- get new path of media record file

		local old_path = fhGetItemText(pL, '~.FILE')
		if old_path:sub(1,6):lower() == 'media\\' or old_path:sub(1,6):lower() == 'media/' then
			old_path = data_folder .. '\\' .. old_path
		end
		tblM.OLDPATH = old_path
		local j = old_path:find('[\\/][^\\/]+$') or 0
		local file = old_path:sub(j+1)
		local ext = file:match('([^%.]+)$')
		local base = file:sub(1,-1 * (ext:len() + 2))
		if area and area ~= '' then base = base .. '_' .. area end
		if tblOptions.MEDIA1 and area == '' then						-- keep all absolute paths
			new_path = old_path
		else
			new_path = export_folder .. '\\' .. base .. '.' .. ext		-- copy to export folder
		end

		-- is this new path unique to this media record?

		-- get table of allocated paths

		local tblPaths = {}
		for RIN, Record in pairs(tblRecords.OBJE) do
			if RIN ~= ID then
				tblPaths[Record.PATH] = true
			end
		end

		-- increment suffix until unique path

		i = nil
		while tblPaths[new_path] do
			if i then i = i + 1 else i = 1 end
			new_path = export_folder .. '\\' .. base .. '_' .. i .. '.' .. ext
		end

		-- update Records table

		tblM.SOURCERIN = fhGetRecordId(pL)
		tblM.PATH = new_path
		tblM.AREA = area
		tblRecords.OBJE[ID] = tblM

		if tblOptions.MEDIARM then
			table.insert(tblOutput, level .. ' OBJE')
			table.insert(tblOutput, level+1 .. ' FILE ' .. new_path)
			if fhGetItemText(pL, '~.FILE.FORM') ~= '' then
				table.insert(tblOutput, level+1 .. ' FORM ' .. fhGetItemText(pL, '~.FILE.FORM'))
			end
			if fhGetItemText(pL, '~.FILE.TITL') ~= '' then
				table.insert(tblOutput, level+1 .. ' TITL ' .. fhGetItemText(pL, '~.FILE.TITL'))
			end
			if first then
				table.insert(tblOutput, level+1 .. ' _PRIM Y')				-- subject image
				first = false
			end
		else
			table.insert(tblOutput, level .. ' OBJE @O' .. ID .. '@')
		end
		if tblOptions.MEDIAF then break end			-- only export first Media link
		pM:MoveNext('SAME_TAG')
	end
end

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

function ExportNoteFields(p, level, tblRecords, tblOptions, tblOutput)

	local pN = fhGetItemPtr(p, '~.NOTE2')
	while pN:IsNotNull() do
		GetNoteText(pN, 'NOTE', level, tblOutput)
		if not tblOptions.SOUR0 then ExportSourceFields(pN, level, tblRecords, tblOptions, tblOutput) end
		pN:MoveNext('SAME_TAG')
	end
end

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

function TabulateExcludedRecords()

	local tblR, tblSort, tblLiv, tblPvt, tblFam = {}, {}, {}, {}, {}

	for _, type in ipairs({'INDI', 'FAM'}) do
		local p = fhNewItemPtr()
		p:MoveToFirstRecord(type)
		while p:IsNotNull() do
			local i = IsIncluded(p, type == 'FAM')
			if i ~= true then
				table.insert(tblR, p:Clone())
				if type == 'INDI' then
					table.insert(tblSort, fhGetItemText(p, '~.NAME:SURNAME') ..
							fhGetItemText(p, '~.NAME:GIVEN_ALL'))
				else
					table.insert(tblSort, 'zzz' .. fhGetItemText(p, '~.HUSB>NAME:SURNAME') ..
							fhGetItemText(p, '~.HUSB>NAME:GIVEN_ALL') ..
							fhGetItemText(p, '~.WIFE>NAME:SURNAME') ..
							fhGetItemText(p, '~.WIFE>NAME:GIVEN_ALL'))
				end
				table.insert(tblLiv, fhGetItemText(p, '~._FLGS.__LIVING'))
				table.insert(tblPvt, fhGetItemText(p, '~._FLGS.__PRIVATE'))
				if not i and type == 'FAM' then
					table.insert(tblFam, 'No known public spouses or one-member family')
				elseif i == -1 then
					table.insert(tblFam, 'Family with private spouse and at least one linked child. ' ..
							'Family record is exported with no further details')
				else
					table.insert(tblFam, '')
				end
			end
			p:MoveNext()
		end
	end

	if #tblR == 0 then
		MessageBox('This Project has no Individuals marked as Living or Private.', 'OK', 'INFORMATION')
		return
	else
		local msg = #tblR .. ' Records will be excluded from the export. Click on OK to close the plugin ' ..
				'and display these records or Cancel to return to the plugin menu.'
		local i = #tblR
		if i == 1 then msg = msg:gsub('Records', 'Record') end
		if MessageBox(msg, 'OKCANCEL', 'INFORMATION') == 2 then return end
	end

	fhOutputResultSetTitles('Excluded private records')
	fhOutputResultSetColumn('Record', 'item', tblR, #tblR, 200)
	fhOutputResultSetColumn('', 'text', tblSort, #tblSort, 0, 'align_left', 1, true, 'default', 'hide')
	fhOutputResultSetColumn('Living', 'text', tblLiv, #tblLiv, 30, 'align_mid')
	fhOutputResultSetColumn('Private', 'text', tblPvt, #tblPvt, 30, 'align_mid')
	fhOutputResultSetColumn('Fam', 'text', tblFam, #tblFam, 350)
	return true
end

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

function GetFamilySpouses(pF)

	-- convert FH assymetric storage of single sex couples to symmetric

	local pH1 = fhGetItemPtr(pF, '~.HUSB[1]')
	local pH2 = fhGetItemPtr(pF, '~.HUSB[2]')
	local pW1 = fhGetItemPtr(pF, '~.WIFE[1]')
	local pW2 = fhGetItemPtr(pF, '~.WIFE[2]')

	if pH2:IsNull() and pW2:IsNull() then
		return fhGetValueAsLink(pH1), fhGetValueAsLink(pW1)
	elseif pH2:IsNotNull() then
		return fhGetValueAsLink(pH1), fhGetValueAsLink(pH2)
	elseif pW2:IsNotNull() then
		return fhGetValueAsLink(pW2), fhGetValueAsLink(pW1)
	end
end

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

function GetFacts()

	local tblFacts = {INDI = {}, FAM = {}}

	-- get standard facts (GEDCOM tags only, all names set to -1)

	local file = fhGetContextInfo('CI_APP_DATA_FOLDER') .. '\\Fact Types\\Standard\\Standard.fhf'
	local s = FSO:OpenTextFile(file, 1, false, -1):ReadAll()
	collectgarbage('collect')
	for line in s:gmatch('[^\r\n]+') do
		local i = line:match('^Item%d+%=(%u+)%-I[EA]$')
		if i then tblFacts.INDI[i] = -1 end
		local f = line:match('^Item%d+%=(%u+)%-F[EA]$')
		if f then tblFacts.FAM[f] = -1 end
	end

	-- get custom facts (excluding those eclipsing standard facts)

	local tblFactSets = {}
	local ProjectFacts = fhGetContextInfo('CI_PROJECT_DATA_FOLDER') .. '\\Fact Types'
	local SystemFacts = fhGetContextInfo('CI_APP_DATA_FOLDER') .. '\\Fact Types\\Custom'
	for _, FactFolder in ipairs({ProjectFacts, SystemFacts}) do
		if FSO:FolderExists(FactFolder) then
			local Folder = FSO:GetFolder(FactFolder)
			for _, file in luacom.pairs(Folder.Files) do
				if file.Name:match('%.fhf$') then
					table.insert(tblFactSets, file.Path)
				end
			end
		end
	end
	for _, FactSet in ipairs(tblFactSets) do
		local ActiveFact, ActiveType
		local s = fhLoadTextFile(FactSet, 'UTF-16LE')
		for line in s:gmatch('[^\r\n]+') do
			local fact, type = line:match('^%[FCT%-([%w%-%_]+)%-([IF]+)[EA]%]$')
			local name = line:match('^Name%=([%g%s]+)$')
			if fact then
				ActiveFact = fact
				ActiveType = type
			elseif ActiveFact and name then
				if ActiveType == 'I' and not tblFacts.INDI[ActiveFact] then
					tblFacts.INDI[ActiveFact] = name
				elseif ActiveType == 'F' and not tblFacts.FAM[ActiveFact] then
					tblFacts.FAM[ActiveFact] = name
				end
				ActiveFact = nil
				ActiveType = nil
			end
		end
	end
	return tblFacts
end

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

function GetGPS()

	local tblGPS = {}
	local p = fhNewItemPtr()

	p:MoveToFirstRecord('_PLAC')
	while p:IsNotNull() do
		local place = fhGetItemText(p, '~.TEXT')
		local lat = fhGetItemText(p, '~.LATLONG:LAT_NUMERIC')
		local long = fhGetItemText(p, '~.LATLONG:LONG_NUMERIC')
		if place ~= '' and lat ~= '' and long ~= '' then
			tblGPS[place] = {LATI = lat, LONG = long}
		end
		p:MoveNext()
	end
	return tblGPS
end

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

function GetGEDCOMDate(pD)

	-- converts FH date to GEDCOM format

	local dtD = fhGetValueAsDate(pD)
	local s = dtD:GetDisplayText('COMPACT')

	-- strip out quotes from date phrases

	if s:match('^%"') and s:match('%"$') then
		return s:sub(2,-2)
	end

	s = s:gsub('c%.', 'ABT')
	s = s:gsub('btw', 'BET')
	s = s:gsub('frm', 'FROM')
	local i = s:find('(est)')
	if i then s = 'EST ' .. s:sub(1, i-3) end
	i = s:find('(cal)')
	if i then s = 'CAL ' .. s:sub(1, i-3) end
	i = s:find('Q1')
	if i then s = 'BET JAN ' .. s:sub(4) .. ' AND MAR ' .. s:sub(4) end
	i = s:find('Q2')
	if i then s = 'BET APR ' .. s:sub(4) .. ' AND JUN ' .. s:sub(4) end
	i = s:find('Q3')
	if i then s = 'BET JUL ' .. s:sub(4) .. ' AND SEP ' .. s:sub(4) end
	i = s:find('Q4')
	if i then s = 'BET OCT ' .. s:sub(4) .. ' AND DEC ' .. s:sub(4) end
	return s:upper()
end

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

function GetNoteText(p, tag, level, tblOutput)

	-- returns non-private text from supplied item

	local s = fhGetValueAsRichText(p):GetPlainText(false)
	local length = 100
	local tblT = {}

	-- split into paragraphs, then into concatenated chunks

	for line in s:gmatch('[^\r\n]+') do
		local tblL = {}
		repeat
			local i = line:find('%S%S%S%S', length)		-- suitable location to split
			if not i then
				table.insert(tblL, line)
				break
			else
				table.insert(tblL, line:sub(1, i+1))
				line = line:sub(i+2)
			end
		until false
		table.insert(tblT, tblL)
	end

	-- populate output table

	if tag == 'DATATEXT' then						-- lumped citation text
		table.insert(tblOutput, level .. ' DATA')
		level = level + 1
		tag = 'TEXT'
	end
	local prefix = level .. ' ' .. tag .. ' '
	for _, v in ipairs(tblT) do
		for _, vv in ipairs(v) do
			table.insert(tblOutput, prefix .. vv)
			prefix = level + 1 .. ' CONC '
		end
		prefix = level + 1 .. ' CONT '
	end
end

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

function IsIncluded(p, family)

	-- returns true (or -1) for in scope individual or family records, defaults to false otherwise

	if not family then
		if fhGetItemText(p, '~._FLGS.__PRIVATE') ~= 'Y' and fhGetItemText(p, '~._FLGS.__LIVING') ~= 'Y' then
			return true
		end
	else
		local pH, pW = GetFamilySpouses(p)

		-- private spouse (return -1 if visible child, false otherwise)

		if (pH:IsNotNull() and not IsIncluded(pH)) or (pW:IsNotNull() and not IsIncluded(pW)) then
			local fact, child
			local pC = fhNewItemPtr()
			pC:MoveToFirstChildItem(p)
			while pC:IsNotNull() do
				if fhIsFact(pC) then fact = true end
				if fhGetTag(pC) == 'CHIL' and IsIncluded(fhGetValueAsLink(pC)) then child = true end
				pC:MoveNext()
			end
			if fact and child then
				return -1									-- no details exported
			elseif not child then
				return false								-- one-member family, so exclude
			end
		end
		return true											-- no private spouse
	end
end

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

function DefineRecordsTable()

	local tblRecords = {INDI = {total = 0, included = 0}, FAM = {total = 0, included = 0},
			SOUR = {included = 0}, OBJE = {}, MaxOBJE = 0, N = {}}

	local p = fhNewItemPtr()
	p:MoveToFirstRecord('OBJE')
	while p:IsNotNull() do
		local ID = fhGetRecordId(p)
		if ID > tblRecords.MaxOBJE then tblRecords.MaxOBJE = ID end
		p:MoveNext()
	end

	return tblRecords
end

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

function FactValue(p)

	if fhGetValueAsText(p) == '' then
		return ''
	else
		return ' ' .. fhGetValueAsText(p)
	end
end

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

function CopyMediaFiles(tblRecords, tblOptions, ANSIsource, ANSIdest)

	local new, updated, cropped, size = 0, 0, 0, 0

	for _, Record in pairs(tblRecords.OBJE) do
		if Record.AREA ~= '' then
			cropped = cropped + 1
			Record.COPY = true
		elseif Record.OLDPATH ~= Record.PATH and not FSO:FileExists(Record.PATH) then
			new = new + 1
			Record.COPY = true
		elseif FSO:FileExists(Record.PATH) and
				FSO:GetFile(Record.OLDPATH).DateLastModified ~= FSO:GetFile(Record.PATH).DateLastModified then
			updated = updated + 1
			Record.COPY = true
		end
		if Record.COPY then
			size = size + FSO:GetFile(Record.OLDPATH).Size
			if FSO:FileExists(Record.PATH) then size = size - FSO:GetFile(Record.PATH).Size end
		end
	end

	if new + updated + cropped == 0 then return end			-- nothing to copy

	local reported_size
	if size > 2^30 then	reported_size = string.format('%.1f GB min.', size / 2^30)
	elseif size > 2^20 then	reported_size = string.format('%.0f MB min.', size / 2^20)
	elseif size > 2^10 then	reported_size = string.format('%.0f kB min.', size / 2^10)
	else reported_size = string.format('%.0f B min.', size) end

	local space = FSO:GetDrive(FSO:GetDriveName(tblOptions.FILE)).AvailableSpace
	local reported_space
	if space > 2^30 then reported_space = string.format('%.1f GB', space / 2^30)
	elseif space > 2^20 then reported_space = string.format('%.0f MB', space / 2^20)
	elseif space > 2^10 then reported_space = string.format('%.0f kB', space / 2^10)
	else reported_space = string.format('%.0f B', space) end

	local ii = FSO:GetFolder(FSO:GetParentFolderName(tblOptions.FILE)).Files.Count
	local jj = FSO:GetFolder(FSO:GetParentFolderName(tblOptions.FILE)).SubFolders.Count

	local msg = 'New files:     \t' .. new ..
			'\nUpdated files: \t' .. updated ..
			'\nCropped files: \t' .. cropped ..
			'\n\nRequired space: \t' .. reported_size ..
			'\nAvailable space: \t' .. reported_space
	if ii + jj > 0 then
		msg = msg .. string.format('\n\nExport folder already contains %.0f files and %.0f folders.', ii, jj)
		if ii == 1 then msg = msg:gsub(' files ', ' file ') end
		if jj == 1 then msg = msg:gsub(' folders ', ' folder ') end
	end
	if space > size then
		msg = msg .. '\n\nCopy Media files to ' .. tblOptions.FILE .. '?'
		if MessageBox(msg, 'OKCANCEL', 'QUESTION', 'Confirm Media File copying') ~= 1 then return -1 end
	else
		msg = msg .. '\n\nThere is insufficient space to copy the Media files.'
		MessageBox(msg, 'OK', 'ERROR')
		return -1
	end
	local start = os.time()
	ProgressBarReset('Copying Media Files', new + updated + cropped)
	iup.SetAttribute(gblProgress.icon, 'START', 'YES')
	for _, R in pairs(tblRecords.OBJE) do
		if R.COPY then
			if R.AREA == '' then
				FSO:CopyFile(R.OLDPATH, R.PATH)
			else
				CopyCroppedFile(R.OLDPATH, R.PATH, R.AREA, ANSIsource, ANSIdest)
			end
			if ProgressUpdate(5) then return -1 end
			collectgarbage('collect')
		end
	end
	gblProgress.dialog:destroy()
	gblProgress = nil
	return os.time() - start			-- copy duration
end

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

function CopyCroppedFile(source, destination, crop, ANSIsource, ANSIdest)

	-- check for Unicode file names

	fhSetConversionLossFlag(false)
	local Fsource = fhConvertUTF8toANSI(source)
	local unicode = fhIsConversionLossFlagSet()
	if unicode then
		fhSetConversionLossFlag(false)
		Fsource = ANSIsource
		FSO:CopyFile(source, ANSIsource)
	end

	-- load image file into image processor and determine format

	local imFull, Error = im.FileImageLoad(Fsource)
	if Error then return 'Image load error ' .. Error end
	local format = im.FileOpen(Fsource):GetInfo()

	-- get dimensions (remove negative crops allowed by FH and convert from top-down to bottom-up)

	local top, left, bottom, right = crop:match('^%{(%-?%d+)%,(%-?%d+)%,(%-?%d+)%,(%-?%d+)%}$')
	top, left, bottom, right = tonumber(top), tonumber(left), tonumber(bottom), tonumber(right)

	if not top or not left or not bottom or not right then
		return 'Image crop error: ' .. source
	end

	local max_height = imFull:Height() - 1			-- allow for zero-based co-ordinates
	local max_width = imFull:Width() - 1
	if top < 0 then top = 0 end
	if bottom > max_height then bottom = max_height end
	if left < 0 then left = 0 end
	if right > max_width then right = max_width end

	top = max_height - top
	bottom = max_height - bottom

	-- create cropped image and convert to OpenGL data format

	local isOK, imCropped = pcall(im.ProcessCropNew, imFull, left, right, bottom, top)
	if not isOK then return 'Cropped image error: ' .. imCropped end
	local cropped_image_width = imCropped:Width()
	local cropped_image_height = imCropped:Height()
	local glData, glForm = imCropped:GetOpenGLData()
	if not glData and glForm then return 'Image creation error: ' .. im.ERR_DATA end
	imCropped = im.ImageCreateFromOpenGLData(cropped_image_width, cropped_image_height, glForm, glData)

	-- save cropped image file to specified path

	local Fdest = fhConvertUTF8toANSI(destination)
	unicode = fhIsConversionLossFlagSet()
	if unicode then
		fhSetConversionLossFlag(false)
		Fdest = ANSIdest
	end
	Error = im.FileImageSave(Fdest, format, imCropped)
	if Error then return 'Image save error ' .. Error end
	imFull:Destroy()
	imCropped:Destroy()

	if unicode then
		FSO:CopyFile(Fdest, destination)
	end
end

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

function fnSaveTextFile(FileName, Data, ANSIdest)

	-- creates file in preferred UTF-8, no BOM (need to remove tmpname path in FH7)

	local f = io.open(ANSIdest, 'w')
	if f then
		f:write(Data)
		f:close()
		FSO:CopyFile(ANSIdest, FileName)
		return true
	end
end

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

function MessageBox(Message, Buttons, Icon, BoxTitle, Default)

	-- replaces built-in function with custom version containing more options

	local msgdlg = iup.messagedlg{value = Message, buttons = Buttons, dialogtype = Icon,
			title = BoxTitle or fhGetContextInfo('CI_PLUGIN_NAME'), buttondefault = Default,
			parentdialog = menudialog}

	-- display message box and return selection

	msgdlg:popup()
	return tonumber(msgdlg.ButtonResponse)
end

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

function ProgressBarReset(type, total)

	-- create if doesn't exist already

	if not gblProgress then
		gblProgress = {}
		gblProgress.bar = iup.progressbar{rastersize = '400x30'}
		gblProgress.icon = iup.animatedlabel{animation = 'IUP_CircleProgressAnimation'}
		gblProgress.btn = iup.button{title = 'Cancel'; padding = '10x3',
				action = function(self) gblProgress.cancel = true end}
		gblProgress.vbox = iup.vbox{gblProgress.bar, gblProgress.icon, gblProgress.btn; gap = 20,
				alignment = 'acenter', margin = '5x15'}
		gblProgress.dialog = iup.dialog{gblProgress.vbox; dialogframe = 'Yes', title = '',
				border = 'Yes', menubox = 'No'}
		iup.SetAttribute(gblProgress.dialog, 'NATIVEPARENT', fhGetContextInfo('CI_APP_HWND'))
		gblProgress.dialog:showxy(iup.CENTER, iup.CENTER)				-- Put up Progress Display
	end
	gblProgress.type = type
	gblProgress.max = total
	gblProgress.value = 0
	gblProgress.bar.max = total
end

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

function ProgressUpdate(step)

	gblProgress.value = gblProgress.value + 1
	if gblProgress.value % (step or 50) == 0 then
		gblProgress.bar.value = gblProgress.value
	end
	if gblProgress.value % 1000 == 0 then collectgarbage('collect') end
	gblProgress.dialog.title = gblProgress.type .. ' (' .. gblProgress.value .. ' of ' ..
			gblProgress.max .. ') ...'
	if gblProgress.cancel then return -1 end
	iup.LoopStep()
	return
end

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

main()

Source:Export-Public-GEDCOM-File-2.fh_lua