Multifact.fh_lua

--[[
@Title: Multifact 
@Author: Helen Wright
@Version: 1.9.1
@LastUpdated: 23 December 2019
@Description: Create multiple Individual facts for a set of individuals
all linked to a single existing source with optional common data and citation elements
]]--

--[[Licence:
All code within this plugin is released under the Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) licence: https://creativecommons.org/licenses/by-nc-sa/4.0/

except where indicated.  Specifically, rights to the encoding functions are reserved to Mike Tate; and rights to the function ProgressBar are reserved to Mike Tate and Jane Taubman

In practice, this means you can steal plunder or reuse any elements of my code (modified if you wish) as long as you credit me by name, link to the licence, and indicate what changes were made. You can't sell any code derived from mine (as if anyone would pay for it!) and you must release it under the same Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) licence: https://creativecommons.org/licenses/by-nc-sa/4.0/

]]--
--[[ChangeLog:
 Version 1: Initial version
1.1, 1.2: Fixed initial crop of bugs
1.3 More bug fixes; create Automatic notes if specific note not specified.
1.4 More bug fixes; better dialog size management; option to specify note appended to autonote
1.5 Bug fix (not all places included)
1.6 Better handling of small screens (less than 999 pixels deep.  It's usable down to 864 pixels and just about usable down to 800, depending on text scaling, but anything below that won't work.
1.7 Minor layout adjustment
1.8 Changes to boilerplate and further minor layout adjustments
1.9 Minor bug fixes
1.9.1 Bug fix to handle odd fact definitions
]]--
--[[Variable type naming conventions
g prefix for global variables
c for constants
int for integer numbers
n for fractional numbers
str for strings 
boo for boolean
tbl for tables
func for functions as parameters

FH API data types

ptr for pointers
dt for date objects


In IUP:

Assume all IUP variables are global

b for "ON"/"OFF" value
tab for tab
dlg for dialogs
norm for normalizer
dlg for dialog
btn for button
txt for text
lab for label
box for hbox or vbox
list for list


Note: where code snippets have been imported these conventions may not be followed
]]--

--------------------------------------------------------------
--EXTERNAL LIBRARIES AND FH VERSION CHECK
--------------------------------------------------------------
do
    if fhInitialise(5,0,9) == false then return end-- requires FH 5.0.9 due to use of folder option in fhGetPluginDataFile and the use of Labelled Text
    require("iuplua") -- GUI
    require("lfs") -- file system access
    require ("luacom") --Microsoft's Component Object Model
    function fhloadrequire(module,extended)
        local function httpRequest(url)
            local http = luacom.CreateObject("winhttp.winhttprequest.5.1")
            http:Open("GET",url,false)
            http:Send()
            http:WaitForResponse(30)
            return http
        end -- local function httpRequest
        if not(extended) then extended = module end
        local function installmodule(module,filename,size)
            local bmodule = false
            if not(filename) then
                filename = module..'.mod'
                bmodule = true
            end
            local storein = fhGetContextInfo('CI_APP_DATA_FOLDER')..'\\Plugins\\'
            -- Check if subdirectory needed
            local path = string.match(filename, "(.-)[^/]-[^%.]+$")
            if path ~= "" then
                path = path:gsub('/','\\')
                -- Create sub-directory
                lfs.mkdir(storein..path)
            end
            local attr = lfs.attributes(storein..filename)
            if attr and attr.mode == 'file' and attr.size == size then return true end
            -- Get file down and install it
            local url = "http://www.family-historian.co.uk/lnk/getpluginmodule.php?file="..filename
            local isOK, reply = pcall(httpRequest,url)
            if not isOK then
                fhMessageBox(reply.."\nLoad Require module finds the Internet inaccessible.")
                return false
            end
            local http = reply
            local status = http.StatusText
            if status == 'OK' then
                --           local length = http:GetResponseHeader('Content-Length')
                local data = http.ResponseBody
                if bmodule then
                    local modlist = loadstring(http.ResponseBody)
                    for x,y in pairs(modlist()) do
                        if type(x) == 'number' and type(y) == 'string' then
                            x = y -- revert to original 'filename' ipairs modlist
                            y = 0
                        end -- otherwise use ['filename']=size pairs modlist
                        if not(installmodule(module,x,y)) then
                            break
                        end
                    end
                else
                    local function OpenFile(strFileName,strMode)
                        local fileHandle, strError = io.open(strFileName,strMode)
                        if not fileHandle then
                            error("\n Unable to open file in \""..strMode.."\" mode. \n "..
                                strFileName.." \n "..tostring(strError).." \n")
                        end
                        return fileHandle
                    end -- OpenFile
                    local function SaveStringToFile(strString,strFileName)
                        local fileHandle = OpenFile(strFileName,"wb")
                        fileHandle:write(strString)
                        assert(fileHandle:close())
                    end -- SaveStringToFile
                    SaveStringToFile(data,storein..filename)
                end
                return true
            else
                fhMessageBox('An error occurred in Download please try later')
                return false
            end
        end
        local function requiref(module)
            require(module)
        end
        local _, err = pcall(requiref,extended)
        if err then
            if  err:match("module '"..extended:gsub("(%W)","%%%1").."' not found") then
                local ans = fhMessageBox(
                    'This plugin requires '..module..' support, please click OK to download and install the module',
                    'MB_OKCANCEL','MB_ICONEXCLAMATION')
                if ans ~= 'OK' then
                    return false
                end
                if installmodule(module) then
                    package.loaded[extended] = nil -- Reset Failed Load
                    require(extended)
                else
                    return false
                end
            else
                fhMessageBox('Error from require("'..module..'") command:\n'..(err or ''))
                return false
            end
        end
        return true
    end




    if not fhloadrequire("pl","pl.init") then return end -- Load required Penlight modules
    pl = require("pl.import_into")
end

--------------------------------------------------------------
--ENVIRONMENT VARIABLES
--------------------------------------------------------------
local cstrPluginDir = fhGetPluginDataFileName("LOCAL_MACHINE",true)
local cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME")
local cstrPluginVersion = "1.8"
-- any other constants go here

local myResults = nil
local myActivity = nil
local mySource = nil
local myIndividuals = nil
local myFacts = nil

--------------------------------------------------------------
--UTILITY MODULES AND CLASSES
--------------------------------------------------------------
--[[ Dialog
@Author:      Helen Wright
@Version:      1.0
@LastUpdated:  29 October 2019
@Description:  Helper functions for IUP dialogs
@V1.0:        Initial version.
]]--
do
    --Prerequisites:
    do
        require("iuplua") -- GUI
        --  also requires fh API for date handling
    end
    --useful constants
    colorred = "255 0 0" -- used to flag erroneous data
    strMin1Char = "/S+" --useful text mask
    strMin1Letter = "/l+" --useful text mask
    strAlphaNumeric = "[0-9a-zA-Z ()]+" --useful text mask

    -- font sizing, normalisation and other layout functions
    do
        function SetTextSize()
            local _,_, height = stringx.partition(iup.GetGlobal("SCREENSIZE"),"x")
            if tonumber(height) > 999 then
                iup.SetGlobal("DEFAULTFONT", "Arial Unicode MS, 11")
            else
                iup.SetGlobal("DEFAULTFONT", "Arial Unicode MS, 8") --reduce font size for smaller screens
            end
        end
        function SetUTF8IfPossible()
            if fhGetAppVersion() > 5 then -- make interface UTF8; fh API will handle conversion if necessary
                iup.SetGlobal("UTF8MODE", "YES")
                iup.SetGlobal ("UTF8MODE_FILE", "NO") --use file names in the current locale
                fhSetStringEncoding("UTF-8")
            end
        end
        function SetMinimumSize(dlg)
            dlg.minsize = iup.NULL
            iup.Refresh(dlg)
            dlg.minsize = dlg.naturalsize
        end

        norm = iup.normalizer{} -- normalizer used to layout buttons and labels neatly
        norm2 = iup.normalizer{} -- normalizer used to layout lists and text neatly
        norm3 = iup.normalizer{} --normalizer used to layout popup dialogs neatly
        function DoNormalize()
            norm.normalize = "HORIZONTAL" 
            norm2.normalize = "HORIZONTAL"
            norm3.normalize = "HORIZONTAL"
        end
    end
    --dialog handling
    do
        function MakeDialog(content, options) 
            local d = iup.dialog{content}
            d.title = options.title or ""
            if options.expand then d.expand = options.expand end --default is "YES"
            if options.resize then d.resize = options.resize end --default is "YES"
            if options.size ~= nil then d.size= options.size else d.size = iup.NULL end
            if options.show ~= nil then 
                d.show_cb = function(self, state)
                    if state == iup.SHOW then SetMinimumSize(self) options.show(state) else return iup.IGNORE end
                end
            else
                d.show_cb = function(self, state)
                    if state == iup.SHOW then SetMinimumSize(self) else return iup.IGNORE end
                end
            end
            d.margin = options.margin or "3x3"
            d.cgap = options.cgap or "1"
            d.menubox = options.menubox or "YES"
            iup.SetHandle(options.name or d.title, d) --will be used to help identify active window
            return d
        end
        function IdentifyActiveWindow()
            --depends on having names associated with all dialogs
            local tblD, _ = iup.GetAllDialogs() --get dialog names, which are not the same as their handles
            local d = nil
            for _, v in ipairs(tblD) do
                d = iup.GetHandle(v)
                if d.ACTIVEWINDOW == "YES" then
                    return d
                end
            end
        end
        function DestroyAllDialogs()
            --depends on having names associated with all dialogs
            local tblD, _ = iup.GetAllDialogs() --get dialog names, which are not the same as their handles
            local d = nil
            for _, v in ipairs(tblD) do
                d = iup.GetHandle(v)
                d:destroy()
            end
        end
    end

    --container handling
    do
        --local constants used when dealing with containers

        local dialogs = dialogs or tablex.index_map({"dialog", "messagedlg", "progesssdlg", "fontdg", "filedlg", "colordlg"})
        local containers = containers or tablex.index_map({"frame", "hbox", "vbox", "zbox", "tabs", "radio", "sbox", "cbox", "gridbox", "multibox", "scrollbox", "detachbox", "expander", "detachbox", "split", "backgroundbox", "spinbox"})
        local datacontrols = datacontrols or tablex.index_map({"list", "text", "val", "link", "multiline", "toggle" })
        local static = static or tablex.index_map({"fill", "normalizer", "button", "label", "menu", "submenu", "item", "separator", "imagergb", "imagergba", "image", "matrix", "cells", "clipboard", "timer", "user", "link"})-- these will be ignored when clearing the dialog
        local toohardtohandle = toohardtohandle or tablex.index_map({"tree", "spin", "canvas"}) --only g*d knows

        function EnableContainer(ih, tblexcludeih, strstate)
            local element = nil
            local e = 1 --element index
            while ih[e]~= nil do -- loop the elements in the parent
                element = ih[e]
                if tblexcludeih[element] == nil then
                    if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then -- this is a container
                        EnableContainer(element,tblexcludeih, strstate)
                    elseif datacontrols[iup.GetClassName(element)] ~= nil or iup.GetClassName(element) == "button" then --this is a data control or a button
                        ih.active = strstate
                    end
                end
                e=e+1
            end
        end
        function ClearContainer(ih, tblexcludeih)
            local function ClearDataControl(ih)
                local cclass = iup.GetClassName(ih)
                ih.fgcolor = TXTFGCOLOR
                if cclass == "list" then
                    if ih.editbox=="YES" or iup.multiple=="YES" then
                        ih.value =""             
                    else
                        ih.value = "0"
                    end
                elseif cclass == "text" or cclass == "multiline" then
                    ih.value = ""
                elseif cclass == "val" then
                    ih.value = "0.0"
                elseif cclass == "toggle" then
                    ih.value = "OFF"
                end
            end
            local element = nil
            local e = 1 --element index
            while ih[e]~= nil do -- loop the elements in the parent
                element = ih[e]
                if tblexcludeih[element] == nil then
                    if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then -- this is a container
                        ClearContainer(element,tblexcludeih)
                    elseif datacontrols[iup.GetClassName(element)] ~= nil then --this is a data control
                        ClearDataControl(element)
                    end
                end
                e=e+1
            end
        end
        function HideContainer(ih, boostatus)
            local strVisible = "NO"
            local strFloating = "YES"
            if boostatus == false then
                strVisible = "YES"
                strFloating = "NO"
            end
            local element = nil
            local e = 1 --element index
            while ih[e]~= nil do -- loop the elements in the parent
                element = ih[e]
                if dialogs[iup.GetClassName(element)] ~= nil or containers[iup.GetClassName(element)] ~= nil then -- this is a container
                    HideContainer(element, booStatus)
                else --hide a control
                    element.visible = strVisible
                    element.floating = strFloating
                    iup.Refresh(element)
                end
                e=e+1
            end
        end 
        function GetValueFromControl(ctl)
            if type(ctl.value) == "string" or type(ctl.value) == "number" then
                return ctl.value
            else
                return ""
            end
        end
        function GetContainerData(tblCtls)
            local is_indexed = (rawget( tblCtls, 1 ) ~= nil)
            local tblVals ={}
            if not is_indexed then
                for k, v in pairs(tblCtls) do
                    tblVals[k] = GetValueFromControl(v)
                end
            else
                for k,v in ipairs(tblCtls) do
                    tblVals[k] = GetValueFromControl(v)
                end
            end
            return tblVals
        end
        function GetDataElements(ih, tblexcludeih, tblElements, keyname)
            if not keyname then keyname=false end
            local child = iup.GetChild(ih,0)
            while child ~= nil do -- loop the elements in the parent and add any data controls to the end of tblElements
                if tblexcludeih[child] == nil then
                    if dialogs[iup.GetClassName(child)] ~= nil or containers[iup.GetClassName(child)] ~= nil then -- this is a container
                        GetDataElements(child, tblexcludeih, tblElements, keyname)
                    elseif datacontrols[iup.GetClassName(child)] ~= nil then --this is a data control
                        if keyname then
                            tblElements[iup.GetName(child)]=child
                        else
                            tblElements[#tblElements+1]=child
                        end
                    end
                end
                child = iup.GetNextChild(ih,child)
            end
            return tblElements -- a table of data controls
        end  
    end
    --message boxes and parameter prompts
    do
        function Messagebox(strTitle, strMessage, bIsAlert)
            local msg = iup.messagedlg{}
            msg.TITLE = strTitle
            msg.VALUE = strMessage
            bIsAlert = bIsAlert or false
            if bIsAlert == true then
                msg.BUTTONS = "OKCANCEL"
                msg.DIALOGTYPE="WARNING"
            else
                msg.BUTTONS = "OK"
                msg.DIALOGTYPE="INFORMATION"
            end
            msg:popup(iup.CENTERPARENT, iup.CENTERPARENT)--display the dialog
            local btnResponse = msg.BUTTONRESPONSE
            msg:destroy()
            if btnResponse == "1" then return "OK" else return "CANCEL" end
        end
        function GetText(strPrompt, strDefault, strMask)
            local function param_action()
                return 1
            end
            local pstrText = strDefault
            local booOK = false
            local pstrParam = strPrompt..": %s"..strMask.."\n" --string with mask
            booOK, pstrText = iup.GetParam(strPrompt, param_action, pstrParam, pstrText)
            pstrText = utils.choose(type(pstrText) == 'string', pstrText, "")
            return booOK, utils.choose(booOK == true, pstrText, "")
        end
        function GetTextandTick(strPrompt, strDefault, strMask, strTickPrompt)
            local function param_action()
                return 1
            end
            local booTick = 0
            local pstrText = strDefault
            local booOK = false
            local pstrParam = strPrompt..": %s"..strMask.."\n"..strTickPrompt.." %b\n" --string with mask plus boolean
            booOK, pstrText, booTick = iup.GetParam(strPrompt, param_action, pstrParam, pstrText, booTick) 
            pstrText = utils.choose(type(pstrText) == 'string', pstrText, "")
            booTick = utils.choose(booTick == 0, false, true)
            return booOK, utils.choose(booOK == true, pstrText, ""), utils.choose(booOK == true, booTick, false)
        end
    end
    --button handling
    do
        function EnableButtons(tblButtons,strSetting)
            -- strSetting can be "YES" or "NO"
            for _,v in ipairs(tblButtons) do
                v.ACTIVE = strSetting
            end
        end
        function MakeButton(options)
            local b=iup.button{}
            local callback = options.callback
            if callback ~= nil then
                if options.close ~= nil then --this is a close button
                    b=iup.button{action = function(self) if callback() == true then return iup.CLOSE end end}
                else
                    b=iup.button{action = function(self) callback() end}
                end
            else -- if callback isn't specified this is a cancel button
                b=iup.button{action = function(self) return iup.CLOSE end}
            end
            b.alignment="ALEFT"
            b.padding ="10x0"
            b.normalizergroup = options.norm or norm
            b.title = options.title or ""
            if options.tip then b.tip = options.tip end
            if options.name then iup.SetHandle(options.name, b) end
            return b
        end
    end
    --list handling
    do
        function PopulateList(l, tblVals)
            local is_indexed = (rawget( tblVals, 1 ) ~= nil)
            l.REMOVEITEM = "ALL"
            if not is_indexed then
                local i=1
                for k, _ in pairs(tblVals) do
                    l[tostring(i)]=k
                    i=i+1
                end
            else
                for i, v in ipairs(tblVals) do
                    l[tostring(i)]=v
                end	
            end
        end
        function MultiListSelectionTrue(l)
            return l.value:match("%+") ~= nil
        end
        function MultiListSelectionClear(l)
            l.value = string.rep('%-',l.count)
        end

        function MakeList(options)
            local l = iup.list{}
            l.editbox = options.editbox or "NO"
            l.sort = options.sort or "YES"
            l.dropdown=options.dropdown or "YES" 
            l.multiple=options.multiple or "NO" 
            if l.dropdown == "YES" then
                l.visibleitems = options.visibleitems or "5"
                l.expand = "HORIZONTAL"
            else
                l.visiblelines = options.visiblelines or "9"
                if options.visiblecolumns then l.visiblecolumns = options.visiblecolumns end
                l.expand = "YES"
            end
            if options.norm then l.normalizergroup = options.norm end
            if options.tip then l.tip = options.tip end
            if type(options.values) == "table" then PopulateList(l,options.values) end
            --now handle callbacks
            if options.action ~= nil then
                l.action = function(self, text, item, state) options.action(l, text,item,state) end

            end
            if options.killfocus ~= nil then
                l.killfocus_cb = function(self) options.killfocus() end
            end
            if options.name then iup.SetHandle(options.name, l) end
            return l
        end
        function GoToInList(str,l)
            --find a value within a list and navigate there
            local intLength = tonumber(l.COUNT)
            if intLength > 0 then
                for intPosition = 1, intLength do
                    if l[tostring(intPosition)]==str then
                        l.value = intPosition
                        return intPosition
                    end
                end
            end
            return 0 --not found
        end
        function GetSingleValue(list)
            return utils.choose(list.value ~=0, list[tostring(list.value)], "")
        end      
        function GetSelectedValues(list)
            local tbl = {} --assume nothing selected
            local intLength = tonumber(list.COUNT)
            if intLength > 0 then
                local strSelectionState = list.value -- a sequence of + and -
                for i = 1, intLength do
                    if strSelectionState:sub(i,i) == '\+' then --item is selected
                        table.insert(tbl, list[tostring(i)]) --insert the list item text in the table
                    end
                end
            end
            return tbl --an indexed list of strings
        end
        function SetSelectedValues(list, tblselected) --tbl selected is an indexed list of strings
            local tbl = tablex.index_map(tblselected) -- a table keyed on the strings
            local strselection = ""
            local intLength = tonumber(list.COUNT)
            if intLength >0 then
                for i = 1, intLength do
                    strselection = strselection..utils.choose(tbl[list[tostring(i)]], "+", "-")
                end
                list.value = strselection
            end
        end
    end
    --other controls
    do

        function MakeText(options)
            local function CheckTextNotBlank(self)
                if self.count == 0 or type(self.value) ~= 'string' then
                    self.value = options.default or ""
                end 
            end
            -- there are no mandatory options
            local t= iup.text{wordwrap = options.wordwrap or "YES", 
                append = options.append or "YES", scrollbar = options.scrollbar or "NO", 
                multiline = options.multiline or "NO",
                visiblelines = options.visiblelines or "2", readonly=options.readonly or "NO",padding ="10x2"}
            if options.tip then t.tip = options.tip end
            if options.expand then t.expand = options.expand end
            if options.norm then t.normalizergroup = options.norm end
            if t.multiline == "YES" then
                t.expand = options.expand or "YES"
            else
                t.expand = options.expand or "HORIZONTAL"
            end
            if options.filter then t.filter = options.filter end
            if options.killfocus ~= nil then
                t.killfocus_cb = function(self) CheckTextNotBlank(self) options.killfocus() end
            else
                t.killfocus_cb = function(self) CheckTextNotBlank(self) end
            end
            if options.mask ~= nil then t.mask = options.mask end
            if options.name then iup.SetHandle(options.name, t) end
            if options.default then t.value = options.default else t.value = "" end
            return t
        end
        function MakeLabel(options) 
            local l = iup.label{title = options.title or "", normalizergroup = options.norm or norm, wordwrap = options.wordwrap or "NO"}
            if options.tip then l.tip = options.tip end
            return l
        end
        function MakeToggle(options)
            local t=iup.toggle{}
            local action = options.action
            if action ~= nil then
                t = iup.toggle{action = function(state) action(state) end}
            end
            t.alignment="ALEFT"
            t.title = options.title or ""
            if options.tip then t.tip = options.tip end
            if options.name then iup.SetHandle(options.name, t) end
            if options.value then t.value = options.value end
            return t
        end
        function MakeExpander(content, title, state)
            local e = iup.expander{content}
            e.title = title
            e.state = state
            e.visible = "YES"
            return e
        end
        function MakeGridbox(options)
            local gbox = iup.gridbox{}
            gbox.expandchildren = options.expandchildren or "HORIZONTAL"
            gbox.alignmentlin = options.alignmentlin or "ACENTER"
            gbox.orientation = options.orientation or "HORIZONTAL"
            gbox.numdiv = options.numdiv or "2"
            gbox.gaplin = "10"
            gbox.gapcol = "10"
            gbox.normalizesize = "YES"
            return gbox
        end
        function MakeDateField(options)
            local txtEntryDate = nil
            local function ValidateDate()
                txtEntryDate.fgcolor = TXTFGCOLOR
                local booDateValid = true
                local d = txtEntryDate.value
                if type(d) == "string" then
                    _, booDateValid = TestTextDate(d)
                end
                if not booDateValid then
                    txtEntryDate.fgcolor = colorred
                end
            end
            txtEntryDate = MakeText{visiblelines = "1", killfocus=ValidateDate, norm = options.norm or norm2, tip = "Enter date. If you type in an invalid date the text will turn red.", norm = options.norm or norm, expand = "NO"}
            return iup.hbox{MakeLabel{title=options.title or "", tip = "Enter date. If you type in an invalid date the text will turn red."}, txtEntryDate; alignment = "ACENTER"}
        end
    end
end

--[[FH Data API

@Author:      Helen Wright
@Version:      1.1
@LastUpdated:  29 October 2019
@Description:  Helper functions to extend the FH API
]]--


do
    --pre-requisites

    if not fhloadrequire("pl","pl.init") then return end -- Load required Penlight modules
    pl = require("pl.import_into")
    require("iuplua") -- GUI

    -- also requires
    --  fh API
    --  Dialog boilerplate

    function PromptForRecords(options)
        local tblrec = {}
        local tbltxt = {}
        local tblptr = {}
        local currdlg=IdentifyActiveWindow()
        if options.recordcount then 
            tblptr = fhPromptUserForRecordSel(options.recordtype or "INDI", options.recordcount) 
        else 
            tblptr = fhPromptUserForRecordSel(options.recordtype or "INDI") 
        end
        currdlg.bringfront = "YES"
        if #tblptr > 0 then
            for i, p in ipairs(tblptr) do
                tblrec[i]=fhGetRecordId(p)
                tbltxt[i]=fhGetDisplayText(p)
            end
        else
            tblrec = {}
            tbltxt={}
        end
        return tblptr, tblrec, tbltxt
    end
    function GetCurrentRecord(options)
        local tblrec = {}
        local tbltxt = {}
        local tblptr = fhGetCurrentRecordSel(options.recordtype or "INDI")
        if #tblptr > 0 then
            for i, p in ipairs(tblptr) do
                tblrec[i]=fhGetRecordId(p)
                tbltxt[i]=fhGetDisplayText(p)
            end
        end
        return tblptr, tblrec, tbltxt
    end
    function Date()
        local currdlg=IdentifyActiveWindow()
        local date = fhPromptUserForDate()
        currdlg.bringfront = "YES"
        return date
    end
    function TestTextDate(strDateText)
        local dt = fhNewDate()
        if strDateText == "" then
            return dt, true
        elseif (stringx.startswith(strDateText,'"') and stringx.endswith(strDateText, '"')) 
        or (stringx.startswith(strDateText,"'") and stringx.endswith(strDateText, "'")) then
            return dt, dt:SetValueAsText(strDateText, true) -- a date phrase
        else
            return dt, dt:SetValueAsText(strDateText, false) --not a date phrase
        end
    end
    function Places()
        local tblPlaces = {}
        local ptrPlace = fhNewItemPtr()
        ptrPlace:MoveToFirstRecord("_PLAC")
        while ptrPlace:IsNotNull() do											-- Loop through all Place Records
            local strPlace = fhGetValueAsText(fhGetItemPtr(ptrPlace,"~.TEXT"))
            tblPlaces[strPlace]=1
            ptrPlace:MoveNext()
        end
        return tblPlaces
    end
    function Addresses()
        local tblAddresses = {}
        local ptr = fhNewItemPtr()
        local pplace = fhNewItemPtr()
        for _, rectype in pairs({"INDI","FAM","REPO","SUBM"}) do
            ptr:MoveToFirstRecord(rectype)
            while ptr:IsNotNull() do
                if fhGetTag(ptr) == "ADDR" then
                    local strAddress = fhGetValueAsText(ptr)
                    pplace:MoveToParentItem(ptr) -- i.e. Fact for current address
                    pplace:MoveTo(pplace,"~.PLAC") -- pplace is place pointer
                    local strPlace= fhGetValueAsText(pplace)
                    if strPlace == nil then strPlace = "" end
                    if tblAddresses[strPlace..strAddress] == nil then --place address combination is unique
                        tblAddresses[strPlace..strAddress]={strAddress, strPlace} 
                    end
                end
                ptr:MoveNextSpecial()
            end
        end
        return tblAddresses
    end
    function AddressesForPlace(place, tblAddress)
        local tblresult = {}
        for _,a in pairs(tblAddress) do
            if a[2]==place then
                table.insert(tblresult,a[1])
            end                
        end
        return tblresult
    end
    function CreateNote(ptr, value)
        local ptrNew = fhCreateItem("NOTE2",ptr)
        fhSetValueAsText(ptrNew,value)
        return ptrNew
    end
    function CreateSharedNote(value)
        local ptrNew = fhCreateItem("NOTE")
        local Text = fhCreateItem("TEXT",ptrNew)
        fhSetValueAsText(Text,value)
        return ptrNew
    end
    function CreateNoteLink(ptrParent,ptrNote)
        local ptrWork = fhCreateItem("NOTE", ptrParent)
        fhSetValueAsLink(ptrWork, ptrNote)
    end
    function CreateTagAsText(ptrParent, strTag, strText)
        local ptrNew = fhCreateItem(strTag, ptrParent)
        fhSetValueAsText(ptrNew,strText)
        return ptrNew
    end
    function SetDate(ptrParent, dtDate)
        local ptrDate = fhCreateItem("DATE", ptrParent)
        fhSetValueAsDate(ptrDate, dtDate)
        return ptrDate
    end
    function allItems(...)
        local iTypeCount = nil
        local iPos = 1
        local p1 = fhNewItemPtr()
        local p2 = fhNewItemPtr()
        local tblRecTypes = {}

        if arg['n'] == 0 then
            -- No parameter do all Record Types
            iTypeCount = fhGetRecordTypeCount() -- Get Count of Record types
            for i = 1,iTypeCount do
                tblRecTypes[i] = fhGetRecordTypeTag(i)
            end
        else
            -- Got Parameters Process them instead
            tblRecTypes = arg
            iTypeCount = arg['n']
        end
        p1:MoveToFirstRecord(tblRecTypes[iPos])
        return function()
            repeat
                while p1:IsNotNull() do
                    p2:MoveTo(p1)
                    p1:MoveNextSpecial()
                    if p2:IsNotNull() then
                        return p2
                    end
                end
                -- Loop through Record Types
                iPos = iPos + 1
                if iPos <= iTypeCount then
                    p1:MoveToFirstRecord(tblRecTypes[iPos])
                end
            until iPos > iTypeCount
        end
    end
    function records(type)
        local pi = fhNewItemPtr()
        local p2 = fhNewItemPtr()
        pi:MoveToFirstRecord(type)
        return function ()
            p2:MoveTo(pi)
            pi:MoveNext()
            if p2:IsNotNull() then return p2 end
        end
    end
    function CopyBranch(ptrSource,ptrTarget)
        local ptrNew = fhCreateItem(fhGetTag(ptrSource),ptrTarget,true)
        fhSetValue_Copy(ptrNew,ptrSource)
        CopyChildren(ptrSource,ptrNew)
    end -- function CopyBranch

    function CopyChildren(ptrSource,ptrTarget)
        local ptrFrom = fhNewItemPtr()
        ptrFrom = ptrSource:Clone()
        ptrFrom:MoveToFirstChildItem(ptrFrom)
        while ptrFrom:IsNotNull() do
            CopyBranch(ptrFrom,ptrTarget)
            ptrFrom:MoveNext()
        end
    end -- function CopyChildren
end
--[[Unicode conversion
@Author:      Mike Tate
@Description:  Convert betweeen UTF16 and UF8/ANSI
]]
do
--Prerequisites:

    -- fh API
    -- fh version > 5

    function StrANSI_UTF8(strText)
        return fhConvertANSItoUTF8(strText) --requires FH version >= 5
    end -- function StrANSI_UTF8
    function StrUTF8_ANSI(strText)
        return fhConvertUTF8toANSI(strText) --requires FH version >= 5
    end
    -- UTF8 <=> UTF16
    local intTop10 = 0
    function StrUtf16toUtf8(strChar1,strChar2) -- Convert a UTF-16 word or pair to UTF-8 bytes --
        local intUtf16 = string.byte(strChar2) * 0x100 + string.byte(strChar1)
        if intUtf16 < 0x80 then								-- U+0000 to U+007F (ASCII) 
            return string.char(intUtf16)
        end
        if intUtf16 < 0x800 then								-- U+0080 to U+07FF
            local intByte1 = intUtf16 % 0x40
            intUtf16 = math.floor( intUtf16 / 0x40 )
            local intByte2 = intUtf16
            return string.char( intByte2 + 0xC0, intByte1 + 0x80 )
        end
        if intUtf16 < 0xD800									-- U+0800 to U+FFFF
        or intUtf16 > 0xDFFF then
            local intByte1 = intUtf16 % 0x40
            intUtf16 = math.floor( intUtf16 / 0x40 )
            local intByte2 = intUtf16 % 0x40
            intUtf16 = math.floor( intUtf16 / 0x40 )
            local intByte3 = intUtf16
            return string.char( intByte3 + 0xE0, intByte2 + 0x80, intByte1 + 0x80 )
        end
        if intUtf16 < 0xDC00 then								-- U+10000 to U+10FFFF High 16-bit Surrogate Supplementary Planes -- V2.6
            intTop10 = ( intUtf16 - 0xD800 ) * 0x400 + 0x10000
            return ""
        end
        intUtf16 = intUtf16 - 0xDC00 + intTop10				-- U+10000 to U+10FFFF Low 16-bit Surrogate Supplementary Planes -- V2.6
        local intByte1 = intUtf16 % 0x40
        intUtf16 = math.floor( intUtf16 / 0x40 )
        local intByte2 = intUtf16 % 0x40
        intUtf16 = math.floor( intUtf16 / 0x40 )
        local intByte3 = intUtf16 % 0x40
        intUtf16 = math.floor( intUtf16 / 0x40 )
        local intByte4 = intUtf16
        return string.char( intByte4 + 0xF0, intByte3 + 0x80, intByte2 + 0x80, intByte1 + 0x80 )
    end -- function StrUtf16toUtf8

    function StrUTF16_UTF8(strText) --Encode UTF16 words into UTF8 bytes
        return ( (strText or ""):gsub("(.)(.)",StrUtf16toUtf8) )
    end -- function StrUTF16_UTF8


    local tblByte = {}
    local tblLead = { 0x80, 0xC0, 0xE0, 0xF0, 0xF8, 0xFC } 
    function StrUtf8toUtf16(strChar) -- Convert UTF-8 bytes to a UTF-16 word or pair

        -- Convert any UTF-8 multibytes to UTF-16 --
        local function strUtf8()
            if #tblByte > 0 then
                local intUtf16 = 0
                for intIndex, intByte in ipairs (tblByte) do  -- Convert UTF-8 bytes to UNICODE U+0080 to U+10FFFF
                    if intIndex == 1 then
                        intUtf16 = intByte - tblLead[#tblByte]
                    else
                        intUtf16 = intUtf16 * 0x40 + intByte - 0x80
                    end
                end
                if intUtf16 > 0xFFFF then            -- U+10000 to U+10FFFF Supplementary Planes -- V2.6
                    tblByte = {}
                    intUtf16 = intUtf16 - 0x10000
                    local intLow10 = 0xDC00 + ( intUtf16 % 0x400 )        -- Low 16-bit Surrogate
                    local intTop10 = 0xD800 + math.floor( intUtf16 / 0x400 )  -- High 16-bit Surrogate
                    local intChar1 = intTop10 % 0x100
                    local intChar2 = math.floor( intTop10 / 0x100 )
                    local intChar3 = intLow10 % 0x100
                    local intChar4 = math.floor( intLow10 / 0x100 )
                    return string.char(intChar1,intChar2,intChar3,intChar4)  -- Surrogate 16-bit Pair
                end
                if intUtf16 < 0xD800              -- U+0080 to U+FFFF (except U+D800 to U+DFFF) -- V2.6
                or intUtf16 > 0xDFFF then            -- Basic Multilingual Plane
                    tblByte = {}
                    local intChar1 = intUtf16 % 0x100
                    local intChar2 = math.floor( intUtf16 / 0x100 )
                    return string.char(intChar1,intChar2)  -- BPL 16-bit
                end
                local strUtf8 = ""                -- U+D800 to U+DFFF Reserved Code Points -- V2.6
                for _, intByte in ipairs (tblByte) do
                    strUtf8 = strUtf8..string.format("%.2X ",intByte)
                end
                local strUtf16 = string.format("%.4X ",intUtf16)
                fhMessageBox("\n UTF-16 Reserved Code Point U+D800 to U+DFFF \n UTF-16 = "..strUtf16.."  UTF-8 = "..strUtf8.."\n Character will be replaced by a question mark. \n")
                tblByte = {}
                return "?\0"
            end
            return ""
        end -- local function strUtf8

        local intUtf8 = string.byte(strChar)
        if intUtf8 < 0x80 then                  -- U+0000 to U+007F (ASCII)
            return strUtf8()..strChar.."\0"          -- Previous UTF-8 multibytes + current ASCII char
        end
        if intUtf8 >= 0xC0 then                -- Next UTF-8 multibyte start
            local strUtf16 = strUtf8()
            table.insert(tblByte,intUtf8)
            return strUtf16                    -- Previous UTF-8 multibytes
        end
        table.insert(tblByte,intUtf8)
        return ""
    end -- function StrUtf8toUtf16
    function StrUTF8_UTF16(strText) -- Encode UTF-8 bytes into UTF-16 words
        tblByte = {}                        -- (0xFF) flushes last UTF-8 character
        return ((strText or "")..string.char(0xFF)):gsub("(.)",StrUtf8toUtf16)
    end
end
--[[File handling
@Author:      Helen Wright
@Version:      1.0
@LastUpdated:  29 October 2019
@Description:  Helper functions for File handling
@V1.0:        Initial version.
]]
do
    --prerequisites
    require ("luacom") --Microsoft's Component Object Model
    if not fhloadrequire("pl","pl.init") then return end -- Load required Penlight modules

    -- also requires
    -- Unicode boilerplate

    function FileExists(strFile)
        return path.isfile(strFile)
    end
    function FileDownload(strURL, strFile)
        -- retrieve the content of a URL
        local function httpRequest(strURL)
            local http = luacom.CreateObject("winhttp.winhttprequest.5.1")
            http:Open("GET",strURL,false)
            http:Send()
            http:WaitForResponse(30)
            return http.Responsebody
        end

        local isOK, body = pcall(httpRequest, strURL)
        if isOK then
            if body ~= nil then 
                -- save the content to a file (assume it's binary)
                local f = assert(io.open(strFile, "wb")) -- open in "binary" mode -- throws an error if the open fails
                if f ~= nil then --open succeeded
                    f:write(body)
                    f:close()
                end
                return true
            end
        end
        return false,'An error occurred in Download. Please try later'
    end
    function WriteFile(strContents, strTarget)
        local f = assert(io.open(strTarget, "wb")) -- open for writing in "binary" mode -- throws an error if the open fails
        if f ~= nil then --open succeeded
            f:write(strContents)
            f:close()
        end
    end
    function ReadFile(strSource)
        local strContent = ""
        local f = assert(io.open(strSource, "rb")) -- open for reading in "binary" mode -- throws an error if the open fails
        if f ~= nil then --open succeeded
            strContent = f:read("*all")
            f:close()
        end
        return strContent
    end


    function CopyFile(old, new)
        WriteFile(ReadFile(old), new) --doing it this way preserve file name case
    end
    local bomUtf16= string.char(0xFF,0xFE)		-- "ÿþ"
    function WriteUTF16File(strUTF8, strTarget)
        --strUTF8 is a UTF8 or ANSI string
        WriteFile(bomUtf16 .. StrUTF8_UTF16(strUTF8:gsub("\n","\r\n") ), strTarget)
    end
    function ReadUTF16File(strSource)
        local strUTF8 = ReadFile(strSource)
        return StrUTF16_UTF8(string.gsub(string.gsub(strUTF8,bomUtf16,""), "\r\n","\n")) --remove BOM, rationalise newlines and convert to UTF8
    end
    function DeleteFile(strFile)
        os.remove(strFile)
    end
    function RenameFile(oldname,newname)
        os.rename(oldname, newname)
    end
--Chillcode code snippets for loading/saving tables to a file -- used for storage of template definitions
--[[
   Save Table to File
   Load Table from File
   v 1.0
   
   Lua 5.2 compatible
   
   Only Saves Tables, Numbers and Strings
   Insides Table References are saved
   Does not save Userdata, Metatables, Functions and indices of these
   ----------------------------------------------------
   table.save( table , filename )
   
   on failure: returns an error msg
   
   ----------------------------------------------------
   table.load( filename or stringtable )
   
   Loads a table that has been saved via the table.save function
   
   on success: returns a previously saved table
   on failure: returns as second argument an error msg
   ----------------------------------------------------
   
   Licensed under the same terms as Lua itself.
]]--
    -- declare local variables
    --// exportstring( string )
    --// returns a "Lua" portable version of the string
    local function exportstring( s )
        return string.format("%q", s)
    end

    --// The Save Function
    function table.save(tbl,filename )
        local charS,charE = "   ","\n"
        local file,err = io.open( filename, "wb" )
        if err then return err end

        -- initiate variables for save procedure
        local tables,lookup = { tbl },{ [tbl] = 1 }
        file:write( "return {"..charE )

        for idx,t in ipairs( tables ) do
            file:write( "-- Table: {"..idx.."}"..charE )
            file:write( "{"..charE )
            local thandled = {}

            for i,v in ipairs( t ) do
                thandled[i] = true
                local stype = type( v )
                -- only handle value
                if stype == "table" then
                    if not lookup[v] then
                        table.insert( tables, v )
                        lookup[v] = #tables
                    end
                    file:write( charS.."{"..lookup[v].."},"..charE )
                elseif stype == "string" then
                    file:write(  charS..exportstring( v )..","..charE )
                elseif stype == "number" then
                    file:write(  charS..tostring( v )..","..charE )
                end
            end

            for i,v in pairs( t ) do
                -- escape handled values
                if (not thandled[i]) then

                    local str = ""
                    local stype = type( i )
                    -- handle index
                    if stype == "table" then
                        if not lookup[i] then
                            table.insert( tables,i )
                            lookup[i] = #tables
                        end
                        str = charS.."[{"..lookup[i].."}]="
                    elseif stype == "string" then
                        str = charS.."["..exportstring( i ).."]="
                    elseif stype == "number" then
                        str = charS.."["..tostring( i ).."]="
                    end

                    if str ~= "" then
                        stype = type( v )
                        -- handle value
                        if stype == "table" then
                            if not lookup[v] then
                                table.insert( tables,v )
                                lookup[v] = #tables
                            end
                            file:write( str.."{"..lookup[v].."},"..charE )
                        elseif stype == "string" then
                            file:write( str..exportstring( v )..","..charE )
                        elseif stype == "number" then
                            file:write( str..tostring( v )..","..charE )
                        end
                    end
                end
            end
            file:write( "},"..charE )
        end
        file:write( "}" )
        file:close()
    end

    --// The Load Function
    function table.load( sfile )
        local ftables,err = loadfile( sfile )
        if err then return _,err end
        local tables = ftables()
        if tables then
            for idx = 1,#tables do
                local tolinki = {}
                for i,v in pairs( tables[idx] ) do
                    if type( v ) == "table" then
                        tables[idx][i] = tables[v[1]]
                    end
                    if type( i ) == "table" and tables[i[1]] then
                        table.insert( tolinki,{ i,tables[i[1]] } )
                    end
                end
                -- link indices
                for _,v in ipairs( tolinki ) do
                    tables[idx][v[2]],tables[idx][v[1]] =  tables[idx][v[1]],nil
                end
            end
            return tables[1]
        else
            iup.Message("Table load fail","No data returned")
        end
    end

end
--[[Progress Bar
@Title: Progress Bar (drop in)
@Author: Jane Taubman / Mike Tate
@LastUpdated: January 2013
@Description: Allows easy adding of a Progress Bar to any long running Plugin
]]
do
    --prerequisites
    require("iuplua") -- GUI

    function ProgressBar(tblGauge)

        local tblGauge	= tblGauge or {}				-- Optional table of external parameters
        local strFont	= tblGauge.Font		or nil			-- Font dialogue default is current font
        local strButton	= tblGauge.Button	or "255 0 0"		-- Button colour default is red
        local strBehind	= tblGauge.Behind	or "255 255 255"	-- Background colour default is white
        local intShowX	= tblGauge.ShowX	or iup.CENTER		-- Show window default position is central
        local intShowY	= tblGauge.ShowY	or iup.CENTER
        local intMax, intVal, intPercent, intStart, intDelta, intScale, strClock, isBarStop
        local lblText, barGauge, lblDelta, btnStop, dlgGauge

        local function doFocus()					-- Bring the Progress Bar window into Focus
            dlgGauge.bringfront="YES"				-- If used too often, inhibits other windows scroll bars, etc
        end -- local function doFocus

        local function doUpdate()					-- Update the Progress Gauge and the Delta % with clock
            barGauge.value = intVal
            lblDelta.title = string.format("%4d %%      %s ",intPercent,strClock)
        end -- local function doUpdate

        local function doReset()					-- Reset all dialogue variables and Update display
            intVal		= 0					-- Current value of Progress Bar
            intPercent	= 0.01					-- Percentage of progress
            intStart	= os.time()				-- Start time of progress
            intDelta	= 0					-- Delta time of progress
            intScale	= math.ceil( intMax / 1000 )		-- Scale of percentage per second (this guess is corrected in Step function)
            strClock	= "00 : 00 : 00"			-- Clock delta time display
            isBarStop	= false					-- Stop button pressed signal
            doUpdate()
            doFocus()
        end -- local function doReset

        local tblProgressBar = {

            Start = function(strTitle,intMaximum)			-- Create & start Progress Bar window
                if not dlgGauge then
                    strTitle = strTitle or ""		-- Dialogue and button title
                    intMax = intMaximum or 100		-- Maximun range of Progress Bar, default is 100
                    local strSize = tostring( math.max( 100, string.len(" Stop "..strTitle) * 8 ) ).."x30"	-- Adjust Stop button size to Title
                    lblText  = iup.label	{ title=" ", expand="YES", alignment="ACENTER", tip="Progress Message" }
                    barGauge = iup.progressbar { rastersize="400x30", value=0, max=intMax, tip="Progress Bar" }
                    lblDelta = iup.label	{ title=" ", expand="YES", alignment="ACENTER", tip="Percentage and Elapsed Time" }
                    btnStop  = iup.button	{ title=" Stop "..strTitle, rastersize=strSize, fgcolor=strButton, tip="Stop Progress Button", action=function() isBarStop = true end }	-- Signal Stop button pressed	return iup.CLOSE -- Often caused main GUI to close !!!
                    dlgGauge = iup.dialog	{ title=strTitle.." Progress ", font=strFont, dialogframe="YES", background=strBehind,	-- Remove Windows minimize/maximize menu
                        iup.vbox{ alignment="ACENTER", gap="10", margin="10x10",
                            lblText,
                            barGauge,
                            lblDelta,
                            btnStop,
                        },
                        move_cb = function(self,x,y) tblGauge.ShowX = x tblGauge.ShowY = y end,
                        close_cb = btnStop.action,		-- Windows Close button = Stop button
                    }
                    dlgGauge:showxy(intShowX,intShowY)					-- Show the Progress Bar window
                    doReset()								-- Reset the Progress Bar display
                end
            end,

            Message = function(strText)								-- Show the Progress Bar message
                if dlgGauge then lblText.title = strText end
            end,

            Step = function(intStep)								-- Step the Progress Bar forward
                if dlgGauge then
                    intVal = intVal + ( intStep or 1 )					-- Default step is 1
                    local intNew = math.ceil( intVal / intMax * 100 * intScale ) / intScale
                    if intPercent ~= intNew then						-- Update progress once per percent or per second, whichever is smaller
                        intPercent = math.max( 0.1, intNew )				-- Ensure percentage is greater than zero
                        if intVal > intMax then intVal = intMax intPercent = 100 end	-- Ensure values do not exceed maximum
                        intNew = os.difftime(os.time(),intStart)
                        if intDelta < intNew then					-- Update clock of elapsed time
                            intDelta = intNew
                            intScale = math.ceil( intDelta / intPercent )		-- Scale of seconds per percentage step
                            local intHour = math.floor( intDelta / 3600 )
                            local intMins = math.floor( intDelta / 60 - intHour * 60 )
                            local intSecs = intDelta - intMins * 60 - intHour * 3600
                            strClock = string.format("%02d : %02d : %02d",intHour,intMins,intSecs)
                        end
                        doUpdate()							-- Update the Progress Bar display
                    end
                    iup.LoopStep()
                end
            end,

            Focus = function()
                if dlgGauge then doFocus() end							-- Bring the Progress Bar window to front
            end,

            Reset = function()									-- Reset the Progress Bar display
                if dlgGauge then doReset() end
            end,

            Stop = function()									-- Check if Stop button pressed
                iup.LoopStep()
                return isBarStop
            end,

            Close = function()									-- Close the Progress Bar window
                isBarStop = false
                if dlgGauge then dlgGauge:destroy() dlgGauge = nil end
            end,

        } -- end newProgressBar
        return tblProgressBar
    end -- function ProgressBar
end

---------------------------------------------------------------
--DATA CLASSES
---------------------------------------------------------------
--[[Various source related classes

@Author:      Helen Wright
@V1.1:        Initial version.
@LastUpdated: 5 November 2019

A UI class that implement:
 1. A source selector optionally with a source definition control, with options to edit the selected source or create a new source
 2. A citation definition control

Multiple instances of the class can be created by a plugin.

2. a Class that implements a number of source-related utility functions

]]--
function SourceTools ()
    --a class containing a number of utility routines for dealing with Sources

    if not fhloadrequire("pl","pl.init") then return end -- Load required Penlight modules
    utils = require("pl.utils")
    --also requires progress boilerplate and fh API

    local Link = function(ptrParent,ptrSour)
        local ptrWork = fhCreateItem("SOUR", ptrParent)
        fhSetValueAsLink(ptrWork, ptrSour)
        return ptrWork
    end
    local CitationDetail = function(ptrParent, values) --values: entrydate, quality, where, from, citationnote
        local ptrData = nil
        if not types.is_empty(values.where) then
            local ptrP = fhCreateItem('PAGE',ptrParent,true)
            fhSetValueAsText(ptrP, values.where)
        end
        if not types.is_empty(values.entrydate) then
            if ptrData == nil then ptrData = fhCreateItem('DATA', ptrParent, true) end
            local ptrD = fhCreateItem('DATE',ptrData,true)
            fhSetValueAsText(ptrD, values.entrydate)
        end
        if not types.is_empty(values.from) then
            if ptrData == nil then ptrData = fhCreateItem('DATA', ptrParent, true) end
            local ptrT = fhCreateItem('TEXT',ptrData, true)
            fhSetValueAsText(ptrT, values.from)
        end
        if not types.is_empty(values.quality) then
            local ptrQ = fhCreateItem('QUAY',ptrParent,true)
            fhSetValueAsText(ptrQ,values.quality)
        end
        if not types.is_empty(values.citationnote) then
            local ptrN = fhCreateItem('NOTE2',ptrParent,true)
            fhSetValueAsText(ptrN,values.citationnote)
        end
    end
    return{Link = Link, CitationDetail = CitationDetail}
end
local mySourceTools = SourceTools()
function Source (imax, booE, booN, booC) --optional parameters to allow editing or creation of sources and citations

    --prerquisities: Penlight libraries, DH Data api
    --TODO: Include making new sources and displaying/editing source fields

    --private state variables

    local booEdit = booE or false --is editing source fields allowed
    local booNew = booN or false --is creating new sources allowed
    local imaxSource = imax or 1 --how many sources can be selected at one (0 = unlimited)
    local booCitation = booC or false --is a citation container required
    if booCitation then imaxSource = 1 end --only one source allowed if a citation is required
    local caller = CallerSources()

    local txtSources = nil --text field to display selected sources
    local tblSources = {} --table of selected source pointers
    local btnSources = nil --button to select a source
    local boxSource = nil
    local boxCitation = nil --will be used to hold citation details if required
    local booEntryDateValid = true

    local tblAssessment = {"Unreliable", "Questionable", "Secondary Evidence", "Primary Evidence", "" } --Source assessment text values

    --public methods
    local function Populate(tbltxt)
        txtSources.VALUE = ""
        if #tbltxt > 0 then txtSources.value = table.concat(tbltxt,"\n") end
    end
    local function ClearCitation()
        if boxCitation then
            ClearContainer(boxCitation, {})
            booEntryDateValid = true
        end
    end
    local Selector = function (strSourceTip, strOrientation, strCitationTip) --returns an iup control
        local function Initialise()
            local tbltxt = {}
            tblSources, _, tbltxt  = GetCurrentRecord {recordtype="SOUR"}
            Populate(tbltxt)
        end
        local function MakeCitation()
            local function DateDetails()
                local dtEntryDate = nil --result of entry date operations
                local txtEntryDate = nil
                local function DateChoose()
                    dtEntryDate = Date()
                    if dtEntryDate ~= nil then
                        txtEntryDate.value = dtEntryDate:GetDisplayText()
                    else
                        txtEntryDate.value= ""
                    end
                    booEntryDateValid = true
                    txtEntryDate.fgcolor = TXTFGCOLOR
                end
                local function ValidateDate()
                    txtEntryDate.fgcolor = TXTFGCOLOR
                    booEntryDateValid = true
                    local d = txtEntryDate.value
                    if type(d) == "string" then
                        dtEntryDate, booEntryDateValid = TestTextDate(d)
                    end
                    if not booEntryDateValid then
                        txtEntryDate.fgcolor = colorred
                    end
                end
                local btnGetEntryDate = MakeButton{title="Date...", callback=DateChoose, tip = "Use the button to call Family Historian's Date Entry Assistant, or type directly into the Date box. \n If you type in an invalid date the text will turn red."}
                txtEntryDate = MakeText{visiblelines = "1", killfocus=ValidateDate, norm = norm2, tip = "Use the button to call Family Historian's Date Entry Assistant, or type directly into the Date box. \n If you type in an invalid date the text will turn red.", name = "entrydate"}
                return iup.hbox{btnGetEntryDate, txtEntryDate; alignment = "ACENTER"}
                --txtEntryDate.value will be returned as part of the Citation Data
            end
            local function QualityDetails()
                local listQuality = nil
                local function CheckQualityValue(self, text, item, state)
                    if item == 5 and state == 1 then listQuality.value = "0" end
                end

                listQuality = MakeList{sort = "NO", action = CheckQualityValue, values = tblAssessment, norm = norm2, name = "quality"}
                return iup.hbox{MakeLabel{title = "Assessment"},listQuality; alignment = "ACENTER"}
                --listquality.value will be returned as part of the Citation Data
            end

            local txtWhere = MakeText{visiblelines="1", name = "where"}
            local txtFrom = MakeText{scrollbar="VERTICAL", multiline="YES", name = "from"} 
            local txtCitationNote = MakeText{scrollbar="VERTICAL", multiline="YES", name = "citationnote"}
            local box = iup.vbox{
                iup.hbox{DateDetails(),QualityDetails()},
                iup.vbox{MakeLabel{title = "Where in Source"}, txtWhere}, 
                iup.vbox{MakeLabel{title = "Text from Source"}, txtFrom}, 
                iup.vbox{MakeLabel{title = "Citation Note"}, txtCitationNote};
            }
            return MakeExpander(box,
                strCitationTip or "Citation (Optional).",
                "OPEN")
        end
        local function Choose()
            local PreviousSource = tblSources[1] --will only be used if booCitation is true
            local tbltxt = {}
            if imaxSource == 0 then
                tblSources, _, tbltxt = PromptForRecords {recordtype="SOUR"}
            else
                tblSources, _, tbltxt = PromptForRecords {recordtype="SOUR", recordcount = imaxSource}
            end
            Populate(tbltxt)
            if booCitation and tblSources[1] ~= PreviousSource then
                ClearCitation()
            end
            caller.CheckUIStatus()
        end

        strSourceTip = strSourceTip or "Use the Source button to choose sources(s) from within Family Historian."
        strOrientation = strOrientation or "HORIZONTAL"
        txtSources = MakeText{readonly = "YES", expand = "HORIZONTAL", norm = norm2, tip = strSourceTip, multiline = utils.choose(imaxSource ~= 1, "YES", "NO")}
        Initialise()
        btnSources = MakeButton{title="Source...", callback=Choose, tip = strSourceTip}
        if strOrientation == "HORIZONTAL" then
            boxSource =  iup.hbox{btnSources, txtSources; alignment = "ACENTER"}
        else
            boxSource =  iup.vbox{btnSources, txtSources; alignment = "ACENTER"}
        end
        if booCitation then
            boxCitation = MakeCitation()
            return iup.vbox{boxSource, boxCitation}
        else
            return boxSource
        end
    end
    local SourceList = function()-- returns a table of source pointers
        return tblSources
    end
    local ClearSelector = function()
        tblSources = {}
        txtSources.value = ""
        if booCitation then ClearCitation() end
    end
    local DisableSelector = function (boo)
        local strStatus = utils.choose(boo, "NO", "YES")
        btnSources.active = strStatus
        txtSources.active = strStatus
        if boo then ClearSelector() end
    end
    local CitationData = function() --returns a table of Citation Data
        local tblCitationElements = GetDataElements(boxCitation,{},{},true)
        local tbldata = GetContainerData(tblCitationElements) --entrydate, quality, where, from, citationnote
        if not types.is_empty(tbldata.quality) then tbldata.quality = tblAssessment[tonumber(tbldata.quality)] end
        return tbldata

    end 
    local CitationState = function(state)
        boxCitation.state = state
    end
    local CitationOK = function()
        return booEntryDateValid
    end
--expose public methods
    return{
        Selector = Selector,
        SourceList = SourceList,
        ClearSelector = ClearSelector,
        DisableSelector = DisableSelector,
        CitationData = CitationData,
        CitationState = CitationState, 
        CitationOK = CitationOK
    }
end
function CallerSources ()
    local CheckUIStatus = function()
        AdjustButtons()
    end
    return {CheckUIStatus = CheckUIStatus}
end
--[[Individuals class

@Author:      Helen Wright
@V1.0:        Initial version.
@LastUpdated: 27 October 2019

A class that implements a field to prompt for one or more individuals

Multiple instances of the Individuals class can be created by a plugin
]]--
function Individuals (iMax)
    --local constants go here

    --private state variables
    local tblIndividuals = {}
    local imaxIndividuals = iMax or 0 --0 for unlimited
    local txtIndividuals = nil
    local btnIndividuals = nil
    local caller = nil --customised class in caller plugin

    --private methods
    local function Populate(tbltxt)
        txtIndividuals.VALUE = ""
        if #tbltxt > 0 then txtIndividuals.value = table.concat(tbltxt,"\n") end
    end

    --public methods
    local Selector = function (strIndividualTip, strOrientation)

        local function Initialise()
            local tbltxt = {}
            tblIndividuals, _, tbltxt  = GetCurrentRecord {recordtype="INDI"}
            Populate(tbltxt)
        end
        local function Choose()
            local tbltxt = {}
            if imaxIndividuals == 0 then
                tblIndividuals, _, tbltxt = PromptForRecords {recordtype="INDI"}
            else
                tblIndividuals, _, tbltxt = PromptForRecords {recordtype="INDI", recordcount = imaxIndividuals}
            end
            Populate(tbltxt)
            caller.CheckUIStatus()
        end
        local strTip = strIndividualTip or "Use the button to choose individual(s) from within Family Historian."
        local Orientation = strOrientation or "HORIZONTAL"
        txtIndividuals = MakeText{readonly = "YES", expand = "YES", norm = norm2, tip = strTip, multiline = utils.choose(imaxIndividuals ~= 1, "YES", "NO")}
        Initialise()
        btnIndividuals = MakeButton{title="Individual(s)...", callback=Choose, tip = strTip}
        if Orientation == "HORIZONTAL" then
            return iup.hbox{btnIndividuals, txtIndividuals; alignment = "ALEFT"}
        else
            return iup.vbox{btnIndividuals, txtIndividuals; alignment = "ALEFT"}
        end

    end
    local IndividualList= function () --returns a table of Individual pointers
        return tblIndividuals
    end
    local ClearSelector = function()
        tblIndividuals = {}
        txtIndividuals.value = ""
    end
    local DisableSelector = function (boo)
        local strStatus = utils.choose(boo, "NO", "YES")
        btnIndividuals.active = strStatus
        txtIndividuals.active = strStatus
        if boo then ClearSelector() end
    end
    local Load = function(tblptr)
        tblIndividuals = tablex.copy(tblptr)
        local tbltxt = {}
        for i, p in ipairs(tblptr) do
            tbltxt[i]=fhGetDisplayText(p)
        end
        Populate(tbltxt)
    end

    caller = CallerIndividuals(iMax)

    --expose public methods
    return{
        Selector = Selector,
        IndividualList = IndividualList,
        ClearSelector = ClearSelector,
        DisableSelector = DisableSelector,
        Load = Load
    }
end

function CallerIndividuals(iMax)
    local CheckUIStatus = function()
        AdjustButtons()
    end
    return {CheckUIStatus = CheckUIStatus}
end
--[[Various fact-related classes

@Author:      Helen Wright
@V1.0:        Initial version.
@LastUpdated: 6 November 2019
]]--
function FactTools ()
    --a class containing a number of utility routines for dealing with Facts

    if not fhloadrequire("pl","pl.init") then return end -- Load required Penlight modules
    utils = require("pl.utils")
    --also requires progress boilerplate and fh API

    local Exists = function(strname, booattr, booIndi)
        booIndi = booIndi or true
        booattr = booattr or false
        local strTag, _ = fhGetFactTag(strname, 
            utils.choose(booattr, "Attribute", "Event"), utils.choose(booIndi, "INDI", "FAM"), false)
        return strTag ~= "", strTag
    end
    local Create = function(strFactTag, ptr, values)
        --values should be a table containing text items (or nil or ""): 
        --date, age, place, address, place2, attribute, cause and note
        --assumes that the calling function has done validation

        local ptrFact = fhCreateItem(strFactTag, ptr) --ptr can be to an Indi or Fam record
        if not types.is_empty(values.factdate) then --add a fact date
            local dt = fhNewDate()
            local DateOK = dt:SetValueAsText(values.factdate, true)
            SetDate(ptrFact, dt)
        end
        if not types.is_empty(values.age) then --add an age
            CreateTagAsText(ptrFact, "AGE", values.age)
        end
        if not types.is_empty(values.place) then -- add a place
            CreateTagAsText(ptrFact, "PLAC", values.place)
        end
        if not types.is_empty(values.address) then -- add address
            CreateTagAsText(ptrFact, "ADDR", values.address)
        end
        if not types.is_empty(values.place2) then --add place2
            CreateTagAsText(ptrFact, "_PLAC", values.place2)
        end
        if not types.is_empty(values.attributevalue) then
            fhSetValueAsText(ptrFact,values.attributevalue)
        end
        if not types.is_empty(values.cause) then
            CreateTagAsText(ptrFact, "CAUS", values.cause)
        end
        if not types.is_empty(values.factnote) then --add a note
            CreateNote(ptrFact, values.factnote)
        end
        return ptrFact --pointer to the fact
    end
    local Iterate = function(pi) --iterate over the facts for an individual or family, pointed to by pi
        local pf = fhNewItemPtr()
        local pf2 = fhNewItemPtr()
        pf:MoveToFirstChildItem(pi)
        return function ()
            while  pf:IsNotNull() do
                pf2:MoveTo(pf)
                pf:MoveNext()
                if fhIsFact(pf2) then return pf2 end
            end
        end
    end
    return{Exists = Exists, Create = Create, Iterate = Iterate}
end
local myFactTools = FactTools()
function Facts(imax)

    local imaxFact = imax or 1 --how many facts can be selected at one (0 = unlimited)
    local caller = CallerFacts()

    local tblSelectedFacts = {}
    local listFacts = nil
    local oldfacts = ""

    local txtFacts = nil
    local dlgFacts = nil

    local boxDetails = nil
    local booFactDateValid = true

    --public methods

    local function Parse()
        local tblFactsets = {} --Fact sets found
        local strfactsetfiletype = ".fhf"
        local cstrFactTypeRoot = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Fact Types"
        local cstrCustomFactsDir = cstrFactTypeRoot.."\\Custom\\"
        local cstrStandardFactsDir = cstrFactTypeRoot.."\\Standard\\"
        local cstrFactSetIndex = cstrStandardFactsDir.."GroupIndex.fhdata"
        local tblFacts = {}
        local function ParseFile(strFacts)
            --returns individual facts that are not hidden; custom facts override standard ones
            local function FactIsUnique(fctname)
                return tblFacts[fctname]==nil
            end
            local function ParseAutonote(ident)
                local noteparse="%[Text%-"..utils.escape(ident).."%-Auto Note%]%c+(Count=%d+%c+.-)" --escape magic characters in ident string
                local strParsed = strFacts:match(noteparse.."%c+%[")
                or strFacts:match(noteparse.."$")
                if strParsed ~= nil then
                    local strlines, content = strParsed:match("Count=(%d+)%c+(.*)")
                    if strlines ~= "0" then
                        local strAutonote = content:gsub("Line%d+=[n0];","") --remove preamble from all lines
                        strAutonote = strAutonote:gsub(";(%c+)","%1") --remove final ; at end of lines
                        return strAutonote:gsub(";$","") --and lose the very last line end
                    end
                end
                return "" --Autonote not found or contains no lines 
            end


            local foundCount=0 --to hold the running count of facts in the file
            local factCount=tonumber(strFacts:match("Count=(%d+)%c")) -- find the first instance of a Count in the file and get the total count of facts in the file from it
            
            local strParseFact = "%[(FCT%-[_%u%-]+%-([IF])([EA]))%]%c+Name=(.-)%c.-Label=(.-)%c(.-)\r\n{END}%c" --order of these elements in the fact file seems standard
            local strFacts = strFacts:gsub("(%c+%[FCT)","\r\n{END}%1").."\r\n{END}"
            for ident, recordtype, facttype, name, label, strdetail in strFacts:gmatch(strParseFact) do
                local hidden = strdetail:match("Hidden=(%a)%c") or "N"
                if recordtype == "I" and hidden == "N" then --this is an individual fact which isn't hidden so process it      
                    if label ~= name then pref=label else pref=name end
                    if FactIsUnique(pref) then -- need to check that it is unique
                        --extract details
                        local facttag = fhGetFactTag(name, utils.choose(facttype=="A","Attribute","Event"), "INDI", false)
                        local abbr=strdetail:match("Abbr=(.-)%c") or "" --may be blank
                        local date=strdetail:match("Field Date=(%d)") or "1"
                        local age=strdetail:match("Field Age=(%d)") or "1"
                        local place=strdetail:match("Field Place=(%d)") or "1"
                        local address=strdetail:match("Field Address=(%d)") or "1"
                        local note=strdetail:match("Field Note=(%d)")  or "1"                    
                        --need to sanitise ident which could have a bunch of extraneous material tacked on the front and use it to get the autonote
                        --   local ident=sident:match(".*%[(FCT%-.-%-)$")..recordtype..facttype
                        local autonote=ParseAutonote(ident) 
                        tblFacts[pref] = {}
                        tblFacts[pref]["ident"] = ident
                        tblFacts[pref]["facttype"] = utils.choose(facttype=="A","attribute","event")
                        tblFacts[pref]["facttag"] = facttag
                        tblFacts[pref]["factname"] = name
                        tblFacts[pref]["factlabel"] = label
                        tblFacts[pref]["factabbr"] = abbr
                        tblFacts[pref]["factdate"] = date
                        tblFacts[pref]["age"] = age
                        tblFacts[pref]["place"] = place
                        tblFacts[pref]["place2"] = utils.choose(facttag == "IMMI" or facttag == "EMIG", "1", "0")
                        tblFacts[pref]["cause"] = utils.choose(facttag == "DEAT", "1", "0")
                        tblFacts[pref]["attributevalue"] = utils.choose(facttype=="A","1","0")
                        tblFacts[pref]["address"] = address
                        tblFacts[pref]["factnote"] = note
                        tblFacts[pref]["autonote"] = autonote
                        --add to the Facts table (keyed on pref)                 
--                    end
                    end
                    foundCount = foundCount+1
                    if foundCount == factCount then return end --decide whether to break: have all facts been found (avoids unnecessary searching of file tail)?
                end
            end
        end
        local function GetFactSize(tblFactsets)
            local intSize = 0
            for _, filename in ipairs(tblFactsets) do   -- Get facts sets file sizes
                local attr = lfs.attributes(utils.choose(filename=="Standard",cstrStandardFactsDir, cstrCustomFactsDir)..filename..strfactsetfiletype)
                intSize = intSize + attr.size
            end
            return intSize
        end

-- get list of files to process

        local strFactsSetList = ReadUTF16File(cstrFactSetIndex)
        for fs,fileind in strFactsSetList:gmatch("([%w_ ]+)=(%d+)") do
            tblFactsets[tonumber(fileind)]=fs
        end
        local progbar = ProgressBar()
        if GetFactSize(tblFactsets) > 400000 then -- start progressbar
            progbar.Start("Loading Fact Sets",#tblFactsets)
        end

        for _, filename in ipairs(tblFactsets) do --process facts sets in ascending fileind order to ensure older definitions don't overwrite new
            progbar.Message("Loading "..filename)
            progbar.Step(1)
            local strFactsSet = ReadUTF16File(utils.choose(filename=="Standard",cstrStandardFactsDir, cstrCustomFactsDir)..filename..strfactsetfiletype)
            ParseFile(strFactsSet)
        end
        progbar.Close()
        --tblFacts now holds the list of facts and their associated parameters

        return tblFacts
    end
    local tblFacts = Parse()
    local function RestrictedFields()
        if boxDetails ~= nil then
            local tblboxDetails = GetDataElements(boxDetails, {}, {})

            tblboxDetails[5].ACTIVE = "NO" -- Place 2
            tblboxDetails[6].ACTIVE = "NO" -- Attribute
            tblboxDetails[7].ACTIVE = "NO" -- Cause

            local tblSelFacts = utils.choose (imax ~= 1,  tblSelectedFacts, {listFacts[1]}) 
            for _, pref in pairs(tblSelFacts) do
                local tblDetails = tblFacts[pref]
                if tblDetails["place2"]== "1" then tblboxDetails[5].ACTIVE = "YES" end
                if tblDetails["facttype"] == "attribute" then tblboxDetails[6].ACTIVE = "YES" end
                if tblDetails["cause"] == "1" then tblboxDetails[7].ACTIVE = "YES" end
            end
        end
    end 
    local EnableRestrictedFactFields = function()
        RestrictedFields()
    end
    local Selector = function (strT, strO) 
        --returns an iup control which may be: 
        --  a dropdown list if imax == 1
        -- a text box plus button plus popup behind it if imax ~= 1

        strOrientation = strO or "HORIZONTAL"

        local function MakeBoxChooseFacts()
            local function FactsConfirm()
                txtFacts.value = "" -- empty existing fact selections if anything
                tblSelectedFacts = {}
                --populate txtFacts and tblSelectedTasks from listFacts
                local strSelectionState = listFacts.value -- a sequence of + and -
                for i = 1, #strSelectionState do
                    if strSelectionState:sub(i,i) == "\+" then --Template is selected
                        table.insert(tblSelectedFacts, listFacts[i])
                        txtFacts.APPEND = listFacts[i]
                    end
                end
                return true
            end
            local function FactsClear()
                listFacts.value = ""
            end
            local function FactsRestore()
                listFacts.value = oldfacts
                return FactsConfirm()
            end
            local function FactsShow(state)
                oldfacts = listFacts.value
            end
            local btnChooseFacts = MakeButton{title="Confirm Facts", callback=FactsConfirm, close="YES", tip = "Confirm that you have selected all the relevant facts."}
            local btnCancelChoice = MakeButton{title="Cancel", tip = "Discard changes and exit.", callback = FactsRestore, close = "YES"}
            local btnClear = MakeButton{title="Clear", callback=FactsClear, tip = "Clear fact selection."}
            listFacts = MakeList{dropdown="NO", visiblelines="20", multiple="YES", values = tblFacts}

            d = MakeDialog(
                iup.vbox{listFacts,
                    iup.hbox{iup.fill{}, btnClear, btnChooseFacts, btnCancelChoice};expandchildren = "HORIZONTAL"
                },
                {title = "Choose Facts", menubox="NO", show = FactsShow })
            d.close_cb = function() 
                d.show_cb = nil --avoid iup bug
                return iup.CLOSE
            end
            return d
        end
        local function FactsChoose()
            --pop up a multiselect combolist
            dlgFacts:popup(iup.CENTERPARENT, iup.CENTERPARENT) --display the fact choice dialog
            caller.CheckUIStatus()
        end
        if imaxFact == 1 then
            --TODO: Not tested -- single fact selector
            local function Updated(l, text,item,state)
                caller.CheckUIStatus()
            end
            local strTip = strT or "Use the dropdown to choose a fact."
            local lblChooseFacts = MakeLabel{title = "Choose fact", tip = strTip}
            listFacts = MakeList{tip = strTip, values = tblFacts, action = Updated}
            return utils.choose(strOrientation == "HORIZONTAL",
                iup.hbox{lblChooseFacts, lstFacts},
                iup.vbox{lblChooseFacts, lstFacts}
            )
        else
            dlgFacts = MakeBoxChooseFacts()
            local strTip = strT or "Use the button to choose one or more facts."
            local btnChooseFacts = MakeButton{title="Fact(s)...", callback=FactsChoose, tip = strTip}
            txtFacts=MakeText{multiline="YES", readonly = "YES", expand = "YES", norm = norm2, tip = strTip}
            return utils.choose(strOrientation == "HORIZONTAL",
                iup.hbox{btnChooseFacts, txtFacts},
                iup.vbox{btnChooseFacts, txtFacts}
            )
        end
    end
    local ClearSelector = function()
        if imaxFact == 1 then
            listFacts.value = 0
        else
            listFacts.value = ""
            txtFacts.value = ""
            tblSelectedFacts = {}
            oldfacts = {}
        end
        if boxDetails then
            ClearContainer(boxDetails,{})
            RestrictedFields()
        end
    end
    local FactList = function()-- returns a table of facts 
        return utils.choose (imax ~= 1,  tblSelectedFacts, {listFacts[1]}) --return a table of fact names
    end

    local Details = function(strTitle, expanderstate, booNoteToggle, strNoteTip)
        local function DateDetails()
            local dtFactDate = nil --result of entry date operations
            local txtFactDate = nil
            local function DateChoose()
                dtFactDate = Date()
                if dtFactDate ~= nil then
                    txtFactDate.value = dtFactDate:GetDisplayText()
                else
                    txtFactDate.value= ""
                end
                booFactDateValid = true
                txtFactDate.fgcolor = TXTFGCOLOR
            end
            local function ValidateDate()
                txtFactDate.fgcolor = TXTFGCOLOR
                booFactDateValid = true
                local d = txtFactDate.value
                if type(d) == "string" then
                    dtFactDate, booFactDateValid = TestTextDate(d)
                end
                if not booFactDateValid then
                    txtFactDate.fgcolor = colorred
                end
            end
            local btnGetFactDate = MakeButton{title="Date...", callback=DateChoose, tip = "Use the button to call Family Historian's Date Entry Assistant, or type directly into the Date box.\nIf you type in an invalid date the text will turn red and you will not be able to use the fact details."}
            txtFactDate = MakeText{visiblelines = "1", killfocus=ValidateDate, norm = norm2, tip = "Use the button to call Family Historian's Date Entry Assistant, or type directly into the Date box.\nIf you type in an invalid date the text will turn red and you will not be able to use the fact details.", name = "factdate"}  
            return iup.hbox{btnGetFactDate, txtFactDate; alignment = "ACENTER"}
            --txtEntryDate.value will be returned as part of the Citation Data
        end

        local function AgeDetails()
            local dlgage = nil
            local txtage = nil
            local function MakeBoxChooseAge()

                local child, infant, stillborn, agebelow, younger, older = nil
                local txtyears, txtmonths, txtdays = nil

                local function AgeConfirm()
                    local strAge = ""
                    if child.value == "ON" then txtage.value = "Child" return end
                    if infant.value == "ON" then txtage.value = "Infant" return end
                    if stillborn.value == "ON" then txtage.value = "Stillborn" return end 

                    local y = txtyears.VALUE
                    local m = txtmonths.VALUE
                    local d = txtdays.VALUE
                    if type(y) == "string" and (y ~= "" and y ~= "0") then
                        strAge = strAge..y.." yr"
                        if tonumber(y) > 1 then strAge = strAge.."s" end
                        strAge = strAge.." "
                    end
                    if type(m) == "string" and (m ~= "" and m ~= "0") then
                        strAge = strAge..m.." mn"
                        if tonumber(m) > 1 then strAge = strAge.."s" end
                        strAge = strAge.." "
                    end
                    if type(d) == "string" and (d ~= "" and d ~= "0") then
                        strAge = strAge..d.." dy"
                        if tonumber(d) > 1 then strAge = strAge.."s" end
                    end
                    strAge = stringx.strip(strAge)
                    if strAge ~= "" then
                        if younger.value == "ON" then
                            strAge = "< "..strAge
                        elseif older.value == "ON" then 
                            strAge = "> "..strAge
                        end
                    end
                    txtage.value = strAge
                    return true
                end

                local btnChooseAge = MakeButton{title="Confirm Age", callback=AgeConfirm, close="YES"}
                local btnCancelAge = MakeButton{title="Cancel"}

                txtyears = MakeText{visiblelines="1", filter="NUMBER", norm=norm}
                txtmonths = MakeText{visiblelines="1", filter="NUMBER", norm=norm}
                txtdays = MakeText{visiblelines="1", filter="NUMBER", norm=norm}

                local boxYears= iup.hbox{MakeLabel{title = "Years"}, txtyears;}
                local boxMonths = iup.hbox{MakeLabel{title = "Months"}, txtmonths;}
                local boxDays = iup.hbox{MakeLabel{title = "Days"}, txtdays;}

                local agespec = iup.vbox{boxYears, boxMonths, boxDays;}
                agespec.active = "YES"
                txtyears.value = ""
                txtmonths.value = ""
                txtdays.value = ""

                local function ToggleAction1(state)
                    if state == 1 then agespec.active = "YES" else agespec.active = "NO" end
                end
                local function ToggleAction2(state)
                    if state == 1 then agespec.active = "NO" else agespec.active = "YES" end
                end

                child = MakeToggle{title="Child", action = ToggleAction1}
                infant = MakeToggle{title="Infant", action = ToggleAction1}
                stillborn = MakeToggle{title="Stillborn", action = ToggleAction1}
                agebelow = MakeToggle{title="Age given below", action = ToggleAction2}
                younger = MakeToggle{title="Younger than age given below", action = ToggleAction2}
                older = MakeToggle{title="Older than age given below", action = ToggleAction2}


                return MakeDialog(
                    iup.vbox{
                        iup.radio{iup.vbox{child,infant,stillborn,agebelow,younger,older}; value=agebelow},
                        agespec, 
                        iup.hbox{iup.fill{}, btnChooseAge, btnCancelAge};
                    }, 
                    {title = "Enter Age", menubox="NO", resize="NO"})

            end

            local function AgeChoose()
                dlgage:popup(iup.CENTERPARENT, iup.CENTERPARENT) 
            end

            dlgage = MakeBoxChooseAge()
            local btnSetAge = MakeButton{title="Age...", callback=AgeChoose, tip = "Use the button to open an Age Entry Assistant, or type directly into the age box.\nYou will not be able to enter an invalid age."}
            txtage = MakeText{visiblelines = "1", norm = norm2, tip = "Use the button to open an Age Entry Assistant, or type directly into the age box.\nYou will not be able to enter an invalid age.", name = "age"}
            txtage.mask = "^(Child|Infant|Stillborn|([<>]?((/d+)|(/d+y)|(/d+y/s/d+m)|(/d+y/s/d+m/s/d+d)|(/d+y/s/d+d)|(/d+m/s/d+d)|(/d+d))))"
            return iup.hbox{btnSetAge, txtage; alignment = "ACENTER"}
        end
        local function PlaceandAddressDetails()
            local txtSelPlace = "" -- selected place
            local txtSelPlace2 = "" --selected place2
            local txtSelAddress = "" --selected address
            local listPlace1 = nil
            local listPlace2 = nil
            local listAddress = nil
            local tblPlaces = Places() --place lookup data
            local tblAddresses = Addresses() -- address lookup data
            local function PlaceNew(p)
                if p ~= "" then --ignore empty strings
                    tblPlaces[p] = 1
                    listPlace1.APPENDITEM = p
                    listPlace2.APPENDITEM = p
                    listAddress.value = ""
                end
            end
            local function Place1LostFocus()
                --Place has lost focus so populate the address list if the place has changed
                local placevalue = stringx.strip(listPlace1.value)
                if placevalue == txtSelPlace then return end --place hasn't changed
                if type(placevalue) == "string" then
                    txtSelPlace = placevalue -- includes an empty string
                    if tblPlaces[txtSelPlace] == nil then
                        --this is a new place or an empty string
                        PlaceNew(txtSelPlace)
                    end
                    listPlace1.value = txtSelPlace
                else --no place Selected
                    txtSelPlace = ""
                end
                --populate addresses
                listAddress.REMOVEITEM = "ALL"
                PopulateList(listAddress,AddressesForPlace(txtSelPlace, tblAddresses))
            end
            local function Place2LostFocus()
                --Place 2 has lost focus so check whether a new place should be created
                local placevalue = stringx.strip(listPlace2.value)
                if placevalue == txtSelPlace2 then return end
                if type(placevalue) == "string" then
                    txtSelPlace2 = placevalue -- includes an empty string
                    if tblPlaces[txtSelPlace2] == nil then
                        --this is a new place or an empty string
                        PlaceNew(gtxtSelPlace2)
                    end
                    listPlace2.value = txtSelPlace2
                else --no place Selected
                    txtSelPlace2 = ""
                end
            end
            local function AddressLostFocus()
                --Address has lost focus so check whether a new address should be created
                local addressvalue = listAddress.value
                if type(addressvalue) == "string" then
                    txtSelAddress = stringx.strip(addressvalue) -- includes an empty string
                    if tblAddresses[txtSelPlace..txtSelAddress] == nil and txtSelAddress ~= "" then -- this is a new address
                        tblAddresses[txtSelPlace..txtSelAddress]={txtSelAddress, txtSelPlace}
                        listAddress.APPENDITEM = txtSelAddress
                    end
                    if txtSelAddress ~= addressvalue then listAddress.value = txtSelAddress end
                else --no place Selected
                    txtSelAddress = ""
                end
            end
            function ClearPlacesandAddress()
                txtSelPlace = "" -- selected place
                txtSelPlace2 = "" --selected place2
                txtSelAddress = "" --selected address
                PopulateList(listAddress,AddressesForPlace(txtSelPlace, tblAddresses))
            end


            listPlace1 = MakeList{editbox = "YES", values=tblPlaces, killfocus = Place1LostFocus, name = "place"}
            listPlace2 = MakeList{editbox = "YES", values=tblPlaces, killfocus = Place2LostFocus, tip = "Place 2 will only be used for facts that accept 2 places,\nsuch as Immigration and Emigration.", name = "place2"}
            listAddress = MakeList{editbox = "YES", killfocus = AddressLostFocus, values = AddressesForPlace("", tblAddresses), name = "address"}
            listPlace2.active = "NO"

            return iup.vbox{iup.hbox{MakeLabel{title = "Place"}, listPlace1;}, 
                iup.hbox{MakeLabel{title = "Address"}, listAddress;},
                iup.hbox{MakeLabel{title = "Place 2", tip = "Place 2 will only be used for facts that accept 2 places,\n: Immigration and Emigration."}, listPlace2;};}
        end
        local function AttributeDetails()
            local txtAttribute = MakeText{tip = "Attribute will only be used for facts which accept a value.", norm =norm2, name = "attributevalue"}
            txtAttribute.active = "NO"
            return iup.hbox{MakeLabel{title="Attribute", tip = "Attribute will only be used for facts which accept a value."}, txtAttribute;}
        end
        local function CauseDetails()
            local txtCause = MakeText{tip = "Cause will only be used for a death fact.", norm = norm2, name = "cause"}
            txtCause.active = "NO"
            return iup.hbox{MakeLabel{title="Cause", tip = "Cause will only be used for a death fact."}, txtCause;}
        end
        local function NoteDetails()
            local txtNote = MakeText{multiline="YES", scrollbar = "VERTICAL", visiblelines = "2", wordwrap = "YES", tip = strNoteTip, name = "factnote"}
            if booNoteToggle then
                local txtAppendNote = MakeToggle{title="Append note to Auto-create Note if one exists", value=0, name = "appendautonote"}
                return iup.vbox{MakeLabel{title = "Note",tip = strNoteTip}, txtNote, txtAppendNote;}
            else
                return iup.vbox{MakeLabel{title = "Note",tip = strNoteTip},txtNote;}
            end
        end       
        boxDetails = iup.vbox{
            iup.hbox{DateDetails(), AgeDetails()}, PlaceandAddressDetails(),
            iup.hbox{AttributeDetails(), CauseDetails()}, NoteDetails();
        }
        return MakeExpander(boxDetails, strTitle, expanderstate)
    end

    local DetailValues = function()
        local tblboxDetails = GetDataElements(boxDetails, {}, {}, true) --a table of controls keyed by control name
        return GetContainerData(tblboxDetails) -- factdate, age, place, address, place2, attributevalue, cause, factnote and appendautonote all as text
    end
    local DetailsValid = function()
        return booFactDateValid
    end
    local FactFields = function(pref)
        return tblFacts[pref]
    end
--expose public methods
    return{
        Selector = Selector,
        ClearSelector = ClearSelector, 
        FactList = FactList,
        Details = Details,
        DetailValues = DetailValues, 
        DetailsValid = DetailsValid,
        EnableRestrictedFactFields = EnableRestrictedFactFields,
        FactFields = FactFields
    }
end

function CallerFacts()
    local CheckUIStatus = function()
        AdjustButtons()
    end
    return {CheckUIStatus = CheckUIStatus}
end


---------------------------------------------------------------
--Results And Activity Log classes
---------------------------------------------------------------
--[[Results And Activity Log classes
@Author:      Helen Wright
@Version:      1.0
@LastUpdated:  27 October 2019
@V1.0:        Initial version.

Classes that manage an ongoing Activity Log in an expander; or manage the results display within FH when the plugin exsts.

Multiple instances of the activity log can be created, but only one instance of the Results class (FH limitation)

]]--
function ActivityLog ()
    require("iuplua") -- GUI
    --also requires Dialog boilerplate

    --public methods and associated private state variables
    local Log = nil --control for activity log
    local Update = function(strupdate)
        Log.append = strupdate
    end
    local Make = function (s,t)
        local state = s or "OPEN"
        local title = t or "Activity Log"
        Log = MakeText{multiline="YES", scrollbar = "BOTH", readonly="YES"}
        return MakeExpander(iup.vbox{Log;}, title, state)
    end

    --expose public methods
    return{Make=Make, Update = Update}

end
function Results (intTableCount)
    require("iuplua") -- GUI
    --also requires fh API and Dialog boilerplate

    --public methods and associated private state variables
    local iRes = 0 -- index used to track results
    local strTitle = ""
    local tblResults = {} --table of results tables
    local tblVisibility = {}
    local tblResultHeadings = {} 
    local tblResultType = {}
    for i = 1, intTableCount do
        tblResults[i] = {}
    end
    local Update = function(tblNewResults)
        iRes = iRes + 1
        for i, v in ipairs(tblNewResults) do
            tblResults[i][iRes] = v
        end
    end
    local Title = function(str)
        strTitle = str
    end
    local Types = function(types)
        tblResultType = tablex.copy(types)
    end
    local Headings = function(headings)
        tblResultHeadings = tablex.copy(headings)
    end
    local Visibility = function(visibility)
        tblVisibility =tablex.copy(visibility)
    end
    local Display = function()
        if iRes > 0 then -- there are results to display
            fhOutputResultSetTitles(strTitle)
            for i, _ in ipairs(tblResults) do
                local strV = utils.choose(tblVisibility[i]==true, "show", "hide")
                fhOutputResultSetColumn(tblResultHeadings[i], tblResultType[i], tblResults[i], iRes, 80 ,"align_left",i, true , "default", strV)
            end
        end
        fhUpdateDisplay()
    end

    --expose public methods
    return{Title = Title, Headings = Headings, Visibility = Visibility, Types = Types, Update = Update, Display = Display}
end

--------------------------------------------------------------
--MAIN DIALOG ACTIONS
--------------------------------------------------------------
function MakeMainDialog()
    local function FactsCreate()
        --check for invalid data
        if mySource.CitationOK() == false then
            Messagebox("Invalid date","Your citation has an invalid entry date so facts cannot be created. Please correct the date highlighted in red and retry",false)
            return
        elseif myFacts.DetailsValid() == false then
            Messagebox("Invalid date","Your fact details have an invalid fact date so facts cannot be created. Please correct the date highlighted in red and retry",false)
            return
        end
        local tblSources = mySource.SourceList() --only the first item will be used (it's a pointer)
        local tblCitation = mySource.CitationData() --user specified entrydate, quality, where, from, citationnote
        local tblIndividuals = myIndividuals.IndividualList()
        local tblFacts = myFacts.FactList()
        local tblFactDetails = myFacts.DetailValues() -- user specified factdate, age, place, address, place2, attributevalue, cause, factnote and appendautonote

        for _, ptrIndi in pairs(tblIndividuals) do
            for _, pref in pairs(tblFacts) do
                local tblFactFields = myFacts.FactFields(pref) --get results of parsing the current fact
                local tbldet = tablex.copy(tblFactDetails) --make a copy of the user specified details to amend based on validity of fields for the current fact
                for _, k in ipairs{"factdate", "age", "place", "address", "place2", "attributevalue", "cause", "factnote"} do --iterate the details needed to make the fact
                    if tblFactFields[k]=="0" then tbldet[k]="" end--clear fields not supported for the current fact
                end
                if tblFactFields["factnote"]=="1" and tbldet["appendautonote"]=="ON" then --add the autonote to the note (if any)
                    tbldet["factnote"] = tbldet["factnote"]..utils.choose(tbldet["factnote"] == "", "", "\n")..tblFactFields["autonote"]
                end
                local ptrFact = myFactTools.Create(tblFactFields["facttag"], ptrIndi, tbldet) --create the fact for the individual
                local ptrCitation = mySourceTools.Link(ptrFact, tblSources[1]) --Link the Source
                mySourceTools.CitationDetail(ptrCitation, tblCitation) --and populate the citation
                myActivity.Update(fhGetDisplayText(tblSources[1]).." | "..fhGetDisplayText(ptrIndi).." | ".. pref)
                myResults.Update({tblSources[1]:Clone(), ptrIndi:Clone(), pref, ptrFact:Clone()}) 
            end
        end


    end
    local function FactsClear()
        mySource.ClearSelector()
        myIndividuals.ClearSelector()
        myFacts.ClearSelector()
        EnableButtons({btnCreateFacts},"NO")
    end
--create buttons
    btnCreateFacts = MakeButton{title="Create Facts", callback = FactsCreate, tip = "Create facts using the specified data."}
    local btnExitFacts = MakeButton{title="Exit"}
    local btnClear = MakeButton{title="Clear", callback= FactsClear, tip = "Clear all selections and data."}  

    --make dialog
    local d = MakeDialog(iup.hbox{
            iup.vbox{
                mySource.Selector("Use the button to choose a source from within Family Historian.\nYou cannot create facts until you have selected a source.", "HORIZONTAL", "Citation (Optional). Any items specified here will be applied to all facts created."),
                iup.hbox{
                    myIndividuals.Selector("Use the button to choose one or more individuals from within FH.\nYou cannot create facts until you have selected one or more invididuals.","VERTICAL"),
                    myFacts.Selector("Use the button to choose one or more Facts.\n You cannot create facts until you have made a selection","VERTICAL") 
                },
                myFacts.Details("Fact details (Optional). Any items specified here will be applied to all facts created.", "OPEN", true, "If you specify a note, that will be added to the facts that are created.\n If you don't specify a note, and a fact has an AutoCreate Note defined,\nthe AutoCreate note will be created for that fact.\nYou can also choose to append this note to an AutoCreate note that is defined."),
                iup.hbox{iup.fill{}, btnClear, btnCreateFacts, btnExitFacts},
                myActivity.Make("CLOSE", "Facts created");
            }
        },
        {title = cstrPluginName})
    d.close_cb = function() 
        d.show_cb = nil --avoid iup bug
        return iup.CLOSE
    end
    DoNormalize() -- normalise all dialogs
    EnableButtons({btnCreateFacts},"NO")
    return d
end
function AdjustButtons()
    EnableButtons({btnCreateFacts},"NO")
    local sourcelist = mySource.SourceList()
    local indilist = myIndividuals.IndividualList()
    local flist = myFacts.FactList()
    myFacts.EnableRestrictedFactFields()
    if #sourcelist >= 1 and #indilist >= 1 and #flist >= 1 then
        EnableButtons({btnCreateFacts},"YES")
    end
end
-------------------------------------
--EXECUTE
-------------------------------------
do
    SetUTF8IfPossible()
    SetTextSize()

    myResults = Results(4) --initialise results handling with 4 results tables
    myResults.Title("Facts created")
    myResults.Headings({"Source", "Individual", "Fact", "Fact link"})
    myResults.Types({"item", "item", "text", "item"})
    myResults.Visibility({true,true,true,true})

    myActivity = ActivityLog()

    mySource = Source(1, false, false, true) --create a source object for a single source with a citation
    myIndividuals = Individuals(0) --create an individuals object for an unlimited set of individuals
    myFacts = Facts(0) --create a facts object for an unlimited set of facts

    dlgmain = MakeMainDialog()
    dlgmain:show()
    mySource.CitationState("CLOSE")
    iup.MainLoop()
    DestroyAllDialogs()
    myResults.Display()
end
--DONE

Source:Multifact-3.fh_lua