Find All Hashtags.fh_lua--[[
@Title: Find All 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 all Hashtags found in the currently selected records (of any type), plus linked Shared Notes and Research Notes. The plugin searches for any hashtag in the format '#TagName tag value' enclosed in double square brackets and captures both the tag name and its value. 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
]]
--------------------------------------------------------------
--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 any hashtag in a string
---This function searches for tags in the format [[#TagName tag value]]
---and returns both the found tag names, values and a count of how many were found.
---@param text string The text string to search in
---@return table[] foundTags Table containing all found tags with name and value
---@return integer count Number of tags found
local function findAllHashtags(text)
local foundTags = {} -- Store all found tag data
local count = 0 -- Count of tags found
-- Use string.gmatch with a pattern that captures both tag name and value
-- Pattern: [[# followed by tag name, then whitespace, then capture everything up to ]]
-- This matches tags like: [[#ToDo Call the archives]] or [[#Research Check census]]
local pattern = "%[%[#([%w]+)%s+([^%]]*)%]%]"
-- Find all matches using string.gmatch
-- This will iterate through all occurrences of any hashtag in the text
for tagName, tagValue in string.gmatch(text, pattern) do
table.insert(foundTags, { name = "#" .. tagName, value = tagValue }) -- Add the tag data 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 all hashtags in this field
local foundTags, count = findAllHashtags(fhGetValueAsText(ptrField))
if count > 0 then
-- Add result for each found tag: Record, Tag, Field, First Tag Value, Count
for _, tagData in ipairs(foundTags) do
myResults.Update({ ptrRec:Clone(), tagData.name, ptrField:Clone(), tagData.value, 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 all hashtags in the linked note's text
local foundTags, count = findAllHashtags(fhGetItemText(ptrLinkedNote, "~.TEXT"))
if count > 0 then
-- Add result for each found tag: Record, Tag, Field, First Tag Value, Count
for _, tagData in ipairs(foundTags) do
myResults.Update({
ptrRec:Clone(),
tagData.name,
ptrField:Clone(),
tagData.value,
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("All Hashtags for Selected Records")
myResults.NoResults("No hashtags found")
myResults.Headings({ "Record", "Tag", "Field", "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 All 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 all Hashtags found in the currently selected records (of any type), plus linked Shared Notes and Research Notes. The plugin searches for any hashtag in the format '#TagName tag value' enclosed in double square brackets and captures both the tag name and its value. 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
]]
--------------------------------------------------------------
--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 any hashtag in a string
---This function searches for tags in the format [[#TagName tag value]]
---and returns both the found tag names, values and a count of how many were found.
---@param text string The text string to search in
---@return table[] foundTags Table containing all found tags with name and value
---@return integer count Number of tags found
local function findAllHashtags(text)
local foundTags = {} -- Store all found tag data
local count = 0 -- Count of tags found
-- Use string.gmatch with a pattern that captures both tag name and value
-- Pattern: [[# followed by tag name, then whitespace, then capture everything up to ]]
-- This matches tags like: [[#ToDo Call the archives]] or [[#Research Check census]]
local pattern = "%[%[#([%w]+)%s+([^%]]*)%]%]"
-- Find all matches using string.gmatch
-- This will iterate through all occurrences of any hashtag in the text
for tagName, tagValue in string.gmatch(text, pattern) do
table.insert(foundTags, { name = "#" .. tagName, value = tagValue }) -- Add the tag data 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 all hashtags in this field
local foundTags, count = findAllHashtags(fhGetValueAsText(ptrField))
if count > 0 then
-- Add result for each found tag: Record, Tag, Field, First Tag Value, Count
for _, tagData in ipairs(foundTags) do
myResults.Update({ ptrRec:Clone(), tagData.name, ptrField:Clone(), tagData.value, 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 all hashtags in the linked note's text
local foundTags, count = findAllHashtags(fhGetItemText(ptrLinkedNote, "~.TEXT"))
if count > 0 then
-- Add result for each found tag: Record, Tag, Field, First Tag Value, Count
for _, tagData in ipairs(foundTags) do
myResults.Update({
ptrRec:Clone(),
tagData.name,
ptrField:Clone(),
tagData.value,
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("All Hashtags for Selected Records")
myResults.NoResults("No hashtags found")
myResults.Headings({ "Record", "Tag", "Field", "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-All-Hashtags.fh_lua