--[[ @Title: Add Media URL Shortcut @Type: Standard @Author: Mike Tate @Contributors: @Version: 1.8 @Keywords: @LastUpdated: 05 Oct 2026 @Licence: This plugin is copyright (c) 2026 Mike Tate & contributors and is licensed under the MIT License which is hereby incorporated by reference (see https://pluginstore.family-historian.co.uk/fh-plugin-licence) @Description: Add a Media tab link to a Media record for a URL Shortcut to a web page. @V1.8: Use FSO to cater for Unicode file paths; Centre windows on FH window; Check for Updates button; FH V8 _ADDR; Add Rec Id; fhInitialise(); @V1.3: FH V7 Lua 3.5 IUP 3.28 compatible version; @V1.2: Allow the URL to contain Lua magic pattern %n capture index. @V1.1: Allows a Title name for new Media record. @V1.0: First published in Plugin Store. ]] require "iuplua" -- To access GUI window builder require "luacom" FSO = luacom.CreateObject("Scripting.FileSystemObject") -- V1.8 local strVersion = "1.8" local strName = "V"..strVersion.." Add Media URL Shortcut" -- Report error message -- local function doError(strMessage,errFunction) -- strMessage ~ error message text -- errFunction ~ optional error reporting function if type(errFunction) == "function" then errFunction(strMessage) else error(strMessage) end end -- local function doError -- Convert filename to ANSI alternative and indicate success -- function FileNameToANSI(strFileName,strAnsiName) -- strFileName ~ full file path -- strAnsiFile ~ ANSI file name & type -- return values ~ ANSI file path, true if original path was ANSI compatible if stringx.encoding() == "ANSI" then return strFileName, true end local isFlag = fhIsConversionLossFlagSet() fhSetConversionLossFlag(false) local strAnsi = fhConvertUTF8toANSI(strFileName) local wasAnsi = true if fhIsConversionLossFlagSet() then strAnsiName = strAnsiName or "ANSI.ANSI" strAnsi = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Plugin Data\\"..strAnsiName wasAnsi = false end fhSetConversionLossFlag(isFlag) return strAnsi, wasAnsi end -- local function FileNameToANSI -- Check if folder exists -- function FlgFolderExists(strFolderName) -- strFolderName ~ full file path -- return value ~ true if it exists return FSO:FolderExists(strFolderName) end -- function FlgFolderExists -- Get parent folder -- function GetParentFolder(strFileName) -- strFileName ~ full file path -- return value ~ parent folder path local strParent = FSO:GetParentFolderName(strFileName) --! Faulty in FH v6 with Unicode chars in path if fhGetAppVersion() == 6 then local _, wasAnsi = FileNameToANSI(strFileName) if not wasAnsi then strParent = strFileName:match("^(.+)[\\/][^\\/]+[\\/]?$") end end return strParent end -- function GetParentFolder -- Make subfolder recursively if does not exist -- function MakeFolder(strFolderName,errFunction) -- strFolderName ~ full source folder path -- errFunction ~ optional error reporting function -- return value ~ true if folder exists or created else false if not FSO:FolderExists(strFolderName) then if not MakeFolder(GetParentFolder(strFolderName),errFunction) then return false end FSO:CreateFolder(strFolderName) if not FSO:FolderExists(strFolderName) then doError("Cannot Make Folder:\n"..strFolderName.."\n",errFunction) return false end end return true end -- function MakeFolder -- Open File with ANSI path and return Handle -- function OpenFile(strFileName,strMode) -- strFileName ~ full file path -- strMode ~ "r", "w", "a" optionally suffixed with "+" &/or "b" -- return value ~ file handle local fileHandle, strError = io.open(strFileName,strMode) if fileHandle == nil then error("\n Unable to open file in \""..strMode.."\" mode. \n "..strFileName.." \n "..strError.." \n") end return fileHandle end -- function OpenFile -- Save string to file -- function SaveStringToFile(strContents,strFileName,strFormat) -- strContents ~ text string -- strFileName ~ full file path -- strFormat ~ optional "UTF-8" or "UTF-16LE" -- return value ~ true if successful else false strFormat = strFormat or "UTF-8" if fhGetAppVersion() > 6 then return fhSaveTextFile(strFileName,strContents,strFormat) end local strAnsi, wasAnsi = FileNameToANSI(strFileName) local fileHandle = OpenFile(strAnsi,"w") fileHandle:write(strContents) assert(fileHandle:close()) if not wasAnsi then MoveFile(strAnsi,strFileName) end return true end -- function SaveStringToFile --[[ @Function: CheckVersionInStore @Author: Mike Tate @Version: 1.4 @LastUpdated: 15 Feb 2026 @Description: Check plugin version against version in Plugin Store @Parameter: Plugin name and version @Returns: None @Requires: luacom @V1.4: Dispense with files and assume called via IUP button; @V1.3: Save and retrieve latest version in file; @V1.2: Ensure the Plugin Data folder exists; @V1.1: Monthly interval between checks; Report if Internet is inaccessible; @V1.0: Initial version; ]] function CheckVersionInStore(strPlugin,strVersion) -- Check if later Version available in Plugin Store require("luacom") local FSO = luacom.CreateObject("Scripting.FileSystemObject") local strFile = fhGetContextInfo("CI_APP_DATA_FOLDER").."\\Plugin Data\\VersionInStore "..strPlugin..".dat" if FSO:FileExists(strFile) then FSO:DeleteFile(strFile,true) end -- Delete obsolete file local function httpRequest(strRequest) -- Luacom http request protected by pcall() below local http = luacom.CreateObject("winhttp.winhttprequest.5.1") http:Open("GET",strRequest,false) http:Send() return http.Responsebody end -- local function httpRequest local function intVersion(strVersion) -- Convert version string to comparable integer local intVersion = 0 local arrNumbers = {} strVersion:gsub("(%d+)", function(strDigits) table.insert(arrNumbers,strDigits) end) for i = 1, 5 do intVersion = intVersion * 100 + tonumber(arrNumbers[i] or 0) end return intVersion end -- local function intVersion local strLatest = "0" if strPlugin then local strRequest ="http://www.family-historian.co.uk/lnk/checkpluginversion.php?name="..tostring(strPlugin) local isOK, strReturn = pcall(httpRequest,strRequest) if not isOK then -- Problem with Internet access fhMessageBox(strReturn.."\n The Internet appears to be inaccessible. ") elseif strReturn then strLatest = strReturn:match("([%d%.]*),%d*") -- Version digits & dots then comma and Id digits end end local strMessage = "No later Version" if intVersion(strLatest) > intVersion(strVersion or "0") then strMessage = "Later Version "..strLatest end fhMessageBox(strMessage.." of this Plugin is available from the 'Plugin Store'.") end -- function CheckVersionInStore function strMakeShortcutFile(strURL) -- Make the URL Shortcut File (.url) local strShortcut = -- URL Shortcut file template [[ [{000214A0-0000-0000-C000-000000000046}] Prop3=19,2 [InternetShortcut] URL= IDList= ]] local tblPattern = {} -- Filename encodings for disallowed chars \/:"<>|*? tblPattern['"'] = "%22" tblPattern["*"] = "%2A" tblPattern["/"] = " " tblPattern[":"] = "%3A" tblPattern["<"] = "%3C" tblPattern[">"] = "%3E" tblPattern["?"] = "%3F" tblPattern["\\"]= "%5C" tblPattern["|"] = "%7C" local strFileName = strURL:gsub("://"," "):gsub('[\\/:"<>|%*%?%.]',tblPattern)..".url" local strMediaDir = fhGetContextInfo("CI_PROJECT_DATA_FOLDER").."\\Media\\" local strMediaURL = strMediaDir.."URL\\" MakeFolder(strMediaURL) -- Ensure ...\Media\URL\ folders exist -- V1.8 strShortcut = strShortcut:gsub("\t",""):gsub("",function() return strURL end) -- V1.2 fix SaveStringToFile(strShortcut,strMediaURL..strFileName) -- Might overwrite an existing file return "Media\\URL\\"..strFileName end -- function strMakeShortcutFile function ptrMakeMediaRecord(strURL,strTitl,strFile) -- Make Media record with URL Title linked to Shortcut file local isOK = false local ptrObje = fhNewItemPtr() ptrObje:MoveToFirstRecord("OBJE") if fhGetAppVersion() < 7 then while ptrObje:IsNotNull() do -- Check for existing Media record for same Shortcut in GEDCOM 5.5 if fhGetItemText(ptrObje,"~._FILE") == strFile and fhGetItemText(ptrObje,"~.TITL") == strTitl then return ptrObje end ptrObje:MoveNext() end ptrObje = fhCreateItem("OBJE") -- Otherwise create new Media record in GEDCOM 5.5 if ptrObje:IsNotNull() then for strTag, strVal in pairs ({ _KEYS="URL"; _FILE=strFile; TITL=strTitl; FORM="url"; }) do local ptrTag = fhCreateItem(strTag,ptrObje,true) if ptrTag:IsNotNull() then isOK = fhSetValueAsText(ptrTag,strVal) if not isOK then break end end end end else while ptrObje:IsNotNull() do -- Check for existing Media record for same Shortcut in GEDCOM 5.5.1 if fhGetItemText(ptrObje,"~.FILE") == strFile and fhGetItemText(ptrObje,"~.FILE.TITL") == strTitl then return ptrObje end ptrObje:MoveNext() end ptrObje = fhCreateItem("OBJE") -- Otherwise create new Media record in GEDCOM 5.5.1 if ptrObje:IsNotNull() then local ptrRoot = ptrObje:Clone() for _, strData in ipairs ({ "_KEYS:URL"; "FILE:"..strFile; "TITL:"..strTitl; "FORM:url"; }) do local strTag, strVal = strData:match("^(.+):(.+)$") local ptrTag = fhCreateItem(strTag,ptrRoot,true) if ptrTag:IsNotNull() then isOK = fhSetValueAsText(ptrTag,strVal) if not isOK then break end end if strTag == "FILE" then ptrRoot = ptrTag end end end end if not isOK then fhMessageBox("\nFailed to add Media record for URL Shortcut.\n") ptrObje:SetNull() end return ptrObje end -- function ptrMakeMediaRecord function ptrLinkMediaRecord(ptrRec,ptrObje) -- Link Media record to Media tab of selected record local isOK = false local ptrLink = fhNewItemPtr() if ptrObje:IsNull() then return ptrLink end ptrLink:MoveTo(ptrRec,"~.OBJE") while ptrLink:IsNotNull() do -- Check for existing Media link for same Shortcut if fhGetValueAsLink(ptrLink):IsSame(ptrObje) then return ptrLink end ptrLink:MoveNext("SAME_TAG") end ptrLink = fhCreateItem("OBJE",ptrRec,true) -- Otherwise create new link to Media record if ptrLink:IsNotNull() then isOK = fhSetValueAsLink(ptrLink,ptrObje) end if not isOK then fhMessageBox("\nFailed to add Media tab link to Media record.\n") ptrLink:SetNull() end return ptrLink end -- function ptrLinkMediaRecord function strGetShortcutURL(ptrRec) -- Get Shortcut URL via user dialogue -- V1.8 local strHelp = -- Help and Advice message [[ This adds a web page URL Shortcut to the Media tab of a chosen record. Enter desired web page URL and Title, then click 'Link Media URL' button. The Plugin then: Adds a Shortcut file to the ....fh_data\Media\URL\ folder Adds a Media record with chosen Title and URL, and Keyword = 'URL' Adds a link to that Media record in the chosen record Media tab To open Media URL click the triangular 'Open in Editor/Player' button. To undo changes use 'Edit > Undo Plugin Updates' before closing FH, & delete new shortcut files in ....fh_data\Media\URL\ folder. ]] local strHead = strName.." to Media tab of '"..fhGetDisplayText(ptrRec):gsub("^%.%.%.of ","").."' ["..fhGetRecordId(ptrRec).."]" local strURL, strTitl local function setArg(txtURL,txtTitl) -- Action for btnLink, btnQuit, and Close window strURL = txtURL if #strURL < 9 then strURL = nil else strTitl = txtTitl if strTitl == "" then strTitl = strURL end end return iup.CLOSE end -- local function setArg -- Define IUP controls for user dialogue local labURL = iup.label { Title="Enter web page URL: "; } local txtURL = iup.text { Expand="Yes"; Tip="Copy and Paste the URL here (http://...)"; } local boxURL = iup.hbox { Expand="Yes"; labURL; txtURL; } local labTitl = iup.label { Title="Enter the media Title:"; } local txtTitl = iup.text { Expand="Yes"; Tip="If left blank, the Title defaults to URL"; } local boxTitl = iup.hbox { Expand="Yes"; labTitl; txtTitl; } local btnLink = iup.button{ Expand="Yes"; Tip="Add Media URL Shortcut" ; Title="Link Media URL" ; FgColor="0 128 0"; action=function() return setArg(txtURL.Value,txtTitl.Value) end; } local btnChck = iup.button{ Expand="Yes"; Tip="Check for Plugin Updates" ; Title="Plugin Updates?" ; FgColor="0 128 0"; } local btnHelp = iup.button{ Expand="Yes"; Tip="Obtain Help and Advice" ; Title="Help and Advice" ; FgColor="0 128 0"; action=function() iup.Message(strName.." ~ Help and Advice",strHelp:gsub("\t","")) end; } local btnQuit = iup.button{ Expand="Yes"; Tip="Quit from this Plugin" ; Title="Quit the Plugin" ; FgColor="255 0 0"; action=function() return setArg(" ") end; } local boxBtn = iup.hbox { Expand="Yes"; btnLink; btnChck; btnHelp; btnQuit; } local labUndo = iup.label { Expand="Yes"; Title="Undo changes by using command 'Edit > Undo Plugin Updates' before closing Family Historian"; Alignment="Acenter"; } local boxAll = iup.vbox { Expand="Yes"; boxURL; boxTitl; boxBtn; labUndo; Homogeneous="Yes"; } local dialog = iup.dialog{ Title=strHead; boxAll; RasterSize="700x280"; MinSize="700x280"; Padding="4x4"; Gap="9"; Margin="8x8"; MinBox="No"; MaxBox="No"; DefaultEnter=btnLink; DefaultEsc=btnQuit; close_cb=function() return setArg(" ") end; } if fhGetAppVersion() > 6 then -- Window centres on FH parent iup.SetAttribute(dialog,"NATIVEPARENT",fhGetContextInfo("CI_PARENT_HWND")) end function btnChck:action() -- Action for Plugin Updates button -- V1.8 dialog.Active = "NO" CheckVersionInStore("Add Media URL Shortcut",strVersion) dialog.Active = "YES" dialog.BringFront = "YES" end -- function btnChck:action dialog:showxy(iup.CENTERPARENT,iup.CENTERPARENT) iup.MainLoop() return strURL, strTitl end -- function strGetShortcutURL function MainAction() local strMessage = "\nPlease select just one Individual, Family, or Source record.\n" if fhGetAppVersion() > 5 then -- Cater for FH V6 Unicode and Place records fhSetStringEncoding("UTF-8") iup.SetGlobal("UTF8MODE","YES") iup.SetGlobal("UTF8MODE_FILE","NO") iup.SetGlobal("CUSTOMQUITMESSAGE","YES") -- Needed for IUP 3.28 strMessage = strMessage:gsub("or Source","Source, or Place") if fhGetAppVersion() > 7 then strMessage = strMessage:gsub("or Place","Place, or Address") -- For FH V8 Addresses -- V1.8 end end local arrRec = {} for _, strTag in ipairs ({"INDI";"FAM";"SOUR";"_PLAC";"_ADDR";}) do -- For FH V8 Addresses -- V1.8 local ptrRec = fhNewItemPtr() ptrRec:MoveToFirstRecord(strTag) if ptrRec:IsNotNull() then -- Search for selected records -- V1.8 arrRec = fhGetCurrentRecordSel(strTag) if #arrRec > 0 then break end end end if #arrRec == 1 then -- Just one record selected, so get URL string local ptrRec = arrRec[1] local strURL, strTitl = strGetShortcutURL(ptrRec) -- Get URL and Title via user dialogue -- V1.8 if strURL then local strFile = strMakeShortcutFile(strURL) -- Make Shortcut file in \Media\URL\ local ptrObje = ptrMakeMediaRecord(strURL,strTitl,strFile) -- Make Media record for Shortcut local ptrLink = ptrLinkMediaRecord(ptrRec,ptrObje) -- Link Media tab to Media record if ptrLink:IsNull() then error("\n\nPlugin failed to add Media URL.") end end else fhMessageBox(strName.."\n"..strMessage) -- Zero or more than one record selected end end -- function MainAction if fhGetContextInfo("CI_APP_MODE") == "Project Mode" then fhInitialise(5,0,0,"save_recommended") -- V1.8 MainAction() else fhMessageBox("\nThis Plugin only works for Projects, not standalone GEDCOM.\n") end