· 5 years ago · Jul 07, 2020, 11:48 PM
1--- gist.lua - Gist client for ComputerCraft
2-- Made by JackMacWindows for CraftOS-PC
3--
4-- @module cc.http.gist
5
6local expect = require and require("cc.expect").expect or dofile("/rom/modules/main/cc/expect.lua").expect
7
8if not http then
9 if _G.config ~= nil then error("Gist requires http API\nSet http_enable to true in the CraftOS-PC configuration")
10 else error("Gist requires http API\nSet http_enable to true in ComputerCraft's configuration") end
11end
12
13local gist = {}
14
15local function emptyfn() end -- to reduce memory/speed footprint when using empty functions
16
17-- Internal functions
18
19local function getGistFile(data)
20 if not data.truncated then return data.content else
21 local handle = http.get(data.raw_url)
22 if not handle then error("Could not connect to api.github.com.") end
23 if handle.getResponseCode() ~= 200 then
24 handle.close()
25 error("Failed to download file data.")
26 end
27 local d = handle.readAll()
28 handle.close()
29 return d
30 end
31end
32
33local function setTextColor(c) if term.isColor() then term.setTextColor(c) elseif c == colors.white or c == colors.yellow then term.setTextColor(colors.white) else term.setTextColor(colors.lightGray) end end
34
35local function requestAuth(headers, interactive)
36 if settings.get("gist.id") ~= nil then
37 headers.Authorization = "token " .. settings.get("gist.id")
38 return true
39 elseif interactive then
40 setTextColor(colors.yellow)
41 write("You need to add a Personal Access Token (PAK) to upload Gists. Follow the instructions at ")
42 setTextColor(colors.blue)
43 write("https://tinyurl.com/GitHubPAK")
44 setTextColor(colors.yellow)
45 write(" to generate one. Make sure to check the '")
46 setTextColor(colors.blue)
47 write("gist")
48 setTextColor(colors.yellow)
49 print("' checkbox on step 7 (under 'Select scopes'). Once done, paste it here.")
50 setTextColor(colors.lime)
51 write("PAK: ")
52 setTextColor(colors.white)
53 local pak = read()
54 if pak == nil or pak == "" then error("Invalid PAK, please try again.") end
55 settings.set("gist.id", pak)
56 settings.save(".settings")
57 headers.Authorization = "token " .. pak
58 return true
59 end
60 return false
61end
62
63-- User API - this can be loaded with require "cc.http.gist"
64
65-- ID can be either just the gist ID or a gist ID followed by a slash and a file name
66-- * If a file name is specified, retrieves that file
67-- * Otherwise, if there's only one file, retrieves that file
68-- * Otherwise, if there's a file named 'init.lua', retrieves 'init.lua'
69-- * Otherwise, if there's more than one file but only one *.lua file, retrieves the Lua file
70-- * Otherwise, retrieves the first Lua file alphabetically (with a warning)
71-- * Otherwise, fails
72
73--- Retrieves one file from a Gist using the specified ID.
74-- @tparam string id The Gist ID to download from. See above comments for more details.
75-- @tparam[opt] function progress A function to use to report status messages.
76-- @treturn string|nil The contents of the specified Gist file, or nil on error.
77-- @treturn string|nil The name of the file that was chosen to be downloaded, or a message on error.
78function gist.get(id, progress)
79 expect(1, id, "string")
80 expect(2, progress, "function", "nil")
81 progress = progress or emptyfn
82 local file
83 if id:find("/") ~= nil then id, file = id:match("^([0-9A-Fa-f:]+)/(.+)$") end
84 if id == nil or not id:match("^[0-9A-Fa-f][0-9A-Fa-f:]+[0-9A-Fa-f]$") then error("bad argument #1 to 'get' (invalid ID)", 2) end
85 if id:find(":") ~= nil then id = id:gsub(":", "/") end
86 progress("Connecting to api.github.com... ")
87 local handle = http.get("https://api.github.com/gists/" .. id)
88 if handle == nil then
89 progress("Failed.\n")
90 return nil, "Failed to connect"
91 end
92 local meta = textutils.unserializeJSON(handle.readAll())
93 local code = handle.getResponseCode()
94 handle.close()
95 if code ~= 200 then
96 progress("Failed.\n")
97 return nil, "Invalid response code (" .. code .. ")" .. (meta and ": " .. meta.message or "")
98 end
99 if meta == nil or meta.files == nil then
100 progress("Failed.\n")
101 return nil, meta and "GitHub API error: " .. meta.message or "Error parsing JSON"
102 end
103 progress("Success.\n")
104 if file then return getGistFile(meta.files[file]), file
105 elseif next(meta.files, next(meta.files)) == nil then return getGistFile(meta.files[next(meta.files)]), next(meta.files)
106 elseif meta.files["init.lua"] ~= nil then return getGistFile(meta.files["init.lua"]), "init.lua"
107 else
108 local luaFiles = {}
109 for k in pairs(meta.files) do if k:match("%.lua$") then table.insert(luaFiles, k) end end
110 table.sort(luaFiles)
111 if #luaFiles == 0 then
112 progress("Error: Could not find any Lua files to download!\n")
113 return nil, "Could not find any Lua files to download"
114 end
115 if #luaFiles > 1 then progress("Warning: More than one Lua file detected, downloading the first one alphabetically.\n") end
116 return getGistFile(meta.files[luaFiles[1]]), luaFiles[1]
117 end
118end
119
120--- Runs a specified Gist. This is a wrapper for convenience.
121-- @tparam string id The Gist ID to download from. See above comments for more details.
122-- @tparam[opt] function progress A function to use to report status messages. If
123-- this is not a function, it will be used as an argument to the script.
124-- @tparam[opt] any ... Any arguments to pass to the script.
125-- @treturn any Any results returned from the script.
126function gist.run(id, progress, ...)
127 expect(1, id, "string")
128 local args = table.pack(...)
129 if type(progress) ~= "function" and progress ~= nil then
130 table.insert(args, 1, progress)
131 progress = nil
132 end
133 local data, name = gist.get(id, progress)
134 if data == nil then return end
135 local fn, err = load(data, name, "t", _ENV)
136 if fn == nil then error(err) end
137 local retval = table.pack(pcall(fn, table.unpack(args)))
138 if not retval[1] then error(retval[2]) end
139 return table.unpack(retval, 2)
140end
141
142--- Retrieves a table of all files from a Gist.
143-- @tparam string id The Gist ID to download.
144-- @tparam[opt] function progress A function to use to report status messages.
145-- @treturn table|nil A key-value list of all files in the Gist, or nil on error.
146-- @treturn string|nil If an error occurred, a string describing the error.
147function gist.getAll(id, progress)
148 expect(1, id, "string")
149 expect(2, progress, "function", "nil")
150 progress = progress or emptyfn
151 if id:find("/") ~= nil then id = id:match("^([0-9A-Fa-f:]+)/.*$") end
152 if id == nil or not id:match("^[0-9A-Fa-f][0-9A-Fa-f:]+[0-9A-Fa-f]$") then error("bad argument #1 to 'getAll' (invalid ID)", 2) end
153 if id:find(":") ~= nil then id = id:gsub(":", "/") end
154 progress("Connecting to api.github.com... ")
155 local handle = http.get("https://api.github.com/gists/" .. id)
156 if handle == nil then progress("Failed.\n") return nil, "Failed to connect" end
157 local meta = textutils.unserializeJSON(handle.readAll())
158 local code = handle.getResponseCode()
159 handle.close()
160 if code ~= 200 then
161 progress("Failed.\n")
162 return nil, "Invalid response code (" .. code .. ")" .. (meta and ": " .. meta.message or "")
163 end
164 if meta == nil or meta.files == nil then
165 progress("Failed.\n")
166 return nil, meta and meta.message and "GitHub API error: " .. meta.message or "Error parsing JSON"
167 end
168 progress("Success.\n")
169 local retval = {}
170 for k, v in pairs(meta.files) do retval[k] = getGistFile(v) end
171 return retval
172end
173
174--- Returns some information about a Gist.
175-- @tparam string id The Gist ID to get info about.
176-- @tparam[opt] function progress A function to use to report status messages.
177-- @treturn table|nil A table of information about the Gist. The table may
178-- contain the following entries:
179-- - description: The description for the Gist.
180-- - author: The username of the author of the Gist.
181-- - revisionCount: The number of revisions that have been made to the Gist.
182-- - files: A list of all file names in the Gist, sorted alphabetically.
183-- @treturn string|nil If an error occurred, a string describing the error.
184function gist.info(id, progress)
185 expect(1, id, "string")
186 expect(2, progress, "function", "nil")
187 progress = progress or emptyfn
188 if id:find("/") ~= nil then id = id:match("^([0-9A-Fa-f:]+)/.*$") end
189 if id == nil or not id:match("^[0-9A-Fa-f][0-9A-Fa-f:]+[0-9A-Fa-f]$") then error("bad argument #1 to 'info' (invalid ID)", 2) end
190 if id:find(":") ~= nil then id = id:gsub(":", "/") end
191 progress("Connecting to api.github.com... ")
192 local handle = http.get("https://api.github.com/gists/" .. id)
193 if handle == nil then progress("Failed.\n") return nil, "Failed to connect" end
194 local meta = textutils.unserializeJSON(handle.readAll())
195 local code = handle.getResponseCode()
196 handle.close()
197 if code ~= 200 then
198 progress("Failed.\n")
199 return nil, "Invalid response code (" .. code .. ")" .. (meta and ": " .. meta.message or "")
200 end
201 if meta == nil or meta.files == nil then
202 progress("Failed.\n")
203 return nil, meta and meta.message and "GitHub API error: " .. meta.message or "Error parsing JSON"
204 end
205 local f = {}
206 for k in pairs(meta.files) do table.insert(f, k) end
207 table.sort(f)
208 progress("Success.\n")
209 return { description = meta.description, author = meta.owner.login, revisionCount = #meta.history, files = f }
210end
211
212--- Uploads a list of files to Gist, updating a previous Gist if desired.
213-- @tparam table files The files to upload to Gist. This table should be
214-- structured with a key as file name and a string with the file contents. If
215-- updating a Gist, files can be deleted by setting the data to textutils.json_null.
216-- @tparam[opt] string description The description of the Gist. This is required
217-- when updating a Gist, but is optional when uploading a Gist for the first
218-- time. If you don't want to change the description when updating, you can get
219-- the current description with gist.info() and pass in the description field.
220-- @tparam[opt] string id The ID of the Gist to update. If nil, a new Gist will
221-- be created.
222-- @tparam[opt] boolean interactive Set this to true to allow asking for a PAK
223-- if one is not available in the settings. If this is not specified, this
224-- function will return nil if gist.id is not available in the settings.
225-- @treturn string|nil The ID of the Gist, or nil on error.
226-- @treturn string|nil The URL of the Gist, or a string on error.
227function gist.put(files, description, id, interactive)
228 expect(1, files, "table")
229 expect(3, id, "string", "nil")
230 expect(2, description, "string", id == nil and "nil" or nil)
231 expect(4, interactive, "boolean", "nil")
232 if id then
233 if id:find("/") ~= nil then id = id:match("^([0-9A-Fa-f:]+)/.*$") end
234 if id == nil or not id:match("^[0-9A-Fa-f][0-9A-Fa-f:]+[0-9A-Fa-f]$") then error("bad argument #3 to 'put' (invalid ID)", 2) end
235 if id:find(":") ~= nil then id = id:gsub(":", "/") end
236 end
237 local data = { files = {}, public = true, description = description }
238 for k, v in pairs(files) do if v == textutils.json_null then data.files[k] = v else data.files[k] = { content = v } end end
239 local headers = { ["Content-Type"] = "application/json" }
240 if not requestAuth(headers, interactive) then return nil, "Authentication required" end
241 if interactive then write("Connecting to api.github.com... ") end
242 local handle
243 if id then handle = http.post{ url = "https://api.github.com/gists/" .. id, body = textutils.serializeJSON(data):gsub("\n", "n"), headers = headers, method = "PATCH" }
244 else handle = http.post("https://api.github.com/gists", textutils.serializeJSON(data):gsub("\n", "n"), headers) end
245 if handle == nil then if interactive then print("Failed.") end return nil, "Could not connect" end
246 local resp = textutils.unserializeJSON(handle.readAll())
247 if handle.getResponseCode() ~= 201 and handle.getResponseCode() ~= 200 or resp == nil then
248 if interactive then print("Failed: " .. handle.getResponseCode() .. ": " .. (resp and resp.message or "Unknown error")) end
249 handle.close()
250 return nil, "Failed: " .. handle.getResponseCode() .. ": " .. (resp and resp.message or "Unknown error")
251 end
252 handle.close()
253 if interactive then print("Success.") end
254 return resp.id, resp.html_url
255end
256
257--- Deletes a Gist.
258-- @tparam string id The Gist ID to delete.
259-- @tparam[opt] boolean interactive Set this to true to allow asking for a PAK
260-- if one is not available in the settings. If this is not specified, this
261-- function will return false if gist.id is not available in the settings.
262-- @treturn boolean Whether the request succeeded.
263-- @treturn string|nil If an error occurred, a message describing the error.
264function gist.delete(id, interactive)
265 expect(1, id, "string")
266 expect(2, interactive, "boolean", "nil")
267 if id:find("/") ~= nil or id:find(":") ~= nil then id = id:match("^([0-9A-Fa-f]+)") end
268 if id == nil or not id:match("^[0-9A-Fa-f][0-9A-Fa-f:]+[0-9A-Fa-f]$") then error("bad argument #1 to 'delete' (invalid ID)", 2) end
269 local headers = {}
270 if not requestAuth(headers, interactive) then return false, "Authentication required" end
271 if interactive then write("Connecting to api.github.com... ") end
272 local handle = http.post{ url = "https://api.github.com/gists/" .. id, headers = headers, method = "DELETE" }
273 if handle == nil then if interactive then print("Failed.") end return false, "Could not connect" end
274 if handle.getResponseCode() ~= 204 then
275 local resp = textutils.unserializeJSON(handle.readAll())
276 if interactive then print("Failed: " .. handle.getResponseCode() .. ": " .. (resp and resp.message or "Unknown error")) end
277 handle.close()
278 return false, "Failed: " .. handle.getResponseCode() .. ": " .. (resp and resp.message or "Unknown error")
279 end
280 handle.close()
281 if interactive then print("Success.") end
282 return true
283end
284
285return gist