Backup and Restore Family Historian Settings via Windows.fh_lua
--[[
@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()
Source: Backup-and-Restore-Family-Historian-Settings-via-Windows-10.fh_lua