Find Research Hashtags.fh_lua

--[[
@Title: Find Research Hashtags
@Type: standard
@Author: Helen Wright
@Contributors: 
@Version: 1.0
@LastUpdated: 3rd August 2025
@Licence: This plugin is copyright (c) 2020 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:
Returns a list of Research Hashtags (#ToDo,#Research,#ResearchLink) found in the currently selected records (of any type), plus linked Shared Notes and Research Notes. Only the first instance of each tag in any field is returned, together with a count of the number of tags found. For Individual records, the plugin will also check the spouse family records.

If no records are selected, the plugin will use the current property box record if one exists.
]]
--[[ChangeLog:
 Version 1.0: Initial release
]]

--------------------------------------------------------------
--CONFIGURATION
--------------------------------------------------------------
local CONFIG = {
	TAGS = {
		"#ToDo", -- Tag for items that need to be done
		"#Research", -- Tag for research items
		"#ResearchLink", -- Tag for research links
	}, --Note: Tags are case insensitive.
}
--------------------------------------------------------------
--FH VERSION CHECK
--------------------------------------------------------------
-- Ensure this plugin runs on Family Historian version 7 or higher
fhInitialise(7)
-- Set string encoding to UTF-8 for proper handling of international characters
fhSetStringEncoding("UTF-8")

--------------------------------------------------------------
--KEY VARIABLES
--------------------------------------------------------------
local ptrRecs --for selected or property box records
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
---This class provides a structured way to collect, organize, and display results
---in Family Historian's result window with proper formatting and sorting.
---@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
	---@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.)

	-- Initialize the results tables - each table will hold one column of data
	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
	---@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
	---@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
	---@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.
	---@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
	---@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"
	---@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
	---@param sort table Array of sort priorities for each column
	local Sort = function(sort)
		tblSort = shallow_copy(sort)
	end

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

			-- Output each column with its configuration
			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
					80, -- Column width (percentage)
					"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
	return {
		Title = Title,
		Headings = Headings,
		Visibility = Visibility,
		Types = Types,
		Update = Update,
		Display = Display,
		Sort = Sort,
		NoResults = NoResults,
	}
end

---Find all occurrences of a research tag in a string
---This function searches for tags in the format [[#TagName tag value]]
---and returns both the found tag values and a count of how many were found.
---@param text string The text string to search in
---@param tagName string The name of the tag to search for (e.g., "#ToDo", "#Research")
---@return string[] foundTags Table containing all found tag values
---@return integer count Number of tags found
local function findResearchTags(text, tagName)
	local foundTags = {} -- Store all found tag values
	local count = 0 -- Count of tags found

	-- Use string.gmatch with a pattern that captures the tag value
	-- Pattern: [[#tagName followed by whitespace, then capture everything up to ]]
	-- This matches tags like: [[#ToDo Call the archives]] or [[#Research Check census]]
	local pattern = "%[%[" .. tagName .. "%s+([^%]]*)%]%]"

	-- Find all matches using string.gmatch
	-- This will iterate through all occurrences of the tag in the text
	for tagValue in string.gmatch(text, pattern) do
		table.insert(foundTags, tagValue) -- Add the tag value to our results
		count = count + 1 -- Increment the counter
	end

	return foundTags, count
end

---Find all rich text field and note links in a record, and optionally Family as Spouse records,
---and search them for research tags.
---
---This function recursively traverses all fields in a record looking for:
---1. Rich text fields (which may contain research tags)
---2. Note links (which link to Shared Notes or Research Notes)
---3. Family links (for spouse families, if booCheckFamily is true)
---@param ptrRec ItemPointer Pointer to the record to search
---@param booCheckFamily boolean Boolean indicating whether to also check spouse family records
local function checkRecordforTags(ptrRec, booCheckFamily)
	---Recursive function to traverse all fields in a record
	---This function walks through the GEDCOM structure of the record,
	---examining each field and its children.
	---@param ptr ItemPointer Pointer to the current item being examined
	local function traverseFields(ptr)
		local ptrField = fhNewItemPtr() -- Create a new pointer for examining fields
		ptrField:MoveToFirstChildItem(ptr) -- Move to the first child field

		-- Loop through all fields at this level
		while ptrField:IsNotNull() do
			local dataclass = fhGetDataClass(ptrField) -- Get the type of data this field contains
			local gedcomtag = fhGetTag(ptrField) -- Get the GEDCOM tag (e.g., "NOTE", "TEXT", "FAMS")

			-- Check if this is a rich text field (contains formatted text)
			if dataclass == "richtext" then
				-- Search for each configured research tag in this field
				for _, tag in ipairs(CONFIG.TAGS) do
					local foundTags, count = findResearchTags(fhGetValueAsText(ptrField), tag)
					if count > 0 then
						-- Add result: Record, Tag, Field, First Tag Value, Count
						myResults.Update({ ptrRec:Clone(), tag, ptrField:Clone(), foundTags[1], count })
					end
				end
			-- Check if this is a link field (references another record)
			elseif dataclass == "link" then
				-- Check if this links to a note (Shared Note or Research Note)
				if gedcomtag == "NOTE" or gedcomtag == "_RNOT" then
					local ptrLinkedNote = fhNewItemPtr()
					ptrLinkedNote:MoveTo(ptrField, "~>") -- Follow the link to the note record
					if ptrLinkedNote and ptrLinkedNote:IsNotNull() then
						-- Search for research tags in the linked note's text
						for _, tag in ipairs(CONFIG.TAGS) do
							local foundTags, count = findResearchTags(fhGetItemText(ptrLinkedNote, "~.TEXT"), tag)
							if count > 0 then
								-- Add result: Record, Tag, Field, First Tag Value, Count
								myResults.Update({ ptrRec:Clone(), tag, ptrField:Clone(), foundTags[1], count })
							end
						end
					end
				-- Check if this is a family link and we should check spouse families
				elseif gedcomtag == "FAMS" and booCheckFamily then
					local ptrLinkedFam = fhNewItemPtr()
					ptrLinkedFam:MoveTo(ptrField, "~>") -- Follow the link to the family record
					-- Recursively check the spouse family (but don't check its families to avoid infinite loops)
					checkRecordforTags(ptrLinkedFam, false)
				end
			end

			-- Recursively process children of this field (nested fields)
			traverseFields(ptrField)

			-- Move to next sibling field
			ptrField:MoveNext()
		end
	end

	-- Start the recursive traversal from the record itself
	traverseFields(ptrRec)
end

-------------------------------------
--EXECUTE
-------------------------------------
-- Get the currently selected records or the Property Box record
ptrRecs = fhGetCurrentRecordSel()
if #ptrRecs == 0 then
	-- No records selected - check for a property box record
	ptrRecs[1] = fhGetCurrentPropertyBoxRecord()
	if ptrRecs[1]:IsNull() then
		fhMessageBox("No selected records or property box record found", "MB_OK", "MB_ICONSTOP")
		return
	end
end

-- Configure the result set for displaying our findings
myResults = Results(5) -- Initialize results handling with 5 columns
myResults.Title("Research Tags for Selected Records")
myResults.NoResults("No research tags found")
myResults.Headings({ "Record", "Tag", "Field", "First Tag Value", "Count" })
myResults.Types({ "item", "text", "item", "text", "integer" })
myResults.Visibility({ "show", "show", "show", "show", "show" })
myResults.Sort({ 1, 3, 2, 4, 5 }) -- Sort by Record, then Field, then Tag, etc.

-- Process each selected record to find research tags
for _, ptrRec in ipairs(ptrRecs) do
	-- Check this record for tags, including spouse families if it's an individual
	checkRecordforTags(ptrRec, true)
end

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

Source:Find-Research-Hashtags.fh_lua