--[[ @Title: Backup and Restore Family Historian Settings via Windows @Type: Standard @Author: Mark Draper @Version: 3.1 @LastUpdated: 26 Oct 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: Backup and restore Family Historian custom settings. This plugin does all the copying directly within Windows rather than in the plugin, giving much faster and more convenient operation. It also provides the option to implement more advanced features such as single-click rapid backup or automated backup that are not available in either the legacy settings backup plugin or the new native settings backup available in version 8 of Family Historian. ]] --[[ Version 2.0 (Dec 2024) Simplified restore without overwrite option (always restores in full) Simplified main menu reflecting this change Enhanced menu pop-up prompts Supports multi-monitor extended desktops Checks existing backups for completeness before updating or restoring Various corrections and enhancements to Unicode support Backup script modified to call 64-bit Registry key specifically if running in Advanced mode (avoids potential issues with missing backup file when called directly from Windows) Version 2.1 (May 2025) Modified 'three test' emulator detection CPU architecture detection ARM64 warning Version 2.1.1 (Jul 2025) Corrected ARM64 warning Version 2.1.4 (Aug 2025) Generalised ARM64 warning Minor tidy-up of old code Version 3.0 (Jun 2026) Simplified for easier maintenance by removing emulator support and legacy compatibility Various other code refinements Version 3.1 (Oct 2026) Modified run command to call cmd specifically to be robust to default app change Simplified script to limit references to Short Paths Backup location must be ANSI, for simpler coding and avoiding Windows script issues with Unicode Significant general code tidying ]] fhInitialise(5,0,0,'save_required') require('luacom') require('iuplua') if fhGetAppVersion() > 6 then fh = require('fhUtils') fh.setIupDefaults() else iup.SetGlobal('CUSTOMQUITMESSAGE','YES') iup.SetGlobal('UTF8MODE','YES') end gblAppPath = 'Calico Pie\\Family Historian' if fhGetAppVersion() > 7 then gblAppPath = gblAppPath .. ' ' .. fhGetAppVersion() end gblOptions = {} FSO = luacom.CreateObject('Scripting.FileSystemObject') -- ********************************************************************* function main() if IsWINE() then return end -- exit with message if emulator detected if not GetOptions() then return end -- get plugin options local MainForm -- set main window static controls and options local Heading = 'This plugin backs up and restores Family Historian settings using Windows commands.\n' .. 'All languages and Windows localisations are supported automatically.\n\n' .. 'Technically-minded users may save the backup configuration as Windows batch ' .. 'files to\nincorporate into their own backup process (including fully automated backups).' local lblTitle = iup.label{title = Heading, alignment = 'ALEFT', padding = '10x20'} -- folder controls and options local lblF = iup.label{title = gblOptions.Path or '',expand = 'HORIZONTAL'} local btnF = iup.button{title = 'Select', padding = '10x3', tip = 'Destination folder where scripts and backup data are written.', action = function(self) SelectFolder(MainForm) UpdateMenu() end} local chkB = iup.toggle{title = 'Write to dated subfolder', expand = 'YES', active = 'NO', tip = 'Write backup to dated subfolder.'} local fraF = iup.frame{iup.vbox{lblF, chkB, btnF; alignment = 'ACENTER'}; title = 'Backup folder'} -- general controls and options (use similar containers for all frames for consistent appearance) local chkM1 = iup.toggle{title = 'Normal', expand = 'HORIZONTAL', tip = 'Run backup or restore immediately.'} local chkM2 = iup.toggle{title = 'Advanced', expand = 'HORIZONTAL', tip = 'Save the script files without running the backup or restore.'} local hboxM = iup.hbox{chkM1, chkM2} local radM = iup.radio{hboxM} local fraM = iup.frame{iup.hbox{radM}, title = 'Operating mode'} -- button display local btnB = iup.button{active = 'NO', action = function(self) Backup() MainForm.BringFront = 'YES' end} local btnR = iup.button{title = 'Restore Script', padding = '10x3', action = function(self) Restore(MainForm) MainForm.BringFront = 'YES' end} local btnH = iup.button{title = 'Help', tip = 'Display plugin help page.', action = function(self) GetHelp(MainForm) end} local btnX = iup.button{title = 'Close', tip = 'Close plugin.', action = function(self) return iup.CLOSE end} local hButtons = iup.hbox{btnB, btnR, btnH, btnX; cgap = 20, cmargin = 'x15', normalizesize = 'BOTH'} -- menu update function function UpdateMenu() if gblOptions.Path then lblF.Title = gblOptions.Path btnB.Active = 'YES' chkB.Active = 'YES' end if gblOptions.Dated then chkB.Value = 'YES' else chkB.Value = 'NO' end if gblOptions.Advanced then chkM2.Value = 'ON' btnB.Title = 'Backup Script' btnR.Title = 'Restore Script' btnB.Tip = 'Save backup batch file to defined backup folder.' btnR.Tip = 'Save restore batch file to defined backup folder.' else chkM1.Value = 'ON' btnB.Title = 'Backup' btnB.Size = btnR.Size btnB.Tip = 'Run backup now using defined options.' btnR.Title = 'Restore' btnR.Size = btnB.Size btnR.Tip = 'Run restore now using defined options.' end end -- callback actions function chkM2:valuechanged_cb() gblOptions.Advanced = (chkM2.Value == 'ON') UpdateMenu() end function chkB:valuechanged_cb() gblOptions.Dated = (chkB.Value == 'ON') end -- assemble form local vForm = iup.vbox{lblTitle, fraM, fraF, hButtons; alignment = 'ACENTER', cgap = 5, cmargin = '10x3'} MainForm = iup.dialog{vForm; minbox='NO', maxbox='NO', resize = 'NO', title='Backup and Restore Family Historian Settings via Windows (3.1)'} MainForm:map() -- determine form layout prior to assigning button labels and resizing UpdateMenu() if fhGetAppVersion() > 6 then iup.SetAttribute(MainForm, 'NATIVEPARENT', fhGetContextInfo('CI_PARENT_HWND')) end MainForm:popup() SaveOptions() end -- ********************************************************************* function SelectFolder(MainForm) local filedlg = iup.filedlg{dialogtype = 'DIR', directory = gblOptions.Path, title = 'Select folder for scripts and backup files', parentdialog = MainForm} filedlg:popup() if filedlg.Status == '-1' then return end fhSetConversionLossFlag(false) local _ = fhConvertUTF8toANSI(filedlg.Value) if fhIsConversionLossFlagSet() then local msg = 'To avoid potential Windows issues with international character sets, selected ' .. 'folder name should contain Latin characters only. Please select an alternative location.' MessageBox(msg, 'OK', 'WARNING', 'Folder name incompatibility') fhSetConversionLossFlag(false) else gblOptions.Path = filedlg.Value end end -- ********************************************************************* function Backup() -- check for existing backup local BackupPath = gblOptions.Path if gblOptions.Dated then BackupPath = BackupPath .. '\\fhBackup_' .. os.date('%Y-%m-%d') end -- check all files and folders present if existing backup local i = 0 if FSO:FolderExists(BackupPath .. '\\fhProgramData') then i = i + 1 end if FSO:FolderExists(BackupPath .. '\\fhAppData') then i = i + 1 end if FSO:FileExists(BackupPath .. '\\fhCURRENT_USER.reg') then i = i + 1 end if FSO:FileExists(BackupPath .. '\\fhLOCAL_MACHINE.reg') then i = i + 1 end if not gblOptions.Advanced then if i > 0 and i < 4 then local msg = 'Existing backup in ' .. BackupPath .. ' is incomplete.\n\nIgnore error and update?' if MessageBox(msg, 'OKCANCEL', 'WARNING', nil, 2) ~= 1 then return end elseif i == 4 then local stamp = FSO:GetFile(BackupPath .. '\\fhCURRENT_USER.reg').DateLastModified local msg = os.date('Update existing backup in ' .. BackupPath .. '?\n\nLast update: ' .. stamp) if MessageBox(msg, 'YESNO', 'QUESTION', 'Confirm Update', 2) ~= 1 then return end end end local tblScript, BackupFile = GetBackupScript(BackupPath) -- write script to file if not SaveTextFile(BackupFile, table.concat(tblScript, '\n')) then MessageBox('Cannot write backup script.', 'OK', 'ERROR', 'File Access Error') return end -- confirm successful write if gblOptions.Advanced then local msg = 'Backup script written to file ' .. gblOptions.Path .. '\\fhBackup.bat.' MessageBox(msg, 'OK', 'INFORMATION', 'File Write Confirmation') return end -- implement backup if IsExtendedDesktop() then MessageBox('The backup will now run in the primary monitor.', 'OK', 'INFORMATION') end if not fhShellExecute(os.getenv('ComSpec'), '/C ' .. BackupFile) then MessageBox('Backup failed to run.', 'OK', 'ERROR', 'Script Error') end end -- ********************************************************************* function GetBackupScript(BackupPath) -- prepare backup script local GlobalKey if fhGetAppVersion() < 8 then GlobalKey = 'HKLM\\SOFTWARE\\WOW6432Node\\' .. gblAppPath .. '\\2.0\\Preferences' else GlobalKey = 'HKLM\\SOFTWARE\\' .. gblAppPath .. '\\2.0\\Preferences' end local UserKey = 'HKCU\\SOFTWARE\\' .. gblAppPath local tblT = {} -- build the script in this table table.insert(tblT, '@echo off') table.insert(tblT, 'echo Backup started...') -- set path according to whether dated subfolder or not if not gblOptions.Dated then table.insert(tblT, 'set p=' .. gblOptions.Path) else table.insert(tblT, 'set basepath=' .. gblOptions.Path) local cmd = 'for /f %%D in ( \'PowerShell Get-Date -Format "_yyyy-MM-dd"\' ) do set suffix=%%D' table.insert(tblT, cmd) table.insert(tblT, 'set p=%basepath%\\fhBackup%suffix%') end -- define command script table.insert(tblT, 'robocopy "' .. gblOptions.ProgramData .. '" "%p%\\fhProgramData" /MIR /XD "' .. fhGetContextInfo('CI_APP_DATA_FOLDER') .. '\\Map\\Cache" /NJH') table.insert(tblT, 'robocopy "' .. gblOptions.AppData .. '" "%p%\\fhAppData" /MIR /NJH') table.insert(tblT, 'reg export "' .. UserKey .. '" "%p%\\fhCURRENT_USER.reg" /y') table.insert(tblT, 'reg export "' .. GlobalKey .. '" "%p%\\fhLOCAL_MACHINE.reg" /y') table.insert(tblT, '') -- determine file used to save command script and add self-delete command if immediate backup local BackupFile if gblOptions.Advanced then BackupFile = gblOptions.Path .. '\\fhBackup.bat' else BackupFile = gblOptions.Temp .. '\\~fhBackup.bat' table.insert(tblT, 'pause') table.insert(tblT, 'del "' .. BackupFile .. '"') end return tblT, BackupFile end -- ********************************************************************* function Restore(MainForm) -- check for Advanced mode local RestoreFolder, RestoreFile if gblOptions.Advanced then RestoreFolder = gblOptions.Path RestoreFile = gblOptions.Path .. '\\fhRestore.bat' else RestoreFolder = SelectRestoreFolder(MainForm) if not RestoreFolder then return end RestoreFile = gblOptions.Temp .. '\\~fhRestore.bat' end local tblScript = GetRestoreScript(RestoreFolder) -- write script to file if not SaveTextFile(RestoreFile, table.concat(tblScript, '\n')) then MessageBox('Cannot write restore script.', 'OK', 'ERROR', 'File Access Error') return end -- confirm successful write if gblOptions.Advanced then local msg = 'Restore script written to file ' .. RestoreFile .. '.' MessageBox(msg, 'OK', 'INFORMATION', 'File Write Confirmation') return end -- remove default files and folders from Registry file before restoring RemoveDefaults(RestoreFolder) -- warn the user what is about to happen and request approval local msg = 'Family Historian will now be closed in order to complete the restore operation.\n\n' .. 'Are you sure that you want to do this?' if MessageBox(msg, 'YESNO', 'WARNING', 'Confirm Automatic Restore - CAUTION', 2) ~= 1 then return end -- generate auto-close script local CloseFile = gblOptions.Temp .. '\\' .. '~fhAutoClose.bat' local tblT = {} table.insert(tblT, '@echo off') table.insert(tblT, 'echo Restore started...') table.insert(tblT, 'powershell start-sleep -s 1') table.insert(tblT, 'cls') table.insert(tblT, 'taskkill /IM fh.exe /f') table.insert(tblT, 'powershell start-sleep -s 1') table.insert(tblT, 'call "' .. RestoreFile .. '"') table.insert(tblT, 'del "' .. RestoreFile .. '"') table.insert(tblT, 'del "' .. CloseFile .. '"') table.insert(tblT, '') if not SaveTextFile(CloseFile, table.concat(tblT, '\n')) then MessageBox('Cannot write auto-close script.', 'OK', 'ERROR', 'File Access Error') return end -- check that only one instance of FH is running if CountInstances() > 1 then local msg = 'There may be more than one instance of Family Historian open.\n\n' .. 'Proceeding with the restore operation could cause data loss. Please close ' .. 'any additional copies and re-run the plugin.' MessageBox(msg, 'OK', 'ERROR', 'Multiple Instances Detected') os.remove(RestoreFile) os.remove(CloseFile) return end -- execute the kamikaze close and bale out... fhShellExecute(CloseFile) end -- ********************************************************************* function SelectRestoreFolder(MainForm) -- select backup to restore local filedlg = iup.filedlg{dialogtype = 'DIR', directory = gblOptions.Path, parentdialog = MainForm, title = 'Select folder that contains the backup you wish to restore'} filedlg:popup() if filedlg.Status == '-1' then return end local RestorePath = filedlg.Value -- check folder contains an identifiable backup local BackupVersion, B1, B2, B3 -- is there a valid new format backup in this folder? if FSO:FolderExists(RestorePath .. '\\fhProgramData') and FSO:FolderExists(RestorePath .. '\\fhAppData') and FSO:FileExists(RestorePath .. '\\fhCURRENT_USER.reg') and FSO:FileExists(RestorePath .. '\\fhLOCAL_MACHINE.reg') then local OptionsFile = RestorePath .. '\\fhProgramData\\Plugin Data\\' .. 'Backup and Restore Family Historian Settings via Windows.ini' local TextStream = FSO:OpenTextFile(OptionsFile, 1, false, 0) while not TextStream.AtEndOfStream do local Line = TextStream:ReadLine() B1, B2, B3 = Line:match('Version=(%d+).(%d+).(%d+)') if B1 and B2 and B3 then BackupVersion = B1 .. '.' .. B2 .. '.' .. B3 break end end end if not (B1 and B2 and B3) then local msg = 'Selected folder does not appear to contain a backup.' MessageBox(msg, 'OK', 'ERROR', 'Folder Selection Error') return end -- check backup version matches local V1, V2, V3 = fhGetAppVersion() B1, B2, B3 = tonumber(B1), tonumber(B2), tonumber(B3) if V1 ~= B1 then local msg = 'Cannot restore a Version ' .. B1 .. ' backup into Version ' .. V1 .. '.' MessageBox(msg, 'OK', 'ERROR', 'Version Incompatibility') return elseif B2 > V2 or (B2 == V2 and B3 > V3) then local msg = 'Cannot restore a Version ' .. BackupVersion .. ' backup into Version ' .. gblOptions.Version .. '.\n\n' .. 'You should update your present copy of Family Historian before restoring the backup.' MessageBox(msg, 'OK', 'ERROR', 'Version Incompatibility') return elseif B2 < V2 or (B2 == V2 and B3 < V3) then local msg = 'Restoring a Version ' .. BackupVersion .. ' backup into Version ' .. gblOptions.Version .. ' is potentially risky.\n\nAre you sure that you want to do this?' if MessageBox(msg, 'YESNO', 'WARNING', 'Potential Version Conflict', 2) ~= 1 then return end end -- get backup creation date and time local stamp = FSO:GetFile(RestorePath .. '\\fhCURRENT_USER.reg').DateLastModified local msg = 'Backup in ' .. RestorePath .. ' was created ' .. stamp .. '. Is this the correct backup?' if MessageBox(msg, 'YESNO', 'QUESTION', 'Confirm Backup Selection', 2) ~= 1 then return end return RestorePath end -- ********************************************************************* function GetRestoreScript(RestoreFolder) local tblT = {} -- build the script in this table table.insert(tblT, '@echo off') local Switch = '/MIR /NJH' table.insert(tblT, 'robocopy "' .. RestoreFolder .. '\\fhProgramData" "' .. gblOptions.ProgramData .. '" ' .. Switch) table.insert(tblT, 'robocopy "' .. RestoreFolder .. '\\fhAppData" "' .. gblOptions.AppData .. '" ' .. Switch) table.insert(tblT, 'reg import "' .. RestoreFolder .. '\\fhCURRENT_USER.reg"') table.insert(tblT, 'reg import "' .. RestoreFolder .. '\\fhLOCAL_MACHINE.reg"') if not gblOptions.Advanced then table.insert(tblT, 'pause') end table.insert(tblT, '') return tblT end -- ********************************************************************* function RemoveDefaults(RestoreFolder) -- remove defaults that may not be valid in new installation local FileName = RestoreFolder .. '\\fhLOCAL_MACHINE.reg' if not FSO:FileExists(FileName) then MessageBox('Restore folder is missing a required Registry file.', 'OK', 'ERROR') return end local tblT = {} local TextStream = FSO:OpenTextFile(FileName, 1, false, -1) while not TextStream.AtEndOfStream do local Line = TextStream:ReadLine() local T1 = Line:match('^"Backup Directory"=') local T2 = Line:match('^"Default File"=') local T3 = Line:match('^"FH Root Folder"=') local T4 = Line:match('^"Emulator%-Compat%-Mode"=') if not (T1 or T2 or T3 or T4) then table.insert(tblT, Line) end end table.insert(tblT, '') TextStream:Close() SaveTextFile(FileName, table.concat(tblT, '\n')) end -- ********************************************************************* function GetOptions() -- get system level options gblOptions.ProgramData = fhGetContextInfo('CI_APP_DATA_FOLDER') local Temp = os.getenv('TEMP') -- always ASCII gblOptions.Temp = Temp gblOptions.AppData = Temp:gsub('Local\\Temp', 'Roaming\\') .. gblAppPath print('Temp = ' .. (gblOptions.Temp or '')) print('ProgramData = ' .. (gblOptions.ProgramData or '')) print('AppData = ' .. (gblOptions.AppData or '')) -- get saved options local File = gblOptions.ProgramData .. '\\Plugin Data\\' .. 'Backup and Restore Family Historian Settings via Windows.ini' if FSO:FileExists(File) then local TextStream = FSO:OpenTextFile(File, 1) while not TextStream.AtEndOfStream do local Line = TextStream:ReadLine() if Line:match('^Path=') then gblOptions.Path = Line:sub(6) elseif Line == 'Dated=1' then gblOptions.Dated = true elseif Line == 'Advanced=1' then gblOptions.Advanced = true end end TextStream:Close() if not FSO:FolderExists(gblOptions.Path) then gblOptions.Path = nil end end -- set version option and save back to file to ensure saved on backup local V1, V2, V3 = fhGetAppVersion() gblOptions.Version = V1 .. '.' .. V2 .. '.' .. V3 SaveOptions() return true end -- ********************************************************************* function SaveOptions() -- save options to file local OptionsFolder = gblOptions.ProgramData .. '\\Plugin Data' if not FSO:FolderExists(OptionsFolder) then FSO:CreateFolder(OptionsFolder) end local OptionsFile = OptionsFolder .. '\\Backup and Restore Family Historian Settings via Windows.ini' local tblO = {} table.insert(tblO, '[Options]') if gblOptions.Version then table.insert(tblO, 'Version=' .. gblOptions.Version) end if gblOptions.Path then table.insert(tblO, 'Path=' .. gblOptions.Path) end if gblOptions.Advanced then table.insert(tblO, 'Advanced=1') end if gblOptions.Dated then table.insert(tblO, 'Dated=1') end table.insert(tblO, '') SaveTextFile(OptionsFile, table.concat(tblO, '\n')) end -- ********************************************************************* function GetHelp(MainForm) fhShellExecute('https://pluginstore.family-historian.co.uk/page/help/' .. 'backup-and-restore-family-historian-settings-via-windows') end -- ********************************************************************* function SaveTextFile(Path, Contents) -- saves file with no BOM local F = io.open(Path, 'w') if F then F:write(Contents) F:close() return true end end -- ********************************************************************* function IsExtendedDesktop() -- determines if system has an extended desktop local xd = iup.GetGlobal('VIRTUALSCREEN') local x, y, width, height = xd:match('^(%S+)%s(%S+)%s(%S+)%s(%S+)$') local fs = iup.GetGlobal('FULLSIZE') local ExtendedDesktop = fs ~= width .. 'x' .. height return ExtendedDesktop end -- ********************************************************************* function CountInstances() -- checks just a single instance of FH is running local F = io.popen('powershell get-process *fh*') if not F then local msg = 'Cannot determine number of Family Historian copies running.' MessageBox(msg, 'OK', 'ERROR', 'File Handle Access Error') return end local S = F:read('*a') F:close() local Instances = 0 for line in S:gmatch('[^\r\n]+') do if line:lower():match('%sfh%s+$') then Instances = Instances + 1 end end return Instances end -- ********************************************************************* function IsWINE() if os.getenv('WINEPREFIX') then local msg = 'Family Historian appears to be running under an emulator. ' .. 'This is not supported by the current plugin version.\n\n' if fhGetAppVersion() < 8 then msg = msg .. 'Please upgrade to Family Historian version 8, which has a built-in ' .. 'settings backup if you wish to save your customisations and settings from ' .. 'within Family Historian.' else msg = msg .. 'You are recommended to use the new settings backup option built into ' .. 'version 8 of Family Historian.' end MessageBox(msg, 'OK', 'ERROR', 'Emulator Compatibility Warning') return true end end -- ********************************************************************* function MessageBox(Message, Buttons, Icon, Title, Default) -- replaces built-in function with custom version containing more options local msgdlg = iup.messagedlg{value = Message, buttons = Buttons, dialogtype = Icon, title = Title or fhGetContextInfo('CI_PLUGIN_NAME'), buttondefault = Default, parentdialog = MainForm} msgdlg:popup() return tonumber(msgdlg.ButtonResponse) end -- ********************************************************************* main()