From 7e458dd39594c6695177cc72cfd5ff6f5c82dc41 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 31 May 2023 16:04:04 -0400 Subject: [PATCH 1/8] Combine features from Primo addons and convert to WebView2. --- AlmaApi.lua | 99 +++++ Catalog.lua | 796 +++++++++++++++++++++++++++++++++++++++ CatalogLayout.xml | 261 +++++++++++++ CatalogLayout_Browse.xml | 202 ++++++++++ CatalogLayout_Import.xml | 202 ++++++++++ Config.xml | 55 +++ CustomizedMapping.lua | 9 + DataMapping.lua | 174 +++++++++ LICENSE | 2 +- README.md | 255 ++++++++++++- Utility.lua | 243 ++++++++++++ WebClient.lua | 61 +++ 12 files changed, 2356 insertions(+), 3 deletions(-) create mode 100644 AlmaApi.lua create mode 100644 Catalog.lua create mode 100644 CatalogLayout.xml create mode 100644 CatalogLayout_Browse.xml create mode 100644 CatalogLayout_Import.xml create mode 100644 Config.xml create mode 100644 CustomizedMapping.lua create mode 100644 DataMapping.lua create mode 100644 Utility.lua create mode 100644 WebClient.lua diff --git a/AlmaApi.lua b/AlmaApi.lua new file mode 100644 index 0000000..7dd3c0a --- /dev/null +++ b/AlmaApi.lua @@ -0,0 +1,99 @@ +local AlmaApiInternal = {}; +AlmaApiInternal.ApiUrl = nil; +AlmaApiInternal.ApiKey = nil; + + +local types = {}; +types["log4net.LogManager"] = luanet.import_type("log4net.LogManager"); +types["System.Net.WebClient"] = luanet.import_type("System.Net.WebClient"); +types["System.Text.Encoding"] = luanet.import_type("System.Text.Encoding"); +types["System.Xml.XmlTextReader"] = luanet.import_type("System.Xml.XmlTextReader"); +types["System.Xml.XmlDocument"] = luanet.import_type("System.Xml.XmlDocument"); + +-- Create a logger +local log = types["log4net.LogManager"].GetLogger(rootLogger .. ".AlmaApi"); + +AlmaApi = AlmaApiInternal; + +local function RetrieveHoldingsList( mmsId ) + local requestUrl = AlmaApiInternal.ApiUrl .."bibs/".. + Utility.URLEncode(mmsId) .."/holdings?apikey=" .. Utility.URLEncode(AlmaApiInternal.ApiKey); + local headers = {"Accept: application/xml", "Content-Type: application/xml"}; + log:DebugFormat("Request URL: {0}", requestUrl); + local response = WebClient.GetRequest(requestUrl, headers); + + return WebClient.ReadResponse(response); +end + +-- idType is either "mms_id" or "ie_id" +local function RetrieveBibs(id, idType) + local requestUrl = AlmaApiInternal.ApiUrl .. "bibs?apikey=".. + Utility.URLEncode(AlmaApiInternal.ApiKey) .. "&" .. idType .. "=" .. Utility.URLEncode(id); + local headers = {"Accept: application/xml", "Content-Type: application/xml"}; + log:DebugFormat("Request URL: {0}", requestUrl); + + local response = WebClient.GetRequest(requestUrl, headers); + + return WebClient.ReadResponse(response); +end + +local function RetrieveItemsSublist(mmsId, holdingId, offset ) + local requestUrl = AlmaApiInternal.ApiUrl .."bibs/" .. + Utility.URLEncode(mmsId) .."/holdings/" .. Utility.URLEncode(holdingId) .. "/items?limit=100&offset=" .. tostring(offset) .. "&apikey=" .. + Utility.URLEncode(AlmaApiInternal.ApiKey); + + local headers = {"Accept: application/xml", "Content-Type: application/xml"}; + + log:DebugFormat("Request URL: {0}", requestUrl); + local response = WebClient.GetRequest(requestUrl, headers); + + return WebClient.ReadResponse(response); +end + +local function RetrieveItemsList(mmsId, holdingId) + local xmlResult, itemsNode; + local offset, totalItems = 0, 0; + + repeat + local xmlSubresult = RetrieveItemsSublist(mmsId, holdingId, offset); + + if (xmlResult == nil) then + xmlResult = xmlSubresult; + itemsNode = xmlResult:SelectSingleNode("/items"); + totalItems = tonumber(itemsNode:SelectSingleNode("@total_record_count").Value); + log:DebugFormat("Holding contains {0} items", totalItems); + else + --Merge the next subset results with the working list + local itemNodes = xmlSubresult:SelectNodes("/items/item"); + + for i = 0, itemNodes.Count - 1 do + local nodeCopy = xmlResult:ImportNode(itemNodes:Item(i), true); + itemsNode:AppendChild(nodeCopy); + end + end + + offset = offset + 100; + + until totalItems <= offset; + + return xmlResult; +end + +local function RetrieveHoldingsRecordInfo(mmsId, holdingId) + local requestUrl = AlmaApiInternal.ApiUrl .."bibs/" .. + Utility.URLEncode(mmsId) .."/holdings/" .. Utility.URLEncode(holdingId) .. "/?apikey=" .. + Utility.URLEncode(AlmaApiInternal.ApiKey); + + local headers = {"Accept: application/xml", "Content-Type: application/xml"}; + + log:DebugFormat("Request URL: {0}", requestUrl); + local response = WebClient.GetRequest(requestUrl, headers); + + return WebClient.ReadResponse(response); +end + +-- Exports +AlmaApi.RetrieveHoldingsList = RetrieveHoldingsList; +AlmaApi.RetrieveBibs = RetrieveBibs; +AlmaApi.RetrieveItemsList = RetrieveItemsList; +AlmaApi.RetrieveHoldingsRecordInfo = RetrieveHoldingsRecordInfo; \ No newline at end of file diff --git a/Catalog.lua b/Catalog.lua new file mode 100644 index 0000000..824b7c3 --- /dev/null +++ b/Catalog.lua @@ -0,0 +1,796 @@ +require("Utility"); + +local settings = {}; +settings.AutoSearch = GetSetting("AutoSearch"); +settings.AvailableSearchTypes = Utility.StringSplit(",", GetSetting("AvailableSearchTypes")); +settings.SearchPriorityList = Utility.StringSplit(",", GetSetting("SearchPriorityList")); +settings.HomeUrl = GetSetting("HomeURL"); +settings.CatalogUrl = GetSetting("CatalogURL"); +settings.AutoRetrieveItems = GetSetting("AutoRetrieveItems"); +settings.RemoveTrailingSpecialCharacters = GetSetting("RemoveTrailingSpecialCharacters"); +settings.AlmaApiUrl = GetSetting("AlmaAPIURL"); +settings.AlmaApiKey = GetSetting("AlmaAPIKey"); +settings.PrimoSiteCode = GetSetting("PrimoSiteCode"); +settings.IdSuffix = GetSetting("IdSuffix"); + +local interfaceMngr = nil; + +-- The catalogSearchForm table allows us to store all objects related to the specific form inside the table so that we can easily +-- prevent naming conflicts if we need to add more than one form and track elements from both. +local catalogSearchForm = {}; +catalogSearchForm.Form = nil; +catalogSearchForm.Browser = nil; +catalogSearchForm.RibbonPage = nil; +catalogSearchForm.ItemsButton = nil; +catalogSearchForm.ImportButton = nil; +catalogSearchForm.SearchButtons = {}; + +local mmsIdsCache = {}; +local holdingsXmlDocCache = {}; +local itemsXmlDocCache = {}; + +luanet.load_assembly("System.Data"); +luanet.load_assembly("System.Drawing"); +luanet.load_assembly("System.Xml"); +luanet.load_assembly("System.Windows.Forms"); +luanet.load_assembly("DevExpress.XtraBars"); +luanet.load_assembly("log4net"); + +local types = {}; +types["System.Data.DataTable"] = luanet.import_type("System.Data.DataTable"); +types["System.Drawing.Size"] = luanet.import_type("System.Drawing.Size"); +types["DevExpress.XtraBars.BarShortcut"] = luanet.import_type("DevExpress.XtraBars.BarShortcut"); +types["System.Windows.Forms.Shortcut"] = luanet.import_type("System.Windows.Forms.Shortcut"); +types["System.Windows.Forms.Keys"] = luanet.import_type("System.Windows.Forms.Keys"); +types["System.Windows.Forms.Cursor"] = luanet.import_type("System.Windows.Forms.Cursor"); +types["System.Windows.Forms.Cursors"] = luanet.import_type("System.Windows.Forms.Cursors"); +types["System.DBNull"] = luanet.import_type("System.DBNull"); +types["System.Windows.Forms.Application"] = luanet.import_type("System.Windows.Forms.Application"); +types["System.Xml.XmlDocument"] = luanet.import_type("System.Xml.XmlDocument"); +types["log4net.LogManager"] = luanet.import_type("log4net.LogManager"); + +local rootLogger = "AtlasSystems.Addons.AlmaPrimoDefinitiveCatalogSearch"; +local log = types["log4net.LogManager"].GetLogger(rootLogger); + +local product = types["System.Windows.Forms.Application"].ProductName; +local cursor = types["System.Windows.Forms.Cursor"]; +local cursors = types["System.Windows.Forms.Cursors"]; +local watcherEnabled = false; +local recordsLastRetrievedFrom = ""; +local layoutMode = "browse"; + +function Init() + interfaceMngr = GetInterfaceManager(); + + -- Create a form + catalogSearchForm.Form = interfaceMngr:CreateForm(DataMapping.LabelName, DataMapping.LabelName); + log:DebugFormat("catalogSearchForm.Form = {0}", catalogSearchForm.Form); + + -- Add a browser + catalogSearchForm.Browser = catalogSearchForm.Form:CreateBrowser(DataMapping.LabelName, "Catalog Search Browser", DataMapping.LabelName, "WebView2"); + log:DebugFormat("catalogSearchForm.Browser = {0}", catalogSearchForm.Browser); + + -- Since we didn't create a ribbon explicitly before creating our browser, it will have created one using the name we passed the CreateBrowser method. We can retrieve that one and add our buttons to it. + catalogSearchForm.RibbonPage = catalogSearchForm.Form:GetRibbonPage(DataMapping.LabelName); + + -- Create the search button(s) + catalogSearchForm.SearchButtons["Home"] = catalogSearchForm.RibbonPage:CreateButton("New Search", GetClientImage(DataMapping.Icons[product]["Web"]), "ShowCatalogHome", "Search Options"); + + log:Info("Creating buttons for available search types."); + for _, searchType in ipairs(settings.AvailableSearchTypes) do + local searchStyle = DataMapping.SearchTypes[searchType].SearchStyle; + + log:DebugFormat("Creating button for search type {0} with search style {1}", searchType, searchStyle); + + catalogSearchForm.SearchButtons[searchType] = catalogSearchForm.RibbonPage:CreateButton(DataMapping.SearchTypes[searchType].ButtonText, GetClientImage(DataMapping.SearchTypes[searchType][product .. "Icon"]), "Placeholder", "Search Options"); + + catalogSearchForm.SearchButtons[searchType].BarButton:add_ItemClick(ButtonSearch); + catalogSearchForm.SearchButtons[searchType].BarButton.Tag = {SearchType = searchType, SearchStyle = searchStyle}; + end + + if (not settings.AutoRetrieveItems) then + catalogSearchForm.ItemsButton = catalogSearchForm.RibbonPage:CreateButton("Retrieve Items", GetClientImage(DataMapping.Icons[product]["Retrieve Items"]), "RetrieveItems", "Process"); + catalogSearchForm.ItemsButton.BarButton.ItemShortcut = types["DevExpress.XtraBars.BarShortcut"](types["System.Windows.Forms.Shortcut"].CtrlR); + end + + catalogSearchForm.ImportButton = catalogSearchForm.RibbonPage:CreateButton("Import", GetClientImage(DataMapping.Icons[product]["Import"]), "DoItemImport", "Process"); + catalogSearchForm.ImportButton.BarButton.ItemShortcut = types["DevExpress.XtraBars.BarShortcut"](types["System.Windows.Forms.Shortcut"].CtrlI); + catalogSearchForm.ImportButton.BarButton.Enabled = false; + + BuildItemsGrid(); + catalogSearchForm.Form:LoadLayout("CatalogLayout_Browse.xml"); + + -- After we add all of our buttons and form elements, we can show the form. + catalogSearchForm.Form:Show(); + + -- Initializing the AlmaApi + AlmaApi.ApiUrl = settings.AlmaApiUrl; + AlmaApi.ApiKey = settings.AlmaApiKey; + + -- Search when opened if autoSearch is true + local transactionNumber = GetFieldValue(DataMapping.SourceFields[product]["TransactionNumber"].Table, DataMapping.SourceFields[product]["TransactionNumber"].Field); + + if settings.AutoSearch and transactionNumber and transactionNumber > 0 then + log:Debug("Performing AutoSearch"); + + PerformSearch({nil, nil}); + else + log:Debug("Navigating to Catalog URL because AutoSearch is disabled."); + ShowCatalogHome(); + end +end + +function StopRecordPageWatcher() + if watcherEnabled then + log:Debug("Stopping record page watcher."); + catalogSearchForm.Browser:StopPageWatcher(); + + watcherEnabled = false; + end +end + +function StartRecordPageWatcher() + if not watcherEnabled then + log:Debug("Starting record page watcher."); + + local checkIntervalMilliseconds = 3000; -- 3 seconds + local maxWatchTimeMilliseconds = 300000; -- 5 minutes + catalogSearchForm.Browser:StartPageWatcher(checkIntervalMilliseconds, maxWatchTimeMilliseconds); + + watcherEnabled = true; + end +end + +function InitializeRecordPageHandler() + catalogSearchForm.Browser:RegisterPageHandler("custom", "IsRecordPageLoaded", "RecordPageHandler", false); + + StartRecordPageWatcher(); +end + +function ShowCatalogHome() + InitializeRecordPageHandler(); + catalogSearchForm.Browser:Navigate(settings.HomeUrl); +end + +-- Search Functions +function Placeholder() + -- Does nothing. This is a placeholder assigned as the function handler when creating the search buttons since a null cannot be used. + -- We are assigning a custom ItemClick event instead that passes event args to ButtonSearch. +end + +function ButtonSearch(sender, args) + local searchType = args.Item.Tag.SearchType; + local searchStyle = args.Item.Tag.SearchStyle; + + log:InfoFormat("{0} search button clicked.", searchType); + + PerformSearch({searchType, searchStyle}); +end + +function GetAutoSearchInfo() + local priorityList = settings.SearchPriorityList; + + log:Info("Determining autosearch type from search priority list."); + for _, searchType in ipairs(priorityList) do + if DataMapping.SearchTypes[searchType] and DataMapping.SearchTypes[searchType][product .. "SourceField"] ~= nil then + + local fieldDefinition = DataMapping.SearchTypes[searchType][product .. "SourceField"]; + local fieldValue = GetFieldValue(fieldDefinition.Table, fieldDefinition.Field); + + log:DebugFormat("Search type: {0}, field value: {1}", searchType, fieldValue); + if fieldValue and fieldValue ~= "" then + return {searchType, DataMapping.SearchTypes[searchType]["SearchStyle"]}; + end + end + end + + return {nil, nil}; +end + +-- searchInfo is a Lua table where index 1 = searchType and index 2 = searchStyle +function PerformSearch(searchInfo) + InitializeRecordPageHandler(); + + if searchInfo[1] == nil then + searchInfo = GetAutoSearchInfo(); + log:DebugFormat("Autosearch type: {0}", searchInfo[1]); + if not searchInfo[1] then + log:Debug("The search type could not be determined using the current request information."); + return; + end + end + + local fieldDefinition = DataMapping.SearchTypes[searchInfo[1]][product .. "SourceField"]; + local searchTerm = GetFieldValue(fieldDefinition.Table, fieldDefinition.Field); + + if searchTerm == nil then + searchTerm = ""; + end + + local searchUrl = ""; + + --Construct the search url based on the base catalog url and search style. + searchUrl = settings.CatalogUrl .. DataMapping.SearchStyleUrls[searchInfo[2]]:gsub("{PrimoSiteCode}", settings.PrimoSiteCode):gsub("{SearchType}", DataMapping.SearchTypes[searchInfo[1]]["PrimoField"]):gsub("{SearchTerm}", Utility.URLEncode(searchTerm)); + + log:InfoFormat("Navigating to {0}", searchUrl); + catalogSearchForm.Browser:Navigate(searchUrl); +end + +function GetMmsIds() + log:DebugFormat("Retrieving IDs from {0}", catalogSearchForm.Browser.Address); + + local itemDetails = catalogSearchForm.Browser:EvaluateScript([[document.getElementById("item-details").innerText;]]).Result; + local ids = {}; + if itemDetails and itemDetails ~= nil then + ids = ExtractIds(itemDetails); + else + log:Debug("Element with ID 'item-details' not found."); + end + + if #ids > 0 then + StopRecordPageWatcher(); + local mmsIds = ConvertIeIdsToMmsIds(ids); + if #mmsIds > 0 then + cursor.Current = cursors.Default; + + log:InfoFormat("Found {0} MMS IDs.", #mmsIds); + return mmsIds; + end + end + + cursor.Current = cursors.Default; + return {}; +end + +function ExtractIds(itemDetails) + local idMatches = {}; + local urlId = (catalogSearchForm.Browser.Address):match("%d+" .. settings.IdSuffix); + idMatches[urlId] = true; + + -- MMS Ids (and presumably IE IDs) all have the same last four digits specific to the institution. + -- 99 is the prefix for MMS IDs, and IE IDs always have 1, 2, or 5 as their first digit and 1 as the second digit. + + log:Info("Extracting IDs from item-details element."); + for id in itemDetails:gmatch("%d+" .. settings.IdSuffix) do + -- Easy way to prevent duplicates regardless of order since the keys get overwritten. + if id:find("^99") or id:find("^[125]1") then + log:DebugFormat("Found ID: {0}", id); + idMatches[id] = true; + end + end + + local ids = {}; + for id, _ in pairs(idMatches) do + ids[#ids+1] = id; + end + + for i = 1, #ids do + log:Debug(ids[i]); + end + return ids; +end + +function ConvertIeIdsToMmsIds(ieIds) + local resolvedIds = {}; + local mmsIds = {}; + + for i = 1, #ieIds do + if ieIds[i]:find("^[125]1") then + local bibResponse = AlmaApi.RetrieveBibs(ieIds[i], "ie_id"); + local totalRecordCount = tonumber(bibResponse:SelectSingleNode("//@total_record_count").Value); + + if totalRecordCount and totalRecordCount > 0 then + local mmsId = bibResponse:SelectSingleNode("bibs/bib/mms_id").InnerXml; + log:DebugFormat("MMS ID: {0}", mmsId); + if mmsId then + log:DebugFormat("IE ID {0} -> MMS ID {1}", ieIds[i], mmsId); + -- We want to avoid duplicates here as well. + resolvedIds[mmsId] = true; + end + end + else + -- Already an MMS ID. + resolvedIds[ieIds[i]] = true; + end + end + + for id, _ in pairs(resolvedIds) do + mmsIds[#mmsIds+1] = id; + end + + return mmsIds; +end + +function IsRecordPageLoaded() + local pageUrl = catalogSearchForm.Browser.Address; + + if pageUrl:find("fulldisplay%?") then + log:DebugFormat("Is a record page. {0}", pageUrl); + + local mmsIds = {}; + if not mmsIdsCache[pageUrl] then + mmsIdsCache[pageUrl] = GetMmsIds(); + end + mmsIds = mmsIdsCache[pageUrl]; + + if #mmsIds == 0 then + log:Debug("Linked Data not loaded."); + StartRecordPageWatcher(); + ToggleItemsUIElements(false); + return false; + end + return true; + else + log:DebugFormat("Is not a record page. {0}", pageUrl); + ToggleItemsUIElements(false); + return false; + end +end + +function RecordPageHandler() + --The record page has been loaded. We now need to wait to see when the holdings information comes in. + ToggleItemsUIElements(true); + + --Re-initialize the record page handler in case the user navigates away from a record page to search again + InitializeRecordPageHandler(); +end + +function Truncate(value, size) + if size == nil then + log:Debug("Size was nil. Truncating to 50 characters"); + size = 50; + end + if ((value == nil) or (value == "")) then + log:Debug("Value was nil or empty. Skipping truncation."); + return value; + else + log:DebugFormat("Truncating to {0} characters: {1}", size, value); + return string.sub(value, 0, size); + end +end + +function ImportField(targetTable, targetField, newFieldValue, targetSize) + if newFieldValue and newFieldValue ~= "" and newFieldValue ~= types["System.DBNull"].Value then + SetFieldValue(targetTable, targetField, Truncate(newFieldValue, targetSize)); + end +end + +function ToggleItemsUIElements(enabled) + if (enabled) then + log:Debug("Enabling UI."); + + if (settings.AutoRetrieveItems) then + -- Prevents the addon from rerunning RetrieveItems on the same page + if (catalogSearchForm.Browser.Address ~= recordsLastRetrievedFrom) then + -- Sets the recordsLastRetrievedFrom to the current page + local hasRecords = RetrieveItems(); + recordsLastRetrievedFrom = catalogSearchForm.Browser.Address; + catalogSearchForm.Grid.GridControl.Enabled = hasRecords; + end + else + catalogSearchForm.ItemsButton.BarButton.Enabled = true; + recordsLastRetrievedFrom = ""; + -- If there's an item in the Item Grid + if(catalogSearchForm.Grid.GridControl.MainView.FocusedRowHandle > -1) then + catalogSearchForm.Grid.GridControl.Enabled = true; + catalogSearchForm.ImportButton.BarButton.Enabled = true; + end + end + else + log:Debug("Disabling UI."); + ClearItems(); + recordsLastRetrievedFrom = ""; + catalogSearchForm.Grid.GridControl.Enabled = false; + catalogSearchForm.ImportButton.BarButton.Enabled = false; + + if (not settings.AutoRetrieveItems) then + catalogSearchForm.ItemsButton.BarButton.Enabled = false; + end + + if layoutMode == "import" then + layoutMode = "browse"; + catalogSearchForm.Form:LoadLayout("CatalogLayout_Browse.xml"); + end + end + log:Debug("Finished Toggling UI Elements"); +end + +function BuildItemsGrid() + catalogSearchForm.Grid = catalogSearchForm.Form:CreateGrid("CatalogItemsGrid", "Items"); + catalogSearchForm.Grid.GridControl.Enabled = false; + + catalogSearchForm.Grid.TextSize = types["System.Drawing.Size"].Empty; + catalogSearchForm.Grid.TextVisible = false; + + local gridControl = catalogSearchForm.Grid.GridControl; + + -- Set the grid view options + local gridView = gridControl.MainView; + gridView.OptionsView.ShowIndicator = false; + gridView.OptionsView.ShowGroupPanel = false; + gridView.OptionsView.RowAutoHeight = true; + gridView.OptionsView.ColumnAutoWidth = true; + gridView.OptionsBehavior.AutoExpandAllGroups = true; + gridView.OptionsBehavior.Editable = false; + + gridControl:BeginUpdate(); + + -- Item Grid Column Settings + local gridColumn; + gridColumn = gridView.Columns:Add(); + gridColumn.Caption = "MMS ID"; + gridColumn.FieldName = "ReferenceNumber"; + gridColumn.Name = "gridColumnReferenceNumber"; + gridColumn.Visible = false; + gridColumn.OptionsColumn.ReadOnly = true; + gridColumn.Width = 50; + + gridColumn = gridView.Columns:Add(); + gridColumn.Caption = "Holding ID"; + gridColumn.FieldName = "HoldingId"; + gridColumn.Name = "gridColumnHoldingId"; + gridColumn.Visible = false; + gridColumn.OptionsColumn.ReadOnly = true; + gridColumn.Width = 50; + + gridColumn = gridView.Columns:Add(); + gridColumn.Caption = "Location"; + gridColumn.FieldName = "Location"; + gridColumn.Name = "gridColumnLocation"; + gridColumn.Visible = true; + gridColumn.VisibleIndex = 0; + gridColumn.OptionsColumn.ReadOnly = true; + + gridColumn = gridView.Columns:Add(); + gridColumn.Caption = "Barcode"; + gridColumn.FieldName = "Barcode"; + gridColumn.Name = "gridColumnBarcode"; + gridColumn.Visible = true; + gridColumn.VisibleIndex = 0; + gridColumn.OptionsColumn.ReadOnly = true; + + gridColumn = gridView.Columns:Add(); + gridColumn.Caption = "Location Code"; + gridColumn.FieldName = "Library"; + gridColumn.Name = "gridColumnLibrary"; + gridColumn.Visible = true; + gridColumn.OptionsColumn.ReadOnly = true; + + gridColumn = gridView.Columns:Add(); + gridColumn.Caption = "Call Number"; + gridColumn.FieldName = "CallNumber"; + gridColumn.Name = "gridColumnCallNumber"; + gridColumn.Visible = true; + gridColumn.VisibleIndex = 1; + gridColumn.OptionsColumn.ReadOnly = true; + + gridColumn = gridView.Columns:Add(); + gridColumn.Caption = "Item Description"; + gridColumn.FieldName = "Description"; + gridColumn.Name = "gridColumnDescription"; + gridColumn.Visible = true; + gridColumn.VisibleIndex = 1; + gridColumn.OptionsColumn.ReadOnly = true; + + gridControl:EndUpdate(); + + gridView:add_FocusedRowChanged(ItemsGridFocusedRowChanged); +end + +function ItemsGridFocusedRowChanged(sender, args) + if (args.FocusedRowHandle > -1) then + catalogSearchForm.ImportButton.BarButton.Enabled = true; + catalogSearchForm.Grid.GridControl.Enabled = true; + else + catalogSearchForm.ImportButton.BarButton.Enabled = false; + end; +end + +function RetrieveItems() + cursor.Current = cursors.WaitCursor; + if layoutMode == "browse" then + layoutMode = "import"; + catalogSearchForm.Form:LoadLayout("CatalogLayout_Import.xml"); + end + + local pageUrl = catalogSearchForm.Browser.Address; + local mmsIds = {}; + + if not mmsIdsCache[pageUrl] then + mmsIds = GetMmsIds(); + end + mmsIds = mmsIdsCache[pageUrl]; + + if #mmsIds > 0 then + local hasHoldings = false; + -- Create a new Item Data Table to Populate + local itemsDataTable = CreateItemsTable(); + for i = 1, #mmsIds do + -- Cache the response if it hasn't been cached + if (holdingsXmlDocCache[mmsIds[i]] == nil) then + log:DebugFormat("Caching Holdings For {0}", mmsIds[i]); + holdingsXmlDocCache[mmsIds[i]] = AlmaApi.RetrieveHoldingsList(mmsIds[i]); + end + + local holdingsResponse = holdingsXmlDocCache[mmsIds[i]]; + + -- Check if it has any holdings available + local totalHoldingCount = tonumber(holdingsResponse:SelectSingleNode("holdings/@total_record_count").Value); + local suppressedNodeList = holdingsResponse:SelectNodes("holdings/holding/suppress_from_publishing[text()='true']"); + local suppressedHoldingsCount = tonumber(suppressedNodeList.Count); + + log:DebugFormat("Records available: {0} ({1} total, {2} suppressed)", totalHoldingCount - suppressedHoldingsCount, totalHoldingCount, suppressedHoldingsCount); + + -- Retrieve Item Data if Holdings are available + if totalHoldingCount - suppressedHoldingsCount > 0 then + hasHoldings = true; + -- Get list of the holding ids + local holdingIds = GetHoldingIds(holdingsResponse); + + for _, holdingId in ipairs(holdingIds) do + log:DebugFormat("Holding ID: {0}", holdingId); + -- Cache the response if it hasn't been cached + if (itemsXmlDocCache[holdingId] == nil ) then + log:DebugFormat("Caching items for {0}", mmsIds[i]); + itemsXmlDocCache[holdingId] = AlmaApi.RetrieveItemsList(mmsIds[i], holdingId); + end + + local itemsResponse = itemsXmlDocCache[holdingId]; + + PopulateItemsDataSources(itemsResponse, itemsDataTable); + end + end + end + + if not hasHoldings then + ClearItems(); + end + cursor.Current = cursors.Default; + return hasHoldings; + else + return false; + end +end + +function CreateItemsTable() + local itemsTable = types["System.Data.DataTable"](); + + itemsTable.Columns:Add("ReferenceNumber"); + itemsTable.Columns:Add("Barcode"); + itemsTable.Columns:Add("HoldingId"); + itemsTable.Columns:Add("Library"); + itemsTable.Columns:Add("Location"); + itemsTable.Columns:Add("CallNumber"); + itemsTable.Columns:Add("Description"); + + return itemsTable; +end + +function ClearItems() + catalogSearchForm.Grid.GridControl:BeginUpdate(); + catalogSearchForm.Grid.GridControl.DataSource = CreateItemsTable(); + catalogSearchForm.Grid.GridControl:EndUpdate(); +end + +function GetHoldingIds(holdingsXmlDoc) + local holdingNodes = holdingsXmlDoc:GetElementsByTagName("holding"); + local holdingIds = {}; + log:DebugFormat("Holding nodes found: {0}", holdingNodes.Count); + + for i = 0, holdingNodes.Count - 1 do + local holdingNode = holdingNodes:Item(i); + table.insert(holdingIds, holdingNode["holding_id"].InnerXml); + end + + return holdingIds; +end + +function SetItemNodeFromCustomizedMapping(itemRow, itemNode, aeonField, mappings) + if itemNode then + if mappings[itemNode.InnerXml] and mappings[itemNode.InnerXml] ~= "" then + itemRow = SetItemNode(itemRow, mappings[itemNode.InnerXml], aeonField); + else + log:DebugFormat("Customized mapping NOT found for {0}. Setting row to innerXml.", aeonField, itemNode.InnerXml); + itemRow = SetItemNode(itemRow, itemNode.InnerXml, aeonField); + end + return itemRow; + else + log:DebugFormat("Cannot set {0}. Item node is Nil", aeonField); + return itemRow; + end +end + +function SetItemNodeFromXML(itemRow, itemNode, aeonField) + if itemNode then + return SetItemNode(itemRow, itemNode.InnerXml, aeonField); + else + log:DebugFormat("Cannot set {0}. Item Node is Nil", aeonField); + return itemRow; + end +end + +function SetItemNode(itemRow, data, aeonField) + local success, error = pcall(function() + itemRow:set_Item(aeonField, data); + end); + + if success then + log:DebugFormat("Setting {0} to {1}", aeonField, data); + else + log:DebugFormat("Error setting {0} to {1}", aeonField, data); + log:ErrorFormat("Error: {0}", error); + end + + return itemRow; +end + +function PopulateItemsDataSources( response, itemsDataTable ) + catalogSearchForm.Grid.GridControl:BeginUpdate(); + + local itemNodes = response:GetElementsByTagName("item"); + log:DebugFormat("Item nodes found: {0}", itemNodes.Count); + + for i = 0, itemNodes.Count - 1 do + local itemRow = itemsDataTable:NewRow(); + local itemNode = itemNodes:Item(i); + + local bibData = itemNode["bib_data"]; + local holdingData = itemNode["holding_data"]; + local itemData = itemNode["item_data"]; + log:DebugFormat("ItemNode: {0}", itemNode.OuterXml); + + itemRow = SetItemNodeFromXML(itemRow, bibData["mms_id"], "ReferenceNumber"); + itemRow = SetItemNodeFromXML(itemRow, holdingData["holding_id"], "HoldingId"); + itemRow = SetItemNodeFromXML(itemRow, holdingData["call_number"], "CallNumber"); + itemRow = SetItemNodeFromCustomizedMapping(itemRow, itemData["location"], "Location", CustomizedMapping.Locations); + itemRow = SetItemNodeFromXML(itemRow, itemData["library"], "Library"); + itemRow = SetItemNodeFromXML(itemRow, itemData["barcode"], "Barcode"); + itemRow = SetItemNodeFromXML(itemRow, itemData["description"], "Description"); + + itemsDataTable.Rows:Add(itemRow); + end + + catalogSearchForm.Grid.GridControl.DataSource = itemsDataTable; + catalogSearchForm.Grid.GridControl:EndUpdate(); +end + +function DoItemImport() + cursor.Current = cursors.WaitCursor; + + log:Debug("Retrieving import row."); + local importRow = catalogSearchForm.Grid.GridControl.MainView:GetFocusedRow(); + + if (importRow == nil) then + log:Debug("Import row was nil. Cancelling the import."); + return; + end; + + log:Info("Importing item values."); + for _, target in ipairs(DataMapping.ImportFields.Item[product]) do + local importValue = importRow:get_Item(target.Value); + + log:DebugFormat("Importing value '{0}' to {1}", importValue, target.Field); + ImportField(target.Table, target.Field, importValue, target.MaxSize); + end + + local mmsId = importRow:get_Item("ReferenceNumber"); + local holdingId = importRow:get_Item("HoldingId"); + + local holdingInformation = GetMarcInformation(mmsId, holdingId); + local bibliographicInformation = GetMarcInformation(mmsId); + + log:Info("Importing bib values."); + for _, target in ipairs(bibliographicInformation) do + log:DebugFormat("Importing value '{0}' to {1}", target.Value, target.Field); + ImportField(target.Table, target.Field, target.Value, target.MaxSize); + end + + log:Info("Importing holding values."); + for _, target in ipairs(holdingInformation) do + log:DebugFormat("Importing value '{0}' to {1}", target.Value, target.Field); + ImportField(target.Table, target.Field, target.Value, target.MaxSize); + end + + cursor.Current = cursors.Default; + ExecuteCommand("SwitchTab", "Detail"); +end + +function GetMarcInformation(mmsId, holdingId) + local marcInformation = {}; + + local marcXmlDoc = nil; + if holdingId then + log:DebugFormat("Retrieving MARC info for holding ID {0}", holdingId); + marcXmlDoc = AlmaApi.RetrieveHoldingsRecordInfo(mmsId, holdingId); + else + log:DebugFormat("Retrieving MARC info for MMS ID {0}", mmsId); + marcXmlDoc = AlmaApi.RetrieveBibs(mmsId, "mms_id"); + end + + local recordNodes = marcXmlDoc:SelectNodes("//record"); + + if recordNodes then + log:InfoFormat("Found {0} MARC records", recordNodes.Count); + + -- Loops through each record + for recordNodeIndex = 0, (recordNodes.Count - 1) do + log:DebugFormat("Processing record {0}", recordNodeIndex); + local recordNode = recordNodes:Item(recordNodeIndex); + + -- Loops through each import mapping + local mappingTable = {}; + if holdingId then + mappingTable = DataMapping.ImportFields.Holding[product]; + else + mappingTable = DataMapping.ImportFields.Bibliographic[product]; + end + + for _, target in ipairs(mappingTable) do + if (target and target.Field and target.Field ~= "") then + local marcSets = Utility.StringSplit(',', target.Value ); + + -- Loops through the MARC sets array + for _, xPath in ipairs(marcSets) do + log:DebugFormat("XPath: {0}", xPath); + local datafieldNode = recordNode:SelectNodes(xPath); + log:DebugFormat("DataField node matches: {0}", datafieldNode.Count); + + if (datafieldNode.Count > 0) then + local fieldValue = ""; + + -- Loops through each data field node retured from xPath and concatenates them (generally only 1) + for datafieldNodeIndex = 0, (datafieldNode.Count - 1) do + local datafieldNodeValue = datafieldNode:Item(datafieldNodeIndex).InnerText; + log:DebugFormat("Datafield node value: {0}", datafieldNodeValue); + fieldValue = fieldValue .. " " .. datafieldNodeValue; + end + + if(settings.RemoveTrailingSpecialCharacters) then + fieldValue = Utility.RemoveTrailingSpecialCharacters(fieldValue); + else + fieldValue = Utility.Trim(fieldValue); + end + + AddMarcInformation(marcInformation, target.Table, target.Field, fieldValue, target.MaxSize); + + -- Need to break from MARC Set loop so the first record isn't overwritten + break; + end + end + end + end + end + end + + return marcInformation; +end + +function AddMarcInformation(marcInformation, targetTable, targetField, fieldValue, targetMaxSize) + local marcInfoEntry = {Table = targetTable, Field = targetField, Value = fieldValue, MaxSize = targetMaxSize} + table.insert( marcInformation, marcInfoEntry ); +end + +function OnError(err) + log:ErrorFormat("Alma Primo Definitive Catalog Search encountered an error: {0}", TraverseError(err)); +end + +function TraverseError(e) + if not e.GetType then + -- Not a .NET type + return nil; + else + if not e.Message then + -- Not a .NET exception + log:Debug(e:ToString()); + return nil; + end + end + + log:Debug(e.Message); + + if e.InnerException then + return TraverseError(e.InnerException); + else + return e.Message; + end +end \ No newline at end of file diff --git a/CatalogLayout.xml b/CatalogLayout.xml new file mode 100644 index 0000000..8e9ecaa --- /dev/null +++ b/CatalogLayout.xml @@ -0,0 +1,261 @@ + + + + + 0 + true + Vertical + false + 0, 0, 0, 0 + @3,Width=983@3,Height=521 + false + Normal + 0, 0, 0, 0 + false + + Top + + + + + + + Tahoma, 8.25pt + Horizontal + + + + + + + Tahoma, 8.25pt + Horizontal + + + + + + + Tahoma, 8.25pt + Horizontal + + + + + + + Tahoma, 8.25pt + Horizontal + + + + + + + Tahoma, 8.25pt + Horizontal + + + + + + + + Tahoma, 8.25pt + Horizontal + + + + + + + Tahoma, 8.25pt + Horizontal + + Root + + Default + Default + + + + + None + + + None + true + + @1,X=0@1,Y=0 + false + Default + True + Default + Always + -1 + true + false + true + + 3 + UseParentOptions + + LayoutGroup + Main Group + Main Group + true + + + + 5 + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=104@2,Height=24 + @3,Width=983@3,Height=424 + @1,X=0@1,Y=0 + + Default + Default + + + + + None + + + None + true + + 2, 2, 2, 2 + Catalog Search + + + + + + Tahoma, 8.25pt + Horizontal + + 0, 0, 0, 0 + 0 + Root + MiddleLeft + -1 + false + true + + Catalog Search Browser + Catalog Search + Default + Left + CustomSize + AtlasSystems.Scripting.UI.AddonControls.ChromiumBrowser + Always + false + Catalog Search + + + Root + 5 + @1,Width=0@1,Height=0 + @3,Width=104@2,Height=24 + @1,X=0@3,Y=428 + + Default + Default + + + + + None + + + None + true + + 2, 2, 2, 2 + 0, 0, 0, 0 + 0 + CatalogItemsGrid + + + + + + Tahoma, 8.25pt + Horizontal + + @1,Width=0@1,Height=0 + false + -1 + @3,Width=983@2,Height=93 + MiddleLeft + + true + Items + Default + CatalogItemsGrid + CustomSize + Left + AtlasSystems.Scripting.UI.AddonControls.Grid + Always + false + CatalogItemsGrid + + + MiddleLeft + Default + item0 + true + + false + + 0 + @1,X=0@3,Y=424 + 5 + Root + + Default + Default + + + + + None + + + None + true + + -1 + @1,Width=0@1,Height=4 + item0 + AutoSize + item0 + @1,Width=4@1,Height=4 + + + + + + Tahoma, 8.25pt + Horizontal + + 0, 0, 0, 0 + 2, 2, 2, 2 + false + Default + @3,Width=983@1,Height=4 + SplitterItem + Always + OnlyAdjacentControls + @1,Width=0@1,Height=0 + + + + DevExpress Style + Skin + true + false + + diff --git a/CatalogLayout_Browse.xml b/CatalogLayout_Browse.xml new file mode 100644 index 0000000..20803e4 --- /dev/null +++ b/CatalogLayout_Browse.xml @@ -0,0 +1,202 @@ + + + + true + false + true + false + true + true + true + false + AcrossThenDown + + + DevExpress Style + Skin + true + false + + + + LayoutGroup + + false + true + True + + 3 + UseParentOptions + + true + LeftToRight + Regular + Default + -1 + false + false + true + Vertical + false + false + Default + + + + + @4,Width=1914@3,Height=848 + false + Normal + Default + Inherited + Top + 0 + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + Root + + false + @1,X=0@1,Y=0 + true + Main Group + Main Group + false + Always + + + AtlasSystems.Scripting.UI.AddonControls.WebView2Browser + Catalog Search + false + CustomSize + Default + + -1 + MiddleLeft + 5 + + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=102@2,Height=22 + TopLeft + true + Default + true + Default + Default + Default + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + Catalog Search + Root + false + @1,Width=0@1,Height=0 + @1,X=0@1,Y=0 + @4,Width=1900@3,Height=834 + true + Catalog Search Browser + Catalog Search + false + Always + Default + + + AtlasSystems.Scripting.UI.AddonControls.Grid + CatalogItemsGrid + false + CustomSize + Default + + -1 + MiddleLeft + 5 + + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=104@2,Height=24 + TopLeft + true + Default + true + Default + Default + Default + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + CatalogItemsGrid + Customization + false + @1,Width=0@1,Height=0 + @1,X=0@3,Y=514 + @4,Width=1900@3,Height=320 + true + Items + CatalogItemsGrid + false + Always + Left + + + \ No newline at end of file diff --git a/CatalogLayout_Import.xml b/CatalogLayout_Import.xml new file mode 100644 index 0000000..e5205b8 --- /dev/null +++ b/CatalogLayout_Import.xml @@ -0,0 +1,202 @@ + + + + true + false + true + false + true + true + true + false + AcrossThenDown + + + DevExpress Style + Skin + true + false + + + + LayoutGroup + + false + true + True + + 3 + UseParentOptions + + true + LeftToRight + Regular + Default + -1 + false + false + true + Vertical + false + false + Default + + + + + @3,Width=988@3,Height=522 + false + Normal + Default + Inherited + Top + 0 + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + Root + + false + @1,X=0@1,Y=0 + true + Main Group + Main Group + false + Always + + + AtlasSystems.Scripting.UI.AddonControls.WebView2Browser + Catalog Search + false + CustomSize + Default + + -1 + MiddleLeft + 5 + + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=102@2,Height=22 + TopLeft + true + Default + true + Default + Default + Default + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + Catalog Search + Root + false + @1,Width=0@1,Height=0 + @1,X=0@1,Y=0 + @3,Width=974@3,Height=337 + true + Catalog Search Browser + Catalog Search + false + Always + Default + + + AtlasSystems.Scripting.UI.AddonControls.Grid + CatalogItemsGrid + false + CustomSize + Default + + -1 + MiddleLeft + 5 + + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=102@2,Height=22 + TopLeft + true + Default + true + Default + Default + Default + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + CatalogItemsGrid + Root + false + @1,Width=0@1,Height=0 + @1,X=0@3,Y=337 + @3,Width=974@3,Height=171 + true + Items + CatalogItemsGrid + false + Always + Left + + + \ No newline at end of file diff --git a/Config.xml b/Config.xml new file mode 100644 index 0000000..0f41cd4 --- /dev/null +++ b/Config.xml @@ -0,0 +1,55 @@ + + + Alma Primo Definitive Catalog Search + Atlas Systems + 1.0.0 + True + Addon + Catalog Search and Import Addon that uses Alma as the catalog and Primo or Primo VE as the discovery layer. + +
FormRequest
+
FormItem
+
+ + + The base URL that the query strings are appended to. + + + Home page of the catalog. + + + Defines whether the search should be automatically performed when the form opens. + + + Defines whether to remove trailing special characters on import or not. + + + The types of searches your catalog supports (e.g. Title, Author, Call Number, Catalog Number) + + + The fields that should be searched on, in order of search priority. Each field in the string will be checked for a valid corresponding search value in the request, and the first search type with a valid corresponding value will be used. + + + Defines whether or not the addon should automatically retrieve items related to a record being viewed. + + + The URL to the Alma API + + + API key used for interacting with the Alma API. + + + The code that identifies the site in Primo Deep Links. Ex: vid={PrimoSiteCode} + + + The last four digits of MMS IDs and IE IDs for your institution. + + + + Catalog.lua + DataMapping.lua + CustomizedMapping.lua + WebClient.lua + AlmaApi.lua + +
diff --git a/CustomizedMapping.lua b/CustomizedMapping.lua new file mode 100644 index 0000000..4332909 --- /dev/null +++ b/CustomizedMapping.lua @@ -0,0 +1,9 @@ +CustomizedMapping = {} +CustomizedMapping.Locations = {}; + +--Note: The mappings listed are prefix matches. The addon will verify if the location value listed below is the prefix of the location code found in the MARC XML data. +--Since the addon is matching based on prefixes, more specific mappings should be listed first. +--If a mapping code is not found, the code will be used as its location. + +-- Example Location Mapping: +-- CustomizedMapping.Locations["finelock"] = "Fine Locked Case"; diff --git a/DataMapping.lua b/DataMapping.lua new file mode 100644 index 0000000..c7da76c --- /dev/null +++ b/DataMapping.lua @@ -0,0 +1,174 @@ +DataMapping = {} +DataMapping.Icons = {}; +DataMapping.SearchTypes = {}; +DataMapping.SearchStyleUrls = {}; +DataMapping.SourceFields = {}; +DataMapping.ImportFields = {}; +DataMapping.ImportFields.Bibliographic = {}; +DataMapping.ImportFields.Holding = {}; +DataMapping.ImportFields.Item = {}; +DataMapping.ImportFields.StaticHolding = {}; + +-- The display name for the addon's tab. +DataMapping.LabelName = "Catalog Search"; + +-- Icons for non-search buttons for Aeon. + -- Icons can also be added for ILLiad or Ares by using those product names as keys. +DataMapping.Icons["Aeon"] = {}; +DataMapping.Icons["Aeon"]["Home"] = "home_32x32"; +DataMapping.Icons["Aeon"]["Web"] = "web_32x32"; +DataMapping.Icons["Aeon"]["Retrieve Items"] = "record_32x32"; +DataMapping.Icons["Aeon"]["Import"] = "impt_32x32"; + +--[[ + SearchTypes + - ButtonText: The text that appears on the ribbon button for the search. + - PrimoField: The field name used by Primo in the seach URL. + - SearchStyle: Query or Browse. + - AeonIcon: The name of the icon file to use as the button's image. + Icons for ILLiad and Ares can be used by adding an "ILLiadIcon" or "AresIcon" property. + - AeonSourceField: The table and field name to draw the search term from in the request. + Source fields for ILLiad and Ares can be used by adding an ILLiadSourceField or AresSourceField property. +--]] +DataMapping.SearchTypes["Title"] = { + ButtonText = "Title", + PrimoField = "title", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ItemTitle" } +}; +DataMapping.SearchTypes["Author"] = { + ButtonText = "Author", + PrimoField = "creator", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ItemAuthor" } +}; +DataMapping.SearchTypes["Call Number"] = { + ButtonText = "Call Number", + PrimoField = "lsr01", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "CallNumber" } +}; +DataMapping.SearchTypes["ISBN"] = { + ButtonText = "ISBN", + PrimoField = "isbn", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ItemISxN" } +}; +DataMapping.SearchTypes["ISSN"] = { + ButtonText = "ISSN", + PrimoField = "issn", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ItemISxN" } +}; +-- Catalog Number uses the Any search type because Primo catalogs don't have built in MMS ID searching. +DataMapping.SearchTypes["Catalog Number"] = { + ButtonText = "Catalog Number", + PrimoField = "any", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ReferenceNumber" } +}; + +-- SearchStyleUrls +-- Words in brackets will be replaced by their corresponding settings or values by the addon. +-- Only one Query and one Browse style URL may be defined. These will be concatenated to the + -- end of the CatalogUrl setting when searching. +DataMapping.SearchStyleUrls["Query"] = "search?vid={PrimoSiteCode}&query={SearchType},contains,{SearchTerm},AND&tab=books&search_scope=default_scope&mode=advanced"; +DataMapping.SearchStyleUrls["Browse"] = "browse?vid={PrimoSiteCode}&browseQuery={SearchTerm}&browseScope={SearchType}&innerPnxIndex=-1&numOfUsedTerms=-1&fn=BrowseSearch"; + +-- Source Fields: Aeon +-- Only necessary for source fields not associated with a SearchType. To define source fields used in searches, + -- add a SourceField property prefixed with the product name to the SearchType. +DataMapping.SourceFields["Aeon"] = {}; +DataMapping.SourceFields["Aeon"]["TransactionNumber"] = { Table = "Transaction", Field = "TransactionNumber" }; + + +-- Import Fields: The table for each ImportFields section must be defined for the product (Aeon, ILLiad, or Ares) + -- running the addon to work properly, even if it is empty. Example of an empty ImportFields table: + -- DataMapping.ImportFields.Bibliographic["Aeon"] = { }; + +--[[ + Bib-level import fields. + - Table and Field determine which request field the value will be imported to. + - The character limit (MaxSize) for a field can be found at https://support.atlas-sys.com/hc/en-us/articles/360011920013-Aeon-Database-Tables + for Aeon, https://support.atlas-sys.com/hc/en-us/articles/360011812074-ILLiad-Database-Tables for ILLiad, + and https://support.atlas-sys.com/hc/en-us/articles/360011923233-Ares-Database-Tables for Ares. The character limit is + in parentheses next to the field type. + - Value must be an XPath expression. + --]] +DataMapping.ImportFields.Bibliographic["Aeon"] = { + { + Table = "Transaction", + Field = "ItemTitle", MaxSize = 255, + Value = "//datafield[@tag='245']/subfield[@code='a']|//datafield[@tag='245']/subfield[@code='b']" + }, + { + Table = "Transaction", + Field = "ItemAuthor", MaxSize = 255, + Value = "//datafield[@tag='100']/subfield[@code='a']|//datafield[@tag='100']/subfield[@code='b'],//datafield[@tag='110']/subfield[@code='a']|//datafield[@tag='110']/subfield[@code='b'],//datafield[@tag='111']/subfield[@code='a']|//datafield[@tag='111']/subfield[@code='b']" + }, + { + Table = "Transaction", + Field ="ItemPublisher", MaxSize = 255, + Value = "//datafield[@tag='260']/subfield[@code='b']" + }, + { + Table = "Transaction", + Field ="ItemPlace", MaxSize = 255, + Value = "//datafield[@tag='260']/subfield[@code='a']" + }, + { + Table = "Transaction", + Field ="ItemDate", MaxSize = 50, + Value = "//datafield[@tag='260']/subfield[@code='c']" + }, + { + Table = "Transaction", + Field ="ItemEdition", MaxSize = 50, + Value = "//datafield[@tag='250']/subfield[@code='a']" + }, + { + Table = "Transaction", + Field ="ItemIssue", MaxSize = 255, + Value = "//datafield[@tag='773']/subfield[@code='g']" + } +}; + +-- Holding-level import fields. Value must be an XPath expression. + DataMapping.ImportFields.Holding["Aeon"] = { + +} + +-- Item-level import fields. Value should not be changed. +DataMapping.ImportFields.Item["Aeon"] = { + { + Table = "Transaction", + Field = "ReferenceNumber", MaxSize = 50, + Value = "ReferenceNumber" + }, + { + Table = "Transaction", + Field = "CallNumber", MaxSize = 255, + Value = "CallNumber" + }, + { + Table = "Transaction", + Field = "ItemNumber", MaxSize = 255, + Value = "Barcode" + }, + { + Table = "Transaction", + Field = "Location", MaxSize = 255, + Value = "Location" + }, + { + Table = "Transaction", + Field = "SubLocation", MaxSize = 255, + Value = "Library" + } +}; \ No newline at end of file diff --git a/LICENSE b/LICENSE index e480325..8ae8cf7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Atlas Systems, Inc. +Copyright (c) 2020 Atlas Systems, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index e137c9e..b10ce49 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,253 @@ -# AlmaPrimoDefinitiveCatalogAddon -Catalog Search and Import Addon that uses Alma as the catalog and Primo or Primo VE as the discovery layer. +# Alma Primo VE Catalog Search + +## Versions + +**1.0 -** Initial release, based on original Alma Primo Catalog Search and Alma Primo VE Catalog Search addons. + +## Summary + +The addon is located within an request or item record of an Atlas Product. It is found on the `Catalog Search` tab (this default name can be chagned in DataMapping). The addon takes information from the fields in the Atlas Product and searches the catalog in the configured order. When the item is found, one selects the desired holding in the *Item Grid* below the browser and clicks *Import*. The addon then makes the necessary API calls to the Alma API and imports the item's information into the Atlas Product. + +> **Note:** Only records with a valid MMS ID or IE ID that can be converted to an MMS ID can be imported with this addon. An example of a record that may not have a valid ID located within the Primo Page is a record coming from an online resource or external resource like HathiTrust. + + +## Settings + +> **CatalogURL:** The base URL that the query strings are appended to. The Catalog URL structure is `{URL of the catalog}/primo-explore/` for Primo and `{URL of the catalog}/discovery/` for Primo VE. +> +> **HomeURL:** Home page of the catalog. The Home URL structure is `{URL of the catalog}/primo-explore/search?vid={Primo Site Code}` for Primo and `{URL of the catalog}/discovery/search?vid={Primo Site Code}` for Primo VE. +> +> **AutoSearch:** Defines whether the search should be automatically performed when the form opens. *Default: `true`* +> +>**RemoveTrailingSpecialCharacters:** Defines whether to remove trailing special characters on import or not. The included special characters are "` \/+,.;:-=.`". *Default: `true`* +>*Examples: `Baton Rouge, La.,` becomes `Baton Rouge, La.`* +> +>**AvailableSearchTypes:** The types of searches your catalog supports. The types in this list must each have a corresponding entry configured in *DataMapping.SearchTypes*. +>*Default: `Title, Author`* +> +>**SearchPriorityList:** The fields that should be searched on, in order of search priority. Each field in the string will be checked for a valid corresponding search value in the request, and the first search type with a valid corresponding value will be used. Each search type must be separated by a comma. Each search type must have a corresponding value in the *AvailableSearchTypes* setting and configured in *DataMapping.SearchTypes*. +>*Default: `Title, Author`* +> +>**AutoRetrieveItems:** Defines whether or not the addon should automatically retrieve items related to a record being viewed. Disabling this setting can save the site on Alma API calls because it will only make a [Retrieve Holdings List](https://developers.exlibrisgroup.com/alma/apis/bibs/GET/gwPcGly021om4RTvtjbPleCklCGxeYAfEqJOcQOaLEvEGUPgvJFpUQ==/af2fb69d-64f4-42bc-bb05-d8a0ae56936e) call when the button is pressed. +> +>**AlmaAPIURL:** The URL to the Alma API. The API URL is generally the same between sites. (ex. `https://api-na.hosted.exlibrisgroup.com/almaws/v1/`) More information can be found on [Ex Libris' Site](https://developers.exlibrisgroup.com/alma/apis). +> +>**AlmaAPIKey:** API key used for interacting with the Alma API. +> +>**PrimoSiteCode:** The code that identifies the site in Primo Deep Links. Ex: vid={PrimoSiteCode} +> +>**IdSuffix:** The last four digits of MMS IDs and IE IDs for your institution. These can be found in the URL of any record opened from the results list. + + +## Buttons + +The buttons for the Alma Primo Catalog Search addon are located in the *"Catalog Search"* ribbon in the top left of the requests. + +>**Back:** Navigate back one page. +> +>**Forward:** Navigate forward one page. +> +>**Stop:** Stop loading the page. +> +>**Refresh:** Refresh the page. +> +>**New Search:** Goes to the home page of the catalog. +> +>**Search Buttons:** Perform the specified search on the catalog using the contents of the specified field. The number of these buttons that appear on the ribbon varies depending on how many search types are configured in *DataMapping.SearchTypes*. +> +>**Retrieve Items:** Retrieves the holding records for that item. *Default: `true`* +>*Note:* This button will not appear when AutoRetrieveItems is enabled. +> +>**Import:** Imports the selected record in the items grid. + + +## Data Mappings +Below are the default configurations for the catalog addon. The mappings within `DataMapping.lua` are settings that typically do not have to be modified from site to site. However, these data mappings can be changed to customize the fields, search queries, and XPath queries. + +>**Caution:** Be sure to backup the `DataMapping.lua` file before making modifications Incorrectly configured mappings may cause the addon to stop functioning correctly. + +### SearchTypes +The search URL is constructed using the formulas defined in *DataMapping.SearchStyleUrls["Query"]* and *DataMapping.SearchStyleUrls["Browse"]*. The default configurations are as follows: + +>Query: *`{Catalog URL}`search?vid=`{Primo Site Code}`&query=`{Search Type}`,contains,`{Search Term}`AND&search_scope=default_scope&mode=advanced* +>Browse: *`{Catalog URL}`browse?vid=`{Primo Site Code}`&browseQuery=`{Search Term}`&browseScope=`{Search Type}`&innerPnxIndex=-1&numOfUsedTerms=-1&fn=BrowseSearch"* + +*Default SearchTypes Configuration:* + +```lua +DataMapping.SearchTypes["Title"] = { + ButtonText = "Title", + PrimoField = "title", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ItemTitle" } +}; +DataMapping.SearchTypes["Author"] = { + ButtonText = "Author", + PrimoField = "creator", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ItemAuthor" } +}; +DataMapping.SearchTypes["Call Number"] = { + ButtonText = "Call Number", + PrimoField = "lsr01", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "CallNumber" } +}; +DataMapping.SearchTypes["ISBN"] = { + ButtonText = "ISBN", + PrimoField = "isbn", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ItemISxN" } +}; +DataMapping.SearchTypes["ISSN"] = { + ButtonText = "ISSN", + PrimoField = "issn", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ItemISxN" } +}; +DataMapping.SearchTypes["Catalog Number"] = { + ButtonText = "Catalog Number", + PrimoField = "any", + SearchStyle = "Query", + AeonIcon = "srch_32x32", + AeonSourceField = { Table = "Transaction", Field = "ReferenceNumber" } +}; +``` + +>**Note:** The *Catalog Number* search type performs an `any` search because Primo does not have a search type for MMS ID. + +### Source Fields +The field that the addon reads from for values used by the addon that are not used in searches. + +#### Aeon + +*Default Configuration:* + +```lua +DataMapping.SourceFields["Aeon"] = {}; +DataMapping.SourceFields["Aeon"]["TransactionNumber"] = { Table = "Transaction", Field = "TransactionNumber" }; +``` + +### Bibliographic Import +The information within this data mapping is used to perform the bibliographic api call. The `Field` is the product field that the data will be imported into, `MaxSize` is the maximum character size the data going into the product field can be, and `Value` is the XPath query to the information. + +>**Note:** One may specify multiple xPath queries for a single field by separating them with a comma. The addon will try each xPath query and returns the first successful one. +> +>*Example:* An author can be inside of `100$a and 100$b` or `110$a and 110$b`. To accomplish this, provide an xPath query for the 100 datafields and an xPath query for the 110 datafields separated by a comma. +>``` +>//datafield[@tag='100']/subfield[@code='a']|//datafield[@tag='100']/subfield[@code='b'], +>//datafield[@tag='110']/subfield[@code='a']|//datafield[@tag='110']/subfield[@code='b'] +>``` + +### Item Import +The information within this data mapping is used import the correct information from the items grid. The `Field` is the product field that the data will be imported into, `MaxSize` is the maximum character size the data going into the product field can be, and `Value` is the FieldName of the column within the item grid. + +*Default Configuration:* + +```lua +DataMapping.ImportFields.Item["Aeon"] = { + { + Table = "Transaction", + Field = "ReferenceNumber", MaxSize = 50, + Value = "ReferenceNumber" + }, + { + Table = "Transaction", + Field = "CallNumber", MaxSize = 255, + Value = "CallNumber" + }, + { + Table = "Transaction", + Field = "ItemNumber", MaxSize = 255, + Value = "Barcode" + }, + { + Table = "Transaction", + Field = "Location", MaxSize = 255, + Value = "Location" + }, + { + Table = "Transaction", + Field = "SubLocation", MaxSize = 255, + Value = "Library" + } +}; +``` + +> **Note:** The Holding ID and Item Description can also be imported by adding another table with a Value of `HoldingId` and `Description`. + + +## Customized Mapping + +The `CustomizedMapping.lua` file contains the mappings to variables that are more site specific. + +### Location Mapping +Maps an item's location code to a full name. If a location mapping isn't given, the addon will display the location code. The location code is taken from the `location` node returned by a [Retrieve Items List](https://developers.exlibrisgroup.com/alma/apis/xsd/rest_items.xsd?tags=GET) API call. + +```lua +CustomizedMapping.Locations["{Location Code }"] = "{Full Location Name}" +``` + +## FAQ + +### How to add or change what information is displayed in the item grid? +There's more item information gathered than what is displayed in the item grid. If you wish to display or hide additional columns on the item grid, find the comment `-- Item Grid Column Settings` within the `BuildItemGrid()` function in the *Catalog.lua* file and change the `gridColumn.Visible` variable of the column you wish to modify. + +### How to modify what bibliographic information is imported? +To import additional bibliographic fields, add another lua table to the `DataMapping.ImportFields.Bibliographic[{Product Name}]` mapping. To remove a record from the importing remove it from the lua table. + +The table takes a `Table` and `Field` which correspond to a table and column name in the product, a `MaxSize` which is the maximum characters to be imported into the specified table column, and a `Value` which is the xPath query to the data returned by the [Retrieve Bibs](https://developers.exlibrisgroup.com/alma/apis/bibs/GET/gwPcGly021q2Z+qBbnVJzw==/af2fb69d-64f4-42bc-bb05-d8a0ae56936e) Alma API call. + +### How to add a new Search Type? +There are search types baked into the addon that are not enabled by default. The available search types are listed below. + +- Title +- Author +- Call Number +- ISBN +- ISSN +- Catalog Number + +To add these additional search types to your addon, open the addon's settings and find the `AvailableSearchTypes` setting. Add the Search Type's name to the comma-separated list within the value column. Save, refresh the cache, and reopen any item pages that may be open. + +The new search type can be added to the addon's default configuration by opening the `Config.xml` document and find the `AvailableSearchTypes` setting. Add the Search Type's name to the comma-separated list within the value attribute of the setting. Save the document and reset the product's cache. + +### How to add a custom Search Type? +**Note:** *Backup the addon before performing this customization. A misconfiguration may break the addon.* + +First navigate to your primo catalog and go to the advanced search page. On the advanced search page, choose the search option that you wish to add. Search for anything using the chosen search option and look at the URL. Find the part that says `query={Search Type},`. Copy the search type (Example: *title, creator*). + +Now that you have the search type, open up the DataMapping.lua file and scroll to the SearchTypes mapping. Copy and paste an existing SearchType mapping. Replace the string on the right side of the equals with your Search Type. Give the search type a name by replacing the string inside the brackets with the name of the search type. (Example: `DataMapping.SearchTypes["{Search Type Name}"] = "{searchType}";`). + +Open up `Config.xml` and find *"AvailableSearchTypes"*. Add the *name* of the Search Type to the comma-separated list within the value attribute (be sure to add a comma between search types). + +Finally, open Catalog.lua and find the commend that says `-- Search Functions`. Copy one of the search functions and paste it at the end of the search functions. Change the function's name to follow this formula; `Search{SearchTypeName}` (*Note: remove any spaces from the Search Type Name, but keep casing the same*). Within the PerformSearch method call, change the second parameter to be the Search Type Name (unmodified). + + +## Developers + +The addon is developed to support Alma Catalogs that use Primo or Primo VE as its discovery layer in [Aeon](https://www.atlas-sys.com/aeon/), [Ares](https://www.atlas-sys.com/ares), and [ILLiad](https://www.atlas-sys.com/illiad/). + +Atlas welcomes developers to extend the addon with additional support. All pull requests will be merged and posted to the [addon directories](https://prometheus.atlas-sys.com/display/ILLiadAddons/Addon+Directory). + +### Addon Files + +* **Config.xml** - The addon configuration file. + +* **DataMapping.lua** - The data mapping file contains mappings for the items that do not typically change from site to site. + +* **CustomizedMapping.lua** - The a data mapping file that contains settings that are more site specific and likely to change (e.g. location codes). + +* **Catalog.lua** - The Catalog.lua is the main file for the addon. It contains the main business logic for importing the data from the Alma API into the Atlas Product. + +* **AlmaApi.lua** - The AlmaApi file is used to make the API calls against the Alma API. + +* **Utility.lua** - The Utility file is used for common lua functions. + +* **WebClient.lua** - Used for making web client requests. +* diff --git a/Utility.lua b/Utility.lua new file mode 100644 index 0000000..7c9c5fa --- /dev/null +++ b/Utility.lua @@ -0,0 +1,243 @@ +local UtilityInternal = {}; +UtilityInternal.DebugLogging = false; + +Utility = UtilityInternal; + +luanet.load_assembly("System"); + +local types = {}; +types["System.Type"] = luanet.import_type("System.Type"); +types["System.Action"] = luanet.import_type("System.Action"); + +local function Log(input, debugOnly) + debugOnly = debugOnly or false; + + if ((not debugOnly) or (debugOnly and UtilityInternal.DebugLogging)) then + local t = type(input); + + if (t == "string" or t == "number") then + LogDebug(input); + elseif (t == "table") then + LogTable(input); + elseif (t == "nil") then + LogDebug("(nil)"); + elseif (t == "boolean") then + if (input == true) then + LogDebug("True"); + else + LogDebug("False"); + end + elseif (t == "function") then + local success, result = pcall(input); + + if (success) then + Log(result, debugOnly); + end + elseif (t == "userdata") then + if (IsType(input, "System.Exception")) then + LogException(input); + else + pcall(function() + LogDebug(input:ToString()); + end); + end + end + end +end + +local function Trim(s) + local n = s:find"%S" + return n and s:match(".*%S", n) or "" +end + +local function IsType(o, t, checkFullName) + if ((o and type(o) == "userdata") and (t and type(t) == "string")) then + local comparisonType = types["System.Type"].GetType(t); + if (comparisonType) then + -- The comparison type was successfully loaded so we can do a check + -- that the object can be assigned to the comparison type. + return comparisonType:IsAssignableFrom(o:GetType()), true; + else + -- The comparison type was could not be loaded so we can only check + -- based on the names of the types. + if(checkFullName) then + return (o:GetType().FullName == t), false; + else + return (o:GetType().Name == t), false; + end + end + end + + return false, false; +end + +local function LogIndented(entry, depth) + depth = (depth or 0); + LogDebug(string.rep("> ", depth) .. entry); +end + +local function LogTable(input, depth) + assert(type(input) == "table", "LogTable expects a LUA table"); + + depth = (depth or 0); + + for key, value in pairs(input) do + if (value and type(value) == "table") then + LogIndented("Key: " .. key, depth); + LogTable(value, depth + 1); + else + local success, result = pcall(string.format, "%s", (value or "(nil)")); + + if (success) then + LogIndented("Key: " .. key .. " = " .. (value or "(nil)"), depth); + else + LogIndented("Key: " .. key .. " = (?)", depth); + end + end + end + + for index, value in ipairs(input) do + if (value and type(value) == "table") then + LogIndented("Index: " .. index, depth); + LogTable(value, depth + 1); + else + LogIndented("Index: " .. index .. " = " .. (value or "(nil)"), depth); + end + end +end + +local function LogException(exception, depth) + depth = (depth or 0); + + if (exception) then + LogIndented(exception.Message, depth); + LogException(exception.InnerException, depth + 1); + end +end + +local function URLDecode(s) + s = string.gsub(s, "+", " "); + s = string.gsub(s, "%%(%x%x)", function(h) + return string.char(tonumber(h, 16)); + end); + + s = string.gsub(s, "\r\n", "\n"); + + return s; +end + +local function StringSplit(delimiter, text) + if delimiter == nil then + delimiter = "%s" + end + local t={}; + local i=1; + for str in string.gmatch(text, "([^"..delimiter.."]+)") do + t[i] = str + i = i + 1 + end + return t +end + +local function URLEncode(s) + if (s) then + s = string.gsub(s, "\n", "\r\n") + s = string.gsub(s, "([^%w %-%_%.%~])", + function (c) + return string.format("%%%02X", string.byte(c)) + end); + s = string.gsub(s, " ", "+") + end + return s +end + +local function CreateQueryString(t) + + local query = nil; + + if (t and type(t) == "table") then + for k, v in pairs(t) do + if (v) then + local success, value = pcall(URLEncode, v); + if (success) then + query = string.format("%s%s=%s", ((query and (query .. "&")) or ""), k, value); + end + end + end + end + + return query; +end + +local function GetNodeCount(xmlElement, xPath, namespaceManager) + if (xmlElement == nil or xPath == nil) then + Log("Invalid Element/Path to retrieve value", true); + return nil; + end + + Log("GetNodeCount Path: ".. xPath, true); + + local datafieldNode = nil; + + if (namespaceManager ~= nil) then + datafieldNode = xmlElement:SelectNodes(xPath, namespaceManager); + else + datafieldNode = xmlElement:SelectNodes(xPath); + end + + return datafieldNode.Count; +end + +local function GetChildValue(xmlElement, xPath, namespaceManager) + Log("[Utility.GetChildValue] "..xPath); + if (xmlElement == nil or xPath == nil) then + Log("Invalid Element/Path to retrieve value."); + return nil; + end + + local datafieldNode = nil; + + if (namespaceManager ~= nil) then + datafieldNode = xmlElement:SelectNodes(xPath, namespaceManager); + else + datafieldNode = xmlElement:SelectNodes(xPath); + end + + Log("Found "..datafieldNode.Count.." node elements matching "..xPath); + local fieldValue = ""; + for d = 0, (datafieldNode.Count - 1) do + Log("datafieldnode value is: " .. datafieldNode:Item(d).InnerText, true); + fieldValue = fieldValue .. " " .. datafieldNode:Item(d).InnerText; + end + + fieldValue = Trim(fieldValue); + Log("GetChildValue Result: " .. fieldValue, true); + + return fieldValue; +end + +local function RemoveTrailingSpecialCharacters(item) + local trailingCharacters = { '\\', '/', ',', '%.', ';', ':', '%-', '=' }; + for _, value in ipairs(trailingCharacters) do + if (string.match(item, value, -1)) then + return Utility.Trim(item:sub(1, -2)) + end + end + return item; +end + +local function RemoveWhiteSpaceFromString(str) + return string.gsub(str, "%s+", ""); +end + +UtilityInternal.Trim = Trim; +UtilityInternal.IsType = IsType; +UtilityInternal.Log = Log; +UtilityInternal.URLDecode = URLDecode; +UtilityInternal.URLEncode = URLEncode; +UtilityInternal.StringSplit = StringSplit; +UtilityInternal.CreateQueryString = CreateQueryString; +UtilityInternal.GetXmlChildValue = GetChildValue; +UtilityInternal.GetXmlNodeCount = GetNodeCount; +UtilityInternal.RemoveWhiteSpaceFromString = RemoveWhiteSpaceFromString; +UtilityInternal.RemoveTrailingSpecialCharacters = RemoveTrailingSpecialCharacters; diff --git a/WebClient.lua b/WebClient.lua new file mode 100644 index 0000000..ae7e8dd --- /dev/null +++ b/WebClient.lua @@ -0,0 +1,61 @@ +WebClient = {}; + +local types = {}; +types["log4net.LogManager"] = luanet.import_type("log4net.LogManager"); +types["System.Net.WebClient"] = luanet.import_type("System.Net.WebClient"); +types["System.Text.Encoding"] = luanet.import_type("System.Text.Encoding"); +types["System.Xml.XmlTextReader"] = luanet.import_type("System.Xml.XmlTextReader"); +types["System.Xml.XmlDocument"] = luanet.import_type("System.Xml.XmlDocument"); + +-- Create a logger +local log = types["log4net.LogManager"].GetLogger(rootLogger .. ".AlmaApi"); + +local function GetRequest(requestUrl, headers) + local webClient = types["System.Net.WebClient"](); + local response = nil; + log:Debug("Created Web Client"); + webClient.Encoding = types["System.Text.Encoding"].UTF8; + + for _, header in ipairs(headers) do + webClient.Headers:Add(header); + end + + local success, error = pcall(function () + response = webClient:DownloadString(requestUrl); + end); + + webClient:Dispose(); + log:Debug("Disposed Web Client"); + + if(success) then + return response; + else + log:InfoFormat("Unable to get response from the request url: {0}", error); + end +end + +local function ReadResponse( responseString ) + if (responseString and #responseString > 0) then + + local responseDocument = types["System.Xml.XmlDocument"](); + + local documentLoaded, error = pcall(function () + responseDocument:LoadXml(responseString); + end); + + if (documentLoaded) then + return responseDocument; + else + log:InfoFormat("Unable to load response content as XML: {0}", error); + return nil; + end + else + log:Info("Unable to read response content"); + end + + return nil; +end + +--Exports +WebClient.GetRequest = GetRequest; +WebClient.ReadResponse = ReadResponse; From 750b2611ab0f5a45fa9a4f2e57d6b1e125062fc6 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 31 May 2023 16:05:53 -0400 Subject: [PATCH 2/8] Remove obsolete layout file. --- CatalogLayout.xml | 261 ---------------------------------------------- 1 file changed, 261 deletions(-) delete mode 100644 CatalogLayout.xml diff --git a/CatalogLayout.xml b/CatalogLayout.xml deleted file mode 100644 index 8e9ecaa..0000000 --- a/CatalogLayout.xml +++ /dev/null @@ -1,261 +0,0 @@ - - - - - 0 - true - Vertical - false - 0, 0, 0, 0 - @3,Width=983@3,Height=521 - false - Normal - 0, 0, 0, 0 - false - - Top - - - - - - - Tahoma, 8.25pt - Horizontal - - - - - - - Tahoma, 8.25pt - Horizontal - - - - - - - Tahoma, 8.25pt - Horizontal - - - - - - - Tahoma, 8.25pt - Horizontal - - - - - - - Tahoma, 8.25pt - Horizontal - - - - - - - - Tahoma, 8.25pt - Horizontal - - - - - - - Tahoma, 8.25pt - Horizontal - - Root - - Default - Default - - - - - None - - - None - true - - @1,X=0@1,Y=0 - false - Default - True - Default - Always - -1 - true - false - true - - 3 - UseParentOptions - - LayoutGroup - Main Group - Main Group - true - - - - 5 - @1,Width=0@1,Height=0 - @1,Width=0@1,Height=0 - @3,Width=104@2,Height=24 - @3,Width=983@3,Height=424 - @1,X=0@1,Y=0 - - Default - Default - - - - - None - - - None - true - - 2, 2, 2, 2 - Catalog Search - - - - - - Tahoma, 8.25pt - Horizontal - - 0, 0, 0, 0 - 0 - Root - MiddleLeft - -1 - false - true - - Catalog Search Browser - Catalog Search - Default - Left - CustomSize - AtlasSystems.Scripting.UI.AddonControls.ChromiumBrowser - Always - false - Catalog Search - - - Root - 5 - @1,Width=0@1,Height=0 - @3,Width=104@2,Height=24 - @1,X=0@3,Y=428 - - Default - Default - - - - - None - - - None - true - - 2, 2, 2, 2 - 0, 0, 0, 0 - 0 - CatalogItemsGrid - - - - - - Tahoma, 8.25pt - Horizontal - - @1,Width=0@1,Height=0 - false - -1 - @3,Width=983@2,Height=93 - MiddleLeft - - true - Items - Default - CatalogItemsGrid - CustomSize - Left - AtlasSystems.Scripting.UI.AddonControls.Grid - Always - false - CatalogItemsGrid - - - MiddleLeft - Default - item0 - true - - false - - 0 - @1,X=0@3,Y=424 - 5 - Root - - Default - Default - - - - - None - - - None - true - - -1 - @1,Width=0@1,Height=4 - item0 - AutoSize - item0 - @1,Width=4@1,Height=4 - - - - - - Tahoma, 8.25pt - Horizontal - - 0, 0, 0, 0 - 2, 2, 2, 2 - false - Default - @3,Width=983@1,Height=4 - SplitterItem - Always - OnlyAdjacentControls - @1,Width=0@1,Height=0 - - - - DevExpress Style - Skin - true - false - - From 727758e99c6d5dd7146009fee2ee50e807e71856 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 31 May 2023 16:12:09 -0400 Subject: [PATCH 3/8] Add information about the layout files to ReadMe. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b10ce49..fc30d76 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,10 @@ Atlas welcomes developers to extend the addon with additional support. All pull * **Config.xml** - The addon configuration file. +* **CatalogLayout_Browse.xml** - The layout file used when not retrieving items on a record page. Displays the full browser window. + +* **CatalogLayout_Import.xml** - The layout file used when retrieving items on a record page (either automatically or via the Retrieve Items button). Displays the items grid at the bottom of the window. + * **DataMapping.lua** - The data mapping file contains mappings for the items that do not typically change from site to site. * **CustomizedMapping.lua** - The a data mapping file that contains settings that are more site specific and likely to change (e.g. location codes). From c0c702f5157d7ffceadc886953d56bd54fcc93cb Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 31 May 2023 16:14:40 -0400 Subject: [PATCH 4/8] Update addon name in ReadME. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fc30d76..7bb44cc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Alma Primo VE Catalog Search +# Alma Primo Definitive Catalog Search ## Versions From 1c6d2e507981f64442761ab57ce6348522d72b00 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 31 May 2023 16:18:28 -0400 Subject: [PATCH 5/8] Update copyright year on license file. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 8ae8cf7..e480325 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Atlas Systems, Inc. +Copyright (c) 2023 Atlas Systems, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From cae51ec7a1dd018f12bdf1599f5cf4d435b0a512 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 31 May 2023 16:39:10 -0400 Subject: [PATCH 6/8] Move logging for request URL to WebClient.lua. --- AlmaApi.lua | 9 ++------- WebClient.lua | 11 +++++------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/AlmaApi.lua b/AlmaApi.lua index 7dd3c0a..0ef02b5 100644 --- a/AlmaApi.lua +++ b/AlmaApi.lua @@ -18,8 +18,8 @@ AlmaApi = AlmaApiInternal; local function RetrieveHoldingsList( mmsId ) local requestUrl = AlmaApiInternal.ApiUrl .."bibs/".. Utility.URLEncode(mmsId) .."/holdings?apikey=" .. Utility.URLEncode(AlmaApiInternal.ApiKey); + local headers = {"Accept: application/xml", "Content-Type: application/xml"}; - log:DebugFormat("Request URL: {0}", requestUrl); local response = WebClient.GetRequest(requestUrl, headers); return WebClient.ReadResponse(response); @@ -29,9 +29,8 @@ end local function RetrieveBibs(id, idType) local requestUrl = AlmaApiInternal.ApiUrl .. "bibs?apikey=".. Utility.URLEncode(AlmaApiInternal.ApiKey) .. "&" .. idType .. "=" .. Utility.URLEncode(id); - local headers = {"Accept: application/xml", "Content-Type: application/xml"}; - log:DebugFormat("Request URL: {0}", requestUrl); + local headers = {"Accept: application/xml", "Content-Type: application/xml"}; local response = WebClient.GetRequest(requestUrl, headers); return WebClient.ReadResponse(response); @@ -43,8 +42,6 @@ local function RetrieveItemsSublist(mmsId, holdingId, offset ) Utility.URLEncode(AlmaApiInternal.ApiKey); local headers = {"Accept: application/xml", "Content-Type: application/xml"}; - - log:DebugFormat("Request URL: {0}", requestUrl); local response = WebClient.GetRequest(requestUrl, headers); return WebClient.ReadResponse(response); @@ -85,8 +82,6 @@ local function RetrieveHoldingsRecordInfo(mmsId, holdingId) Utility.URLEncode(AlmaApiInternal.ApiKey); local headers = {"Accept: application/xml", "Content-Type: application/xml"}; - - log:DebugFormat("Request URL: {0}", requestUrl); local response = WebClient.GetRequest(requestUrl, headers); return WebClient.ReadResponse(response); diff --git a/WebClient.lua b/WebClient.lua index ae7e8dd..2187c76 100644 --- a/WebClient.lua +++ b/WebClient.lua @@ -13,24 +13,23 @@ local log = types["log4net.LogManager"].GetLogger(rootLogger .. ".AlmaApi"); local function GetRequest(requestUrl, headers) local webClient = types["System.Net.WebClient"](); local response = nil; - log:Debug("Created Web Client"); webClient.Encoding = types["System.Text.Encoding"].UTF8; for _, header in ipairs(headers) do webClient.Headers:Add(header); end - + + log:DebugFormat("Request URL: {0}", requestUrl); local success, error = pcall(function () response = webClient:DownloadString(requestUrl); end); webClient:Dispose(); - log:Debug("Disposed Web Client"); if(success) then return response; else - log:InfoFormat("Unable to get response from the request url: {0}", error); + log:ErrorFormat("Unable to get response from the request url: {0}", error); end end @@ -46,11 +45,11 @@ local function ReadResponse( responseString ) if (documentLoaded) then return responseDocument; else - log:InfoFormat("Unable to load response content as XML: {0}", error); + log:WarnFormat("Unable to load response content as XML: {0}", error); return nil; end else - log:Info("Unable to read response content"); + log:Warn("Unable to read response content."); end return nil; From a53fc8b4c74fcd6354d64a2848440ece7fd68b08 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 7 Jun 2023 11:43:26 -0400 Subject: [PATCH 7/8] Add support for falling back to Chromium, add check for item-details element to record page evaluation. --- Catalog.lua | 35 ++- CatalogLayout_Browse_Chromium.xml | 202 ++++++++++++++++++ ...e.xml => CatalogLayout_Browse_WebView2.xml | 0 CatalogLayout_Import_Chromium.xml | 202 ++++++++++++++++++ ...t.xml => CatalogLayout_Import_WebView2.xml | 0 5 files changed, 418 insertions(+), 21 deletions(-) create mode 100644 CatalogLayout_Browse_Chromium.xml rename CatalogLayout_Browse.xml => CatalogLayout_Browse_WebView2.xml (100%) create mode 100644 CatalogLayout_Import_Chromium.xml rename CatalogLayout_Import.xml => CatalogLayout_Import_WebView2.xml (100%) diff --git a/Catalog.lua b/Catalog.lua index 824b7c3..07999ec 100644 --- a/Catalog.lua +++ b/Catalog.lua @@ -58,6 +58,12 @@ local cursors = types["System.Windows.Forms.Cursors"]; local watcherEnabled = false; local recordsLastRetrievedFrom = ""; local layoutMode = "browse"; +local browserType = nil; +if AddonInfo.Browsers ~= nil and AddonInfo.Browsers.WebView2 ~= nil and AddonInfo.Browsers.WebView2 == true then + browserType = "WebView2"; +else + browserType = "Chromium"; +end function Init() interfaceMngr = GetInterfaceManager(); @@ -67,8 +73,7 @@ function Init() log:DebugFormat("catalogSearchForm.Form = {0}", catalogSearchForm.Form); -- Add a browser - catalogSearchForm.Browser = catalogSearchForm.Form:CreateBrowser(DataMapping.LabelName, "Catalog Search Browser", DataMapping.LabelName, "WebView2"); - log:DebugFormat("catalogSearchForm.Browser = {0}", catalogSearchForm.Browser); + catalogSearchForm.Browser = catalogSearchForm.Form:CreateBrowser(DataMapping.LabelName, "Catalog Search Browser", DataMapping.LabelName, browserType); -- Since we didn't create a ribbon explicitly before creating our browser, it will have created one using the name we passed the CreateBrowser method. We can retrieve that one and add our buttons to it. catalogSearchForm.RibbonPage = catalogSearchForm.Form:GetRibbonPage(DataMapping.LabelName); @@ -98,7 +103,7 @@ function Init() catalogSearchForm.ImportButton.BarButton.Enabled = false; BuildItemsGrid(); - catalogSearchForm.Form:LoadLayout("CatalogLayout_Browse.xml"); + catalogSearchForm.Form:LoadLayout("CatalogLayout_Browse_" .. browserType .. ".xml"); -- After we add all of our buttons and form elements, we can show the form. catalogSearchForm.Form:Show(); @@ -245,6 +250,7 @@ end function ExtractIds(itemDetails) local idMatches = {}; local urlId = (catalogSearchForm.Browser.Address):match("%d+" .. settings.IdSuffix); + -- Easy way to prevent duplicates regardless of order since the keys get overwritten. idMatches[urlId] = true; -- MMS Ids (and presumably IE IDs) all have the same last four digits specific to the institution. @@ -252,7 +258,6 @@ function ExtractIds(itemDetails) log:Info("Extracting IDs from item-details element."); for id in itemDetails:gmatch("%d+" .. settings.IdSuffix) do - -- Easy way to prevent duplicates regardless of order since the keys get overwritten. if id:find("^99") or id:find("^[125]1") then log:DebugFormat("Found ID: {0}", id); idMatches[id] = true; @@ -303,22 +308,10 @@ end function IsRecordPageLoaded() local pageUrl = catalogSearchForm.Browser.Address; + local itemDetails = catalogSearchForm.Browser:EvaluateScript([[document.getElementById("item-details").innerText;]]).Result; - if pageUrl:find("fulldisplay%?") then + if pageUrl:find("fulldisplay%?") and itemDetails then log:DebugFormat("Is a record page. {0}", pageUrl); - - local mmsIds = {}; - if not mmsIdsCache[pageUrl] then - mmsIdsCache[pageUrl] = GetMmsIds(); - end - mmsIds = mmsIdsCache[pageUrl]; - - if #mmsIds == 0 then - log:Debug("Linked Data not loaded."); - StartRecordPageWatcher(); - ToggleItemsUIElements(false); - return false; - end return true; else log:DebugFormat("Is not a record page. {0}", pageUrl); @@ -389,7 +382,7 @@ function ToggleItemsUIElements(enabled) if layoutMode == "import" then layoutMode = "browse"; - catalogSearchForm.Form:LoadLayout("CatalogLayout_Browse.xml"); + catalogSearchForm.Form:LoadLayout("CatalogLayout_Browse_" .. browserType .. ".xml"); end end log:Debug("Finished Toggling UI Elements"); @@ -490,14 +483,14 @@ function RetrieveItems() cursor.Current = cursors.WaitCursor; if layoutMode == "browse" then layoutMode = "import"; - catalogSearchForm.Form:LoadLayout("CatalogLayout_Import.xml"); + catalogSearchForm.Form:LoadLayout("CatalogLayout_Import_" .. browserType .. ".xml"); end local pageUrl = catalogSearchForm.Browser.Address; local mmsIds = {}; if not mmsIdsCache[pageUrl] then - mmsIds = GetMmsIds(); + mmsIdsCache[pageUrl] = GetMmsIds(); end mmsIds = mmsIdsCache[pageUrl]; diff --git a/CatalogLayout_Browse_Chromium.xml b/CatalogLayout_Browse_Chromium.xml new file mode 100644 index 0000000..e7e88a7 --- /dev/null +++ b/CatalogLayout_Browse_Chromium.xml @@ -0,0 +1,202 @@ + + + + true + false + true + false + true + true + true + false + AcrossThenDown + + + Basic + Skin + true + false + + + + LayoutGroup + + false + true + True + + 3 + UseParentOptions + + true + LeftToRight + Regular + Default + -1 + false + false + true + Vertical + false + false + Default + + + + + @3,Width=988@3,Height=522 + false + Normal + Default + Inherited + Top + 0 + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + Root + + false + @1,X=0@1,Y=0 + true + Main Group + Main Group + false + Always + + + AtlasSystems.Scripting.UI.AddonControls.ChromiumBrowser + Catalog Search + false + CustomSize + Default + + -1 + MiddleLeft + 5 + + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=102@2,Height=22 + TopLeft + true + Default + true + Default + Default + Default + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + Catalog Search + Root + false + @1,Width=0@1,Height=0 + @1,X=0@1,Y=0 + @3,Width=974@3,Height=508 + true + Catalog Search Browser + Catalog Search + false + Always + Default + + + AtlasSystems.Scripting.UI.AddonControls.Grid + CatalogItemsGrid + false + UseParentOptions + Default + + -1 + MiddleLeft + 5 + + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=104@2,Height=24 + TopLeft + true + Default + true + Default + Default + Default + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + CatalogItemsGrid + Customization + false + @1,Width=0@1,Height=0 + @1,X=0@3,Y=479 + @3,Width=974@2,Height=29 + true + Items + CatalogItemsGrid + false + Always + Default + + + \ No newline at end of file diff --git a/CatalogLayout_Browse.xml b/CatalogLayout_Browse_WebView2.xml similarity index 100% rename from CatalogLayout_Browse.xml rename to CatalogLayout_Browse_WebView2.xml diff --git a/CatalogLayout_Import_Chromium.xml b/CatalogLayout_Import_Chromium.xml new file mode 100644 index 0000000..201ad93 --- /dev/null +++ b/CatalogLayout_Import_Chromium.xml @@ -0,0 +1,202 @@ + + + + true + false + true + false + true + true + true + false + AcrossThenDown + + + Basic + Skin + true + false + + + + LayoutGroup + + false + true + True + + 3 + UseParentOptions + + true + LeftToRight + Regular + Default + -1 + false + false + true + Vertical + false + false + Default + + + + + @3,Width=988@3,Height=522 + false + Normal + Default + Inherited + Top + 0 + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + Root + + false + @1,X=0@1,Y=0 + true + Main Group + Main Group + false + Always + + + AtlasSystems.Scripting.UI.AddonControls.ChromiumBrowser + Catalog Search + false + CustomSize + Default + + -1 + MiddleLeft + 5 + + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=102@2,Height=22 + TopLeft + true + Default + true + Default + Default + Default + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + Catalog Search + Root + false + @1,Width=0@1,Height=0 + @1,X=0@1,Y=0 + @3,Width=974@3,Height=374 + true + Catalog Search Browser + Catalog Search + false + Always + Default + + + AtlasSystems.Scripting.UI.AddonControls.Grid + CatalogItemsGrid + false + UseParentOptions + Default + + -1 + MiddleLeft + 5 + + @1,Width=0@1,Height=0 + @1,Width=0@1,Height=0 + @3,Width=106@2,Height=22 + TopLeft + true + Default + true + Default + Default + Default + + Default + Default + + + 0 + 1 + 0 + 1 + + + + + None + false + Default + + + None + true + false + Default + + CatalogItemsGrid + Root + false + @1,Width=0@1,Height=0 + @1,X=0@3,Y=374 + @3,Width=974@3,Height=134 + true + Items + CatalogItemsGrid + false + Always + Default + + + \ No newline at end of file diff --git a/CatalogLayout_Import.xml b/CatalogLayout_Import_WebView2.xml similarity index 100% rename from CatalogLayout_Import.xml rename to CatalogLayout_Import_WebView2.xml From 4491eb3383ff32cf4ddebc8acc548c78813dfb4d Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 7 Jun 2023 11:48:02 -0400 Subject: [PATCH 8/8] Remove redundant nil check on itemDetails. --- Catalog.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Catalog.lua b/Catalog.lua index 07999ec..f3258c4 100644 --- a/Catalog.lua +++ b/Catalog.lua @@ -226,7 +226,7 @@ function GetMmsIds() local itemDetails = catalogSearchForm.Browser:EvaluateScript([[document.getElementById("item-details").innerText;]]).Result; local ids = {}; - if itemDetails and itemDetails ~= nil then + if itemDetails then ids = ExtractIds(itemDetails); else log:Debug("Element with ID 'item-details' not found.");