local M = {}
M.TRASH = "trash"
M.PERMISSIONS = { view = "view", edit = "edit" }
local CSRF_COOKIE = "excalidash-csrf-client"
local CSRF_COOKIE_PATTERN = CSRF_COOKIE:gsub("%-", "%%-") .. "=([^;]+)"
local NULL_MARK = "__excalidash_null_5f3a91__"
local function encode_with_nulls(body)
local encoded = json.encode(body)
return (encoded:gsub('"' .. NULL_MARK .. '"', "null"))
end
local function session_only(label)
return "excalidash: " .. label .. " is not reachable with an API key; pass token= "
.. "(a session access token) or set EXCALIDASH_TOKEN"
end
function M.client(opts)
opts = opts or {}
local api_key = opts.api_key or env.get("EXCALIDASH_API_KEY")
local token = opts.token or env.get("EXCALIDASH_TOKEN")
local base_url = (opts.base_url or env.get("EXCALIDASH_URL") or ""):gsub("/+$", "")
local api_path = opts.api_path or env.get("EXCALIDASH_API_PATH") or "/api"
api_path = api_path:gsub("/+$", "")
if base_url == "" then
error("excalidash: no base url; pass base_url= or set EXCALIDASH_URL")
end
local csrf = nil
local function urlencode(str)
return tostring(str):gsub("([^%w%-%.%_%~])", function(ch)
return string.format("%%%02X", string.byte(ch))
end)
end
local function build_query(params)
if not params then return "" end
local parts = {}
for k, v in pairs(params) do
if v ~= nil then parts[#parts + 1] = urlencode(k) .. "=" .. urlencode(v) end
end
table.sort(parts)
return #parts > 0 and "?" .. table.concat(parts, "&") or ""
end
local function url(route, query_params)
return base_url .. api_path .. route .. build_query(query_params)
end
local function decode(resp)
if resp.body and resp.body ~= "" then
local ok, parsed = pcall(json.parse, resp.body)
if ok then return parsed end
end
return nil
end
local function fail(verb, route, resp)
local detail = resp.body or ""
local parsed = decode(resp)
if type(parsed) == "table" then
local parts = {}
if parsed.error then parts[#parts + 1] = tostring(parsed.error) end
if parsed.code then parts[#parts + 1] = "(" .. tostring(parsed.code) .. ")" end
if parsed.message then parts[#parts + 1] = tostring(parsed.message) end
if #parts > 0 then detail = table.concat(parts, " ") end
end
error("excalidash: " .. verb .. " " .. route .. " HTTP " .. resp.status .. ": " .. detail)
end
local function credential(needs_session, label)
if needs_session then
if not token then error(session_only(label)) end
return "session"
end
if api_key then return "key" end
if token then return "session" end
error("excalidash: no credential; pass api_key= or token=, or set "
.. "EXCALIDASH_API_KEY / EXCALIDASH_TOKEN")
end
local function ensure_csrf()
if csrf then return csrf end
local resp = http.get(url("/csrf-token"), { headers = { ["Accept"] = "application/json" } })
if resp.status ~= 200 then fail("GET", "/csrf-token", resp) end
local parsed = decode(resp) or {}
local set_cookie = (resp.headers or {})["set-cookie"] or ""
local cookie = set_cookie:match(CSRF_COOKIE_PATTERN)
if not parsed.token or not cookie then
error("excalidash: /csrf-token did not answer both a token and a " .. CSRF_COOKIE .. " cookie")
end
csrf = {
token = parsed.token,
header = parsed.header or "x-csrf-token",
cookie = cookie,
}
return csrf
end
local function headers(mode, mutating)
local h = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }
if mode == "key" then
h["Authorization"] = "Bearer " .. api_key
return h
end
h["Authorization"] = "Bearer " .. token
if mutating then
local cs = ensure_csrf()
h[cs.header] = cs.token
h["Cookie"] = CSRF_COOKIE .. "=" .. cs.cookie
end
return h
end
local function decode_json_body(verb, route, resp)
local parsed = decode(resp)
if parsed == nil and resp.body and resp.body ~= "" then
error("excalidash: " .. verb .. " " .. route .. " answered HTTP " .. resp.status
.. " with a non-JSON body; is api_path (" .. (api_path == "" and "<empty>" or api_path)
.. ") right for this host?")
end
return parsed
end
local function api_get(route, query_params, needs_session, label)
local mode = credential(needs_session, label or route)
local resp = http.get(url(route, query_params), { headers = headers(mode, false) })
if resp.status == 404 then return nil end
if resp.status ~= 200 then fail("GET", route, resp) end
return decode_json_body("GET", route, resp)
end
local function api_send(verb, fn, route, payload, needs_session, label)
local mode = credential(needs_session, label or route)
local resp = fn(url(route), payload or {}, { headers = headers(mode, true) })
if resp.status ~= 200 and resp.status ~= 201 and resp.status ~= 204 then
fail(verb, route, resp)
end
return decode_json_body(verb, route, resp)
end
local function api_post(route, payload, needs_session, label)
return api_send("POST", http.post, route, payload, needs_session, label)
end
local function api_put(route, payload, needs_session, label)
return api_send("PUT", http.put, route, payload, needs_session, label)
end
local function api_patch(route, payload, needs_session, label)
return api_send("PATCH", http.patch, route, payload, needs_session, label)
end
local function api_delete(route, needs_session, label)
local mode = credential(needs_session, label or route)
local resp = http.delete(url(route), { headers = headers(mode, true) })
if resp.status ~= 200 and resp.status ~= 204 then fail("DELETE", route, resp) end
return true
end
local function list_params(o)
o = o or {}
local params = {}
if o.search then params.search = o.search end
if o.collection_id then params.collectionId = o.collection_id end
if o.include_data ~= nil then params.includeData = tostring(o.include_data) end
if o.include_preview ~= nil then params.includePreview = tostring(o.include_preview) end
if o.limit then params.limit = tostring(o.limit) end
if o.offset then params.offset = tostring(o.offset) end
if o.sort_field then params.sortField = o.sort_field end
if o.sort_direction then params.sortDirection = o.sort_direction end
return params
end
local function elements_of(value)
if value == nil then return json.array({}) end
if type(value) == "table" and next(value) == nil then return json.array({}) end
return value
end
local c = {}
c.drawings = {}
function c.drawings:list(o)
return api_get("/drawings", list_params(o))
end
function c.drawings:get(id)
return api_get("/drawings/" .. urlencode(id))
end
function c.drawings:create(spec)
spec = spec or {}
return api_post("/drawings", {
name = spec.name,
collectionId = spec.collection_id,
elements = elements_of(spec.elements),
appState = spec.app_state or {},
files = spec.files,
preview = spec.preview,
})
end
function c.drawings:update(id, patch)
patch = patch or {}
local body = {}
if patch.name ~= nil then body.name = patch.name end
if patch.collection_id ~= nil then body.collectionId = patch.collection_id end
if patch.elements ~= nil then body.elements = elements_of(patch.elements) end
if patch.app_state ~= nil then body.appState = patch.app_state end
if patch.files ~= nil then body.files = patch.files end
if patch.preview ~= nil then body.preview = patch.preview end
if patch.version ~= nil then body.version = patch.version end
return api_put("/drawings/" .. urlencode(id), body)
end
function c.drawings:delete(id)
return api_delete("/drawings/" .. urlencode(id))
end
function c.drawings:duplicate(id)
return api_post("/drawings/" .. urlencode(id) .. "/duplicate", {}, true, "duplicating a drawing")
end
function c.drawings:shared(o)
return api_get("/drawings/shared", list_params(o), true, "the shared-with-me list")
end
c.collections = {}
function c.collections:list()
return api_get("/collections")
end
function c.collections:create(name)
return api_post("/collections", { name = name })
end
function c.collections:rename(id, name)
return api_put("/collections/" .. urlencode(id), { name = name })
end
function c.collections:delete(id)
return api_delete("/collections/" .. urlencode(id))
end
function c.collections:shares(id)
local payload = api_get("/collections/" .. urlencode(id) .. "/shares", nil, true,
"collection sharing")
return payload and payload.shares or {}
end
function c.collections:share(id, identifier, role)
local payload = api_post("/collections/" .. urlencode(id) .. "/shares",
{ identifier = identifier, role = role or M.PERMISSIONS.view }, true, "collection sharing")
return payload and payload.share
end
function c.collections:set_share_role(id, user_id, role)
api_patch("/collections/" .. urlencode(id) .. "/shares/" .. urlencode(user_id),
{ role = role }, true, "collection sharing")
return true
end
function c.collections:unshare(id, user_id)
return api_delete("/collections/" .. urlencode(id) .. "/shares/" .. urlencode(user_id),
true, "collection sharing")
end
function c.collections:resolve_users(id, q)
local payload = api_get("/collections/" .. urlencode(id) .. "/share-resolve", { q = q }, true,
"collection user lookup")
return payload and payload.users or {}
end
c.history = {}
function c.history:list(drawing_id, o)
o = o or {}
local params = {}
if o.limit then params.limit = tostring(o.limit) end
if o.offset then params.offset = tostring(o.offset) end
return api_get("/drawings/" .. urlencode(drawing_id) .. "/history", params, true,
"version history")
end
function c.history:get(drawing_id, snapshot_id)
return api_get("/drawings/" .. urlencode(drawing_id) .. "/history/" .. urlencode(snapshot_id),
nil, true, "version history")
end
function c.history:restore(drawing_id, snapshot_id, version)
local body = {}
if version ~= nil then body.version = version end
return api_post(
"/drawings/" .. urlencode(drawing_id) .. "/history/" .. urlencode(snapshot_id) .. "/restore",
body, true, "version history")
end
c.sharing = {}
function c.sharing:get(drawing_id)
return api_get("/drawings/" .. urlencode(drawing_id) .. "/sharing", nil, true,
"drawing sharing")
end
function c.sharing:grant(drawing_id, user_id, permission)
local payload = api_post("/drawings/" .. urlencode(drawing_id) .. "/permissions",
{ granteeUserId = user_id, permission = permission or M.PERMISSIONS.view }, true,
"drawing sharing")
return payload and payload.permission
end
function c.sharing:revoke(drawing_id, permission_id)
return api_delete(
"/drawings/" .. urlencode(drawing_id) .. "/permissions/" .. urlencode(permission_id),
true, "drawing sharing")
end
function c.sharing:create_link(drawing_id, spec)
spec = spec or {}
local body = { permission = spec.permission or M.PERMISSIONS.view }
if spec.expires_at == false then
body.expiresAt = NULL_MARK
elseif spec.expires_at ~= nil then
body.expiresAt = spec.expires_at
end
local payload = api_post("/drawings/" .. urlencode(drawing_id) .. "/link-shares",
encode_with_nulls(body), true, "drawing sharing")
return payload and payload.share
end
function c.sharing:revoke_link(drawing_id, share_id)
return api_delete(
"/drawings/" .. urlencode(drawing_id) .. "/link-shares/" .. urlencode(share_id),
true, "drawing sharing")
end
function c.sharing:resolve_users(drawing_id, q)
local payload = api_get("/drawings/" .. urlencode(drawing_id) .. "/share-resolve", { q = q },
true, "drawing user lookup")
return payload and payload.users or {}
end
c.base_url = base_url
c.api_path = api_path
c.has_api_key = api_key ~= nil
c.has_session = token ~= nil
return c
end
function M.all_drawings(c, opts)
local o = {}
for k, v in pairs(opts or {}) do o[k] = v end
o.limit = o.limit or 200
o.offset = o.offset or 0
local all = {}
while true do
local page = c.drawings:list(o)
local rows = page and page.drawings or {}
for _, d in ipairs(rows) do all[#all + 1] = d end
if #rows < o.limit then return all end
o.offset = o.offset + #rows
if page.totalCount and #all >= page.totalCount then return all end
end
end
function M.find_drawing_by_name(c, name, opts)
local o = {}
for k, v in pairs(opts or {}) do o[k] = v end
o.search = name
for _, d in ipairs(M.all_drawings(c, o)) do
if d.name == name then return d end
end
return nil
end
function M.ensure_drawing(c, spec)
if type(spec) ~= "table" or not spec.name then
error("excalidash: ensure_drawing requires spec.name")
end
local found = M.find_drawing_by_name(c, spec.name, { collection_id = spec.collection_id })
if found then return found end
return c.drawings:create(spec)
end
function M.collections(c)
local out = {}
for _, col in ipairs(c.collections:list() or {}) do
if col.id ~= M.TRASH and col.isOwner ~= false then out[#out + 1] = col end
end
return out
end
function M.resolve_collection(c, name)
local cols = M.collections(c)
if name then
for _, col in ipairs(cols) do
if col.name == name then return col end
end
error("excalidash: no collection named " .. tostring(name))
end
if #cols == 0 then error("excalidash: account has no collections") end
if #cols > 1 then
error("excalidash: account has " .. #cols .. " collections; pass a name to disambiguate")
end
return cols[1]
end
function M.ensure_collection(c, name)
if not name or name == "" then error("excalidash: ensure_collection requires a name") end
for _, col in ipairs(M.collections(c)) do
if col.name == name then return col end
end
return c.collections:create(name)
end
function M.trash(c, drawing_id)
return c.drawings:update(drawing_id, { collection_id = M.TRASH })
end
function M.undo_last_change(c, drawing_id)
local page = c.history:list(drawing_id, { limit = 1 })
local newest = page and page.snapshots and page.snapshots[1]
if not newest then return nil end
local current = c.drawings:get(drawing_id)
if not current then return nil end
return c.history:restore(drawing_id, newest.id, current.version)
end
return M