Files
plainleaf/libraries/Library/Std/Infrastructure/Library.md
T

10 KiB

#meta

This implements library management

Implementation

-- namespace
library = {
  libraryTag = "meta/library",
  remoteLibraryTag = "meta/library/remote",
  repositoryTag = "meta/repository"
}

local libraryPrefix = "Library/"
local repositoryPrefix = "Repositories/"

-- Schema of frontmatter of the library page
-- Schema of #meta/library objects
local librarySchema = {
  type = "object",
  properties = {
    name = schema.string(),
    files = schema.array(schema.string())
  },
  required = {"name"}
}

local remoteLibrarySchema = {
  type = "object",
  properties = {
    name = schema.string(),
    description = schema.string(),
    website = schema.string(),
    uri = schema.string(),
  },
  required = {"name", "uri"}
}

-- Repo management commands
command.define {
  name = "Library: Add Repository",
  run = function()
    local repoUri = editor.prompt("URI:")
    if not repoUri then
      return
    end
    local text = net.readURI(repoUri, {encoding="text/markdown"})
    local suggestedName = library.suggestRepoNameFromUri(repoUri)
    local repoPage = editor.prompt("Install into:", repositoryPrefix .. suggestedName)
    if not repoPage then
      return
    end
    if space.pageExists(repoPage) then
      editor.flashNotification(repoPage .. " already exists", "error")
      return
    end
    space.writePage(repoPage, share.setFrontmatter({
      uri = repoUri,
      hash = share.contentHash(text),
      mode = "pull"
    }, text))
    editor.flashNotification "Library installed"
    editor.navigate(repoPage)
    reloadEverything()
  end
}

command.define {
  name = "Library: Update All Repositories",
  run = function()
    for repo in query[[from index.tag(library.repositoryTag)]] do
      if repo.share then
        share.sharePage(repo.name)
      end
    end
    editor.flashNotification "All repositories updated"
  end
}

-- Library management commands
command.define {
  name = "Library: Install",
  run = function()
    local uri = editor.prompt("URI:")
    if not uri then
      return
    end
    library.install(uri)
    editor.flashNotification "Library installed"
    reloadEverything()
    editor.navigate("Library/Std/Pages/Library Manager")
  end
}

command.define {
  name = "Library: Update All",
  run = function()
    for lib in query[[
      from index.tag(library.libraryTag)
      where _.share and _.share.mode == "pull"
    ]] do
      if library.update(lib.name, false) then
        editor.flashNotification("Updated " .. lib.name)
      end
    end
    editor.flashNotification "Update complete!"
    reloadEverything()
  end
}

command.define {
  name = "Library: Manager",
  run = function()
    editor.navigate("Library/Std/Pages/Library Manager")
  end
}

-- Look up an installed library by name
function library.getInstalled(uri)
  local installedFMs = query[[
    from index.tag(library.libraryTag)
    where _.share and _.share.uri == uri
  ]]
  if #installedFMs == 0 then
    return nil
  else
    return installedFMs[1]
  end
end

-- Install a library
function library.install(uri, currentHash)
  -- Fetch from remote URL
  local text = net.readURI(uri, {
    encoding = "text/markdown"
  })

  if not text then
    error("Could not fetch: " .. uri)
  end

  local remoteLibFM = index.extractFrontmatter(text).frontmatter
  local remoteHash = share.contentHash(text)
  
  local err = jsonschema.validateObject(librarySchema, remoteLibFM)
  if err then
    editor.flashNotification("Library frontmatter validation error: " .. err, "error")
    return
  end
  
  local pageName = remoteLibFM.name

  -- If this is a fresh install, don't override existing pages
  if not currentHash and space.pageExists(pageName) then
    error("Page already exists: " .. pageName)
  end

  -- Check if update is required based on hash
  if currentHash and currentHash == remoteHash then
    return false
  end

  -- Ok, let's do this
  local sourceBase = urlDir(uri)
  local targetBase = urlDir(pageName)
  text = share.setFrontmatter({
    uri = uri,
    hash = remoteHash,
    mode = "pull"
  }, text)
  
  -- First write the main file
  space.writePage(pageName, text)
  
  -- And download and write assets, if any
  if remoteLibFM.files then
    for file in remoteLibFM.files do
      print("Downloading file", file)
      local targetPath = targetBase .. file
      local data = net.readURI(sourceBase .. file, {encoding="application/octet-stream"})
      if not data then
        error("Could not fetch file: " .. sourceBase .. file)
      end
      space.writeFile(targetPath, data)
    end
  end
  return true
end

-- Update a library
function library.update(pageName, force)
  local text = space.readPage(pageName)
  local fm = index.extractFrontmatter(text).frontmatter
  if fm.share then
     return library.install(fm.share.uri, fm.share.hash)
  else
    print("No 'share' key found in frontmatter for " .. pageName)
  end
end

function library.remove(pageName)
  local text = space.readPage(pageName)

  if not text then
    error("Could not read: " .. pageName)
  end

  local fm = index.extractFrontmatter(text).frontmatter

  -- Remove associated files first
  if fm.files then
    for file in fm.files do
      local p = pageName .. "/" .. file
      space.deleteFile(p)
    end
  end
  
  -- Then page itself
  space.deletePage(pageName)
end

function library.installedLibrariesWidget()
  local rows = {}
  for lib in query[[
    from index.tag "meta/library"
  ]] do
    table.insert(rows, dom.tr {
      dom.td { "[[" .. lib.name .. "|" .. lib.name .. "]]" },
      dom.td {
        (
          not lib.share and ""
        ) or (
          lib.share.mode == "pull" and
          dom.span {
            widgets.button("Update", function()
              local updated = library.update(lib.name, true)
              if updated then
                editor.flashNotification "Updated."
                reloadEverything()
              else
                editor.flashNotification "No update available."
              end
            end, { class = "update" }),
            widgets.button("Remove", function()
              if editor.confirm("Are you sure?") then
                library.remove(lib.name)
                editor.flashNotification "Done!"
                reloadEverything()
              end
            end, { class = "remove"})  
          }
        ) or (
          dom.span {
            widgets.button("Publish", function()
              local updated = library.update(lib.name, true)
              if updated then
                editor.flashNotification "Published."
                reloadEverything()
              else
                editor.flashNotification "No changes to publish."
              end
            end, { class = "update" })
          }
        )
      }
    })
  end
  if #rows == 0 then
    rows = {
      dom.tr {
        dom.td { colspan="2", "_Nothing yet_" }
      }
    }
  end
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Library"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

function library.installableLibrariesWidget()
  local installableLibraries = query[[from index.tag "meta/library/remote"]]
  local rows = {}
  for lib in query[[
    from installableLibraries where not library.getInstalled(_.uri)
  ]] do
    table.insert(rows, dom.tr {
      dom.td {
        lib.website and dom.a {
          href = lib.website,
          "**" .. lib.name .. "**",
        } or "**" .. lib.name .. "**",
        " _([[" .. lib.page .. "]])_",
        lib.description and dom.p { lib.description } or ""
      },
      dom.td {
        widgets.button("Install", function()
          library.install(lib.uri)
          editor.flashNotification "Done!"
          reloadEverything()
        end, { class = "install" })
      }
    })
  end
  
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Library"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

function library.installedRepositoriesWidget()
  local rows = {}
  for repo in query[[from index.tag(library.repositoryTag)]] do
    table.insert(rows, dom.tr {
      dom.td { "[[" .. repo.name .. "|" .. repo.name .. "]]" },
      dom.td {
        repo.share and dom.span {
          widgets.button("Update", function()
            if share.sharePage(repo.name) then
              editor.flashNotification "Repository updated"
              reloadEverything()
            else
              editor.flashNotification "No changes"
            end
          end),
          widgets.button("Remove", function()
            if editor.confirm("Are you sure?") then
              space.deletePage(repo.name)
              editor.flashNotification "Done!"
              reloadEverything()
            end
          end, { class = "remove" })
        } or ""
      }
    })
  end
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Repository"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

-- Utility functions
local function urlDir(url)
  return string.match(url, "^(.*/)[^/]+$")
end

-- Construct full library path from name
function library.libPath(name)
  return libraryPrefix .. name
end

-- Extract name from full path
function library.libName(path)
  return path:sub(#libraryPrefix + 1)
end

function library.repoName(path)
  return path:sub(#repositoryPrefix + 1)
end

function library.suggestRepoNameFromUri(uri)
  -- Take the bit after the last :
  local uriParts = uri:split(":")
  local suggestedName = uriParts[#uriParts]
  -- Strip .md
  local suggestedName = suggestedName:gsub("%.md$", "")
  -- Strip any some generic names
  for strippableSuffix in {"/REPO", "/silverbullet-libraries"} do
    suggestedName = suggestedName:gsub(strippableSuffix .. "$", "")
  end
  -- Strip branch names (@something)
  suggestedName = suggestedName:gsub("@.+$", "")
  -- Take the the last bit
  local parts = suggestedName:split("/")
  return parts[#parts]
end

function reloadEverything()
  mq.awaitEmptyQueue("indexQueue")
  editor.reloadConfigAndCommands()
  codeWidget.refreshAll()
end

Styles

table.manage-library td {
  vertical-align: top;
}
table.manage-library thead td:last-child {
  text-align: right;
}

table.manage-library tbody td:last-child {
  text-align: right;
}