Research Planner.fh_lua

--[[
@Title: Research Planner 
@Author: Helen Wright
@Version: 2.0.1
@LastUpdated: 6 December 2019
@Description: Support creation of research tasks using a custom attribute or shared notes; optionally allows templates to support the creation of tasks in bulk
]]--

--[[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
 Version 2: Enhanced functionality, including config options, ability use shared Notes or Facts to record tasks, and  ability to create one-off tasks (not using templates).
 Version 2.0.1: Modified help file extraction method to work for Wine users

]]--
--[[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 cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME")
local cstrPluginVersion = "2.0"
local cstrFactName = "Task"
local cstrFactTag = ""

local myHelp = nil
local myConfig = nil
local myTemplates = nil
local indi = nil
local myResults = nil
local myActivity = nil
local myReports = nil

local strSep = ": "
local strRule = string.rep('_', 20).."\n"

--------------------------------------------------------------
--UTILITY CODE AND OBJECTS
--------------------------------------------------------------
--[[ 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.normalizergroup = options.norm or norm
            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
--[[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

--[[Routine to extract zip files]]--
do
    if not fhloadrequire("zip") then return end
function ExtractZip(zipPath, zipFilename, destinationPath)
    
    local zfile = assert(zip.open(zipPath.."\\"..zipFilename),"Failed to open zip file "..zipPath.."\\"..zipFilename) --open the zip file for processing

    local function CopyFile(file)
        local currFile, err = zfile:open(file.filename) --open a file within the zipfile
        local currFileContents = currFile:read("*a") -- read entire contents of current file
        local hBinaryOutput = io.open(destinationPath .."\\".. file.filename, "wb") --open an outputfile
        -- write current file inside zip to a file outside zip
        if hBinaryOutput then
            hBinaryOutput:write(currFileContents) --write the new file as a copy of the file within the zipfile
            hBinaryOutput:close() --close the new file
        end
        currFile:close() --close the file within the zipfile
    end

    -- iterate through each file inside the zip file
    for file in zfile:files() do
        local newdir = path.dirname(file.filename)
        if not path.exists(destinationPath.."\\"..newdir) then
            local destPath = destinationPath
            for nextdir in newdir:gmatch("[^/]+") do
                destPath = destPath.."\\"..nextdir
                lfs.mkdir(destPath)
            end
        end
        if path.basename(file.filename) ~= "" then CopyFile(file) end
    end

    zfile:close()
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
function FactSet (factsetname)
    --[[A class to create a single factset -- can be invoked many times (once per fact set)]]--

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

    --Prerequisites: FH API, Files boilerplate

    --initialise location variables

    local FactSetDirectory = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Fact Types\\Custom\\"
    local strfactsetfiletype = ".fhf"

    local cstrSaveLocation = fhGetPluginDataFileName("LOCAL_MACHINE",true).."\\"..factsetname..strfactsetfiletype
    local cstrInstallLocation = FactSetDirectory..factsetname..strfactsetfiletype

    --initialise Factset

    local FactCount = 0
    local FactDef = {}
    local tblFactIDs = {}

--TODO: Include handling of witness roles (not currently supported

--[[Tasks have a Name, Label (defaults to Name), Abbreviation (defaults to ""), 
   a FactType (Attribute or Event), a RecordType (Individual or Family),
   a Sentence template, default "{individual} experienced {label} {date} {place}"
   a Time frame (None, Pre-Birth, Birth, Shortly After Birth, Life - the default, Marriage, Post-Marriage, Death, Post-Death)
   optional Fields Date Age Place Address Note (all default to 1)
   Can be on the Fast-Add Menu (default N)
   Can be Hidden (default N)
   Can Use an Overide template string for fact tabs Listings (default N)
   Can use an Override template string for Record window Listings (default N)
   Can have an Auto-Note string (default blank)
]]--   

    local AddFact = function(Name, factoptions)

        --set contents according to options or defaults
        local Label = factoptions.Label or Name
        local Abbr = factoptions.Abbreviation or Name
        local Record = factoptions.Record or "I"
        local Type = factoptions.Type or "E"
        local Sentence = factoptions.Sentence or "{individual} experienced {label} {date} {place}"
        local Timeframe = string.upper(factoptions.Timeframe or "Life")
        local Date = factoptions.Date or "1"
        local Age = factoptions.Age or "1"
        local Place = factoptions.Place or "1"
        local Address = factoptions.Address or "1"
        local Note = factoptions.Note or 1
        local FastAdd = factoptions.FastAdd or "N"
        local Hidden = factoptions.Hidden or "N"
        local OverrideFactsTab = factoptions.OverrideFactsTab or ""
        local OverrideRecordWindow = factoptions.OverrideRecordWindow or ""
        local AutoNote = factoptions.AutoNote or ""

        FactCount = FactCount + 1
        local FactID = utils.choose(Type == Event, "EVEN-", "_ATTR-")..stringx.replace(string.upper(Name)," ","-")..[[-]]..Record..Type
        tblFactIDs[FactCount] = [[Item]]..FactCount..[[=]]..FactID

        local FactLines = {}
        table.insert(FactLines, "[FCT-"..FactID.."]")
        table.insert(FactLines, "Name="..Name)
        table.insert(FactLines, "Template="..Sentence)
        table.insert(FactLines, "Event Tab="..OverrideFactsTab)
        table.insert(FactLines, "Rec Win="..OverrideRecordWindow)
        table.insert(FactLines, "Label="..Label)
        table.insert(FactLines, "Abbr="..Abbr)
        table.insert(FactLines, "Timeframe=".. Timeframe)
        table.insert(FactLines, "Field Date="..Date)
        table.insert(FactLines, "Field Age="..Age)
        table.insert(FactLines, "Field Place="..Place)
        table.insert(FactLines, "Field Address="..Address)
        table.insert(FactLines, "Field Note="..Note)
        table.insert(FactLines, "Fast-Add Menu="..FastAdd)
        table.insert(FactLines, "Hidden="..Hidden)
        table.insert(FactLines, "[Text-FCT-"..FactID.."-Auto Note]")

        --now create the autonote lines, if any

        if stringx.strip(AutoNote) == "" then 
            table.insert(FactLines, "Count=0")
        else
            local tblAN = stringx.splitlines(AutoNote)
            local ANcount = #tblAN
            table.insert(FactLines, "Count="..ANcount)
            for i = 1, ANcount do
                if i == ANcount then 
                    table.insert(FactLines, "Line"..i.."=0;"..tblAN[i]..";") --last line in Autonote
                else
                    table.insert(FactLines, "Line"..i.."=n;"..tblAN[i]..";")
                end
            end
        end
        table.insert(FactLines, "[FCT-"..FactID.."-ROLE]")
        table.insert(FactLines, "Roles=0") --Witnesses not currently supported

        FactDef[FactCount]=table.concat(FactLines, "\n") --Add the fact definition to the table of fact definitions
    end
    local Save = function() --this will overwrite any existing fact set with the same name

        --first create the factset file contents

        local strFactSetPreamble = [[
[.index]
Ver1=4
Ver2=0
]]
        local strFileContents =
        strFactSetPreamble.."Count="..#tblFactIDs.."\n"..
        table.concat(tblFactIDs, "\n").."\n"..
        table.concat(FactDef, "\n").."\n"

        --now write it as a UTF16 file

        WriteUTF16File(strFileContents, cstrSaveLocation)

    end

    local Install = function()
        if FileExists(cstrSaveLocation) then --copy the file to the installation location
            CopyFile(cstrSaveLocation, cstrInstallLocation) --doing it this way preserve file name case
        end
    end

    local Download = function(downloadlocation)
        --TODO: NOT TESTED --should be used for factsets with a fixed content
        FileDownload(downloadlocation, cstrInstallLocation)
    end

    local Exists = function()
        return FileExists(cstrInstallLocation)
    end

    return{AddFact = AddFact, Save = Save, Install = Install, Download = Download, Exists = Exists}

end
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()
--[[HTML Help class

A class that provides support for a html help file

Only a single optional instance of the Help class is supported per plugin. Best choice if context sensitive help is required but uses more disk space usually

@Author:      Helen Wright
@V1.0:        Initial version.
@LastUpdated: 8 November 2019
]]--

function HTMLHelp(strV)

    if not fhloadrequire("pl","pl.init") then return end -- Load required Penlight modules
    pl = require("pl.import_into")
    require "iupluaweb"
    --  Also requires File handling boilerplate, Zipfile handling boilerplate 

    local strVersion = strV or "1.0"
    local cstrPluginDir = fhGetPluginDataFileName("LOCAL_MACHINE",true)
    local cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME")
    local HelpFileDirectory = cstrPluginDir.."\\"..cstrPluginName.." Help "..strVersion
    local HelpBrowserWindow = nil
    local HelpBrowserControl = nil
    local DialogTitle = cstrPluginName.." Help "..strVersion

    local function GetHelpFile()
        local downloadname = cstrPluginName.." Help "..strVersion
        local downloadlocation  = "http://www.fhug.org.uk/colevalleygirl/"..downloadname..".zip"
        local booStatus,_ = FileDownload(downloadlocation, cstrPluginDir.."\\"..downloadname..".zip") --download the zipped help file to the plugin data directory
        if booStatus == false then
            Messagebox("Download failed", "Failed to download the up-to-date Help file. The plugin will try again next time you run it, or (recommended if this error persists) you can download it yourself from "..downloadlocation.." and extract its contents to directory "..cstrPluginDir)
        else
            Messagebox("About to extract Help files", "About to extract Help files which may take some time; please wait")
            ExtractZip(cstrPluginDir, downloadname..".zip", cstrPluginDir)
            DeleteFile(cstrPluginDir.."\\"..downloadname..".zip")
        end
    end
    local function MakeHelpBrowser()
        local d = nil
        if HelpBrowserControl == nil then
            HelpBrowserControl = iup.webbrowser{}
        end
        if HelpBrowserWindow == nil then
            HelpBrowserWindow = MakeDialog(HelpBrowserControl,{title=DialogTitle})
            HelpBrowserWindow.size = "HALFxHALF"
            HelpBrowserWindow.close_cb = function()
                HelpBrowserWindow:hide()
                return iup.IGNORE
            end
        end
        HelpBrowserWindow:show()
    end

    if path.isdir(HelpFileDirectory) then
        --Up to date help file exists
    else --help file must be downloaded
        local tblOldHelpFiles = dir.getdirectories(cstrPluginDir)
        for _, v in ipairs(tblOldHelpFiles) do
            if stringx.beginswith(v, HelpFileDirectoryBase) then
                dir.rmtree(v)
            end
        end
        tblOldHelpFiles = dir.getfiles(cstrPluginDir, "*.chm")
        for _, v in ipairs(tblOldHelpFiles) do
            DeleteFile(v) --Delete all old help files
        end
        GetHelpFile() --Get the new help file
    end

    local Button = function(optnorm, topicpath)
--Topic path is the name within the help file of the topic required -- no changes to capitalisation or anything else
        local function ShowHelpFile()
            if path.isdir(HelpFileDirectory) == true then 
                topicpath = topicpath or "index"
                MakeHelpBrowser()
                HelpBrowserControl.value = HelpFileDirectory.."\\"..topicpath..".html"
            end
        end
        optnorm = optnorm or norm
        return utils.choose(path.isdir(HelpFileDirectory) == true, MakeButton{title="Help", callback=ShowHelpFile, tip = "Show help", norm = optnorm}, nil)
    end

    return {Button= Button}
end

--[[Config class

A Config class that provides support for a config dialog and storage for the config parameter

Only a single optional instance of the Config class is supported per plugin.

Uses a complementary class within the calling plugin to handle plugin-specific actions.

@Author:      Helen Wright
@V1.0:        Initial version.
@LastUpdated: 29 October 2019
]]
function Config (strV, HelpClass)
    --booH true if a help file is to be used; booO true if config data required; strV identifies the version of help file and/or config file to be used. 

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

        --also requires:
        --  fh API
        --  File handling boilerplate
        --  Dialog boilerplate
    end
    options = {} --make the options table global because it will be widely used

    --local constants

    local strVersion = strV or "1.0"
    local cstrPluginDir = fhGetPluginDataFileName("LOCAL_MACHINE",true)
    local cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME")
    local ConfigFile = cstrPluginDir.."\\"..cstrPluginName..".cfg"

    --private state variables

    local ctlContainer = nil
    local tblControls = {}
    local tblDefaults = {}
    local caller = nil
    local dlgoptions = nil

    --private methods

    local SaveConfig = function()
        options.ConfigVersion = strVersion
        local strConfig = "#"..cstrPluginName.." configuration "
        for key, val in pairs(options) do
            strConfig = strConfig.."\n"..key.."="..tostring(val)
        end
        WriteFile(strConfig, ConfigFile) -- write new config file
    end
    local DisplayConfig = function()
        for key, ctl in pairs(tblControls) do --update the control values
            ctl.value = options[key]
        end
    end
    local SetConfigToDefault = function()
        for key, val in pairs(tblDefaults) do
            options[key]=val
        end
        DisplayConfig()
        caller.ActionConfig()
        SaveConfig()
    end
    local MakePromptDialog = function()
        local function NewConfig()
            for key, ctl in pairs(tblControls) do --read the config from the controls and update tblConfig
                options[key]=ctl.value
            end
            caller.ActionConfig()
            SaveConfig()
            return true
        end
        local function CancelConfig()
            DisplayConfig() --reset config controls to last saved values which will already have been saved and actioned
            caller.ActionConfig()
            return true
        end
        local btnSaveConfig = MakeButton{title="Confirm", tip = "Confirm options", callback = NewConfig, close = "YES", norm = norm3}
        local btnResetConfig = MakeButton{title="Reset", tip = "Reset to default options", callback = SetConfigToDefault, norm = norm3}
        local btnCancelConfig = MakeButton{title = "Cancel", tip = "Discard changes and exit", callback = CancelConfig, close = "YES", norm = norm3}
        local btnHelp = utils.choose(HelpClass == nil, nil, HelpClass.Button(norm3, "options"))
        local btnBox = iup.vbox{btnHelp, btnSaveConfig, btnResetConfig, btnCancelConfig}
        dlgoptions = MakeDialog(iup.hbox{ctlContainer, btnBox},
            {title="Options", expand = "NO", resize = "NO", menubox="NO"})
        DoNormalize()
    end
    local PromptConfig = function()
        dlgoptions:popup(iup.CENTERPARENT, iup.CENTERPARENT)
    end
    --public methods
    local Initialise = function()
        ctlContainer = caller.GetContainer()
        tblControls = GetDataElements(ctlContainer, {}, {}, true)
        tblDefaults = caller.GetDefaults()
        MakePromptDialog()
        if options.ConfigVersion ~= strVersion then --the config file doesn't exist or isn't up-to-date
            --supplement what options exist if any with default values
            for key, val in pairs(tblDefaults) do
                if not options[key] then options[key] = val end
            end
            DisplayConfig()
            caller.ActionConfig()
            PromptConfig() --get user preferences and action them and save them
        else
            DisplayConfig()
            caller.ActionConfig()
        end 
        SaveConfig()
    end

    local OptionsButton = function ()
        return MakeButton{title = "Options", tip = "Select plugin options", callback = PromptConfig}
    end

    caller = CallerConfig()
    if FileExists(ConfigFile) then options, _ = config.read(ConfigFile, {convert_numbers = false}) end

    --expose public methods
    return{OptionsButton = OptionsButton, Initialise = Initialise}
end
function CallerConfig ()

    local tblD = {} -- default config values
    tblD.UseAttribute= "ON"
    tblD.UseNotes = "OFF"
    tblD.UseGroup = "ON"  
    tblD.GroupLabel = "Group"
    tblD.UseTag = "ON"
    tblD.PrefixTag = "#" --not a config option
    tblD.TagLabel = "RP"
    tblD.UseDueDate = "OFF"
    tblD.DueDateLabel = "Due" --not a config option
    tblD.UseUpdateDate = "OFF"
    tblD.UpdateDateLabel = "Updated" --not a config option
    tblD.UseFactDate = "OFF"
    tblD.FactDateLabel = "Fact date" --not a config option
    tblD.UseField1 = "ON"
    tblD.Field1Label = "Title"
    tblD.UseField2 = "ON"
    tblD.Field2Label="Status"
    tblD.UseField3 = "ON"
    tblD.Field3Label="Priority"
    tblD.UseField4 = "ON"
    tblD.Field4Label="Objective"
    tblD.UseField5 = "ON"
    tblD.Field5Label="Notes"

    --config field local variables put here for scope reasons

    local UseAttribute, UseNotes, 
    UseGroup, GroupLabel,
    UseTag, PrefixTag, TagLabel, 
    UseDueDate, DueDateLabel, 
    UseUpdateDate, UpdateDateLabel, 
    UseFactDate, FactDateLabel, 
    UseField1, Field1Label,
    UseField2, Field2Label,
    UseField3, Field3Label, 
    UseField4, Field4Label,
    UseField5, Field5Label,
    hboxReload
    = nil --config container fields put here for scope
    local GetContainer = function()
        --returns a container of config controls with Names equivalent to the config keys

        --make a gridbox, 2 columns wide
        local gboxOptions1 = MakeGridbox{}

        --make mechanism radio
        UseNotes = MakeToggle{title="Use Notes", name = "UseNotes", tip = "Create research tasks using shared notes"}
        UseAttribute = MakeToggle{title="Use Facts*", name = "UseAttribute", tip = "Create research tasks using a custom 'Task' attribute"}
        gboxOptions1:append(iup.radio{iup.vbox{UseAttribute, UseNotes}})

        --make Date Options
        UseDueDate = MakeToggle{title="Due Date", name = "UseDueDate", tip = "Include a field for due date in the task details"}
        UseUpdateDate = MakeToggle{title="Update Date", name = "UseUpdateDate", tip = "Include a field for updated date in the task details"}
        gboxOptions1:append(iup.vbox{UseDueDate, UseUpdateDate})

        --make Tag and Group Options

        UseTag = MakeToggle{title="Research Tag", name = "UseTag", tip = "Include a research tag in the task details"}
        TagLabel = MakeText{name = "TagLabel", tip = "String to use as a research tag. Will be prefixed with "..tblD.PrefixTag..". If no tag is specified it will default to "..tblD.TagLabel, mask = strMin1Letter, default = tblD.TagLabel}
        function UseTag:action(state)
            TagLabel.active = utils.choose(state==1, "YES", "NO")
        end
        UseGroup = MakeToggle{title="Group", name = "UseGroup", tip = "Include a group field in the task details (specified when you create the task)"}
        GroupLabel = MakeText{name = "GroupLabel", tip = "Word to identify the group field in the task details. If no value is specified it will default to "..tblD.GroupLabel, mask = strMin1Char, default = tblD.GroupLabel}
        function UseGroup:action(state)
            GroupLabel.active = utils.choose(state==1, "YES", "NO")
        end
        gboxOptions1:append(UseTag)
        gboxOptions1:append(TagLabel)
        gboxOptions1:append(UseGroup)
        gboxOptions1:append(GroupLabel)

        UseField1 = MakeToggle{title="Field1", name = "UseField1", tip = "Include a single line Field1 in the task details -- if using Attributes if will be treated as the Attribute value." }
        Field1Label = MakeText{name = "Field1Label", tip = "Word to identify Field1 in the task details. If no value is specified it will default to "..tblD.Field1Label, mask = strMin1Char, default = tblD.Field1Label}
        function UseField1:action(state)
            Field1Label.active = utils.choose(state==1, "YES", "NO")
        end
        gboxOptions1:append(UseField1)
        gboxOptions1:append(Field1Label)

        UseField2= MakeToggle{title="Field2", name = "UseField2", tip = "Include a single line Field2 in the task details"}
        Field2Label = MakeText{name = "Field2Label", tip = "Word to identify Field2 in the task details. If no value is specified it will default to "..tblD.Field2Label, mask = strMin1Char, default = tblD.Field2Label}
        function UseField2:action(state)
            Field2Label.active = utils.choose(state==1, "YES", "NO")
        end
        gboxOptions1:append(UseField2)
        gboxOptions1:append(Field2Label)

        UseField3 = MakeToggle{title="Field3", name = "UseField3", tip = "Include a single line Field3 in the task details"}
        Field3Label = MakeText{name = "Field3Label", tip = "Word to identify Field3 in the task details. If no value is specified it will default to "..tblD.Field3Label, mask = strMin1Char, default = tblD.Field3Label}
        function UseField3:action(state)
            Field3Label.active = utils.choose(state==1, "YES", "NO")
        end
        gboxOptions1:append(UseField3)
        gboxOptions1:append(Field3Label)

        UseField4= MakeToggle{title="Field4", name = "UseField4", tip = "Include a multiline Field4 in the task details"}
        Field4Label = MakeText{name = "Field4Label", tip = "Word to identify Field4 in the task details. If no value is specified it will default to "..tblD.Field4Label, mask = strMin1Char, default = tblD.Field4Label}
        function UseField4:action(state)
            Field4Label.active = utils.choose(state==1, "YES", "NO")
        end
        gboxOptions1:append(UseField4)
        gboxOptions1:append(Field4Label)

        UseField5 = MakeToggle{title="Field5", name = "UseField5", tip = "Include a multiline Field5 in the task details"}
        Field5Label = MakeText{name = "Field5Label", tip = "Word to identify Field5 in the task details. If no value is specified it will default to "..tblD.Field5Label, mask = strMin1Char, default = tblD.Field5Label}
        function UseField5:action(state)
            Field5Label.active = utils.choose(state==1, "YES", "NO")
        end
        gboxOptions1:append(UseField5)
        gboxOptions1:append(Field5Label)

        UseFactDate = MakeToggle{title="Fact Date", name = "UseFactDate", tip = "Include a date field in the task details"}

        gboxOptions1:append(UseFactDate)

        --make attribute warning
        hboxReload = iup.hbox{MakeLabel{title = "* If using Facts, you will need to restart FH to\ncreate tasks with the specified configuration\ndirectly within Family Historian", norm = ""}}

        function UseNotes:action(state) --disable/enable various fields if Notes is being used
            UseFactDate.active = utils.choose(state==1, "NO", "YES") --Notes don't have fact dates
            UseFactDate.value = utils.choose(state==1, "OFF", UseFactDate.value)
            UseTag.active = utils.choose(state==1, "NO", "YES") --force it to be inactive if Notes are being used
            TagLabel.active = utils.choose(state == 1, "YES", TagLabel.active)
        end

        return iup.vbox{gboxOptions1, hboxReload}
    end
    local GetDefaults = function()
        return tblD
    end
    local ActionConfig = function()
--adjusts the main plugin UI< state etc. (including the config dialog) according to the current options
        local function AdjustConfigDialog()
            GroupLabel.active = utils.choose(options.UseGroup == "ON", "YES", "NO")
            Field1Label.active = utils.choose(options.UseField1 == "ON", "YES", "NO")
            Field2Label.active = utils.choose(options.UseField2 == "ON", "YES", "NO")
            Field3Label.active = utils.choose(options.UseField3 == "ON", "YES", "NO")
            Field4Label.active = utils.choose(options.UseField4 == "ON", "YES", "NO")
            Field5Label.active = utils.choose(options.UseField5 == "ON", "YES", "NO")
            UseFactDate.active = utils.choose(options.UseNotes == "ON", "NO", "YES") --Notes don't have fact dates
            UseTag.active = utils.choose(options.UseNotes == "ON", "NO", "YES") --force it to be inactive if Notes are being used
            TagLabel.active = utils.choose(options.UseNotes == "ON", "YES", TagLabel.active)
        end
        local function AdjustTemplateTab()
            if dlgmain ~= nil then --Templates tab exists
                for i = 1,5 do
                    HideContainer(boxTemplateEdit[i], options["UseField"..i] == "OFF") --hide the container if it is no in use
                    boxTemplateEdit[i][1].title = options["Field"..i.."Label"] --modify the fild label
                end
            end
        end
        local function AdjustTasksTab()
            if dlgmain ~= nil then --Tasks tab exists
                HideContainer(boxGroupData, options.UseGroup == "OFF")
                boxGroupData[1].title = options.GroupLabel
                for i = 1,5 do
                    HideContainer(boxTaskEdit[i], options["UseField"..i] == "OFF") --hide the container if it is no in use
                    boxTaskEdit[i][1].title = options["Field"..i.."Label"] --modify the fild label
                end
                HideContainer(boxTFactDate, options.UseFactDate == "OFF")
                HideContainer(boxTDueDate, options.UseDueDate == "OFF")
            end           
        end
        local function MakeFactDefinition()
            local myFactSet = FactSet(cstrPluginName)

            local factsentence = [[{label}: <{value}> ]]..
            utils.choose(options.UseField2 =="OFF", "",
                [[{=CombineText( "[]]..options.Field2Label..[[: ", GetLabelledText( %FACT.NOTE2%, "]]..options.Field2Label..[[: " ), "]", "" )}]])..
            utils.choose(options.UseField3 =="OFF", "",
                [[{=CombineText( "[]]..options.Field3Label..[[: ", GetLabelledText( %FACT.NOTE2%, "]]..options.Field3Label..[[: " ), "]", "" )}]])


            local Anote = MakeNoteText{
                GroupValue = "",
                DueDateValue = "",
                UpdateDateValue = "",
                FactDateValue = "",
                IncludeIndividual = false,
                Individual = nil,
                Tag = options.TagLabel,
                UseField1 = options.UseField1, 
                Field1Value = "",
                Field1Value = "",
                Field2Value = "",
                Field3Value = "",
                Field4Value = "",
                Field5Value = "",
                Source = nil
            }

            local DateValue = utils.choose(options.UseFactDate =="OFF", 0, 1)

            local factparms = {Record="I", Type="A", 
                Sentence = factsentence, Timeframe = "Post-Death", Date = DateValue, Age = 0,
                Place = 0 ,Address = 0, Note = 1, FastAdd = "Y", Hidden = "N",
                OverrideFactsTab = factsentence,
                OverrideRecordWindow = factsentence,
                AutoNote = Anote}
            myFactSet.AddFact(cstrFactName,factparms)
            myFactSet.Save()                

            --Check if Fact exists
            local booFactExists = false
            local booFactSetExists = false
            booFactSetExists = myFactSet.Exists()
            if cstrFactTag ~= "" and not booFactSetExists then
                --the user has created their own fact so do nothing further
            else
                myFactSet.Install()
                booFactExists, cstrFactTag = myFactTools.Exists(cstrFactName, true, true)
            end
        end
        local function MakeQuery()
            function Query (strqtype, strtitle, strdescription, booReadOnly, strOrientation)

                --prerequisites: File handling boilerplate; Penlight libraries
                local cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME")
                local cstrPluginDir = fhGetPluginDataFileName("LOCAL_MACHINE",true)
                local QueryDirectory = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Queries\\Custom\\"
                local strqueryfiletype = ".fhq"
                local strQueryTitle = strtitle

                local QueryTypes = {}
                QueryTypes.Individual = "INDI"
                QueryTypes.Family="FAM"
                QueryTypes.Note = "NOTE"
                QueryTypes.Source = "SOURCE"
                QueryTypes.Repository = "REPO"
                QueryTypes.Submitter = "SUBM"
                QueryTypes.Submission = "SUBN"
                QueryTypes.Media = "OBJE"
                QueryTypes.Fact = "FACT"
                QueryTypes.PLace = "PLAC"


                local RowType = {}
                RowType["Add if"] = "ADD,IF"
                RowType["Add unless"] = "ADD,UNLESS"
                RowType["Exclude if"] = "EXC,IF"
                RowType["Exclude unless"] = "EXC,UNLESS"

                local Comparators = {}
                Comparators["matches"] = "="
                Comparators["comes after"] = "<"
                Comparators["comes before"] = ">"
                Comparators["begins with"]= "begins"
                Comparators["ends with"] = "ends"
                Comparators["contains"] = "contains"
                Comparators["is null"] = "null"
                Comparators["is true"] = "true"

                local RelTypes = {}
                RelTypes["Ancestor"] = "ANC"
                RelTypes["Descendant"] = "DESC"
                RelTypes["Spouse"] = "SPOUSE"

                --construct preamble
                local strPreamble = [[
[Family Historian Query]
VERSION=3.0
TYPE=QQQQ
DESC=DDDD
.
TITLE="YYYY"
SUBTITLE="%#x"
ORIENTATION="OOOO"
]]
                strPreamble = stringx.replace(strPreamble, "DDDD", strdescription)
                strPreamble = stringx.replace(strPreamble, "YYYY", strtitle)
                if booReadOnly then
                    strPreamble = stringx.replace(strPreamble, "TYPE=QQQQ", "TYPE=QQQQ\nATTR=READ_ONLY")
                end
                strPreamble = stringx.replace(strPreamble, "QQQQ", QueryTypes[strqtype]) 
                strPreamble = stringx.replace(strPreamble, "OOOO", string.upper(strOrientation))

                local tblcolumns = {}       
                --[[column format is 
    TAG = "header", "datareference", "type", "sort"
    header is a string
    datareference is a string (exactly as taken from the expression field within a query)
    optional type can be "HIDDEN" "BUDDY" or nil -- cannot be omitted
    optional sort can be "ASC" "DESC" or nil -- cannot be omitted
    ]]--

                local AddColumn = function(sheader,sdatareference, stype, ssort)
                    local str = [[TAG="]]..sheader..[[","]]..sdatareference..[["]]
                    if stype then str = str..[[,"]]..stype..[["]] end
                    if ssort then
                        str = str..utils.choose(stype,[[,]]..ssort, [[,,]]..ssort)
                    end
                    table.insert(tblcolumns,str)
                end
                local tblprompts = {}
                --[[Prompts are not an fH concept, but it can be useful to set a prompt and default for a field within a query.
    The Query class will create prompts before filters, so that the filters can refer to the values returned by prompts
    Prompt parameters are is:
        default is the default value -- use * for all and * for none and "" for no default
        Label is the string to use (followed by a prefix determined by the default) in the prompt and later filters        
    ]]--
                local AddPrompt = function(default, label)
                    local valuepromptsuffix =""
                    if default == "*" then 
                        valuepromptsuffix = " (* for all)"
                    elseif default == "-" then
                        valuepromptsuffix = " (- for none)"
                    elseif default ~= "" then
                        valuepromptsuffix =" ("..default..")"
                    end
                    local s = [[FILTER=GEN,EXC,IF,Y,"]]..label..valuepromptsuffix..[[","=Text(%INDI.NOTE[1000]%)",,"=",TEXT,"]]..default..[["]]
                    table.insert(tblprompts, s)
                end

                local tblfilters = {}
                --TODO: Not fully TESTED
                local AddGenFilter = function(rowtype, booparm, strparmlabel, expression, boomatchcase, comparator, valuetype, value)

                    local rowtext = RowType[rowtype]..","
                    local parametertext = utils.choose(booparm, "Y,", "N,")
                    local labeltext = utils.choose(booparm, [["]]..strparmlabel..[[",]],[["",]])
                    local expressiontext = [["]]..expression..[[",]]
                    local comparatortext = [["]]..Comparators[comparator]..[[",]]
                    local matchtext = utils.choose(boomatchcase, "MC", "")..[[,]]

                    local strRow = "FILTER=GEN,"..rowtext..parametertext..labeltext..expressiontext..matchtext..comparatortext
                    if valuetype ~= nil then strRow = strRow..string.upper(valuetype)..[[,]] end
                    if value ~= nil then strRow = strRow..utils.choose(valuetype=="NUMBER", value, [["]]..value..[["]]) end
                    strRow = stringx.rstrip(strRow,",") --remove any trailing commas
                    table.insert(tblfilters, strRow)

                end
                local AddRelFilter = function(rowtype, indichoice, strparmlabel, relationship, booincludeoriginal, booincludespouses, maxgen)
                    --TODO: NOT AT ALL TESTED

                    local rowtext = RowType[rowtype]..","
                    booparm = indichoice == "Individual"
                    local parametertext = utils.choose(booparm, "Y,", "N,")
                    local labeltext = utils.choose(booparm, [["]]..strparmlabel..[[",]],[["",]])
                    local relationshiptext = [["]]..RelTypes[relationship]..[[",]]
                    local originaltext = utils.choose(booincludeoriginal, "ORG", "")..[[,]]
                    local spousetext = utils.choose(booincludespouses, "SP", "")..[[,]]


                    local strRow = "FILTER=REL,"..rowtext..parametertext..labeltext..relationshiptext..originaltext..spousetext
                    if maxgen ~= nil then local strRow=strRow..maxgen end
                    strRow = stingx.rstrip(strRow,",") --remove any trailing commas
                    table.insert(tblfilters, strRow)
                end

                local AddListFilter = function(rowtype, list)
                    --TODO: NOT AT ALL TESTED
                    local rowtext = RowType[rowtype]..","
                    local parametertext = "N,"
                    local labeltext = ","
                    local listtext = [["]]..list..[["]]
                    local strRow = "FILTER=LST,"..rowtext..parametertext..labeltext..listtext
                    table.insert(tblfilters, strRow)
                end

                local Install = function()
                    local sQ = strPreamble.."\n"..table.concat(tblcolumns,"\n").."\n"..table.concat(tblprompts,"\n").."\n"..table.concat(tblfilters,"\n") --UTF8
                    WriteUTF16File(sQ, QueryDirectory..strQueryTitle..strqueryfiletype)
                end
                local Download = function(downloadlocation, strQueryTitle)
                    --TODO: NOT TESTED
                    FileDownload(downloadlocation, QueryDirectory..strQueryTitle..strqueryfiletype) 
                end
                return {AddColumn = AddColumn, AddPrompt = AddPrompt, AddGenFilter = AddGenFilter, AddRelFilter= AddRelFilter, AddListFilter = AddListFilter, Install = Install, Download = Download}
            end
            local strQueryType = utils.choose(options.UseNotes == "ON", "Note", "Fact")
            local strTextField = utils.choose(options.UseNotes == "ON", "%NOTE.TEXT%", "%FACT.NOTE2%")
            local myQuery = Query(
                strQueryType, 
                "Research Tasks ("..strQueryType.."s)",
                "All Research Tasks ("..strQueryType.."s) optionally filtered by various criteria",
                true, "LANDSCAPE"
            )
            do --make columns
                if strQueryType == "Note" then --identify task
                    myQuery.AddColumn("Individual", 
                        [[=Record(TextToNumber(GetLabelledText(%NOTE.TEXT%,""Individual: "")),""I"")]],nil,"ASC")
                    myQuery.AddColumn("Note", "%NOTE%", nil, "ASC")
                else
                    myQuery.AddColumn("Individual","=FactOwner(%FACT%,1,MALES_FIRST)",nil,"ASC")
                    myQuery.AddColumn("Task", "FACT",nil,"ASC")
                end
                for _, f in ipairs{"Group", "Tag", "FactDate", "DueDate", "UpdateDate", "Field1", "Field2", "Field3", "Field4"} do
                    if options["Use"..f] == "ON" then
                        if f == "FactDate" then
                            myQuery.AddColumn("Date","FACT.DATE")
                        elseif f == "UpdateDate" then
                            myQuery.AddColumn("Updated", 
                                utils.choose(strQueryType=="Note", 
                                    [[=LastUpdated()]], 
                                [[=GetLabelledText(%FACT.NOTE2%,""Updated: "")]])
                            )
                        elseif f == "Tag" then
                            myQuery.AddColumn("Tag",[[=Text(""]]..options.PrefixTag..[["".GetLabelledText(]]..strTextField..[[,""]]..options.PrefixTag..[[""))]])
                        else
                            if strQueryType == "Note" or f ~= "Field1" then
                                myQuery.AddColumn(options[f.."Label"],
                                    [[=GetLabelledText(]]..strTextField..[[,""]]..options[f.."Label"]..[[: "")]])
                            end
                        end
                    end
                end
                myQuery.AddColumn("Source",string.upper(strQueryType)..".SOUR>")
                myQuery.AddColumn("Repository", string.upper(strQueryType)..".SOUR>REPO>")
            end
            do --make defaults/prompts  
                local tblDefaults = {}
                for _,f in ipairs{"Group","Tag", "Field2", "Field3"} do
                    if options["Use"..f] == "ON" then
                        local strLabel = utils.escape(utils.choose(f == "Tag", "Tag", options[f.."Label"]))
                        myQuery.AddPrompt("*", strLabel)
                    end
                end
                for _, f in ipairs{"Task", "Source", "Repository"} do
                    myQuery.AddPrompt("-", f.." words")
                end
            end           
            do --make filters
                for _,f in ipairs{"Tag","Group", "Field2", "Field3"} do
                    if options["Use"..f] == "ON" then
                        local expression ='=IsTrue((Text([""LLLL (* for all)""]) = ""*"") or (GetLabelledText(%NOTE.TEXT%,""AAAA"") = [""LLLL (* for all)""]))'
                        expression = stringx.replace(expression, "LLLL", utils.escape(utils.choose(f == "Tag", "Tag", options[f.."Label"])))
                        expression = stringx.replace(expression, "AAAA", utils.escape(utils.choose(f == "Tag", options.PrefixTag, options[f.."Label"]..": ")))
                        myQuery.AddGenFilter("Exclude unless", false, "", expression, false, "is true")
                    end
                end
                myQuery.AddGenFilter("Exclude unless", false, "", '=IsTrue((Text([""Task words (- for none)""]) = ""-"") or ContainsText(%'..string.upper(strQueryType)..'%,[""Task words (- for none)""],STD))', false, "is true")
                myQuery.AddGenFilter("Exclude unless", false, "", '=IsTrue((Text([""Source words (- for none)""])= ""-"") or ContainsText(%'..string.upper(strQueryType)..'.SOUR>%,[""Source words (- for none)""],STD))', false, "is true")
                myQuery.AddGenFilter("Exclude unless", false, "", '=IsTrue((Text([""Repository words (- for none)""]) = ""-"") or ContainsText(%'..string.upper(strQueryType)..'.SOUR>REPO>%,[""Repository words (- for none)""],STD))', false, "is true")

                if strQueryType == "Fact" then
                    myQuery.AddGenFilter("Exclude unless", false, "", "=FactLabel(%FACT%)", false, "matches", "TEXT", "Task") --filter on Task facts only
                end
            end
            myQuery.Install() 
        end
        local function AdjustToolsTab()
            HideContainer(htoolsFactsBox, options.UseAttribute == "OFF")
            HideContainer(htoolsNotesBox, options.UseNotes == "OFF")
        end
        local function AdjustResults()
            --set Results parameters to defaults for creating tasks -- if any of the options on tabTools are used, they will override this
            myResults.Types({"item","item","text","text","text","text","text","text","text","text","text","item","item"})
            myResults.Headings({"Individual", "Task", 
                    "Tag", options.GroupLabel, options.FactDateLabel, 
                    options.DueDateLabel, options.UpdateDateLabel, 
                    options.Field1Label, options.Field2Label, 
                    options.Field3Label,options.Field4Label, 
                    "Source", "Repository"})
            myResults.Visibility({true, true,
                    options.UseTag == "ON", options.UseGroup == "ON",
                    options.UseFactDate == "ON", options.UseDueDate =="ON", options.UseUpdateDate == "ON", 
                    options.UseField1 =="ON" and options.UseNotes == "ON", options.UseField2 == "ON", 
                    options.UseField3 == "ON", options.UseField4 == "ON", 
                    true, true})
        end
        AdjustConfigDialog()
        AdjustTemplateTab()
        AdjustTasksTab()
        _, cstrFactTag = myFactTools.Exists(cstrFactName, true, true)
        if options.UseAttribute == "ON" then MakeFactDefinition() end
        MakeQuery()
        AdjustToolsTab()
        AdjustResults()
    end

    return{
        GetContainer = GetContainer,
        GetDefaults = GetDefaults,
        ActionConfig = ActionConfig
    }
end

--[[Template handling  class

A Templates class that implements a set ot text-based template deinitions. The contents of the templates are determined by the calling plugin, which interacts with the template class via defined methods and makes plug-in specific features (i.e. non text-fields) available via an associated PluginTemplates class.

Multiple instances of the Templates class can be created by a plugin, as long as the plugin specifies a separate subdirectory to hold templates other than the first set (which are held in the main plugin directory.

Templates can be defined to be Global (available to all fH projects), or Project (available to a single project only).  Project templates will typically be required when a template includes project-specific data such as a source identifier.

Use as follows

MyTemplate = Template(initvalue) to instantiate
MyTemplate.DoSomething(parms) to call a method

TODO: (future enhancement): the ability to use templates that include tokens (text substitions when the template is used, with default values)

@Author:      Helen Wright
@V1.0:        Initial version.
@LastUpdated: 27 October 2019
]]--
function Templates (booAllowProjectTemplates, subdir, booTokens)

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

        -- fH API boilerplate
        -- Dialog boilerplate
        -- File handling boilerplate
    end

    local caller = CallerTemplates(subdir) --initialise caller class

    --variables and functions for handling global and project templates
    local booProjectTemplatesAllowed = booAllowProjectTemplates
    local strGlobalTemplateDir = fhGetPluginDataFileName("LOCAL_MACHINE",true)
    local strProjectTemplateDir = utils.choose(booProjectTemplatesAllowed, fhGetPluginDataFileName("CURRENT_PROJECT", true), nil) 
    if subdir then
        strGlobalTemplateDir = path.join(strGlobalTemplateDir, subdir)
        if not path.isdir(strGlobalTemplateDir) then
            path.makepath(strGlobalTemplateDir)
        end
        if booProjectTemplatesAllowed then
            strProjectTemplateDir = path.join(strProjectTemplateDir, subdir)
            if not path.isdir(strProjectTemplateDir) then
                path.makepath(strProjectTemplateDir)
            end
        end
    end
    local function TemplatePath(boop) --returns directory
        return utils.choose(boop,strProjectTemplateDir,strGlobalTemplateDir)
    end

    --data and function for Template details
    local tblTemplates = {} --keyed by Template Name which is unique; values are Template Directory and boolean 'IsProjectTemplate'
    local tblSelectors = {} --list of selectors that have been created using tblTemplates
    local GetTemplates = function()
        local tcount = 1
        local tname = ""
        tblTemplates = {}
        for _, t in ipairs(dir.getfiles(strGlobalTemplateDir, "*.dat")) do
            tname = path.basename(path.splitext(t)) --isolate the file name which will be the template name
            tblTemplates[tname] = {strGlobalTemplateDir, false}
            tcount = tcount+1
        end
        if booProjectTemplatesAllowed then
            for _, t in ipairs(dir.getfiles(strProjectTemplateDir, "*.dat")) do
                tname = path.basename(path.splitext(t)) --isolate the file name which will be the template name
                tblTemplates[tname] = {strProjectTemplateDir, true}
                tcount = tcount+1
            end
        end  
    end
    local function RepopulateSelectors()
        for _, l in ipairs(tblSelectors) do
            --save current selection
            if l.MULTIPLE == "YES" then
                local ltbl = GetSelectedValues(l)
                PopulateList(l, tblTemplates) 
                SetSelectedValues(l, ltbl)
            else
                local lvalue = GetSingleValue(l)
                PopulateList(l, tblTemplates)
                GoToInList(lvalue, l)
            end
        end
    end

    GetTemplates() -- populate the template details when class is instantiated
    local PromptTemplateName = function(prompt, default) --returns string
        local booOK, strName = GetText(prompt, default, strAlphaNumeric)
        return utils.choose(booOK, stringx.strip(strName), "")
    end
    local PromptTemplateNameAndType = function (prompt, default) --returns boolean, string
        local booOK, strName, booProject = GetTextandTick(prompt, default, strAlphaNumeric, "Project template?")
        return booProject, utils.choose(booOK, stringx.strip(strName), "")
    end
    local function PromptName() --returns name and project indicator
        local boop = false
        local name = ""
        if booProjectTemplatesAllowed then
            boop, name = PromptTemplateNameAndType("Enter new name", "")
        else -- get target name
            name = PromptTemplateName("Enter new name", "")
        end
        return boop, name
    end
    local function SaveTemplate(values, name, directory)
        table.save(values, path.join(directory, name.."\.dat")) --save the file
    end
    local function ReadTemplate(name) --returns table of values
        return table.load(path.join(tblTemplates[name][1], name.."\.dat"))
    end
    local function AddTemplate(values, name, booproject)
        SaveTemplate(values, name, TemplatePath(booproject))
        GetTemplates() --repopulate the template data
        RepopulateSelectors()
    end
    local function DeleteTemplate(name)
        DeleteFile(path.join(tblTemplates[name][1], name.."\.dat"))
        GetTemplates() --repopulate the template data
        RepopulateSelectors()
    end
    local function RenameTemplate(oldname, newname)
        --rename the file
        local oldpath = path.join(tblTemplates[oldname][1], oldname.."\.dat")
        local newpath = path.join(tblTemplates[oldname][1], newname.."\.dat")
        RenameFile(oldpath, newpath)
        GetTemplates() --repopulate the template data
        RepopulateSelectors()
    end
    local TemplatesExist = function() --returns boolean
        return tablex.size(tblTemplates) > 0
    end

    --data and functions for Template container handling
    local function TemplateGetFromContainer(container) --returns table of values
        local tblvalues = GetContainerData(GetDataElements(container, {},{}, false))
        return caller.Get(tblvalues, container)       
    end
    local function TemplateLoadToContainer(container, tblvalues)
        for k, v in ipairs(GetDataElements(container, {},{}, false)) do
            v.VALUE = tblvalues[k]
        end
        caller.Load(tblvalues, container)
        EnableContainer(container, {}, "YES")
    end
    local function ContainerClear(container)
        ClearContainer(container, {})
        caller.Clear(container)
    end

    --data for Template selection controls

    --methods for selecting a single template
    local btnUseNew =nil
    local btnUseUpdate = nil
    local UseContainer = nil --iup container for the use control
    local ListUse = nil
    local UseSelector = function(container) --returns list
        UseContainer = container
        local function Use(ctl, name) --load template into UseContainer
            local values = ReadTemplate(name)
            local booProject = tblTemplates[name][2]
            TemplateLoadToContainer(UseContainer, values)
            btnUseNew.active = "ON"
            btnUseUpdate.active = "ON"
            caller.Display(booProject, UseContainer)
            caller.CheckUIStatus()
        end
        local function New()
            local booProject, strNewName = PromptName()
            if strNewName ~= "" then --not cancelled
                if tblTemplates[strNewName] then --this is a duplicate name
                    Messagebox("New template failed","A template called "..strNewName.." already exists", false) 
                    return
                end
                local booOK, strError = caller.OK(booProject, UseContainer)
                if booOK then
                    AddTemplate(TemplateGetFromContainer(UseContainer), strNewName, booProject)
                    GoToInList(strNewName, ListUse)
                    btnUseNew.active = "ON"
                    btnUseUpdate.active = "ON"
                    caller.Display(booProject, UseContainer)
                    caller.CheckUIStatus()
                    Messagebox("New template succeeded","Template "..strNewName.." created",false)  
                else
                    Messagebox("Save failed","Cannot save "..strNewName.." - "..strError, true)
                end
            end
        end
        local function Update()
            local strname = GetSingleValue(ListUse)
            local booOK, strError = caller.OK(tblTemplates[strname][2],UseContainer)
            if booOK then
                SaveTemplate(TemplateGetFromContainer(UseContainer), strname, tblTemplates[strname][1])
            else
                Messagebox("Save failed","Cannot save "..strname.." - "..strError, true)
            end
        end

        ListUse = MakeList{values = tblTemplates, action = Use, 
            tip = "Choose a template"}
        table.insert(tblSelectors, ListUse)
        btnUseNew = MakeButton{title = "New", callback = New, tip = "Create a new template from the contents"}
        btnUseUpdate = MakeButton{title = "Update", callback = Update, tip = "Update the template from the contents"}
        btnUseUpdate.active = "OFF"
        btnUseNew.active = "ON"
        return iup.vbox{iup.hbox{MakeLabel{title = "Template", tip = cstrTemplateTip}, ListUse}, iup.hbox{iup.fill{}, btnUseNew, btnUseUpdate};}
    end
    local ClearUseSelector = function()
        ContainerClear(UseContainer)
        ListUse.value = 0
        btnUseNew.active = "ON"
        btnUseUpdate.active = "OFF"
    end
    local GetUseName = function()
        return GetSingleValue(ListUse)
    end
    local GetUseValues = function()
        return TemplateGetFromContainer(UseContainer)
    end

--methods for selecting templates in bulk

    local listBulk = nil 
    local BulkSelector= function() --returns list
        local function Action()
            caller.CheckUIStatus()
        end
        listBulk = MakeList{dropdown="NO", visiblelines = "20", multiple = "YES", 
            values = tblTemplates, action = Action, tip = "Choose one or more templates"}
        table.insert(tblSelectors, listBulk)
        return listBulk
    end
    local ClearBulkSelector = function()
        MultiListSelectionClear(listBulk)
        caller.CheckUIStatus()        
    end
    local BulkTemplates = function() --returns successive tables of values
        --iterator through selected templates
        local t = GetSelectedValues(listBulk)
        local contents = ""
        local k, v = next(t, nil) --v is a name
        return function ()
            if k then
                contents = ReadTemplate(v)
                k, v =next(t, k)
                return contents
            end
        end
    end
    local BulkTemplatesAreSelected = function() --returns boolean
        return MultiListSelectionTrue(listBulk)
    end
--methods and controls to manipulate template definitions

    local EditContainer = nil -- iup container for the edit control
    local tblLastSaved = nil
    local strLastEdited = ""
    local tblbtnTemplates = {}
    local listEdit = nil
    local function Save()
        local booOK, strError = caller.OK(tblTemplates[strLastEdited][2],EditContainer)
        if booOK then
            tblLastSaved = TemplateGetFromContainer(EditContainer)
            SaveTemplate(tblLastSaved, strLastEdited, tblTemplates[strLastEdited][1])
        else
            Messagebox("Save failed","Cannot save "..strLastEdited.." - "..strError, true)
        end
    end
    local function CheckEditChanges() --returns boolean
        local values = TemplateGetFromContainer(EditContainer)
        if tablex.compare(values, tblLastSaved, "==") then return true end --no changes since last save
        if Messagebox("Unsaved template changes","You have template changes to save. Press OK to Save those changes and Continue, or Cancel to Discard them and Continue", true) == "OK" then               
            local booOK, strError = caller.OK(EditContainer)
            if not booOK then --cannot save so will not continue
                Messagebox("Save failed","Cannot save "..strLastEdited.." - "..strError.." so will not continue with selected operation", true)
                return false--abort
            end
            Save() --save changes
        else
            TemplateLoadToContainer(EditContainer, tblLastSaved) --restore last saved value
        end
        return true --continue
    end
    local EditSelector = function(container) --returns vbox
        local cstrProjectTemplate = "Project template"
        local cstrGlobalTemplate = "Global template"
        local labProjectTemplate = MakeLabel{title="", norm = norm2}
        local ctlProjectTemplateContainer = iup.hbox{labProjectTemplate}
        local function SetTemplateLabel(boop)
            labProjectTemplate.title = utils.choose(boop, cstrProjectTemplate, cstrGlobalTemplate)
        end
        local function Edit(ctl, name)
            if name == strLastEdited then return end --nothing changed
            if CheckEditChanges() then
                local values = ReadTemplate(name)
                local booProject = tblTemplates[name][2]
                TemplateLoadToContainer(EditContainer, values)
                SetTemplateLabel(booProject)
                EnableButtons(tblbtnTemplates, "YES")
                caller.Display(booProject, EditContainer)
                strLastEdited = name
                tblLastSaved = values
            else
                GoToInList(strLastEdited, listEdit) -- return to the previous selection
            end            
        end
        local function New()
            if CheckEditChanges() then
                local booProject, strNewName = PromptName()
                if strNewName ~= "" then --not cancelled
                    if tblTemplates[strNewName] then --this is a duplicate name
                        Messagebox("New template failed","A template called "..strNewName.." already exists", false) 
                        return
                    end
                    ContainerClear(EditContainer)
                    tblLastSaved = TemplateGetFromContainer(EditContainer)
                    AddTemplate(tblLastSaved, strNewName, booProject)
                    GoToInList(strNewName, listEdit)
                    SetTemplateLabel(booProject)
                    EnableButtons(tblbtnTemplates, "YES")
                    EnableContainer(EditContainer, {}, "YES")
                    caller.Display(booProject, EditContainer)
                    caller.CheckUIStatus()
                    Messagebox("Create succeeded","Template "..strNewName.." created",false)
                    strLastEdited = strNewName                   
                end
            end
        end
        local function Copy()
            local strOldName = GetSingleValue(listEdit)
            if CheckEditChanges() then
                local booProject, strNewName = PromptName()
                if strNewName ~= "" then --not cancelled
                    if tblTemplates[strNewName] then --this is a duplicate name
                        Messagebox("Copy template failed","A template called "..strNewName.." already exists", false) 
                    else
                        AddTemplate(tblLastSaved, strNewName, booProject)
                        GoToInList(strNewName, listEdit)
                        Messagebox("Copy template succeeded", strOldName.." copied to "..strNewName, false)
                        strLastEdited = strNewName
                    end
                end
            end
        end
        local function Rename()
            local strNewName=PromptTemplateName("Enter new name","") --can't change type of an existing template
            local strOldName = GetSingleValue(listEdit)
            if strNewName ~= "" then --not cancelled
                if tblTemplates[strNewName] then --this is a duplicate name
                    Messagebox("Rename template failed","A template called "..strNewName.." already exists", false) 
                else
                    RenameTemplate(strOldName, strNewName)
                    GoToInList(strNewName, listEdit)
                    Messagebox("Rename template succeeded", strOldName.." renamed to "..strNewName, false)
                    strLastEdited = strNewName
                end
            end           
        end
        local function Delete()
            local name = GetSingleValue(listEdit)
            if Messagebox("Confirm template deletion","Confirm deletion of "..name, true) == "OK" then
                DeleteTemplate(name)
                ContainerClear(EditContainer)
                EnableContainer(EditContainer, {}, "NO") --no selected template
                SetTemplateLabel(false) --default is not a project
                caller.CheckUIStatus()
                Messagebox("Delete template succeeded","Template "..name.." deleted",false)
                strLastEdited = ""
                tblLastSaved = TemplateGetFromContainer(EditContainer)
            end
        end

        local function MakeButtons() --returns hbox
            local btnCopy = MakeButton{title="Copy", callback = Copy, tip = "Copy selected template"}
            local btnRename = MakeButton{title="Rename", callback = Rename, tip = "Rename selected template"}
            local btnDelete = MakeButton{title="Delete", callback = Delete, tip = "Delete selected template"}
            local btnSave = MakeButton{title="Save", callback = Save, tip = "Save changes to selected template"}
            tblbtnTemplates = {btnCopy, btnSave, btnRename, btnDelete}
            EnableButtons(tblbtnTemplates, "NO") -- can't do any of this until a template is selected       
            return iup.hbox{iup.fill{}, btnCopy, btnSave, btnRename, btnDelete, iup.fill{}}
        end

        local cstrTemplateTip = "Choose a template to modify or view"
        EditContainer = container
        tblLastSaved = TemplateGetFromContainer(EditContainer) --empty values
        EnableContainer(EditContainer, {}, "NO")
        listEdit = MakeList{values=tblTemplates, action = Edit, tip = cstrTemplateTip}
        table.insert(tblSelectors, listEdit)
        local vbox =  iup.vbox{
            iup.hbox{MakeLabel{title = "Template", tip = cstrTemplateTip},
                listEdit, 
                MakeButton{title="New", callback = New, tip = "Create new template"}; 
                alignment = "ACENTER"},
            ctlProjectTemplateContainer, 
            MakeButtons()
        }
        SetTemplateLabel(false) --default is not a project
        if booProjectTemplatesAllowed == false then 
            HideContainer(ctlProjectTemplateContainer) 
        end
        return vbox
    end
    local function CheckForUnsavedEditChanges()
        if CheckEditChanges() then caller.CheckUIStatus() return true end
        return false
    end
    local function ClearEditSelector()
        ContainerClear(EditContainer)
        tblLastSaved = TemplateGetFromContainer(EditContainer)
        strLastEdited = ""
        listEdit.value = 0
        EnableButtons(tblbtnTemplates, "NO")
        caller.CheckUIStatus()
    end

--expose public methods
    return{TemplatesExist = TemplatesExist,
        UseSelector = UseSelector,
        ClearUseSelector = ClearUseSelector,
        GetUseName = GetUseName,
        GetUseValues = GetUseValues,
        BulkSelector = BulkSelector,
        BulkTemplates = BulkTemplates,
        BulkTemplatesAreSelected = BulkTemplatesAreSelected,
        ClearBulkSelector = ClearBulkSelector,
        EditSelector = EditSelector,
        ClearEditSelector = ClearEditSelector,
        CheckForUnsavedEditChanges = CheckForUnsavedEditChanges}
end

function CallerTemplates(subdir)

    local source = nil

    local function IdentifySource(container)
        return utils.choose(container == boxTemplateEdit, TemplateSource, TaskSource)
    end
    --public methods
    local Display = function(boop, container)
        source = IdentifySource(container)
        source.DisableSelector(not boop and container ~= boxTaskEdit)
    end
    local Load = function(tblvalues, container)
        --handle the source as the last item in the template
        local source = IdentifySource(container)
        if tblvalues[#tblvalues] ~= "" then
            local ptr = fhNewItemPtr()
            ptr:MoveToRecordById('SOUR', tblvalues[#tblvalues])
            source.Load({ptr})
        else
            source.ClearSelector()
        end
    end
    local Get = function(tblvalues, container) --returns a set of values suitable to save as a template
        local source = IdentifySource(container)
        --now handle the source as the last item in the template
        local tbls = source.SourceList()
        if #tbls > 0 then
            tblvalues[#tblvalues] = fhGetRecordId(tbls[1])
        else
            tblvalues[#tblvalues] = ""
        end
        return tblvalues
    end
    local Clear = function(container)
        local source = IdentifySource(container)
        source.ClearSelector()
        EnableContainer(container, {}, utils.choose(container == boxTemplateEdit, "NO", "YES"))
    end
    local OK = function(boop, container) --returns boolean and optional error message
        local source = IdentifySource(container)
        local slist = source.SourceList()
        local booOK = boop == true or #slist == 0
        local strError = utils.choose(booOK, "", "A global template cannot specify a source")
        return booOK, strError
    end
    local InitialiseTemplate = function(boop, container)
        return --do nothing
    end
    local CheckUIStatus = function()
        AdjustButtons()
    end
    return{
        Display = Display,
        Load = Load,
        Get = Get,
        Clear = Clear,
        OK = OK,
        InitialiseTemplate = InitialiseTemplate,
        CheckUIStatus = CheckUIStatus, 
    }
end
--[[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()
        return --no need to do anything
    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 = "HORIZONTAL", norm = norm2, tip = strTip, multiline = utils.choose(imaxIndividuals ~= 1, "YES", "NO")}
        Initialise()
        btnIndividuals = MakeButton{title="Individual...", callback=Choose, tip = strTip}
        if Orientation == "HORIZONTAL" then
            return iup.hbox{btnIndividuals, txtIndividuals; alignment = "ACENTER"}
        else
            return iup.vbox{btnIndividuals, txtIndividuals; alignment = "ACENTER"}
        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

--[[Results And Activity Log classes
@Author:      Helen Wright
@Version:      1.0
@LastUpdated:  18 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
--[[Search class

A class that implements a Generic TextSearch object

]]--
function Search (strPattern, booPlain, booWhole, booSensitive)
    --TODO: Fully test Search class
    --TODO: Generalise to take case sensitivity into account

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

    local function isWordFoundInString(w,s)
        return select(2,s:gsub('^' .. w .. '%W+','')) +
        select(2,s:gsub('%W+' .. w .. '$','')) +
        select(2,s:gsub('^' .. w .. '$','')) +
        select(2,s:gsub('%W+' .. w .. '%W+','')) > 0
    end

    --private state variables

    local plain = booPlain or true
    local whole = booWhole or true

    local spattern = strPattern
    if plain then spattern = utils.escape(spattern) end --escape magic characters

    local Found = function (strSearch)
        local ssearch = strSearch
        if whole then 
            return isWordFoundInString(spattern, ssearch)
        else
            return string.find(spattern, ssearch) ~= nil
        end
    end

    --expose public methods
    return{Found = Found}
end
function Report ()
    --class to create reports

    --prerequisites: File handling boilerplate; Penlight libraries
    local cstrPluginName = fhGetContextInfo("CI_PLUGIN_NAME")
    local cstrPluginDir = fhGetPluginDataFileName("LOCAL_MACHINE",true)
    local ReportDirectory = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Reports\\Custom\\"
    local strreportfiletype = ".fhr"

    local Download = function(strReportTitle)
        local downloadlocation  = "http://www.fhug.org.uk/colevalleygirl/"..strReportTitle..strreportfiletype
        FileDownload(downloadlocation, ReportDirectory..strReportTitle..strreportfiletype) 
    end
    return {Download = Download}
end
myReports = Report()
--------------------------------------------------------------
--MAIN DIALOG ACTIONS
--------------------------------------------------------------
function AdjustButtons()
    local tblIndividuals = indi.IndividualList()
    local booBulkOK = myTemplates.TemplatesExist() and myTemplates.BulkTemplatesAreSelected() and #tblIndividuals > 0
    btnBulkMake.active = utils.choose(booBulkOK, "YES", "NO")
    local booTextOK = #tblIndividuals > 0
    btnTaskMake.active = utils.choose(booTextOK, "YES", "NO") --can only make a task from text when an individual is selected
end

--[[taskoptions components; there are no defaults
 These are used in both MakeNoteText and MakeTask
    GroupValue
    DueDateValue
    UpdateDateValue
    FactDateValue
    IncludeIndividual = true|false
    Individual = ptr
    Tag
    UseField1
    Field1Value
    Field2Value
    Field3Value
    Field4Value
    Field5Value
    Source = ptr   
    ]]--

function MakeNoteHeader(note, taskoptions)
    local strNote = note.."\[\[\n" --privacy markers
    if taskoptions.UseField1 == "ON" then --set first line of note as title
        strNote = strNote..options.Field1Label..strSep..taskoptions.Field1Value.."\n"..strRule.."\n"
    end
    if taskoptions.IncludeIndividual then       
        local txtIndi = fhGetDisplayText(taskoptions.Individual)
        local intIndi = fhGetRecordId(taskoptions.Individual)
        strNote = strNote.."Individual: "..tostring(intIndi).." "..txtIndi.."\n"..strRule.."\n"--add the identity of the individual
    end
    return strNote
end
function MakeNoteTail(note)
    return note.."\n\]\]"
end
function AddTagTextandGroup(note, booTag, tag, booGroup, group)
    if booTag and tag then
        note = note..options.PrefixTag..tag.."\n"
    end
    if booGroup then
        note = note..options.GroupLabel..strSep..group.."\n"
    end
    if booTag or booGroup then
        note = note..strRule.."\n"
    end
    return note
end
function MakeNoteText(taskoptions)
    local strNote = ""

    local booRuleNeeded = false
    local function InsertRuleIfNeeded()
        if booRuleNeeded == true then
            strNote = strNote..strRule.."\n"
            booRuleNeeded = false
        end
    end
    if options.UseNotes == "ON" then strNote = MakeNoteHeader(strNote, taskoptions) end
    strNote = AddTagTextandGroup(strNote, options.UseTag =="ON", taskoptions.Tag, options.UseGroup == "ON", taskoptions.GroupValue)

    if options.UseDueDate == "ON" then
        strNote = strNote..options.DueDateLabel..strSep..taskoptions.DueDateValue .."\n"
        booRuleNeeded = true
    end
    if options.UseUpdateDate == "ON" and options.UseAttribute == "ON" then
        strNote = strNote..options.UpdateDateLabel..strSep..taskoptions.UpdateDateValue .."\n"
        booRuleNeeded = true
    end
    InsertRuleIfNeeded()

    for i = 2,3 do
        if options["UseField"..tostring(i)] == "ON" then
            strNote = strNote..options["Field"..tostring(i).."Label"]..strSep..taskoptions["Field"..tostring(i).."Value"].."\n"
            booRuleNeeded = true
        end
    end
    for i = 4,5 do
        if options["UseField"..tostring(i)] == "ON" then
            InsertRuleIfNeeded()
            strNote = strNote..options["Field"..tostring(i).."Label"]..strSep..taskoptions["Field"..tostring(i).."Value"].."\n"
            booRuleNeeded = true
        end
    end

    if options.UseNotes == "ON" then strNote = MakeNoteTail(strNote) end
    return strNote
end

function MakeTask(strNote, taskoptions)
    local ptrTask = nil --will hold a pointer to the new task item
    if options.UseAttribute == "ON" then
        local values = {}
        values.attributevalue = utils.choose(options.UseField1 == "ON", taskoptions.Field1Value, "")
        if options.UseFactDate == "ON" then
            values.factdate = taskoptions.FactDateValue
        end
        values.factnote = strNote
        ptrTask = myFactTools.Create(cstrFactTag, taskoptions.Individual, values) --create the task fact linked to the individual with fact date and note if appropriate
    else --using Notes
        ptrTask = CreateSharedNote(strNote) --create the note
        CreateNoteLink(taskoptions.Individual, ptrTask) --link the note to the individuals
    end

    local ptrS = taskoptions.Source
    local ptrR = nil
    if ptrS:IsNotNull() then 
        mySourceTools.Link(ptrTask,ptrS) --add source link if specified
        if fhGetItemPtr(ptrS,'~.REPO>'):IsNotNull() then
            ptrR = fhGetItemPtr(ptrS,'~.REPO>')
        end
    end 

    local txtIndi = fhGetDisplayText(taskoptions.Individual)
    local strLogText = txtIndi.." | ".. taskoptions.Field1Value
    if options.UseGroup == "ON" then
        if taskoptions.GroupValue ~= "" then 
            strLogText = strLogText .." | "..options.GroupLabel..": "..taskoptions.GroupValue  
        end
    end
    myActivity.Update(strLogText)

    local resultstable = {taskoptions.Individual, ptrTask, options.PrefixTag..options.TagLabel, taskoptions.GroupValue,
        taskoptions.FactDateValue, taskoptions.DueDateValue, taskoptions.UpdateDateValue, 
        taskoptions.Field1Value, taskoptions.Field2Value, taskoptions.Field3Value, taskoptions.Field4Value,
        ptrS, ptrR}
    myResults.Update(resultstable)
    myResults.Title("Tasks created")
    EnableContainer(tabTools,{},"NO")--disable tools because results tables are being used for task creation
end

function MakeMainDialog()
    local function MakeBulkTasks()
        local tblTemplateValues = {}
        for tblTemplateValues in myTemplates.BulkTemplates() do --returns a table of values 1-6, 6 being the source
            local taskoptions = {
                DueDateValue = "",
                UpdateDateValue = os.date("%x"),
                FactDateValue = os.date("%x"),
                GroupValue = boxGroupData[2].value,
                Field1Value = tblTemplateValues[1],
                Field2Value = tblTemplateValues[2],
                Field3Value = tblTemplateValues[3],
                Field4Value = tblTemplateValues[4],
                Field5Value = tblTemplateValues[5],
                UseField1 = options.UseField1,
                Tag = options.TagLabel
            }
            for k, v in pairs(taskoptions) do
                taskoptions[k] = utils.choose(type(v) == 'string', v, "")
            end
            taskoptions.IncludeIndividual = true           
            local tblindi = indi.IndividualList()
            taskoptions.Individual = tblindi[1]
            local sptr = fhNewItemPtr()
            local ints = tonumber(tblTemplateValues[6]) --SourceID
            if type(ints) == 'number' then
                sptr:MoveToRecordById('SOUR', ints)
            else
                sptr:SetNull()
            end
            taskoptions.Source = sptr
            local strTaskNote = MakeNoteText(taskoptions)
            MakeTask(strTaskNote, taskoptions)
        end
    end
    local function MakeTaskFromText()
        local tblTemplateValues = myTemplates.GetUseValues() --returns a table of values 1-6, 6 being the source
        local taskoptions = {
            DueDateValue = boxTDueDate[2].value,
            UpdateDateValue = os.date("%x"),
            FactDateValue = boxTFactDate[2].value,
            GroupValue = boxGroupData[2].value,
            Field1Value = tblTemplateValues[1],
            Field2Value = tblTemplateValues[2],
            Field3Value = tblTemplateValues[3],
            Field4Value = tblTemplateValues[4],
            Field5Value = tblTemplateValues[5],
            UseField1 = options.UseField1,
            Tag = options.TagLabel
        }
        for k, v in pairs(taskoptions) do
            taskoptions[k] = utils.choose(type(v) == 'string', v, "")
        end
        taskoptions.IncludeIndividual = true
        local tblindi = indi.IndividualList()
        taskoptions.Individual = tblindi[1]
        local sptr = fhNewItemPtr()
        local ints = tonumber(tblTemplateValues[6]) --SourceID
        if type(ints) == 'number' then
            sptr:MoveToRecordById('SOUR', ints)
        else
            sptr:SetNull()
        end
        taskoptions.Source = sptr
        local strTaskNote = MakeNoteText(taskoptions)
        MakeTask(strTaskNote, taskoptions)
    end
    local function FindTag()
        --TODO: Possibly search for multiple tags or all tags

        local TagSearch = nil

        --get search text
        local booOK, strTag = GetText("Specify tag, which will be prefixed with "..options.PrefixTag, options.TagLabel, strMin1Letter)
        if not booOK then 
            return false 
        else
            strTag = options.PrefixTag..strTag
            TagSearch = Search(strTag, true, true)
        end 

        --identify data classes that will be searched
        local tblClass = {text=1,longtext=1,name=0,place=0,wordlist=1, word = 1}
        for strType, v in pairs(tblClass) do 
            tblClass[strType] = (v==1) --make values boolean
        end

        --Process all items in data

        local booFound = false
        for ptrItem in allItems() do
            local strDataClass = fhGetDataClass(ptrItem)
            if fhGetValueType(ptrItem) == 'text' and tblClass[strDataClass] == true then
                local strPtrText = fhGetValueAsText(ptrItem)
                if TagSearch.Found(strPtrText) then -- add to results
                    booFound = true
                    local strRecordType = fhGetTag(ptrItem)
                    --get the parent item
                    local ptrParent =fhNewItemPtr()
                    ptrParent:MoveToParentItem(ptrItem)
                    --get a parent item date if one exists
                    local strDate = ""
                    local dtptr = fhGetItemPtr(ptrParent,'~.DATE')
                    if dtptr:IsNotNull() then
                        strDate = fhGetValueAsDate(dtptr):GetValueAsText()
                    end
                    --get an updated date if possible
                    local strUpdated = fhGetLabelledText(ptrItem, options.UpdateDateLabel..strSep) --look for labelled text first
                    if strUpdated == "" and fhGetDataClass(ptrParent) == "record" then
                        local dtupdated = fhCallBuiltInFunction ("LastUpdated", ptrParent) --look for a record updated date, returns a datept
                        if dtupdated:IsNull() == false then
                            local d = fhNewDate()
                            d:SetSimpleDate(dtupdated)
                            strUpdated = d:GetDisplayText("ABBREV")
                        end
                    end
                    --get the parent source and corresponding repository if they exist
                    local ptrS = nil
                    local ptrR = nil
                    ptrS = fhGetItemPtr(ptrParent, '~.SOUR>')
                    if ptrS:IsNull() then
                        ptrS = nil
                    else
                        ptrR = fhGetItemPtr(ptrS,'~.REPO>')
                        if ptrR:IsNull() then 
                            ptrR = nil 
                        end
                    end
                    myResults.Update({ptrParent:Clone(),ptrItem:Clone(),  
                            strTag, fhGetLabelledText(ptrItem,options.GroupLabel..strSep), 
                            strDate, fhGetLabelledText(ptrItem,options.DueDateLabel..strSep), strUpdated, 
                            fhGetLabelledText(ptrItem,options.Field1Label..strSep),
                            fhGetLabelledText(ptrItem,options.Field2Label..strSep),
                            fhGetLabelledText(ptrItem,options.Field3Label..strSep),
                            fhGetLabelledText(ptrItem,options.Field4Label..strSep),
                            ptrS, ptrR})
                end
            end
        end
        if booFound == false then 
            Messagebox("No results found", "No results found")
        else
            --results will be displayed on exit
            myResults.Title("Tagged items")
            myResults.Headings({"Parent", "Item", 
                    "Tag", options.GroupLabel, options.FactDateLabel, 
                    options.DueDateLabel, options.UpdateDateLabel, 
                    options.Field1Label, options.Field2Label, 
                    options.Field3Label,options.Field4Label, 
                    "Source", "Repository"})
            myResults.Visibility({true, true,
                    true, options.UseGroup == "ON",
                    options.UseFactDate == "ON", options.UseDueDate =="ON", options.UseUpdateDate == "ON", 
                    options.UseField1 =="ON" and options.UseNotes == "ON", options.UseField2 == "ON", 
                    options.UseField3 == "ON", options.UseField4 == "ON", 
                    true, true})
        end
        return booFound
    end
    local function ConvertFacts()
        local function MakeNewNote(pi,pf,strTag)
            local ptrt = fhNewItemPtr()
            ptrt:MoveTo(pf, "~.NOTE2")
            local strText = fhGetValueAsText(ptrt)
            local strNote = ""
            local taskoptions = {
                IncludeIndividual = true,
                Individual = pi,
            }

            --set UseField1 and Field1Value

            taskoptions.Field1Value = fhGetValueAsText(pf)
            taskoptions.UseField1 = utils.choose(taskoptions.Field1Value == "", "OFF", "ON")
            strNote = MakeNoteHeader(strNote, taskoptions)

            --set tag and group if in use

            local strGroup = fhGetLabelledText(ptrt, options.GroupLabel..strSep)
            local existingTag = fhGetLabelledText(ptrt, options.PrefixTag)
            local usetag = utils.choose(existingTag == "", strTag, existingTag)
            strNote = AddTagTextandGroup(strNote, true, utils.choose(existingTag == "", strTag, existingTag), options.UseGroup == "ON", strGroup)
            if existingTag ~= "" then 
                strText = stringx.replace(strText, options.PrefixTag..existingTag, "")
            end--remove tag from the text
            if options.UseGroup == "ON" and strGroup ~= "" then
                strText = stringx.replace(strText, options.GroupLabel..strSep..strGroup, "") --remove group
            end
            strText = stringx.replace(strText, options.GroupLabel..strSep, "") --remove group label

            --concertt old separators to new, and then remove double separators

            strText = stringx.replace(strText, "====================", strRule)
            strText = stringx.replace(strText, strRule.."%s."..strRule, strRule)

            --remove leading and trailing whitespace
            strText = stringx.strip(strText)

            --add anything left to the note without changing it; worst case, the user gets some extraneous separators but doesn't lose any data

            strNote = strNote..strText
            strNote = MakeNoteTail(strNote)

            return strNote
        end


        local booconversionsdone = false 
        local booOK, strDefaultTag, boodeletefacts = GetTextandTick("Specify tag to use if one isn't found", options.TagLabel, strMin1Letter, "Delete facts after conversion?")
        if not booOK then return false end --abort
        for pi in records('INDI') do --iterate all individuals
            for pf in myFactTools.Iterate(pi) do --iterate all facts
                if fhGetTag(pf) == cstrFactTag then --test if Fact is a Task 
                    booconversionsdone = true

                    local notetext = MakeNewNote(pi, pf, strDefaultTag)
                    ptrTask = CreateSharedNote(notetext) --create the note
                    CreateNoteLink(pi, ptrTask) --link the note to the individuals                  
                    CopyChildren(pf, ptrTask) --copy the sources
                    if boodeletefacts then
                        fhDeleteItem(pf)
                    end
                    local ptrt = fhNewItemPtr()
                    ptrt:MoveTo(ptrTask, "~.TEXT")
                    local strText = fhGetValueAsText(ptrt)
                    local resultstable = {pi:Clone(), ptrTask:Clone(), fhGetLabelledText(ptrt, options.PrefixTag), 
                        fhGetLabelledText(ptrt, options.GroupLabel..strSep),
                        "", "", "", fhGetLabelledText(ptrt, options.Field1Label..strSep), "", "", "", pf:Clone(), nil}
                    myResults.Update(resultstable)
                end
            end
        end
        if booconversionsdone then
            myResults.Title("Converted facts")
            myResults.Headings({"Individual", "Task", 
                    "Tag", options.GroupLabel, options.FactDateLabel, 
                    options.DueDateLabel, options.UpdateDateLabel, 
                    options.Field1Label, options.Field2Label, 
                    options.Field3Label,options.Field4Label, 
                    "Source fact", ""})
            myResults.Visibility({true, true, true, options.UseGroup == "ON",
                    false, false, false, true, false, false, false, utils.choose(boodeletefacts, false, true), false})

            Messagebox("Things to delete?", [[Once you've converted all your Task facts to notes, you can safely delete the query "Research Tasks (Facts)", the "Research Planner" Fact Set, and any queries you downloaded associated with version 1 of the Research PLanner plugin.]])
            return true
        else
            Messagebox("No facts found to convert", "No facts found to convert")
            return false
        end
    end
    local function ConvertNotes()
        local function MakeNewNote(ptrtext)

            local strText = fhGetValueAsText(ptrtext)
            --remove privacy markers
            strText = stringx.replace(strText,"[[\n","")
            strText = stringx.replace(strText,"\n]]","")

            --Get field1 if it exists and remove it from the note

            local f1value = fhGetLabelledText(ptrtext, options.Field1Label..strSep)
            if f1value ~= "" then
                strText = stringx.replace(strText, options.Field1Label..strSep..f1value.."\n"..strRule,"")
            end

            --get the individual

            local strIndividual = fhGetLabelledText(ptrtext, "Individual: ")
--            local iIndividual = TextToNumber(strIndividual)
            local indi = fhNewItemPtr()
            if strIndividual ~= "" then
--          --      indi:MoveToRecordById("INDI", tonumber(strIndividual))
                indi:MoveToRecordById("INDI", fhCallBuiltInFunction("TextToNumber", strIndividual))
                strText = stringx.replace(strText, "Individual: "..strIndividual.."\n"..strRule, "") --remove the individual identifiers
            end

            --convert old separators to new, and then remove double separators

            strText = stringx.replace(strText, "====================", strRule)
            strText = stringx.replace(strText, strRule.."%s."..strRule, strRule)
            strText = stringx.strip(strText)

            return strText, indi, f1value
        end


        local booconversionsdone = false 
        local booOK, strTag, boodeletenotes = GetTextandTick("Specify tag to search for", options.TagLabel, strMin1Letter, "Delete notes after conversion?")
        if not booOK then return false end --abort
        for pn in records('NOTE') do --iterate all Notes

            local ptrt = fhNewItemPtr()
            ptrt:MoveTo(pn, "~.TEXT")

            local thistag = fhGetLabelledText(ptrt, options.PrefixTag)
            if thistag == strTag then
                booconversionsdone = true
                local notetext, pi, f1value = MakeNewNote(ptrt) --get the note individual and field1 value
                if pi ~= nil then --can't make a fact if the individual isn't identified
                    ptrTask = myFactTools.Create(cstrFactTag, pi, f1value) --create the task fact linked to the individual
                    if notetext ~= "" then CreateNote(ptrTask, notetext) end --add the note if it isn't blank
                    if options.UseFactDate == "ON" then
                        local dt = fhNewDate()
                        local DateOK = dt:SetValueAsText(os.date("%x"), true) --defaults to today
                        SetDate(ptrTask, dt)
                    end             
                    CopyChildren(pn, ptrTask) --copy the sources
                    if boodeletenotes then
                        fhDeleteItem(pn)
                    end
                    local ptrt = fhNewItemPtr()
                    ptrt:MoveTo(ptrTask, "~.TEXT")
                    local strText = fhGetValueAsText(ptrt)
                    local resultstable = {pi:Clone(), ptrTask:Clone(), strTag, 
                        fhGetLabelledText(ptrt, options.GroupLabel..strSep),
                        "", "", "", fhGetLabelledText(ptrt, options.Field1Label..strSep), "", "", "", pn:Clone(), nil}
                    myResults.Update(resultstable)

                end
            end
        end
        if booconversionsdone then
            myResults.Title("Converted notes")
            myResults.Headings({"Individual", "Task", 
                    "Tag", options.GroupLabel, options.FactDateLabel, 
                    options.DueDateLabel, options.UpdateDateLabel, 
                    options.Field1Label, options.Field2Label, 
                    options.Field3Label,options.Field4Label, 
                    "Original note", ""})
            myResults.Visibility({true, true, true, options.UseGroup == "ON",
                    false, false, false, false, false, false, false, utils.choose(boodeletefacts, false, true), false})
            Messagebox("Things to delete?", [[Once you've converted all your Task notes, you can safely delete the query "Research Tasks (Notes)"]])
            return true
        else
            Messagebox("No notes found to convert", "No notes found to convert")
            return false
        end
    end
    local function FactsReport()
        myReports.Download("Tasks - Individual (Facts)")
        Messagebox("Report installed", "Report installed")
    end
    local function NotesReport()
        myReports.Download("Tasks - Individual (Notes)")
        Messagebox("Report installed", "Report installed")
    end

    local function MakeTemplateBox(source)
        --The template box contains 6 containers each with 2 items in them (either a button or a label, and a text field)
        --the elements can be accessed using mybox[n] for the containers or mybox[n][m] for the elements

        local txtField1 = MakeText{expand="HORIZONTAL", wordwrap="NO"}
        local labField1= MakeLabel{title=options.Field1Label}
        local hboxField1 = iup.hbox{labField1, txtField1}

        local txtField2 = MakeText{expand="HORIZONTAL", wordwrap="NO"}
        local labField2 = MakeLabel{title=options.Field2Label}
        local hboxField2 = iup.hbox{labField2, txtField2}

        local txtField3 = MakeText{expand="HORIZONTAL", wordwrap="NO"}
        local labField3 = MakeLabel{title=options.Field3Label}
        local hboxField3 = iup.hbox{labField3, txtField3}

        local txtField4 = MakeText{wordwrap="YES", multiline="YES", scrollbar = "VERTICAL", visiblelines = "2"}
        local labField4 = MakeLabel{title=options.Field4Label}
        local vboxField4 = iup.vbox{labField4, txtField4}

        local txtField5 = MakeText{wordwrap="YES", multiline="YES", scrollbar = "VERTICAL", visiblelines = "2"}
        local labField5 = MakeLabel{title=options.Field5Label}
        local vboxField5 = iup.vbox{labField5, txtField5}

        return iup.vbox{
            hboxField1, hboxField2, hboxField3, vboxField4, vboxField5, source.Selector("Use the Source button to choose a source from within Family Historian.","HORIZONTAL");
        }
    end

    local function MakeTabTasks(strTabTitle)
        local UseSelector = nil
        boxTDueDate = nil
        boxTFactDate = nil
        local function TabMakeText()
            local function ClearTabMakeText()
                myTemplates.ClearUseSelector(UseSelector) --clears Template selector and container
                boxTDueDate[2].value = ""
                boxTDueDate[2].value = ""                
            end
            --define buttons
            btnTaskMake = MakeButton{title="Make", callback = MakeTaskFromText, tip = "Make task from specified details"}
            local btnClear = MakeButton{title="Clear", callback = ClearTabMakeText, tip = "Clear task details"}
            local btns = iup.hbox{iup.fill{}, btnClear, btnTaskMake}
            btnTaskMake.active = "YES"
            --define template fields
            TaskSource = Source(1)
            boxTaskEdit = MakeTemplateBox(TaskSource)
            --define date fields
            boxTDueDate = MakeDateField{title=options.DueDateLabel}
            boxTFactDate = MakeDateField{title=options.FactDateLabel}

            UseSelector = myTemplates.UseSelector(boxTaskEdit)
            return iup.vbox{UseSelector, 
                UseSelector, boxTaskEdit, iup.vbox{boxTDueDate, boxTFactDate}, btns;
                tabtitle = "From Text"}
        end
        local function TabMakeBulk()
            --define buttons
            btnBulkMake = MakeButton{title="Make", callback = MakeBulkTasks, tip = "Make tasks from selected templates"}
            local btnClear = MakeButton{title="Clear", callback = myTemplates.ClearBulkSelector, tip = "Clear selected templates"}
            local btns = iup.hbox{iup.fill{}, btnClear, btnBulkMake}
            btnBulkMake.active = "NO"
            local boxBulkMake = iup.vbox{
                myTemplates.BulkSelector(),
                btns;
            } --define container
            return iup.vbox{boxBulkMake; tabtitle = "In Bulk"}
        end
        --define tab-wide buttons and fields
        local btns = iup.hbox{iup.fill{}, myHelp.Button(norm, "creating_and_editing_tasks" ), myConfig.OptionsButton(), MakeButton{title="Exit"}}

        --Project field
        local labGroup = MakeLabel{title = options.GroupLabel, tip = "Grouping to use when creating tasks"}
        local txtGroup = MakeText{tip = "Grouping to use when creating tasks", default = ""}
        boxGroupData = iup.hbox{labGroup, txtGroup}

        return iup.vbox{
            indi.Selector("Use the button to choose an individual from within Family Historian.\nYou cannot create tasks until you have selected an individual."), 
            boxGroupData,
            iup.tabs{TabMakeText(), TabMakeBulk()},
            btns, myActivity.Make("OPEN", "Task history"); tabtitle  = strTabTitle}

    end
    local function MakeTabTemplates(strTabTitle)

        TemplateSource = Source(1)
        boxTemplateEdit = MakeTemplateBox(TemplateSource)
        local btnExit = MakeButton{title="Exit", callback = myTemplates.CheckForUnsavedEditChanges, close = "YES"}
        local btns = iup.hbox{iup.fill{}, myHelp.Button(norm, "managing_task_templates"), myConfig.OptionsButton(), btnExit}

        return iup.vbox{myTemplates.EditSelector(boxTemplateEdit), iup.frame{boxTemplateEdit}, btns; tabtitle = strTabTitle}      
    end
    local function MakeTabTools(strTabTitle)
        local btns = iup.hbox{iup.fill{}, myHelp.Button(norm,"miscellaneous_tools" ), myConfig.OptionsButton(), MakeButton{title="Exit"}}
        hFindBox = iup.hbox{
            MakeButton{title = "Find Items", callback = FindTag, close = "YES", tip = "Find items with a specified tag"}
        }

        htoolsFactsBox = iup.hbox{
            MakeButton{title = "Convert Notes", callback = ConvertNotes, close = "YES", tip = "Convert any Note records with a specified tag to Task facts"},
            MakeButton{title = "Download Report", callback = FactsReport, close = "YES", tip = "Download a Report for Tasks using Facts"}
        } --tools available when facts are in use

        htoolsNotesBox = iup.hbox{
            MakeButton{title = "Convert Facts", callback = ConvertFacts, close = "YES", tip = "Convert any Task facts to Note records with a specified tag"},
            MakeButton{title = "Download Report", callback = NotesReport, close = "YES", tip = "Download a Report for Tasks using Notes"}
        } --tools available when notes are in use


        return iup.vbox{hFindBox, htoolsFactsBox, htoolsNotesBox, btns;
            tabtitle = "Tools"} 
    end
    local strtabTasksTitle = "Make tasks"
    local strtabTemplatesTitle = "Manage templates"
    local strtabToolsTitle = "Tools"

    local tabTasks = MakeTabTasks(strtabTasksTitle)
    local tabTemplates = MakeTabTemplates(strtabTemplatesTitle)
    tabTools = MakeTabTools(strtabToolsTitle)

    local MainTabs = iup.tabs{tabTasks, tabTemplates, tabTools}
    MainTabs.tabchange_cb = function(self, newTab, oldTab)
        if oldTab.tabTitle == strtabTemplatesTitle and myTemplates.CheckForUnsavedEditChanges() == false then
            tabs.value = tabTemplates --revert tab
        end
    end
    local d = MakeDialog(MainTabs, {title = cstrPluginName})
    d.close_cb = function() 
        if myTemplates.CheckForUnsavedEditChanges() then
            d.show_cb = nil --avoid iup bug
            return iup.CLOSE
        else
            return iup.IGNORE
        end
    end
    DoNormalize()
    return d
end
-------------------------------------
--EXECUTE
-------------------------------------
do
    SetUTF8IfPossible()
    SetTextSize()
    myHelp = HTMLHelp(cstrPluginVersion) --create Help object to display Help with a browser interface
    myConfig = Config(cstrPluginVersion, myHelp ) --create config object to make all methods available
    myTemplates = Templates(true) --create template object to make all methods available -- allow project templates; do not use a subdirectory and don't use tokens
    myResults = Results(13) --initialise results handling with 13 results tables; remaining details will depend on user actions
    myActivity = ActivityLog()
    indi = Individuals(1) --create an object of the Individuals class that allows a single individual
    dlgmain = MakeMainDialog()
    myConfig.Initialise() --initialise the configuration
    dlgmain:show()
    iup.MainLoop()
    DestroyAllDialogs()
    myResults.Display()
end
--DONE



Source:Research-Planner-3.fh_lua