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()
--[[
@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