Check For Unlinked Media FH7.fh_lua

--[[
@Title:       Check For Unlinked Media (FH7)
@Type:        Standard
@Author:      Mark Draper
@Version:     1.1
@LastUpdated: 14 May 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: Lists all files in media folders not linked to Media Records in the current project, excluding 
temporary files (beginning with either a tilde (~) or a dot), and database files with a .db extension. This 
new plugin fully supports Unicode file names and is not restricted to searching the project Media folder,
but does not offer the option of deleting or moving the files.
]]

--[[
Version 1.0 (Feb 2024)
	- Initial Plugin Store version
Version 1.1 (May 2025)
	- Support for multiple monitors and enhanced tool-tips added
]]

fhInitialise(7)
fhu = require('fhUtils')
fhu.setIupDefaults()
FSO = luacom.CreateObject('Scripting.FileSystemObject')

function main()

	-- get media files and list of folders used

	local tblFiles = {}
	local tblFolders = {}
	local tblUsedFolders = {}
	local tblN = {Files = 0, Folders = 0}
	local DataFolder = fhGetContextInfo('CI_PROJECT_DATA_FOLDER') .. '\\'
	local tblOptions = {Selection = 2, Split = true}

	tblN.TotalFiles = Initialise()
	if not tblN.TotalFiles then return end

	-- get options

	local Scope = 'CURRENT_PROJECT'
	if fhGetContextInfo('CI_APP_MODE') ~= 'Project Mode' then Scope = 'LOCAL_MACHINE' end

	local OptionsFile = fhGetPluginDataFileName(Scope):sub(1, -5) .. '.ini'
	if FSO:FileExists(OptionsFile) then
		tblOptions.Selection = fhGetIniFileValue(OptionsFile, 'Options', 'Selection', 'integer', 2)
		tblOptions.Split = fhGetIniFileValue(OptionsFile, 'Options', 'Split', 'bool', true)
		tblOptions.Folder = fhGetIniFileValue(OptionsFile, 'Options', 'Folder', 'text', '')
	end

	-- present menu

	if not Menu(tblOptions) then return end

	-- get user-selected folder

	if tblOptions.Selection == 3 or tblOptions.Selection == 4 then
		if not GetDefinedFolder(tblOptions) then return end
	end

	-- get folder list before tabulating media files if a specified location

	if tblOptions.Selection < 5 then
		tblFolders = GetFolders(tblOptions)
		if not tblFolders or #tblFolders == 1 and not FSO:FolderExists(tblFolders[1]) then return end
	end

	-- tabulate project media files

	ProgressBarStart(tblN.TotalFiles)

	local pM = fhNewItemPtr()
	pM:MoveToFirstRecord('OBJE')
	while pM:IsNotNull() do
		tblN.Files = tblN.Files + 1
		local pF = fhNewItemPtr()
		pF:MoveTo(pM, '~.FILE')
		while pF:IsNotNull() do
			local FileName = fhGetValueAsText(pF)
			if FileName:match('^Media%\\') then
				FileName = DataFolder .. FileName
			end
			if FSO:FileExists(FileName) then
				local objFile = FSO:GetFile(FileName)
				tblFiles[FileName:lower()] = true
				local j = FileName:find('\\[^\\]+$')		-- ParentFolder method not supported in WINE
				local ParentFolder = FileName:sub(1, j-1)
				tblUsedFolders[ParentFolder] = true
			end
			pF:MoveNext('SAME_TAG')
		end
		gblProgBar.Dialog.Title = 'Tabulating project Media Records (' .. tblN.Files .. ' of ' ..
				tblN.TotalFiles .. ')...'
		gblProgBar.bar.Value = tblN.Files
		if gblProgBar.Cancel then return end
		iup.LoopStep()
		pM:MoveNext()
	end
	gblProgBar.Dialog:destroy()

	-- populate folders table with used folders if general search

	if tblOptions.Selection == 5 then
		for Folder, _ in pairs(tblUsedFolders) do
			table.insert(tblFolders, Folder)
		end
	end

	if not tblFolders then return end

	-- loop through listed folders

	local tblUFiles = {}
	local tblUFolders = {}
	local tblUPath = {}
	ProgressBarStart(#tblFolders)

	for _, Folder in ipairs(tblFolders) do
		gblProgBar.Dialog.Title = 'Getting folder listings...'
		tblN.Folders = tblN.Folders + 1
		gblProgBar.bar.Value = tblN.Folders

		local objFolder = FSO:GetFolder(Folder)
		for _, File in luacom.pairs(objFolder.Files) do
			if not File.Name:match('^[.~]') and not File.Name:match('.db$') and not
					tblFiles[Folder:lower() .. '\\' .. File.Name:lower()] then
				if tblOptions.Split then
					table.insert(tblUFiles, File.Name)
					table.insert(tblUFolders, StripDataFolder(Folder, DataFolder))
				else
					table.insert(tblUPath, Folder .. '\\' .. File.Name)
				end
			end
		end
		if gblProgBar.Cancel then return end
		iup.LoopStep()
	end
	gblProgBar.Dialog:destroy()

	-- save and return selections

	fhSaveTextFile(OptionsFile, '[Options]', 'UTF-16LE')
	fhSetIniFileValue(OptionsFile, 'Options', 'Selection', 'integer', tblOptions.Selection)
	fhSetIniFileValue(OptionsFile, 'Options', 'Split', 'bool', tblOptions.Split)
	fhSetIniFileValue(OptionsFile, 'Options', 'Folder', 'text', tblOptions.Folder)

	-- output results

	if #tblUFiles + #tblUPath == 0 then
		MessageBox('No unlinked files in location specified.', 'OK', 'INFORMATION')
		return
	end

	fhOutputResultSetTitles('Unlinked Files')
	if tblOptions.Split then
		fhOutputResultSetColumn('File', 'text', tblUFiles, #tblUFiles, 120, 'align_left', 2)
		fhOutputResultSetColumn('Folder', 'text', tblUFolders, #tblUFolders, 200, 'align_left', 1)
	else
		fhOutputResultSetColumn('File', 'text', tblUPath, #tblUPath, 250, 'align_left', 1)
	end
end

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

function Initialise()

	-- count media records

	local pM = fhNewItemPtr()
	local Count = 0

	pM:MoveToFirstRecord('OBJE')
	while pM:IsNotNull() do
		Count = Count + 1
		pM:MoveNext()
	end

	-- exit if no media

	if Count == 0 then
		MessageBox('No Media Records.', 'OK', 'INFORMATION')
		return
	end
	return Count
end

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

function Menu(tblOptions)

	local ok
	local tblO = {}

	table.insert(tblO, iup.toggle{title = 'Project Media Folder'})
	table.insert(tblO, iup.toggle{title = 'Project Media Folder and subfolders'})
	table.insert(tblO, iup.toggle{title = 'Selected Folder'})
	table.insert(tblO, iup.toggle{title = 'Selected Folder and subfolders'})
	table.insert(tblO, iup.toggle{title = 'All linked folders',
			tip = 'All folders containing a linked media file'})

	local vboxOptions = iup.vbox{tblO[1], tblO[2], tblO[3], tblO[4], tblO[5]; gap = 10, margin = '10x10'}
	local radioOptions = iup.radio{vboxOptions}
	local frameOptions = iup.frame{radioOptions; title = 'Search location'}

	table.insert(tblO, iup.toggle{title = 'Separate File/Folder columns'})
	table.insert(tblO, iup.toggle{title = 'Full file path'})
	local vboxOutput = iup.vbox{tblO[6], tblO[7]; gap = 10, margin = '10x10'}
	local radioOutput = iup.radio{vboxOutput}
	local frameOutput = iup.frame{radioOutput; title = 'Output format'}

	local btnList = iup.button{title = 'List Files', padding = '5x3', tip = 'List unlinked Media files',
			action = function(self) ok = true return iup.CLOSE end}
	local btnClose = iup.button{title = 'Close', tip = 'Close plugin',
			action = function(self) return iup.CLOSE end}
	local hboxButtons = iup.hbox{btnList, btnClose; gap = 40, margin = 'x25', normalizesize = 'BOTH'}

	local vbox = iup.vbox{frameOutput, hboxButtons; alignment = 'ACENTER', margin = '20x'}
	local hbox = iup.hbox{frameOptions, vbox; margin = '20x20'}

	-- define enhanced tool tips

	local enhanced = true					-- comment out this line if enhanced tool tips are not required
	if enhanced then
		for _, control in ipairs(tblO) do
			control.TipBalloon = 'YES'
			control.TipBalloonTitleIcon = 1			-- modify individually if different
		end
		tblO[5].TipBalloonTitle = 'All Linked Folders'
		btnList.TipBalloon = 'YES'
		btnList.TipBalloonTitle = 'List files'
		btnList.TipBalloonTitleIcon = 1
		btnClose.TipBalloon = 'YES'
		btnClose.TipBalloonTitle = 'Close plugin'
		btnClose.TipBalloonTitleIcon = 1
	end

	local dialog = iup.dialog{hbox; resize = 'NO', minbox = 'NO', maxbox = 'NO',
			title = 'Check For Unlinked Media (FH7) (1.1)'}
	iup.SetAttribute(dialog, 'NATIVEPARENT', fhGetContextInfo('CI_PARENT_HWND'))
	if fhGetContextInfo('CI_APP_MODE') ~= 'Project Mode' then
		tblO[1].Active = 'NO'
		tblO[2].Active = 'NO'
	end

	if fhGetContextInfo('CI_APP_MODE') == 'Project Mode' then
		tblO[tblOptions.Selection].Value = 'ON'
	elseif tblOptions.Selection > 2 then
		tblO[tblOptions.Selection].Value = 'ON'
	else
		tblO[3].Value = 'ON'
	end
	if not tblOptions.Split then tblO[7].Value = 'ON' end

	dialog:popup()

	if not ok then return end
	for i=1, 5 do
		if tblO[i].Value == 'ON' then tblOptions.Selection = i end
	end
	tblOptions.Split = tblO[6].Value == 'ON'
	return true
end

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

function GetDefinedFolder(tblOptions)

	local tblForbidden = {}
	for _, Folder in ipairs({'C:\\', 'C:\\Program Files', 'C:\\Program Files (x86)',
			'C:\\ProgramData', 'C:\\Users', 'C:\\Windows'}) do
		tblForbidden[Folder] = true
	end

	repeat
		local filedlg = iup.filedlg{dialogtype = 'DIR', directory = tblOptions.Folder,
				title = 'Select Media Folder'}
		filedlg:popup()
		if filedlg.Status == '-1' then return end

		if tblForbidden[filedlg.Value] or filedlg.Value:match('^C:\\Users\\[^\\]+$') then
			MessageBox(filedlg.Value .. ' is a system folder.', 'OK', 'ERROR')
		else
			tblOptions.Folder = filedlg.Value
			return true
		end
	until false
end

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

function GetFolders(tblOptions)

	local tblF = {}

	local function GetSubfolders(objParentFolder, tblF)
		for _, Folder in luacom.pairs(objParentFolder.SubFolders) do
			table.insert(tblF, Folder.Path)
			GetSubfolders(Folder, tblF)
		end
	end

	if tblOptions.Selection < 3 then
		local MediaFolder = fhGetContextInfo('CI_PROJECT_DATA_FOLDER') .. '\\Media'
		table.insert(tblF, MediaFolder)
		if tblOptions.Selection == 1 then return tblF end
		local objFolder = FSO:GetFolder(MediaFolder)
		GetSubfolders(objFolder, tblF)
	elseif tblOptions.Selection < 5 then
		table.insert(tblF, tblOptions.Folder)
		if tblOptions.Selection == 3 then return tblF end
		local objFolder = FSO:GetFolder(filedlg.Value)
		GetSubfolders(objFolder, tblF)
	end
	return tblF
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 'Check For Unlinked Media (FH7)', buttondefault = Default}
	msgdlg:popup()
	return msgdlg.ButtonResponse
end

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

function StripDataFolder(Folder, MediaFolder)

	local _, j = Folder:find(MediaFolder, 1, true)

	if j then Folder = Folder:sub(j+1) end
	return Folder
end

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

function ProgressBarStart(Max)

	-- create and display a simple progress bar, and store in a global table

	gblProgBar = {}
	gblProgBar.bar = iup.progressbar{max = Max; rastersize = '400x30'}
	gblProgBar.button = iup.button{title = 'Cancel'; padding = '10x3',
			action = function(self) gblProgBar.Cancel = true end}
	gblProgBar.vbox = iup.vbox{gblProgBar.bar, gblProgBar.button; gap = 20,
			alignment = 'acenter', margin = '5x15'}
	gblProgBar.Dialog = iup.dialog{gblProgBar.vbox; dialogframe = 'Yes', title = '',
			border = 'Yes', menubox = 'No'}
	iup.SetAttribute(gblProgBar.Dialog, 'NATIVEPARENT', fhGetContextInfo('CI_PARENT_HWND'))
	gblProgBar.Dialog:showxy(iup.CENTER, iup.CENTER)					-- Put up Progress Display
end

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

main()

Source:Check-For-Unlinked-Media-FH7-3.fh_lua