Link Lister.fh_lua

--[[
@Title: Link Lister
@Type: standard
@Author: Helen Wright
@Contributors:
@Version: 1.0
@LastUpdated: 7th August 2025
@Licence: This plugin is copyright (c) 2025 Helen Wright & contributors and is licensed under the MIT License which is hereby incorporated by reference (see https://pluginstore.family-historian.co.uk/fh-plugin-licence)
@Description: This plugin will find all records that are linked From or To the currently selected record, or the Property Box record if there is no selected record.  It displays the From Record, the Field that contains the link (with its parent field and date if any for context), and the To Record.
]]
--[[ChangeLog:
 Version 1.0: Initial release
]]
--------------------------------------------------------------
--FH VERSION CHECK
--------------------------------------------------------------
-- Ensure this plugin runs on Family Historian version 7 or higher
-- It might run on earlier versions but I'm not in a position to test or support them.
if fhGetAppVersion() < 7 then
	fhMessageBox("This plugin requires FH7 and will exit.")
	return
end
-- Set string encoding to UTF-8 for proper handling of international characters
fhSetStringEncoding("UTF-8")

--------------------------------------------------------------
--KEY VARIABLES
--------------------------------------------------------------

local ptrFocus -- for selected or property box record - the record we're analyzing for links
local myResults --for result display - stores the Results object for managing output

-----------------------------------------------------------------
--HELPER FUNCTIONS AND LIBRARIES
-----------------------------------------------------------------
---Results class for managing the results display within an FH plugin
---@param intTableCount integer Number of columns in the results table
---@return table Results object with methods for managing result display
local function Results(intTableCount)
	---Local shallow copy helper - creates a copy of a table without deep copying nested structures
	---This is used to safely copy configuration arrays without creating references
	---@param tbl table Table to copy
	---@return table Shallow copy of the input table
	local function shallow_copy(tbl)
		local t = {}
		for k, v in pairs(tbl) do
			t[k] = v
		end
		return t
	end

	--public methods and associated private state variables
	local iRes = 0 -- index used to track results - counts how many result rows we have
	local strTitle = "" -- stores the title for the results window
	local strNoResults = "" -- stores the message to display when there are no results
	local tblResults = {} --table of results tables - stores all the data for each column
	local tblVisibility = {} -- controls which columns are visible in the results
	local tblSort = {} -- defines the sort order for each column
	local tblResultHeadings = {} -- stores the column headers
	local tblResultType = {} -- defines the data type for each column (text, integer, item, etc.)
	tblResultWidth = {} -- defines the width for each column

	-- Initialize the results tables - each table will hold one column of data
	-- This creates separate arrays for each column to store the data efficiently
	for i = 1, intTableCount do
		tblResults[i] = {}
	end

	---Update function: adds a new row of results to the display
	---tblNewResults should contain one value for each column
	---This is the main method for adding data to the results
	---@param tblNewResults table Array of values for the new row
	local Update = function(tblNewResults)
		iRes = iRes + 1 -- increment the result counter
		for i, v in ipairs(tblNewResults) do
			tblResults[i][iRes] = v -- store each value in its appropriate column
		end
	end

	---Title function: sets the title for the results window
	---This will be displayed at the top of the results window
	---@param str string Title for the results window
	local Title = function(str)
		strTitle = str
	end

	---NoResults function: sets the message to display when there are no results
	---This provides user feedback when no links are found
	---@param str string Message to display when there are no results
	local NoResults = function(str)
		strNoResults = str
	end

	---Types function: defines the data type for each column
	---Types can be: "text", "integer", "item", "date", etc.
	---This affects how FH displays and sorts the data
	---@param types table Array of data types for each column
	local Types = function(types)
		tblResultType = shallow_copy(types)
	end

	---Headings function: sets the column headers
	---These are the labels that appear at the top of each column
	---@param headings table Array of column header strings
	local Headings = function(headings)
		tblResultHeadings = shallow_copy(headings)
	end

	---Visibility function: controls which columns are shown
	---Values can be "show" or "hide"
	---"buddy" makes a column invisible but keeps it for sorting purposes
	---@param visibility table Array of visibility settings for each column
	local Visibility = function(visibility)
		tblVisibility = shallow_copy(visibility)
	end

	---Sort function: defines the sort order for each column
	---Lower numbers = higher priority in sorting
	---This determines the default sort order when results are displayed
	---@param sort table Array of sort priorities for each column
	local Sort = function(sort)
		tblSort = shallow_copy(sort)
	end

	---Width function: defines the width for each column
	---@param width table Array of widths for each column
	local Width = function(width)
		tblResultWidth = shallow_copy(width)
	end

	---Display function: outputs all collected results to Family Historian's result window
	---This is the final step that shows all the collected data to the user
	local Display = function()
		if iRes > 0 then -- there are results to display
			-- Set the window title
			fhOutputResultSetTitles(strTitle)

			-- Output each column with its configuration
			-- This creates the actual result set in FH's display
			for i, _ in ipairs(tblResults) do
				fhOutputResultSetColumn(
					tblResultHeadings[i], -- Column header
					tblResultType[i], -- Data type
					tblResults[i], -- The data for this column
					iRes, -- Number of rows
					tblResultWidth[i] or 80, -- Column width or 80 if not set
					"align_left", -- Text alignment
					tblSort[i], -- Sort priority
					true, -- Sortable
					"default", -- Sort direction
					tblVisibility[i] -- Visibility setting
				)
			end
			-- Update the display to show the results
			fhUpdateDisplay()
		else
			-- No results found - show informational message
			fhMessageBox(strNoResults, "MB_OK", "MB_ICONINFORMATION")
		end
	end

	--expose public methods - return an object with all the public functions
	-- This creates the public interface for the Results class
	return {
		Title = Title,
		Headings = Headings,
		Visibility = Visibility,
		Types = Types,
		Update = Update,
		Display = Display,
		Sort = Sort,
		NoResults = NoResults,
		Width = Width,
	}
end

---getAllRecords function: Retrieves all records in the FH database except the focus record
---This creates a comprehensive list of all records to check for links
---@return table Array of ItemPointer objects representing all records
local function getAllRecords()
	local recs = {}

	---allrecords function: Creates an iterator for all records of a specific type
	---This is a helper function that efficiently iterates through records
	---@param type string The record type to iterate through
	---@return function Iterator function that returns ItemPointer objects
	local function allrecords(type)
		local pi = fhNewItemPtr() -- Primary pointer for iteration
		local p2 = fhNewItemPtr() -- Secondary pointer for returning current record
		pi:MoveToFirstRecord(type) -- Start at the first record of this type
		return function()
			p2:MoveTo(pi) -- Copy current position to return pointer
			pi:MoveNext() -- Move to next record
			if p2:IsNotNull() then
				return p2 -- Return the current record
			end
		end
	end

	-- Get the total number of record types in the database
	local recordTypeCount = fhGetRecordTypeCount()

	-- Iterate through all record types
	for i = 0, recordTypeCount - 1 do
		local recordType = fhGetRecordTypeTag(i) -- Get the tag for this record type
		-- Iterate through all records of this type
		for ptr in allrecords(recordType) do
			-- Only include records that are not the same as our focus record
			-- This prevents self-referencing and reduces unnecessary processing
			if ptr:IsNotNull() and not ptr:IsSame(ptrFocus) then
				table.insert(recs, ptr:Clone()) -- Add a copy to our list
			end
		end
	end
	return recs
end

-------------------------------------
--GET LINKED RECORDS
-------------------------------------
---GetLinkedRecords function: Gets all records and fields that are linked to the focus record, both Incoming and Outgoing, and updates myResults
---This is the core function that finds all links to and from the focus record
---@param ptrFocus ItemPointer The item to get linked records for
local function getLinkedRecords(ptrFocus)
	-- Get all records in the database (except the focus record)
	local tblCheck = getAllRecords()

	---Recursive function to check all fields in a record for links
	---@param direction string The direction of the link (From or To)
	---@param record ItemPointer The record to check
	---@param target ItemPointer The target record to find links to
	---@param source ItemPointer The source record (for results display)
	local function checkFieldsForLinks(direction, record, target, source)
		local ptrField = fhNewItemPtr()
		ptrField:MoveToFirstChildItem(record) -- Start with the first field

		-- Iterate through all fields in this record
		while ptrField:IsNotNull() do
			-- Check if this field contains a link to our target record
			local linkValue = fhGetValueAsLink(ptrField)
			if linkValue and linkValue:IsNotNull() and linkValue:IsSame(target) then
				-- Found a link! Add it to our results
				local ptrParent = fhNewItemPtr()
				ptrParent:MoveToParentItem(ptrField)
				local strField = fhGetDisplayText(ptrField)
				local ptrRole = fhGetItemPtr(ptrField, "~.ROLE") --only present for _SHAR or ASSO tags
				if ptrRole:IsNotNull() then
					strField = strField .. " (" .. fhGetDisplayText(ptrRole) .. ")"
				end --special handling for witnesses
				local ptrDate = fhGetItemPtr(ptrParent, "~.DATE")
				local dpDate = fhNewDatePt()
				if ptrDate:IsNotNull() then
					local dtDate = fhGetValueAsDate(ptrDate)
					dpDate = dtDate:GetDatePt1()
				end
				myResults.Update({
					direction, -- "From" or "To" indicating link direction
					source:Clone(), -- The record containing this field
					fhGetRecordId(source), -- The record ID of the source
					fhGetDisplayText(ptrParent), -- The parent field description
					ptrParent:Clone(), --The parent field itself
					dpDate:Clone(), --The date of the item
					strField, -- The field name/label
					ptrField:Clone(), -- The field itself
					target:Clone(), -- The target record being linked to
					fhGetRecordId(target), -- The record ID of the target
				})
			end
			-- Recursively check child fields
			checkFieldsForLinks(direction, ptrField, target, source)
			ptrField:MoveNext() -- Move to next field
		end
	end

	--Check each record for links to/from the focus record
	for _, record in ipairs(tblCheck) do
		--Check Outgoing Links (records that ptrFocus links to)
		-- This finds records that the focus record points to
		if fhCallBuiltInFunction("LinksFrom", ptrFocus, record, "EXT") > 0 then
			-- Find the specific fields in ptrFocus that link to this record
			checkFieldsForLinks("From", ptrFocus, record, ptrFocus)
		end

		--Check Incoming Links (records that link to ptrFocus)
		-- This finds records that point to the focus record
		if fhCallBuiltInFunction("LinksFrom", record, ptrFocus, "EXT") > 0 then
			-- Find the specific fields in this record that link to ptrFocus
			checkFieldsForLinks("To", record, ptrFocus, record)
		end
	end
end

-------------------------------------
--MAIN
-------------------------------------
-- Get the currently selected record or the Property Box record, to be analysed for links
local ptrRecs = fhGetCurrentRecordSel()
if #ptrRecs == 0 or ptrRecs[1]:IsNull() then
	-- No record selected - check for a property box record
	ptrFocus = fhGetCurrentPropertyBoxRecord()
	if ptrFocus:IsNull() then
		-- No record available to analyze
		fhMessageBox("No selected record or property box record found", "MB_OK", "MB_ICONSTOP")
		return
	end
elseif #ptrRecs > 1 then
	fhMessageBox("Multiple records selected - please select only one", "MB_OK", "MB_ICONSTOP")
	return
else
	-- Use the first selected record
	ptrFocus = ptrRecs[1]
end

-- Configure the result set for displaying our findings
myResults = Results(10) -- Initialize results handling with 9 columns
myResults.Title("Link Lister: " .. fhGetDisplayText(ptrFocus)) -- Set window title with record name
myResults.NoResults("No links found") -- Message when no links are discovered
myResults.Headings({ "Direction", "From", "From ID", "Context", "", "Date", "Field", "", "To", "To ID" }) -- Column headers
myResults.Types({ "text", "item", "integer", "text", "item", "date-point", "text", "item", "item", "integer" }) -- Data types for each column
myResults.Visibility({ "hide", "show", "show", "show", "buddy", "show", "show", "buddy", "show", "show" }) -- Which columns to show
myResults.Width({ 0, 180, 25, 180, 0, 80, 180, 0, 180, 25 }) -- Width for each column
myResults.Sort({ 1, 2, 3, 7, 0, 8, 6, 0, 9, 10 }) -- Sort by Direction, then From Record and Field, then Context and Date, and finally To Record

-- Process the selected record to find incoming and outgoing links
getLinkedRecords(ptrFocus)

-- Display all the results we found
myResults.Display()

Source:Link-Lister-1.fh_lua