Support for SilverBullet Share for:
As well as URI support (both read and write) for the following schemes:
https://github.com/username/repo/blob/branch/pathhttps://gist.github.com/username/gist-idhttps://gist.github.com/username/gist-id/file (one file of a gist, as collections use them)github:username/repo@branch/pathgithub:username/repo/path (defaults to main branch)ghr:username/repo@version/path (github release support)Reading public content from Github URLs requires no configuration. Downloads and update checks are anonymous, even when you are signed in or have a token configured. Private repositories are not supported for these reads.
To write to Github repos and gists you need to connect to Github. This happens automatically the first time you share or access something that needs it: a device code is shown, which you enter on github.com to authorize SilverBullet. The resulting token is stored on this device only (see Credentials: List) and can be cleared with Credentials: Clear.
Optionally, you can use a personal Github token (with repo and gists permissions) instead. Configure your token somewhere in Space Lua (use a space-lua block), ideally a SECRETS page. When set (and you have not connected through the device flow), it is used without asking you to sign in.
config.set("github.token", "your token")
To use your own Github OAuth app for the sign-in instead of the default one, set its client ID:
config.set("github.clientId", "your client id")
In addition, to push to Github repos you need to configure a name and email that will be part of the commit:
config.set("github.name", "John Doe")
config.set("github.email", "[email protected]")
-- priority: 50
github = {}
config.define("github", {
description = "GitHub integration settings",
type = "object",
properties = {
name = {
type = "string",
description = "Author name for Git commits",
ui = { category = "GitHub", label = "Author name", priority = 3 },
},
email = {
type = "string",
description = "Author email for Git commits",
ui = { category = "GitHub", label = "Author email", priority = 2 },
},
clientId = {
type = "string",
description = "Client ID of a GitHub OAuth app (device flow), overrides the default",
ui = { category = "GitHub", label = "OAuth client ID", priority = 0 },
},
token = {
type = "string",
description = "Personal access token for GitHub API (optional alternative to signing in)",
ui = { category = "GitHub", label = "GitHub token", priority = 1, inputType = "password" },
},
},
additionalProperties = false,
})
-- priority: 5
-- GitHub tokens do not expire, so there is no refresh
auth.defineProvider {
id = "github",
connect = function()
local token = oauth.deviceFlow {
deviceCodeUrl = "https://github.com/login/device/code",
tokenUrl = "https://github.com/login/oauth/access_token",
clientId = config.get("github.clientId") or "Ov23liZmRh8sR6UeL8fQ",
scope = "repo gist",
}
return {access_token = token.access_token}
end,
configured = function()
local token = config.get("github.token")
return token and {access_token = token}
end,
headers = function(cred)
return {Authorization = "Bearer " .. cred.access_token}
end,
}
-- priority: 10
-- "github:owner/repo[@ref]/path" or "https://github.com/owner/repo/blob/ref/path"
-- -> "owner/repo", ref (nil when not given), path as written ("" for a root).
-- nil when it is neither.
local function parseRepoURI(uri)
if uri:startsWith("github:") then
local owner, name, path = uri:match("^github:([^/]+)/([^/]+)/?(.*)$")
if not owner then
return nil
end
local name, ref = table.unpack(name:split("@"))
return owner .. "/" .. name, ref, path
end
local repo, ref, path = uri:match("github%.com/([^/]+/[^/]+)/[^/]+/([^/]+)/?(.*)$")
if repo then
return repo, ref, path
end
end
-- Public, kept for Space Lua that calls it: (owner/repo, branch, path) of an
-- https://github.com/... file URL, nil for anything else
function github.extractData(url)
if url == nil or url:startsWith("github:") then
return nil
end
local repo, ref, path = parseRepoURI(url)
if repo and path != "" then
return repo, ref, path
end
end
-- Like parseRepoURI, for a URI of a file: an error for a root or other URI
local function parseFileURI(uri)
local repo, ref, path = parseRepoURI(uri)
if not repo or path == "" then
error("Expected owner/repo/path, got " .. uri)
end
return repo, ref, path
end
function github.buildAPIURL(repo, path)
return "https://api.github.com/repos/" .. repo .. "/contents/" .. path
end
function github.buildAPIURLWithBranch(repo, branch, path)
return github.buildAPIURL(repo, path) .. "?ref=" .. branch
end
function github.buildURI(repo, branch, path)
return "https://github.com/" .. repo .. "/blob/" .. branch .. "/" .. path
end
-- "https://github.com/u/r/blob/main/a/B.md" or "github:u/r@main/a/B.md" -> "a/B.md"
local function filePath(uri)
local _, _, path = parseRepoURI(uri)
if path != "" then
return path
end
end
-- GitHub's heading anchors: lower-case, spaces to "-", punctuation and
-- symbols gone (also outside ASCII: quotes, dashes, emoji), letters kept
local function slug(anchor)
local s = anchor:lower():gsub("%p", function(c)
if c == "-" or c == "_" then
return c
end
return ""
end)
s = s:gsub("[\u{00A1}-\u{00A9}\u{00AB}-\u{00B1}\u{00B4}\u{00B6}-\u{00B8}\u{00BB}\u{00BF}\u{00D7}\u{00F7}\u{2010}-\u{2BFF}\u{2E00}-\u{2E7F}\u{3000}-\u{303F}\u{FE00}-\u{FE0F}\u{FF01}-\u{FF0F}\u{D800}-\u{DFFF}]", "")
return (s:gsub(" ", "-"))
end
-- Segments are encoded, but not again when the URI already is
local function encodePath(path)
local segments = {}
for _, segment in ipairs(path:split("/")) do
local ok, decoded = pcall(encoding.urlDecode, segment)
table.insert(segments, encoding.urlEncode(ok and decoded or segment))
end
return table.concat(segments, "/")
end
-- Path from the directory of file "from" to file "to", both repo-relative
local function relativePath(from, to)
local fromDirs = from:split("/")
table.remove(fromDirs)
local toParts = to:split("/")
local common = 0
while common < #fromDirs and common < #toParts - 1
and fromDirs[common + 1] == toParts[common + 1] do
common = common + 1
end
local parts = {}
for _ = common + 1, #fromDirs do
table.insert(parts, "..")
end
for i = common + 1, #toParts do
table.insert(parts, toParts[i])
end
return table.concat(parts, "/")
end
-- Descriptor for share.transform: relative Markdown links between shared files
local function githubTarget(data)
return {
kind = "github",
defaults = {links = true, comments = true, bake = true},
webUrl = function(uri)
if not uri:startsWith("github:") then
return uri
end
local repo, ref, path = parseFileURI(uri)
return github.buildURI(repo, ref or "main", path)
end,
memberUri = function(root, path)
return root .. "/" .. encodePath(path) .. ".md"
end,
link = function(l)
local target = ""
if l.toUri != nil and l.toUri != l.fromUri then
local from, to = filePath(l.fromUri), filePath(l.toUri)
if from == nil or to == nil then
return l.text
end
target = encodePath(relativePath(from, to))
if not target:endsWith(".md") then
target = target .. ".md"
end
end
if l.anchor != nil then
target = target .. "#" .. slug(l.anchor)
end
if target == "" then
return l.text
end
return "[" .. l.text .. "](" .. target .. ")"
end,
}
end
for _, selector in ipairs({"share:target:https://github.com/*", "share:target:github:*"}) do
service.define {
selector = selector,
match = {priority = 10},
run = githubTarget,
}
end
local apiHeaders = {Accept = "application/vnd.github+json"}
-- The body of a successful response (nil for a 404 when missingOk). Any
-- other status is an error, so a failed read never looks like a missing file.
function github.responseBody(res, what, missingOk)
if missingOk and res.status == 404 then
return nil
end
if not res.ok then
local reason = type(res.body) == "table" and res.body.message
error("GitHub refused " .. what .. ": HTTP " .. res.status
.. (reason and (": " .. reason) or ""))
end
return res.body
end
local responseBody = github.responseBody
local function readBody(res, url)
return responseBody(res, "reading " .. url, true)
end
-- An API request that signs in when needed
function github.request(url, method, body)
return auth.fetch("github", url, {
method = method,
headers = apiHeaders,
body = body,
}, true)
end
function github.checkConfig()
if not config.get("github.name") then
error("github.name needs to be set")
return
end
if not config.get("github.email") then
error("github.email needs to be set")
return
end
end
service.define {
selector = "share:onboard",
match = {
name = "Github file",
description = "Share this page as a file on a github repo",
},
run = function(data)
local name = data.name
local checkOk, err = pcall(github.checkConfig)
if not checkOk then
editor.flashNotification(err, "error")
return
end
repo = editor.prompt "Github repo (user/repo):"
if not repo then
return
end
branch = editor.prompt("Branch:", "main")
if not branch then
return
end
path = editor.prompt("File path:", name .. ".md")
if not path then
return
end
-- Nothing is uploaded here: the first share creates the file
return {
uri = github.buildURI(repo, branch, path),
mode = "push"
}
end
}
-- writeURI support
service.define {
selector = "net.writeURI:https://github.com/*",
match = {priority=10},
run = function(data)
local uri = data.uri
local content = data.content
local checkOk, err = pcall(github.checkConfig)
if not checkOk then
editor.flashNotification(err, "error")
return
end
local repo, branch, path = parseFileURI(uri)
-- An existing file needs its SHA to be replaced, a missing one is created
local oldContent = github.request(github.buildAPIURLWithBranch(repo, branch, path), "GET")
local sha
if oldContent.ok then
sha = oldContent.body.sha
elseif oldContent.status != 404 then
error("Could not check " .. path .. " on GitHub: HTTP " .. oldContent.status)
end
local message = editor.prompt("Commit message:", "Commit")
local resp = github.request(github.buildAPIURL(repo, path), "PUT", {
message = message,
committer = {
name = config.get("github.name"),
email = config.get("github.email"),
},
branch = branch,
sha = sha,
content = encoding.base64Encode(content)
})
responseBody(resp, "the write")
end
}
service.define {
selector = "net.writeURI:github:*",
match = {priority=10},
run = function(data)
local repo, ref, path = parseFileURI(data.uri)
return net.writeURI(github.buildURI(repo, ref or "main", path), data.content)
end
}
-- A github: or https://github.com/ root (a directory, or the repo itself) ->
-- "owner/repo", ref, directory path decoded ("" for the repo root)
local function splitRoot(root)
local repo, ref, dir = parseRepoURI(root)
if not repo then
error("Expected github:owner/repo or https://github.com/owner/repo/blob/branch, got " .. root)
end
return repo, ref or "main", encoding.urlDecode(dir)
end
local function repoAPIURL(repo, path)
return "https://api.github.com/repos/" .. repo .. "/" .. path
end
-- The path in the repo of a file URI under root, decoded
local function repoPath(uri)
return encoding.urlDecode(filePath(uri))
end
-- The Markdown files under root, as memberUri builds them, with their blob
-- SHAs. Signs in only with data.signIn (a publish; check never prompts).
local function listFiles(data)
local root = data.uri
local repo, ref, dir = splitRoot(root)
local function get(path)
local url = repoAPIURL(repo, path)
if data.signIn then
return github.request(url, "GET")
end
return net.proxyFetch(url, {headers = apiHeaders})
end
-- Only the root's subtree, so entry paths are relative to it
local treeish = dir == "" and ref or (ref .. ":" .. encodePath(dir))
local resp = get("git/trees/" .. treeish .. "?recursive=1")
-- A folder not created yet, on a branch that exists
if resp.status == 404 and dir != "" and get("git/ref/heads/" .. ref).ok then
return {}
end
if resp.status == 404 then
error("GitHub: " .. repo .. "@" .. ref .. " not found (or not signed in to GitHub)")
end
local body = responseBody(resp, "the listing")
if body.truncated then
error("GitHub: too many files under " .. root .. " to list")
end
local entries = {}
for _, entry in ipairs(body.tree or {}) do
if entry.type == "blob" and entry.path:endsWith(".md") then
table.insert(entries, {uri = root .. "/" .. encodePath(entry.path), hash = entry.sha})
end
end
return entries
end
-- JSON by hand: a nil sha (deleting the file) would vanish from a Lua table
local function treeBody(baseTree, writes, deletes)
local entries = {}
for _, w in ipairs(writes) do
table.insert(entries, '{"path":' .. js.stringify(repoPath(w.uri))
.. ',"mode":"100644","type":"blob","content":' .. js.stringify(w.content) .. "}")
end
for _, uri in ipairs(deletes) do
table.insert(entries, '{"path":' .. js.stringify(repoPath(uri))
.. ',"mode":"100644","type":"blob","sha":null}')
end
return '{"base_tree":' .. js.stringify(baseTree) .. ',"tree":['
.. table.concat(entries, ",") .. "]}"
end
-- All writes and deletes of a collection publish as one commit
local function writeBatch(data)
github.checkConfig()
local message = editor.prompt("Commit message:", "Publish " .. data.root)
if not message or message:trim() == "" then
return {canceled = true}
end
local repo, ref = splitRoot(data.root)
local head = responseBody(github.request(
repoAPIURL(repo, "git/ref/heads/" .. ref), "GET"), "the branch").object.sha
local baseTree = responseBody(github.request(
repoAPIURL(repo, "git/commits/" .. head), "GET"), "the commit").tree.sha
local tree = responseBody(github.request(repoAPIURL(repo, "git/trees"), "POST",
treeBody(baseTree, data.writes or {}, data.deletes or {})), "the tree").sha
local person = {name = config.get("github.name"), email = config.get("github.email")}
local commit = responseBody(github.request(repoAPIURL(repo, "git/commits"), "POST", {
message = message,
tree = tree,
parents = {head},
author = person,
committer = person,
}), "the commit").sha
local resp = github.request(repoAPIURL(repo, "git/refs/heads/" .. ref), "PATCH",
{sha = commit})
if resp.status == 422 then
error("The branch moved while publishing; publish again")
end
responseBody(resp, "the branch update")
end
for _, scheme in ipairs({"github:", "https://github.com/"}) do
service.define {
selector = "net.listURI:" .. scheme .. "*",
match = {priority = 10},
run = listFiles,
}
service.define {
selector = "share:writeBatch:" .. scheme .. "*",
match = {priority = 10},
run = writeBatch,
}
end
-- readURI
-- ghr:owner/repo/path (latest)
-- ghr:owner/repo@version/path
service.define {
selector = "net.readURI:ghr:*",
match = { priority=10 },
run = function(data)
-- Same shape as github:, with a release version for the ref
local repo, version, path = parseFileURI("github:" .. data.uri:sub(#"ghr:"+1))
local url
if not version or version == "latest" then
url = repoAPIURL(repo, "releases/latest")
else
url = repoAPIURL(repo, "releases/tags/" .. version)
end
local releaseInfo = readBody(net.proxyFetch(url, {headers = apiHeaders}), url)
if releaseInfo == nil then
return nil
end
local url = "https://github.com/" .. repo .. "/releases/download/" .. releaseInfo.tag_name .. "/" .. path
return readBody(net.proxyFetch(url, {responseEncoding = data.encoding}), url)
end
}
-- readURI
-- github:owner/repo/path (defaults to "main" branch)
-- github:owner/repo@branch/path
service.define {
selector = "net.readURI:github:*",
match = { priority=10 },
run = function(data)
local repo, ref, path = parseFileURI(data.uri)
local url = "https://raw.githubusercontent.com/" .. repo .. "/" .. (ref or "main") .. "/" .. path
return readBody(net.proxyFetch(url), url)
end
}
-- readURI
-- https://github.com/owner/repo/blob/branch/path
service.define {
selector = "net.readURI:https://github.com/*",
match = {priority=10},
run = function(data)
local repo, ref, path = parseFileURI(data.uri)
local url = "https://raw.githubusercontent.com/" .. repo .. "/" .. ref .. "/" .. path
return readBody(net.proxyFetch(url), url)
end
}
-- priority: 10
-- https://gist.github.com/[user/]id[/file] -> user (nil when not given), id,
-- file (nil for the gist itself: a single-page share's first .md file). File
-- names hold a "." and ids and user names never do.
local function splitGist(uri)
local segments = uri:sub(#"https://gist.github.com/" + 1):gsub("/+$", ""):split("/")
if #segments >= 3 then
return segments[1], segments[2], segments[3]
elseif #segments == 2 and segments[2]:find(".", 1, true) then
return nil, segments[1], segments[2]
elseif #segments == 2 then
return segments[1], segments[2]
end
return nil, segments[1]
end
local gistHeaders = {Accept = "application/vnd.github+json"}
local function gistAPIURL(id)
return "https://api.github.com/gists/" .. id
end
-- The gist's page, at the file's anchor for a file URI
local function gistWebUrl(uri)
local user, id, file = splitGist(uri)
local url = "https://gist.github.com/" .. (user and (user .. "/") or "") .. id
if file then
url = url .. "#file-" .. file:lower():gsub("[^%w_-]", "-")
end
return url
end
-- One file per member, named after its path
service.define {
selector = "share:target:https://gist.github.com/*",
match = {priority = 10},
run = function()
return {
kind = "gist",
defaults = {links = true, comments = true, bake = true},
webUrl = gistWebUrl,
memberUri = function(root, path)
return root .. "/" .. (path:gsub("/", "-")) .. ".md"
end,
link = function(l)
if l.toUri == nil or l.toUri == l.fromUri then
return l.text
end
local _, fromId = splitGist(l.fromUri)
local _, toId, toFile = splitGist(l.toUri)
if toFile == nil or toId != fromId then
return l.text
end
return "[" .. l.text .. "](" .. gistWebUrl(l.toUri) .. ")"
end,
}
end,
}
-- The Markdown files of the gist, as memberUri builds them. Only names:
-- large files come without their content. Signs in only with data.signIn (a publish).
service.define {
selector = "net.listURI:https://gist.github.com/*",
match = {priority = 10},
run = function(data)
local _, id = splitGist(data.uri)
local url = gistAPIURL(id)
local resp
if data.signIn then
resp = github.request(url, "GET")
else
resp = net.proxyFetch(url, {headers = gistHeaders})
end
if resp.status == 404 then
error("GitHub: gist " .. id .. " not found (or not signed in to GitHub)")
end
local gist = github.responseBody(resp, "the gist listing")
if gist.truncated then
error("GitHub: too many files in " .. data.uri .. " to list")
end
local names = {}
for name in pairs(gist.files or {}) do
if name:endsWith(".md") then
table.insert(names, name)
end
end
table.sort(names)
local entries = {}
for _, name in ipairs(names) do
table.insert(entries, {uri = data.uri .. "/" .. name})
end
return entries
end,
}
-- PATCH with the file as JSON null, which a Lua table can't hold
service.define {
selector = "net.deleteURI:https://gist.github.com/*",
match = {priority = 10},
run = function(data)
local _, id, file = splitGist(data.uri)
if not file then
error("Not a gist file: " .. data.uri)
end
local resp = github.request(gistAPIURL(id), "PATCH",
'{"files":{' .. js.stringify(file) .. ":null}}")
github.responseBody(resp, "the delete", true)
end,
}
service.define {
selector = "share:onboard",
match = {
name = "Github Gist",
description = "Share this page as a gist"
},
run = function(data)
local split = string.split(editor.getCurrentPath(), "/")
local currentFile = split[#split]
local filename = editor.prompt("File name", currentFile)
if not filename then
return
end
local public = editor.filterBox(
"Gist visibility",
{
{ name = "Public", description = "This Gist will be visible publicly on your profile", value = true },
{ name = "Private", description = "This Gist is only visible to anyone with the direct link", value = false },
},
"Select whether you want to share this page publicly or as a secret Gist."
)
local resp = github.request("https://api.github.com/gists", "POST", {
public = public.value,
files = {
[filename] = {
content = data.content
}
}
})
if resp.ok then
return {
uri = resp.body.html_url,
mode = "push"
}
else
editor.flashNotification("Error, check console")
js.log("Error", resp)
end
end
}
-- readURI supports
-- https://gist.github.com/user/gistid (the first file with content)
-- https://gist.github.com/user/gistid/file
service.define {
selector = "net.readURI:https://gist.github.com/*",
match = {priority=10},
run = function(data)
local _, gistId, file = splitGist(data.uri)
local resp = net.proxyFetch(gistAPIURL(gistId), {headers = gistHeaders})
if file then
local gist = github.responseBody(resp, "reading " .. data.uri, true)
local meta = gist and gist.files and gist.files[file]
if meta == nil then
return nil
end
-- Large files come truncated or without content
if meta.truncated or meta.content == nil then
return github.responseBody(net.proxyFetch(meta.raw_url),
"reading " .. data.uri)
end
return meta.content
end
if resp.status != 200 then
print("Failed to fetch gist", resp)
return nil
end
local files = resp.body.files
for filename, data in pairs(files) do
if data.content then
return data.content
end
end
return nil
end
}
-- writeURI
service.define {
selector = "net.writeURI:https://gist.github.com/*",
match = {priority=10},
run = function(data)
local _, gistId, file = splitGist(data.uri)
if file then
github.responseBody(github.request(gistAPIURL(gistId), "PATCH", {
files = {[file] = {content = data.content}},
}), "the gist write")
return
end
-- First fetch the gist to find the file name
local resp = github.request("https://api.github.com/gists/" .. gistId, "GET")
if not resp.ok then
js.log("Error with gist writeURI", resp)
error("Gist writeURI failed")
end
local files = resp.body.files
local selectedFilename
-- Find a .md file in the gist to overwrite
for filename, meta in pairs(files) do
if filename:endsWith(".md") then
selectedFilename = filename
break
end
end
if not selectedFilename then
error("Could not find markdown file in gist")
end
local resp = github.request("https://api.github.com/gists/" .. gistId, "PATCH", {
public = true,
files = {
[selectedFilename] = {
content = data.content
}
}
})
if not resp.ok then
js.log("Error", resp)
error("Error publishing gist, check console")
end
end
}