Add book note via OpenLibrary metadata Lbrary

Hi all,

I was inspired by the MediaDB plugin for Obsidian to write this library. The idea is to quickly search for and add books that you want to make notes about by pulling basic metadata from OpenLibrary.

You can integrate by adding the following library:
https://github.com/ravenscroftj/silverbullet-libraries/blob/main/Library/ravenscroftj/BookManager.md

Once you install and load the plugin run the command “Add Book to Knowledge Base”

You’ll be prompted for some search criteria - you can use book title, author name, ISBN etc.

Then you’ll see some possible matches, select the corresponding one (press ESC if none match or if you changed your mind)

Finally the book note will be created with some metadata (if you already imported this book it will do nothing and just navigate to the existing page)

You can override the templates for the filename and the generated note content via the config API. Details are in the library.

I am still having some teething problems with the template rendering (not sure how I can render arrays like ${author_name} inside an existing template. Maybe someone can give me a pointer?

Anyway hopefully this is useful to someone else! Feedback welcome!

4 Likes

This is very cool, nice!

I’ll use this a lot, many thanks!

1 Like

Here is my version with a couple of added fields and some formatting improvements.

  • solved the “Author” array problem using the author_block variable and parsing the separate authors one by one outside of the template.
  • added quotes “” to all the metadata entries to be handled as strings, more reliable to handle string data.
  • removed the ID-field , because it was already part of the URL, so no duplicate entries.
  • adds a short cleaned up description of the book to the metadata
  • adds the book cover (L-Size) to the metadata.

-- ==============================
--  Open Library → SilverBullet
-- ==============================

local cfg = config.get("bookmanager") or ""

local pagePrefix = cfg.prefix or "MediaDB/Books/"
local filenameTemplate = cfg.filenameTemplate or [==[${title} (${first_publish_year})]==]
local pageTemplate = cfg.pageTemplate or [==[---
type: "book"
title: "${title}"
year: "${first_publish_year}"
author:
${author_block}
isbn: "${isbn}"
url: "https://openlibrary.org${olid}"
tags: "mediaDB/book"
dataSource: "OpenLibraryAPI"
cover: "${cover_image_url}"
description: "${description}"
---
# ${title} - ${author_name[1]}
]==]

function searchBook(queryString)
  -- Search by title, author, or ISBN
  local url = "https://openlibrary.org/search.json?q=" .. 
              string.gsub(queryString, " ", "+")
  
  local resp = net.proxyFetch(url, {
    method = "GET",
    headers = { Accept = "application/json" }
  })

  if not resp.ok then
    error("Failed to fetch from Open Library: " .. resp.status)
  end
  
  local data = resp.body

  if #data.docs == 0 then
    editor.flashNotification("No book found for: " .. queryString)
    return nil
  end

  options = {}
  -- show prompt to select docs
  for i, doc in ipairs(data.docs) do
    options[i] = {
      name=doc.title, 
      value=i, 
      description=string.format("By %s, published %s", doc.author_name, doc.first_publish_year)
  }  
  end
  
  local result = editor.filterBox("Select:", options)

  if not result then
    editor.flashNotification("Cancelled book search")
    return nil
  end
  
  return data.docs[result.value]  -- return first match
end

command.define {
  name = "Open Library: Add Book to Knowledge Base",
  run = function()
    local queryString = editor.prompt("Search for book (title, author, or ISBN):")
    if not queryString then
      return
    end

    print("Searching for book " .. queryString)
    
    local book = searchBook(queryString)
    if not book then
      return
    end
    
    local titleTemplate = template.new(filenameTemplate)

    local safeBook = {}

    for k in {'title','first_publish_year','author_name', 'isbn'} do
      safeBook[k] = book[k] or "Unknown"
    end

    safeBook['olid'] = book['key']

    if book.cover_i then
      safeBook['cover_image_url'] = "https://covers.openlibrary.org/b/id/" .. book.cover_i .. "-L.jpg"
    else
      safeBook['cover_image_url'] = ""
    end

    -- build dynamic author block
    local author_lines = {}
    if type(book.author_name) == "table" then
      for _, name in ipairs(book.author_name) do
        table.insert(author_lines, '  - "' .. name .. '"')
      end
    end
    safeBook['author_block'] = table.concat(author_lines, "\n")

    -- determine ISBN robustly (search fields, then edition JSON)
    local function extract_first_isbn(b)
      if not b then return nil end

      if type(b.isbn) == "table" and #b.isbn > 0 then
        return b.isbn[1]
      elseif type(b.isbn) == "string" then
        return b.isbn
      end

      if type(b.isbn_13) == "table" and #b.isbn_13 > 0 then
        return b.isbn_13[1]
      end
      if type(b.isbn_10) == "table" and #b.isbn_10 > 0 then
        return b.isbn_10[1]
      end

      local edition = b.cover_edition_key or (type(b.edition_key) == "table" and b.edition_key[1]) or nil
      if edition then
        local ed_url = "https://openlibrary.org/books/" .. edition .. ".json"
        local ed_resp = net.proxyFetch(ed_url, {
          method = "GET",
          headers = { Accept = "application/json" }
        })
        if ed_resp.ok then
          local ed = ed_resp.body
          if type(ed.isbn_13) == "table" and #ed.isbn_13 > 0 then
            return ed.isbn_13[1]
          end
          if type(ed.isbn_10) == "table" and #ed.isbn_10 > 0 then
            return ed.isbn_10[1]
          end
        end
      end

      return nil
    end

    local found_isbn = extract_first_isbn(book)
    if found_isbn and found_isbn ~= "" then
      safeBook['isbn'] = found_isbn
    else
      safeBook['isbn'] = "Unknown"
    end

    -- fetch and clean description/plot from the work JSON
    local description_text = ""
    if book.key then
      local work_url = "https://openlibrary.org" .. book.key .. ".json"
      local work_resp = net.proxyFetch(work_url, {
        method = "GET",
        headers = { Accept = "application/json" }
      })
      if work_resp.ok then
        local w = work_resp.body
        if w.description then
          if type(w.description) == "table" then
            description_text = w.description.value or ""
          elseif type(w.description) == "string" then
            description_text = w.description
          end
        elseif w.first_sentence then
          if type(w.first_sentence) == "table" then
            description_text = w.first_sentence.value or ""
          elseif type(w.first_sentence) == "string" then
            description_text = w.first_sentence
          end
        end
      end
    end

    if description_text and description_text ~= "" then
      -- truncate at earliest marker indicating references/footnotes/extra sections
      local function cut_at_first_marker(text)
        local lower = string.lower(text)
        local markers = {
          "([source",     -- "([source" pattern
          "[source",      -- "[source" pattern
          "----------",   -- separator line
          "preceded by:", -- preceded by
          "see also:",    -- see also
          "followed by:", -- followed by
          "contains:"     -- contains
        }
        local first_pos = nil
        for _, m in ipairs(markers) do
          local s = string.find(lower, m, 1, true)
          if s and (not first_pos or s < first_pos) then
            first_pos = s
          end
        end
        if first_pos then
          return string.sub(text, 1, first_pos - 1)
        end
        return text
      end

      description_text = cut_at_first_marker(description_text)

      -- remove markdown links [text](url)
      description_text = string.gsub(description_text, "%b[]%b()", "")
      -- remove any remaining bracketed reference markers like [1], [2], etc.
      description_text = string.gsub(description_text, "%[%d+%]", "")
      -- remove reference-style links and footnotes like "[1]: http..."
      description_text = string.gsub(description_text, "%[%d+%]:%s*https?://%S+", "")
      -- remove explicit [source] or [Source] occurrences if anything left
      description_text = string.gsub(description_text, "%[source%]", "")
      description_text = string.gsub(description_text, "%[Source%]", "")
      -- remove raw URLs
      description_text = string.gsub(description_text, "https?://%S+", "")
      -- remove separators and excessive whitespace/newlines
      description_text = string.gsub(description_text, "[\r\n]+", " ")
      description_text = string.gsub(description_text, "%s+", " ")
      -- escape double quotes for frontmatter
      description_text = string.gsub(description_text, '"', '”')
      description_text = string.gsub(description_text, "^%s*(.-)%s*$", "%1")
      safeBook['description'] = description_text
    else
      safeBook['description'] = ""
    end

    local rawTitle = titleTemplate(safeBook)
    local safeTitle = string.gsub(rawTitle, "[/%\\:'\"%*%?%<%>%|]", "")
    safeTitle = string.gsub(safeTitle, "%s+", " ")

    local pageName = pagePrefix .. safeTitle

    if not space.pageExists(pageName) then
      local pageTemplate = template.new(pageTemplate)
      
      local content = pageTemplate(safeBook)
      
      -- Create the page
      space.writePage(pageName, content)
      editor.flashNotification("Book added: " .. safeTitle)
    else
      editor.flashNotification("Book already in collection: " .. safeTitle)
    end
    
    editor.navigate(pageName)

  end
}

All these modifications where necessary so I can use it in my “MediaGallery” which I’m currently working on, which gathers books, movies, tv-series, games, or basically any object or page with a title, subtitle, year, score, etc. and most importantly an image URL in it’s metadata and displays it in a beautiful Grid Gallery.

Here is a first sneak peak of the Media Gallery displaying the Book Gallery with the added books from OpenLibrary

3 Likes

This is awesome. Nice work