Add API for external addon to be able to register their own books to Librarian

Orionik [10-03-22 - 04:43]
Add API for external addon to be able to register their own books to Librarian
Filename
API.lua
CHANGELOG
ESOUIModifications.lua
Librarian.lua
Librarian.txt
LibrarianData.lua
LibrarianUI.lua
README
diff --git a/API.lua b/API.lua
new file mode 100644
index 0000000..127ccc3
--- /dev/null
+++ b/API.lua
@@ -0,0 +1,221 @@
+--[[
+Hello fellow addon developper,
+You will find bellow a way of adding custom book to Librarian.
+Prerequisite: To be sure your addon gets loaded after Librarian the easiest way is to add an optional dependency from your addon to Librarian
+If you are not used to it, you can copy this line to your Addon.txt file:
+## OptionalDependsOn: Librarian
+]]
+
+--[[
+LIBRARIAN:RegisterNewCategory(name, totalBooks, GetBookInfo, OpenCategory)
+* Should be called at the end of your addon initialization
+
+- categoryIdentifier (*string*) : Identifier that will be used to identify your category from outside of the addon (save or API)
+    You can use the same as the english name if you want
+- name (*string*) : Name of the category that will be displayed in the "Category" column
+- totalBooks (*integer*) : Total number of books in the category. Will be used when initializing Librarian with all the known book of your addon.
+    If you want to disable this feature you can give 0 and the rest of the addon should still work)
+- GetBookInfo (*function*) : It is the main function that will be used to retrieve data for a book
+    The bookIndex is an index starting from 1 to the total number of book or the maximum bookIndex received byt the OnBookDisplayed function (it can't be more than 10000 though)
+    Prototype:
+    * GetBookInfo(*integer* bookIndex)
+    ** _Returns:_ *string* _title_, *bool* _known_, *string* _body_, *[BookMedium|#BookMedium]* _medium_, *bool* _showTitle_
+- OpenCategory (*function*, optional) : This is called when the player press the key to "Go to Category" from Librarian.
+    So you should open your UI with the given book selected if you can.
+    You can give nil instead, then the option won't be shown to the player
+    Prototype:
+    * OpenCategory(*integer* bookIndex)
+]]
+
+function Librarian:RegisterNewCategory(categoryIdentifier, name, totalBooks, GetBookInfo, OpenCategory)
+    if not self.globalSavedVars.registeredCollectionList then
+        self.globalSavedVars.registeredCollectionList = {}
+        self.globalSavedVars.nextCollectionToRegister = 1
+    end
+
+    if not self.globalSavedVars.registeredCollectionList[categoryIdentifier] then
+        self.globalSavedVars.registeredCollectionList[categoryIdentifier] = self.globalSavedVars.nextCollectionToRegister
+        self.globalSavedVars.nextCollectionToRegister = self.globalSavedVars.nextCollectionToRegister + 1
+    end
+
+    local newCollection = {
+        collectionId = categoryIdentifier,
+        name = name,
+        totalBooks = totalBooks,
+        GetBookInfo = GetBookInfo,
+        OpenCategory = OpenCategory,
+    }
+    self.registeredCollection[self.globalSavedVars.registeredCollectionList[categoryIdentifier]] = newCollection
+    self:RefreshData()
+end
+
+--[[
+LIBRARIAN:OnBookDisplayed(categoryIdentifier, bookIndex)
+* Should be called when displaying a book with LoreReader UI
+* It will add the displayed book to Librarian and activate the read/unread icon and actions to lore reader
+
+
+- categoryIdentifier (string) : Identifier of the category you used when registering your new category
+- bookIndex (*integer*) : Index of the book shown in LoreReader and it will then be used with the GetBookInfo function you registered with your category
+]]
+
+function Librarian:OnBookDisplayed(categoryIdentifier, bookIndex)
+    for collectionIndex, collection in pairs(self.registeredCollection) do
+        if collection.collectionId == categoryIdentifier then
+            local title, _, body, medium, showTitle = collection.GetBookInfo(bookIndex)
+            if title and body and medium then
+                local bookId = self:GetCustomBookIdFromIndices(collectionIndex, bookIndex)
+                Librarian.OnShowBook(eventCode, title, body, medium, showTitle, bookId)
+            end
+            return
+        end
+    end
+end
+
+----------------------------------------------------------------------------------------------------------
+-- INTERNAL (shouldn't be called from outside)
+--
+-- Functions below are here to be able to switch between ESO official API and registered custom categories
+----------------------------------------------------------------------------------------------------------
+
+Librarian.constants.CUSTOM_CATEGORY = GetNumLoreCategories() + 1
+Librarian.constants.CUSTOM_BOOK_ID_START = 200000
+Librarian.constants.MAX_BOOK_PER_CUSTOM_COLLECTION = 10000
+Librarian.registeredCollection = {}
+
+function Librarian:GetNumLoreCategories()
+    if next(self.registeredCollection) == nil then
+        return GetNumLoreCategories()
+    else
+        return Librarian.constants.CUSTOM_CATEGORY
+    end
+end
+
+function Librarian:GetLoreCollectionCount(categoryIndex)
+    if categoryIndex < self.constants.CUSTOM_CATEGORY then
+        return select(2, GetLoreCategoryInfo(categoryIndex))
+    else
+        assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+        return self.globalSavedVars.nextCollectionToRegister - 1
+    end
+end
+
+function Librarian:GetLoreCollectionName(categoryIndex, collectionIndex)
+    if categoryIndex < self.constants.CUSTOM_CATEGORY then
+        return select(1, GetLoreCollectionInfo(categoryIndex, collectionIndex))
+    else
+        assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+        local collection = self.registeredCollection[collectionIndex]
+        assert(collection)
+        return collection.name
+    end
+end
+
+function Librarian:GetLoreCollectionTotalBook(categoryIndex, collectionIndex)
+    if categoryIndex < self.constants.CUSTOM_CATEGORY then
+        return select(4, GetLoreCollectionInfo(categoryIndex, collectionIndex))
+    else
+        assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+        local collection = self.registeredCollection[collectionIndex]
+        if collection then
+            return collection.totalBooks
+        end
+    end
+    return 0
+end
+
+function Librarian:IsLoreCollectionHidden(categoryIndex, collectionIndex)
+    if categoryIndex < self.constants.CUSTOM_CATEGORY then
+        return select(5, GetLoreCollectionInfo(categoryIndex, collectionIndex))
+    else
+        assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+        assert(self.registeredCollection[collectionIndex])
+        return false
+    end
+end
+
+function Librarian:CanOpenCollection(categoryIndex, collectionIndex)
+    if categoryIndex and collectionIndex then
+        if categoryIndex < self.constants.CUSTOM_CATEGORY then
+            return collectionIndex ~= nil
+        else
+            assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+            local collection = self.registeredCollection[collectionIndex]
+            assert(collection)
+            return type(collection.OpenCategory) == "function"
+        end
+    end
+    return false
+end
+
+function Librarian:OpenCollection(categoryIndex, collectionIndex, bookIndex)
+    if categoryIndex < self.constants.CUSTOM_CATEGORY then
+        local collectionId = select(7, GetLoreCollectionInfo(categoryIndex, collectionIndex))
+        LORE_LIBRARY:SetCollectionIdToSelect(collectionId)
+        MAIN_MENU_KEYBOARD:ShowScene("loreLibrary")
+    else
+        assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+        local collection = self.registeredCollection[collectionIndex]
+        assert(collection)
+        collection.OpenCategory(bookIndex)
+    end
+end
+
+function Librarian:GetBookInfo(categoryIndex, collectionIndex, bookIndex)
+    if categoryIndex < self.constants.CUSTOM_CATEGORY then
+        local title, _, known, bookId = GetLoreBookInfo(categoryIndex, collectionIndex, bookIndex)
+        return title, known, bookId
+    else
+        assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+        local collection = self.registeredCollection[collectionIndex]
+        assert(collection)
+        local title, known = collection.GetBookInfo(bookIndex)
+        local bookId = self:GetCustomBookIdFromIndices(collectionIndex, bookIndex)
+        return title, known, bookId
+    end
+end
+
+function Librarian:GetCustomBookIdFromIndices(collectionIndex, bookIndex)
+    return self.constants.CUSTOM_BOOK_ID_START + collectionIndex * self.constants.MAX_BOOK_PER_CUSTOM_COLLECTION + bookIndex
+end
+
+function Librarian:GetLoreBookIndicesFromBookId(bookId)
+    if bookId < self.constants.CUSTOM_BOOK_ID_START then
+        return GetLoreBookIndicesFromBookId(bookId)
+    else
+        local bookIndexAndCollection = bookId - self.constants.CUSTOM_BOOK_ID_START
+        local collectionIndex = math.floor(bookIndexAndCollection / self.constants.MAX_BOOK_PER_CUSTOM_COLLECTION)
+        if self.registeredCollection[collectionIndex] then
+            local bookIndex = bookIndexAndCollection - (collectionIndex * self.constants.MAX_BOOK_PER_CUSTOM_COLLECTION)
+            return self.constants.CUSTOM_CATEGORY, collectionIndex, bookIndex
+        end
+    end
+
+    return nil, nil, nil
+end
+
+function Librarian:ReadLoreBook(categoryIndex, collectionIndex, bookIndex)
+    if categoryIndex < self.constants.CUSTOM_CATEGORY then
+        return ReadLoreBook(categoryIndex, collectionIndex, bookIndex)
+    else
+        assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+        local collection = self.registeredCollection[collectionIndex]
+        assert(collection)
+        local _, _, body, medium, showTitle = collection.GetBookInfo(bookIndex)
+        return body, medium, showTitle
+    end
+end
+
+function Librarian:GetBookTitleAndBody(categoryIndex, collectionIndex, bookIndex)
+    if categoryIndex < self.constants.CUSTOM_CATEGORY then
+        local title = GetLoreBookInfo(categoryIndex, collectionIndex, bookIndex)
+        local body = ReadLoreBook(categoryIndex, collectionIndex, bookIndex)
+        return title, body
+    else
+        assert(categoryIndex == self.constants.CUSTOM_CATEGORY)
+        local collection = self.registeredCollection[collectionIndex]
+        assert(collection)
+        local title, _, body = collection.GetBookInfo(bookIndex)
+        return title, body
+    end
+end
diff --git a/CHANGELOG b/CHANGELOG
index 51c9568..ef6b3c9 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,6 +1,9 @@
 -------------------------------------------------------------------------------
 Librarian v3.0 2022-07-25
 -------------------------------------------------------------------------------
+3.5 2022-10-03
+- Add API for external addon to be able to register their own books to Librarian
+
 3.4 2022-09-11
 - Add keybind in lore reader only when the book is recognized by Librarian (it avoids conflicting with other addon like TheLibrarium)
 - Add possibility to delete books which don't have a category
diff --git a/ESOUIModifications.lua b/ESOUIModifications.lua
index f256c03..f8880c5 100644
--- a/ESOUIModifications.lua
+++ b/ESOUIModifications.lua
@@ -4,8 +4,8 @@ function Librarian:AddLoreReaderUnreadToggle()
         if book.title then
             return book.title == title
         end
-        local categoryIndex, collectionIndex, bookIndex = GetLoreBookIndicesFromBookId(book.bookId)
-        local bookTitle = GetLoreBookInfo(categoryIndex, collectionIndex, bookIndex)
+        local categoryIndex, collectionIndex, bookIndex = self:GetLoreBookIndicesFromBookId(book.bookId)
+        local bookTitle = self:GetBookInfo(categoryIndex, collectionIndex, bookIndex)
         return bookTitle == title
     end

diff --git a/Librarian.lua b/Librarian.lua
index b882729..df0d467 100644
--- a/Librarian.lua
+++ b/Librarian.lua
@@ -145,7 +145,7 @@ function Librarian:ReadBook(data)
     self.lastShownBookId = data.bookId

     if data.categoryIndex and data.collectionIndex and data.bookIndex then
-        local body, medium, showTitle = ReadLoreBook(data.categoryIndex, data.collectionIndex, data.bookIndex)
+        local body, medium, showTitle = self:ReadLoreBook(data.categoryIndex, data.collectionIndex, data.bookIndex)
         if medium ~= 0 then
             LORE_READER:SetupBook(data.title, body, medium, showTitle)
         else
diff --git a/Librarian.txt b/Librarian.txt
index 798f8b7..06aa0c9 100644
--- a/Librarian.txt
+++ b/Librarian.txt
@@ -11,6 +11,7 @@

 Librarian.lua

+API.lua
 ESOUIModifications.lua
 LibrarianData.lua
 LibrarianSettings.lua
diff --git a/LibrarianData.lua b/LibrarianData.lua
index 38567d6..b6c24fa 100644
--- a/LibrarianData.lua
+++ b/LibrarianData.lua
@@ -23,22 +23,27 @@ function Librarian:ImportFromLoreLibrary()
     local function step()
         if bookIndex >= totalBooks then
             if collectionIndex >= numCollections then
-                if categoryIndex >= GetNumLoreCategories() then
+                if categoryIndex >= self:GetNumLoreCategories() then
                     endSearch()
                     return false
                 else
                     categoryIndex = categoryIndex + 1
                     local loreCategoryName
-                    numCollections = select(2, GetLoreCategoryInfo(categoryIndex))
+                    numCollections = self:GetLoreCollectionCount(categoryIndex)
                     collectionIndex = 0
                 end
             end
             collectionIndex = collectionIndex + 1
-            totalBooks = select(4, GetLoreCollectionInfo(categoryIndex, collectionIndex))
+            totalBooks = self:GetLoreCollectionTotalBook(categoryIndex, collectionIndex)
             bookIndex = 0
+
+            if totalBooks == 0 then
+                -- if there is no book in this collection, it is probably an obsolete collection so let's skip it instead of crashing later here
+                return true
+            end
         end
         bookIndex = bookIndex + 1
-        local title, _, known, bookId = GetLoreBookInfo(categoryIndex, collectionIndex, bookIndex)
+        local title, known, bookId = self:GetBookInfo(categoryIndex, collectionIndex, bookIndex)
         if known then
             local book = self:FindBook(bookId)
             local characterBook = self:FindCharacterBook(bookId)
@@ -94,7 +99,7 @@ function Librarian:AddBookToGlobalSave(book)
     book.timeStamp = GetTimeStamp()
     book.unread = true

-    local categoryIndex, collectionIndex, bookIndex = GetLoreBookIndicesFromBookId(book.bookId)
+    local categoryIndex, collectionIndex, bookIndex = self:GetLoreBookIndicesFromBookId(book.bookId)
     if categoryIndex and collectionIndex and bookIndex then
         -- if these indexes exist in the API, this mean that we will be able to get these info when needed (so no need to save them)
         book.title = nil
@@ -135,7 +140,7 @@ function Librarian:AddBook(book, refreshDataRightAway)
         table.insert(self.characterBooks, characterBook)

         if book.unread then
-            local categoryIndex, collectionIndex = GetLoreBookIndicesFromBookId(book.bookId)
+            local categoryIndex, collectionIndex = self:GetLoreBookIndicesFromBookId(book.bookId)
             if categoryIndex and collectionIndex then
                 self:AddUnreadBookInCollection(categoryIndex, collectionIndex)
             end
@@ -207,7 +212,7 @@ end
 function Librarian:ToggleReadBook(book)
     book.unread = not book.unread

-    local categoryIndex, collectionIndex = GetLoreBookIndicesFromBookId(book.bookId)
+    local categoryIndex, collectionIndex = self:GetLoreBookIndicesFromBookId(book.bookId)
     if categoryIndex and collectionIndex then
         if not self.unreadPerCollections[categoryIndex] then self.unreadPerCollections[categoryIndex] = {} end
         if not self.unreadPerCollections[categoryIndex][collectionIndex] then self.unreadPerCollections[categoryIndex][collectionIndex] = 0 end
diff --git a/LibrarianUI.lua b/LibrarianUI.lua
index a7668b4..3f9aefb 100644
--- a/LibrarianUI.lua
+++ b/LibrarianUI.lua
@@ -37,12 +37,10 @@ function Librarian:InitializeKeybindStripDescriptors()
             name = GetString(LIBRARIAN_GO_TO_CATEGORY),
             keybind = "UI_SHORTCUT_TERTIARY",
             visible = function()
-                return self.mouseOverRow and self.mouseOverRow.data.categoryIndex and self.mouseOverRow.data.collectionIndex
+                return self.mouseOverRow and self:CanOpenCollection(self.mouseOverRow.data.categoryIndex, self.mouseOverRow.data.collectionIndex)
             end,
             callback = function()
-                local collectionId = select(7, GetLoreCollectionInfo(self.mouseOverRow.data.categoryIndex, self.mouseOverRow.data.collectionIndex))
-                LORE_LIBRARY:SetCollectionIdToSelect(collectionId)
-                MAIN_MENU_KEYBOARD:ShowScene("loreLibrary")
+                self:OpenCollection(self.mouseOverRow.data.categoryIndex, self.mouseOverRow.data.collectionIndex, self.mouseOverRow.data.bookIndex)
             end,
         },
         {
@@ -50,7 +48,10 @@ function Librarian:InitializeKeybindStripDescriptors()
             name = GetString(LIBRARIAN_DELETE_BOOK),
             keybind = "UI_SHORTCUT_NEGATIVE",
             visible = function()
-                return self.mouseOverRow and (not self.mouseOverRow.data.categoryIndex or not self.mouseOverRow.data.collectionIndex)
+                return self.mouseOverRow
+                    and (not self.mouseOverRow.data.categoryIndex
+                        or not self.mouseOverRow.data.collectionIndex
+                        or self.mouseOverRow.data.categoryIndex == self.constants.CUSTOM_CATEGORY)
             end,
             callback = function()
                 self.potentialBookIdToDelete = self.mouseOverRow.data.bookId
@@ -86,21 +87,24 @@ function Librarian:InitializeScene()
 end

 function Librarian:BuildMasterList()
-    local GetLoreBookIndicesFromBookId, GetLoreCollectionInfo = GetLoreBookIndicesFromBookId, GetLoreCollectionInfo
-    local GetLoreBookInfo = GetLoreBookInfo
-
     local function ShouldDisplayBook(book)
         if not self.settings.showHiddenBook then
-            local category, collection = GetLoreBookIndicesFromBookId(book.bookId)
+            local category, collection = self:GetLoreBookIndicesFromBookId(book.bookId)
             if category and collection then
-                local hiddenCollection = select(5, GetLoreCollectionInfo(category, collection))
-                if hiddenCollection then
+                if self:IsLoreCollectionHidden(category, collection) then
                     return false
                 end
             else
                 return false
             end
         end
+
+        if book.bookId > self.constants.CUSTOM_BOOK_ID_START then
+            local category, collection = self:GetLoreBookIndicesFromBookId(book.bookId)
+            if not category or not collection then
+                return false
+            end
+        end
         return true
     end

@@ -121,19 +125,18 @@ function Librarian:BuildMasterList()

             -- because we reuse the same list between 2 refresh, the data may already be filled up so no need to recompute it, these won't change
             if not data.categoryIndex or not data.collectionIndex or not data.bookIndex then
-                data.categoryIndex, data.collectionIndex, data.bookIndex = GetLoreBookIndicesFromBookId(book.bookId)
+                data.categoryIndex, data.collectionIndex, data.bookIndex = self:GetLoreBookIndicesFromBookId(book.bookId)
             end
             if data.categoryIndex and data.collectionIndex and data.bookIndex and (not data.title or not data.body or not data.category) then
-                data.title = GetLoreBookInfo(data.categoryIndex, data.collectionIndex, data.bookIndex)
-                data.body = ReadLoreBook(data.categoryIndex, data.collectionIndex, data.bookIndex)
-                data.category = GetLoreCollectionInfo(data.categoryIndex, data.collectionIndex)
+                data.title, data.body = self:GetBookTitleAndBody(data.categoryIndex, data.collectionIndex, data.bookIndex)
+                data.category = self:GetLoreCollectionName(data.categoryIndex, data.collectionIndex)
             end

             if not data.category or data.category == "" then
                 data.category = GetString(LIBRARIAN_NO_CATEGORY)
             end

-            if not data.wordCount then
+            if not data.wordCount or data.wordCount == 0 then
                 local wordCount = 0
                 if data.body then
                     for w in data.body:gmatch("%S+") do
diff --git a/README b/README
index c23b0db..3a04960 100644
--- a/README
+++ b/README
@@ -12,13 +12,13 @@ https://account.elderscrollsonline.com/add-on-terms
 -------------------------------------------------------------------------------
 Description
 -------------------------------------------------------------------------------
-Librarian records every book your character reads in-game and keeps a list of
-when it was found and whether you have marked it as read, allowing you to continue
-questing (and not hold up other players) while being confident you won't forget
-to read anything later.
+Librarian records every book your character reads in-game and keeps a list of
+when it was found and whether you have marked it as read, allowing you to
+continue questing (and not hold up other players) while being confident you
+won't forget to read anything later.

-Flamage (the original author) and Calia1120 (the second author) aren't playing ESO
-at present, so I'll update this addon!
+Flamage (the original author) and Calia1120 (the second author) aren't playing
+ESO at present, so I'll update this addon!

 -------------------------------------------------------------------------------
 Installation
@@ -43,3 +43,14 @@ For Mac: ~/Documents/Elder Scrolls Online/liveeu/
    the addons menu. Enable your addons from there.

 -------------------------------------------------------------------------------
+For fellow addon developpers
+-------------------------------------------------------------------------------
+If you have an addon adding books to the game and you would like them to be
+displayed in Librarian, go check the API.lua file, there are some information
+on how to process.
+
+And if you want to contribute, you can contact us (the 3 authors) on esoui.com
+or in the comment of the Librarian page :
+https://www.esoui.com/downloads/fileinfo.php?id=188
+Also, don't hesitate to check out the Librarian git repos :
+http://git.esoui.com/?a=summary&p=Librarian