Implementation

-- priority: 10
share = {}

-- The hash pull and sync shares keep in share.hash, of the text both sides
-- agreed on at the last pull or sync: the first 8 characters of the sha256 of
-- the cleaned text. The name stays from when it was on its way out.
function share.legacyHash(content)
  content = share.cleanFrontmatter(content)
  return crypto.sha256(content):sub(1, 8)
end

-- An older name for it
share.contentHash = share.legacyHash

-- The git blob id of text: what GitHub's tree listing reports for a file with
-- that content
function share.gitBlobSha(text)
  -- # and string.len count UTF-16 units and byte buffers have no length, so
  -- derive the UTF-8 byte count from the unpadded Base64 length
  local b64 = encoding.base64Encode(text)
  local padding = #b64:match("=*$")
  local size = #b64 // 4 * 3 - padding
  -- string.char(0) because the "\0" escape is not parsed as a NUL byte
  return crypto.sha1("blob " .. size .. string.char(0) .. text)
end

function share.cleanFrontmatter(text)
  return index.patchFrontmatter(text, {
    -- A share: map, as well as dotted share.* keys
    {op="delete-key", path="share"},
    {op="delete-key", path="share.uri"},
    {op="delete-key", path="share.hash"},
    {op="delete-key", path="share.mode"},
    {op="delete-key", path="share.transform"},
  })
end

-- Sets the keys of m that are present (uri, mode, transform, hash)
function share.setFrontmatter(m, text)
  local patches = {}
  for _, key in ipairs({"uri", "mode", "transform", "hash"}) do
    if m[key] != nil then
      table.insert(patches, {op="set-key", path="share." .. key, value=m[key]})
    end
  end
  return index.patchFrontmatter(text, patches)
end

-- Whether the page has a tag pred accepts, in its text or in the index
local function pageHasTag(name, text, pred)
  local function anyTag(tags)
    if type(tags) == "string" then
      tags = {tags}
    end
    if type(tags) != "table" then
      return false
    end
    for _, tag in ipairs(tags) do
      if pred((tostring(tag):gsub("^#", ""))) then
        return true
      end
    end
    return false
  end
  if anyTag(index.extractFrontmatter(text).frontmatter.tags) then
    return true
  end
  local ok, meta = pcall(space.getPageMeta, name)
  return ok and meta != nil and anyTag(meta.tags)
end

local managedTags = {["meta/library"] = true, ["meta/repository"] = true}

-- Installed libraries and repositories: their remote is the source of truth
function share.isManaged(name, text)
  return pageHasTag(name, text, function(tag)
    return managedTags[tag] == true
  end)
end

-- Tool Pages, and pages tagged meta or meta/* (such as templates), are shared
-- as they are
function share.isExempt(name, text)
  if name:startsWith("Library/") then
    return true
  end
  return pageHasTag(name, text, function(tag)
    return tag == "meta" or tag:startsWith("meta/")
  end)
end

-- The share settings of a page and the text it sends: transformed for push,
-- only without share keys otherwise
function share.prepare(name, text)
  local m = index.extractFrontmatter(text).frontmatter.share
  if type(m) != "table" or m.uri == nil then
    error("Not configured")
  end
  local mode = m.mode or "push"
  local outgoing, target
  if mode == "push" then
    target = share.target(m.uri)
    outgoing = share.transform(text, {
      page = name,
      uri = m.uri,
      target = target,
      members = {[name] = m.uri},
      steps = share.resolveSteps(name, text, target, m.transform),
    })
  else
    outgoing = share.cleanFrontmatter(text)
  end
  return {m = m, mode = mode, outgoing = outgoing, target = target}
end

-- Makes sure a transport exists for uri (e.g. connects to a SilverBullet
-- space this page was shared to from another device)
function share.ensureTransport(uri)
  local matches = service.discover("share:ensure", {uri = uri})
  if matches and #matches > 0 then
    service.invoke(matches[1], {uri = uri})
  end
end

-- Whether share.hash describes text. A hash written without quotes may read
-- back as a number (e.g. 83464e55)
local function hashMatches(hash, text)
  if hash == nil then
    return false
  end
  local expected = share.legacyHash(text)
  if type(hash) == "number" then
    return tonumber(expected) == hash
  end
  return tostring(hash) == expected
end

-- For Std pages: a memo of results by key, each kept while its inputs (a
-- table of values compared with ==) stay the same. memo(key, inputs, compute)
-- returns the result, computed only when the inputs changed; copy (if given)
-- makes what callers get, so changing it leaves the memo alone.
function share.memo(copy)
  copy = copy or function(r)
    return r
  end
  local entries = {}
  local function sameInputs(a, b)
    for k, v in pairs(a) do
      if b[k] != v then
        return false
      end
    end
    for k in pairs(b) do
      if a[k] == nil then
        return false
      end
    end
    return true
  end
  return function(key, inputs, compute)
    local entry = entries[key]
    if entry == nil or not sameInputs(entry.inputs, inputs) then
      entry = {inputs = inputs, result = compute()}
      entries[key] = entry
    end
    return copy(entry.result)
  end
end

-- The remote's text without its share keys (a remote page can be shared
-- onward itself), or nil when it is missing
function share.readRemote(uri)
  local remoteText = net.readURI(uri, {encoding="text/markdown"})
  if remoteText then
    remoteText = share.cleanFrontmatter(remoteText)
  end
  return remoteText
end
local readRemote = share.readRemote

-- How a remote stands against the outgoing text, as share.check and
-- share.checkCollection report it. r.remote is nil when missing, else {text}
-- or {sha} (a git blob SHA, see share.gitBlobSha). r.hash is the page's
-- share.hash (pull and sync only): without one, there is no telling which
-- side changed. It is compared with r.remote.text only.
function share.remoteState(r)
  local remote = r.remote
  if remote == nil then
    return "missing"
  end
  local remoteIs
  if remote.text != nil then
    remoteIs = function(text)
      return text == remote.text
    end
  else
    remoteIs = function(text)
      return share.gitBlobSha(text) == remote.sha
    end
  end
  if remoteIs(r.outgoing) then
    return "upToDate"
  elseif r.hash == nil then
    return "differs"
  elseif remote.text != nil and hashMatches(r.hash, remote.text) then
    return "localChanged"
  elseif hashMatches(r.hash, r.outgoing) then
    return "remoteChanged"
  end
  return "bothChanged"
end

-- The share in progress for a page: its settings, what it sends and its
-- share.hash
local function standing(name, text)
  local prepared = share.prepare(name, text)
  local m = prepared.m
  return {
    name = name,
    text = text,
    m = m,
    uri = m.uri,
    mode = prepared.mode,
    target = prepared.target,
    outgoing = prepared.outgoing,
    hash = m.hash,
  }
end

-- Same naming as SilverBullet sync's conflict copies
local function saveConflictCopy(name, remoteText)
  local copyName = name .. ".conflicted-" .. crypto.sha256(remoteText):sub(1, 8)
  space.writePage(copyName, remoteText)
  return copyName
end

-- Writes body, the remote's text just taken, to the page with its hash
local function writeLocal(s, body)
  local m = {
    uri = s.m.uri,
    mode = s.m.mode,
    transform = s.m.transform,
    hash = share.legacyHash(body),
  }
  local updated = share.setFrontmatter(m, body)
  if s.editing then
    editor.setText(updated)
  else
    space.writePage(s.name, updated)
  end
end

-- For pull and sync, after a share that agreed on a text with the remote:
-- sets the page's share.hash to match it, on the page as it is now (it may
-- have been edited during the share, or written with the hash already)
local function keepHash(s)
  local text
  if s.editing then
    text = editor.getText()
  else
    text = space.readPage(s.name)
  end
  local m = index.extractFrontmatter(text).frontmatter.share
  local hash = type(m) == "table" and m.hash or nil
  if hashMatches(hash, s.agreed) then
    return
  end
  local updated = share.setFrontmatter({hash = share.legacyHash(s.agreed)}, text)
  if s.editing then
    editor.setText(updated)
  else
    space.writePage(s.name, updated)
  end
end

local function takeRemote(s, remoteText)
  writeLocal(s, remoteText)
  s.agreed = remoteText
  return "shared"
end

local function writeRemote(s, body, conditional)
  local written = net.writeURI(s.uri, body, {conditional = conditional})
  if written and written.conflict then
    return "retry"
  end
  s.agreed = body
  return "shared"
end

-- For pull and sync: the remote text, or nil when it is what this side has
-- (then agreed on)
local function remoteDiffering(s)
  local remoteText = readRemote(s.uri)
  if remoteText == s.outgoing then
    s.agreed = s.outgoing
    return nil
  end
  if not remoteText then
    error("Could not read " .. s.uri)
  end
  return remoteText
end

local strategies = {}

-- The local page wins (a missing remote page is created), unless the remote
-- already has it. A transport that can't read is written to all the same.
function strategies.push(s)
  share.ensureTransport(s.uri)
  -- A transport set up just now may come with its own target, which changes
  -- what is sent
  if share.target(s.uri).kind != s.target.kind then
    local editing = s.editing
    s = standing(s.name, s.text)
    s.editing = editing
  end
  local ok, remoteText = pcall(readRemote, s.uri)
  if ok and remoteText == s.outgoing then
    return "unchanged"
  end
  return writeRemote(s, s.outgoing, false)
end

-- The remote wins, asking first when there are local changes (or no
-- share.hash to tell). Reads through whatever reader matches, never
-- connecting (e.g. libraries).
function strategies.pull(s)
  local remoteText = remoteDiffering(s)
  -- Only local changes: nothing to pull
  if remoteText == nil or hashMatches(s.hash, remoteText) then
    return "unchanged"
  end
  local message
  if s.hash == nil then
    if not share.isManaged(s.name, s.text) then
      message = s.name .. " differs from " .. s.uri .. ". Overwrite it with the remote version?"
    end
  elseif not hashMatches(s.hash, s.outgoing) then
    message = "You changed " .. s.name .. " locally. Overwrite it with the version from " .. s.uri .. "?"
  end
  if message and not editor.confirm(message) then
    return "unchanged"
  end
  return takeRemote(s, remoteText)
end

-- When there is no telling which side to keep: the user picks
local function chooseVersion(s, remoteText, message)
  local choice = editor.chooseVersion {
    title = "Which version of " .. s.name .. "?",
    message = message,
    mine = s.outgoing,
    theirs = remoteText,
    choices = {
      {id = "theirs", label = "Take theirs"},
      {id = "mine", label = "Keep mine"},
      {id = "copy", label = "Save theirs as a copy"},
    },
  }
  if choice == "theirs" then
    return takeRemote(s, remoteText)
  elseif choice == "mine" then
    return writeRemote(s, s.outgoing, true)
  elseif choice == "copy" then
    local copyName = saveConflictCopy(s.name, remoteText)
    editor.flashNotification("Saved their version as " .. copyName)
  end
  -- Canceled: the sides still differ, as the user knows
  return "notified"
end

-- share.hash tells which side changed since the last pull or sync
function strategies.sync(s)
  share.ensureTransport(s.uri)
  local remoteText = remoteDiffering(s)
  if remoteText == nil then
    return "unchanged"
  end
  if s.hash == nil then
    return chooseVersion(s, remoteText,
      "There's no record of the last share to tell which side changed.")
  end
  local localChanged = not hashMatches(s.hash, s.outgoing)
  local remoteChanged = not hashMatches(s.hash, remoteText)
  if not localChanged then
    return takeRemote(s, remoteText)
  elseif not remoteChanged then
    return writeRemote(s, s.outgoing, true)
  end
  return chooseVersion(s, remoteText, "Both sides changed since the last share.")
end

-- One share of the page (the current one without a name). Returns "shared",
-- "unchanged", "notified" (the user was told the outcome, e.g. a conflict) or
-- "retry" (a sync write found the remote changed since it was read).
local function shareOnce(name)
  local editing = not name
  local text
  if editing then
    name = editor.getCurrentPage()
    text = editor.getText()
  else
    text = space.readPage(name)
  end
  local s = standing(name, text)
  s.editing = editing
  local outcome = (strategies[s.mode] or strategies.sync)(s)
  if s.mode != "push" and s.agreed != nil then
    keepHash(s)
  end
  return outcome
end

local results = {shared = true, unchanged = false}

-- Returns true if something was shared, false if there was nothing to do,
-- nil if the user was already notified of the outcome (conflicts)
function share.sharePage(name)
  local outcome = shareOnce(name)
  if outcome == "retry" then
    outcome = shareOnce(name)
  end
  if outcome == "retry" then
    error("Conflict: the remote changed again while sharing, try again")
  end
  return results[outcome]
end

-- Offline: has the page changed since its last pull or sync, as its
-- share.hash tells? state is "unchanged", "localChanged", "unknown" (no
-- share.hash) or, for push, "unchecked" (nothing to compare with offline).
-- Memoized per page on its text, as views ask for it often
local statusMemo = share.memo(function(r)
  return {uri = r.uri, mode = r.mode, state = r.state}
end)

function share.status(name)
  local text = space.readPage(name)
  return statusMemo(name, {text = text}, function()
    local m = index.extractFrontmatter(text).frontmatter.share
    if type(m) != "table" or m.uri == nil then
      error("Not configured")
    end
    local mode = m.mode or "push"
    local state = "unchecked"
    if mode == "push" then
      -- Push keeps no share.hash: only the remote can tell
    elseif m.hash == nil then
      state = "unknown"
    elseif hashMatches(m.hash, share.cleanFrontmatter(text)) then
      state = "unchanged"
    else
      state = "localChanged"
    end
    return {uri = m.uri, mode = mode, state = state}
  end)
end

local function checkOne(name)
  local s = standing(name, space.readPage(name))
  -- Never connects: a remote that needs a sign-in throws
  local remoteText = readRemote(s.uri)
  return {
    uri = s.uri,
    mode = s.mode,
    state = share.remoteState {
      remote = remoteText and {text = remoteText},
      outgoing = s.outgoing,
      -- A push page's share.hash (from older versions) says nothing
      hash = s.mode != "push" and s.hash or nil,
    },
  }
end

-- Reads the remotes of the pages and compares them with the local pages and
-- their share.hash. Never prompts, connects or writes.
function share.check(names)
  local results = {}
  for _, name in ipairs(names) do
    local ok, result = pcall(checkOne, name)
    if ok then
      results[name] = result
    else
      results[name] = {state = "error", error = tostring(result)}
    end
  end
  return results
end

-- Shares the pages in order. opts.onProgress(index, total, name) is called
-- before each page, opts.canceled() before each page too: a true stops the
-- run, leaving the remaining pages out of the result.
function share.shareMany(names, opts)
  opts = opts or {}
  local results = {}
  for i, name in ipairs(names) do
    if opts.canceled and opts.canceled() then
      break
    end
    if opts.onProgress then
      opts.onProgress(i, #names, name)
    end
    local ok, result = pcall(share.sharePage, name)
    if ok then
      results[name] = {ok = true, result = result}
    else
      results[name] = {ok = false, error = tostring(result)}
    end
  end
  return results
end

-- Whether to replace the installed page with remoteText: only when the
-- remote changed since it was installed (as share.hash tells), and when the
-- page was edited since, only if the user agrees. Without a share.hash the
-- remote wins.
local function shouldUpdate(installed, uri, remoteText)
  local text = space.readPage(installed)
  local localText = share.cleanFrontmatter(text)
  if localText == remoteText then
    return false
  end
  local m = index.extractFrontmatter(text).frontmatter.share
  local hash = type(m) == "table" and m.hash or nil
  if hashMatches(hash, remoteText) then
    return false
  end
  local edited = hash != nil and not hashMatches(hash, localText)
  return not edited or editor.confirm("You changed " .. installed
    .. " locally. Overwrite it with the version from " .. uri .. "?")
end

-- What share.install(uri, opts) would write, without writing: see
-- docs/API/share.md
function share.libraryInfo(uri, opts)
  opts = opts or {}
  local remoteText = net.readURI(uri, {encoding="text/markdown"})
  if type(remoteText) != "string" then
    error("Could not fetch " .. uri)
  end
  local fm = index.extractFrontmatter(remoteText).frontmatter
  local page = opts.installed or opts.page or fm.name
  if not page then
    error("Library frontmatter missing required 'name'")
  end
  if not opts.installed and not page:startsWith("Library/") then
    page = "Library/" .. page
  end
  return {
    uri = uri,
    page = page,
    files = fm.files,
    requires = fm.requires,
    exists = space.pageExists(page),
    text = share.cleanFrontmatter(remoteText),
  }
end

-- Writes what share.libraryInfo read, without reading or checking again: see
-- docs/API/share.md
function share.installInfo(info)
  space.writePage(info.page, share.setFrontmatter({
    uri = info.uri,
    mode = "pull",
    hash = share.legacyHash(info.text),
  }, info.text))
end

-- The Library Manager's pull: see docs/API/share.md
function share.install(uri, opts)
  opts = opts or {}
  local info = share.libraryInfo(uri, opts)
  local result = {page = info.page, files = info.files, changed = false}
  if opts.installed then
    if not opts.force and not shouldUpdate(opts.installed, uri, info.text) then
      return result
    end
  elseif info.exists then
    error("Page already exists: " .. info.page)
  end
  share.installInfo(info)
  result.changed = true
  return result
end
Loading SilverBullet…