· 4 years ago · Jul 20, 2021, 03:34 AM
1-- gist.lua - Gist client for ComputerCraft
2-- Made by JackMacWindows for CraftOS-PC
3
4if not http then
5 printError("Gist requires http API")
6 if _G.config ~= nil then printError("Set http_enable to true in the CraftOS-PC configuration")
7 else printError("Set http_enable to true in ComputerCraft's configuration") end
8 return 2
9end
10
11local gist
12do
13--- gist.lua - Gist client for ComputerCraft
14-- Made by JackMacWindows for CraftOS-PC
15--
16-- MIT License
17--
18-- Copyright (c) 2019-2020 JackMacWindows
19--
20-- Permission is hereby granted, free of charge, to any person obtaining a copy
21-- of this software and associated documentation files (the "Software"), to deal
22-- in the Software without restriction, including without limitation the rights
23-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
24-- copies of the Software, and to permit persons to whom the Software is
25-- furnished to do so, subject to the following conditions:
26--
27-- The above copyright notice and this permission notice shall be included in all
28-- copies or substantial portions of the Software.
29--
30-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
33-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
34-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
35-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
36-- SOFTWARE.
37
38local expect = require and require("cc.expect").expect or dofile("/rom/modules/main/cc/expect.lua").expect
39
40if not http then
41 if _G.config ~= nil then error("Gist requires http API\nSet http_enable to true in the CraftOS-PC configuration")
42 else error("Gist requires http API\nSet http_enable to true in ComputerCraft's configuration") end
43end
44
45gist = {}
46
47local function emptyfn() end -- to reduce memory/speed footprint when using empty functions
48
49-- Internal functions
50
51local function getGistFile(data)
52 if not data.truncated then return data.content else
53 local handle = http.get(data.raw_url)
54 if not handle then error("Could not connect to api.github.com.") end
55 if handle.getResponseCode() ~= 200 then
56 handle.close()
57 error("Failed to download file data.")
58 end
59 local d = handle.readAll()
60 handle.close()
61 return d
62 end
63end
64
65local 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
66
67local function requestAuth(headers, interactive)
68 if settings.get("gist.id") ~= nil then
69 headers.Authorization = "token " .. settings.get("gist.id")
70 return true
71 elseif interactive then
72 setTextColor(colors.yellow)
73 write("You need to add a Personal Access Token (PAK) to upload Gists. Follow the instructions at ")
74 setTextColor(colors.blue)
75 write("https://tinyurl.com/GitHubPAK")
76 setTextColor(colors.yellow)
77 write(" to generate one. Make sure to check the '")
78 setTextColor(colors.blue)
79 write("gist")
80 setTextColor(colors.yellow)
81 print("' checkbox on step 7 (under 'Select scopes'). Once done, paste it here.")
82 setTextColor(colors.lime)
83 write("PAK: ")
84 setTextColor(colors.white)
85 local pak = read()
86 if pak == nil or pak == "" then error("Invalid PAK, please try again.") end
87 settings.set("gist.id", pak)
88 settings.save(".settings")
89 headers.Authorization = "token " .. pak
90 return true
91 end
92 return false
93end
94
95-- User API - this can be loaded with require "cc.http.gist"
96
97-- ID can be either just the gist ID or a gist ID followed by a slash and a file name
98-- * If a file name is specified, retrieves that file
99-- * Otherwise, if there's only one file, retrieves that file
100-- * Otherwise, if there's a file named 'init.lua', retrieves 'init.lua'
101-- * Otherwise, if there's more than one file but only one *.lua file, retrieves the Lua file
102-- * Otherwise, retrieves the first Lua file alphabetically (with a warning)
103-- * Otherwise, fails
104
105--- Retrieves one file from a Gist using the specified ID.
106-- @tparam string id The Gist ID to download from. See above comments for more details.
107-- @tparam[opt] function progress A function to use to report status messages.
108-- @treturn string|nil The contents of the specified Gist file, or nil on error.
109-- @treturn string|nil The name of the file that was chosen to be downloaded, or a message on error.
110function gist.get(id, progress)
111 expect(1, id, "string")
112 expect(2, progress, "function", "nil")
113 progress = progress or emptyfn
114 local file
115 if id:find("/") ~= nil then id, file = id:match("^([0-9A-Fa-f:]+)/(.+)$") end
116 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
117 if id:find(":") ~= nil then id = id:gsub(":", "/") end
118 progress("Connecting to api.github.com... ")
119 local handle = http.get("https://api.github.com/gists/" .. id)
120 if handle == nil then
121 progress("Failed.\n")
122 return nil, "Failed to connect"
123 end
124 local meta = textutils.unserializeJSON(handle.readAll())
125 local code = handle.getResponseCode()
126 handle.close()
127 if code ~= 200 then
128 progress("Failed.\n")
129 return nil, "Invalid response code (" .. code .. ")" .. (meta and ": " .. meta.message or "")
130 end
131 if meta == nil or meta.files == nil then
132 progress("Failed.\n")
133 return nil, meta and "GitHub API error: " .. meta.message or "Error parsing JSON"
134 end
135 progress("Success.\n")
136 if file then return getGistFile(meta.files[file]), file
137 elseif next(meta.files, next(meta.files)) == nil then return getGistFile(meta.files[next(meta.files)]), next(meta.files)
138 elseif meta.files["init.lua"] ~= nil then return getGistFile(meta.files["init.lua"]), "init.lua"
139 else
140 local luaFiles = {}
141 for k in pairs(meta.files) do if k:match("%.lua$") then table.insert(luaFiles, k) end end
142 table.sort(luaFiles)
143 if #luaFiles == 0 then
144 progress("Error: Could not find any Lua files to download!\n")
145 return nil, "Could not find any Lua files to download"
146 end
147 if #luaFiles > 1 then progress("Warning: More than one Lua file detected, downloading the first one alphabetically.\n") end
148 return getGistFile(meta.files[luaFiles[1]]), luaFiles[1]
149 end
150end
151
152--- Runs a specified Gist. This is a wrapper for convenience.
153-- @tparam string id The Gist ID to download from. See above comments for more details.
154-- @tparam[opt] function progress A function to use to report status messages. If
155-- this is not a function, it will be used as an argument to the script.
156-- @tparam[opt] any ... Any arguments to pass to the script.
157-- @treturn any Any results returned from the script.
158function gist.run(id, progress, ...)
159 expect(1, id, "string")
160 local args = table.pack(...)
161 if type(progress) ~= "function" and progress ~= nil then
162 table.insert(args, 1, progress)
163 progress = nil
164 end
165 local data, name = gist.get(id, progress)
166 if data == nil then return end
167 local fn, err = load(data, name, "t", _ENV)
168 if fn == nil then error(err) end
169 local retval = table.pack(pcall(fn, table.unpack(args)))
170 if not retval[1] then error(retval[2]) end
171 return table.unpack(retval, 2)
172end
173
174--- Retrieves a table of all files from a Gist.
175-- @tparam string id The Gist ID to download.
176-- @tparam[opt] function progress A function to use to report status messages.
177-- @treturn table|nil A key-value list of all files in the Gist, or nil on error.
178-- @treturn string|nil If an error occurred, a string describing the error.
179function gist.getAll(id, progress)
180 expect(1, id, "string")
181 expect(2, progress, "function", "nil")
182 progress = progress or emptyfn
183 if id:find("/") ~= nil then id = id:match("^([0-9A-Fa-f:]+)/.*$") end
184 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
185 if id:find(":") ~= nil then id = id:gsub(":", "/") end
186 progress("Connecting to api.github.com... ")
187 local handle = http.get("https://api.github.com/gists/" .. id)
188 if handle == nil then progress("Failed.\n") return nil, "Failed to connect" end
189 local meta = textutils.unserializeJSON(handle.readAll())
190 local code = handle.getResponseCode()
191 handle.close()
192 if code ~= 200 then
193 progress("Failed.\n")
194 return nil, "Invalid response code (" .. code .. ")" .. (meta and ": " .. meta.message or "")
195 end
196 if meta == nil or meta.files == nil then
197 progress("Failed.\n")
198 return nil, meta and meta.message and "GitHub API error: " .. meta.message or "Error parsing JSON"
199 end
200 progress("Success.\n")
201 local retval = {}
202 for k, v in pairs(meta.files) do retval[k] = getGistFile(v) end
203 return retval
204end
205
206--- Returns some information about a Gist.
207-- @tparam string id The Gist ID to get info about.
208-- @tparam[opt] function progress A function to use to report status messages.
209-- @treturn table|nil A table of information about the Gist. The table may
210-- contain the following entries:
211-- - description: The description for the Gist.
212-- - author: The username of the author of the Gist.
213-- - revisionCount: The number of revisions that have been made to the Gist.
214-- - files: A list of all file names in the Gist, sorted alphabetically.
215-- @treturn string|nil If an error occurred, a string describing the error.
216function gist.info(id, progress)
217 expect(1, id, "string")
218 expect(2, progress, "function", "nil")
219 progress = progress or emptyfn
220 if id:find("/") ~= nil then id = id:match("^([0-9A-Fa-f:]+)/.*$") end
221 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
222 if id:find(":") ~= nil then id = id:gsub(":", "/") end
223 progress("Connecting to api.github.com... ")
224 local handle = http.get("https://api.github.com/gists/" .. id)
225 if handle == nil then progress("Failed.\n") return nil, "Failed to connect" end
226 local meta = textutils.unserializeJSON(handle.readAll())
227 local code = handle.getResponseCode()
228 handle.close()
229 if code ~= 200 then
230 progress("Failed.\n")
231 return nil, "Invalid response code (" .. code .. ")" .. (meta and ": " .. meta.message or "")
232 end
233 if meta == nil or meta.files == nil then
234 progress("Failed.\n")
235 return nil, meta and meta.message and "GitHub API error: " .. meta.message or "Error parsing JSON"
236 end
237 local f = {}
238 for k in pairs(meta.files) do table.insert(f, k) end
239 table.sort(f)
240 progress("Success.\n")
241 return { description = meta.description, author = meta.owner.login, revisionCount = #meta.history, files = f }
242end
243
244--- Uploads a list of files to Gist, updating a previous Gist if desired.
245-- @tparam table files The files to upload to Gist. This table should be
246-- structured with a key as file name and a string with the file contents. If
247-- updating a Gist, files can be deleted by setting the data to textutils.json_null.
248-- @tparam[opt] string description The description of the Gist. This is required
249-- when updating a Gist, but is optional when uploading a Gist for the first
250-- time. If you don't want to change the description when updating, you can get
251-- the current description with gist.info() and pass in the description field.
252-- @tparam[opt] string id The ID of the Gist to update. If nil, a new Gist will
253-- be created.
254-- @tparam[opt] boolean interactive Set this to true to allow asking for a PAK
255-- if one is not available in the settings. If this is not specified, this
256-- function will return nil if gist.id is not available in the settings.
257-- @treturn string|nil The ID of the Gist, or nil on error.
258-- @treturn string|nil The URL of the Gist, or a string on error.
259function gist.put(files, description, id, interactive)
260 expect(1, files, "table")
261 expect(3, id, "string", "nil")
262 expect(2, description, "string", id == nil and "nil" or nil)
263 expect(4, interactive, "boolean", "nil")
264 if id then
265 if id:find("/") ~= nil then id = id:match("^([0-9A-Fa-f:]+)/.*$") end
266 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
267 if id:find(":") ~= nil then id = id:gsub(":", "/") end
268 end
269 local data = { files = {}, public = true, description = description }
270 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
271 local headers = { ["Content-Type"] = "application/json" }
272 if not requestAuth(headers, interactive) then return nil, "Authentication required" end
273 if interactive then write("Connecting to api.github.com... ") end
274 local handle
275 if id then handle = http.post{ url = "https://api.github.com/gists/" .. id, body = textutils.serializeJSON(data):gsub("\n", "n"), headers = headers, method = "PATCH" }
276 else handle = http.post("https://api.github.com/gists", textutils.serializeJSON(data):gsub("\n", "n"), headers) end
277 if handle == nil then if interactive then print("Failed.") end return nil, "Could not connect" end
278 local resp = textutils.unserializeJSON(handle.readAll())
279 if handle.getResponseCode() ~= 201 and handle.getResponseCode() ~= 200 or resp == nil then
280 if interactive then print("Failed: " .. handle.getResponseCode() .. ": " .. (resp and resp.message or "Unknown error")) end
281 handle.close()
282 return nil, "Failed: " .. handle.getResponseCode() .. ": " .. (resp and resp.message or "Unknown error")
283 end
284 handle.close()
285 if interactive then print("Success.") end
286 return resp.id, resp.html_url
287end
288
289--- Deletes a Gist.
290-- @tparam string id The Gist ID to delete.
291-- @tparam[opt] boolean interactive Set this to true to allow asking for a PAK
292-- if one is not available in the settings. If this is not specified, this
293-- function will return false if gist.id is not available in the settings.
294-- @treturn boolean Whether the request succeeded.
295-- @treturn string|nil If an error occurred, a message describing the error.
296function gist.delete(id, interactive)
297 expect(1, id, "string")
298 expect(2, interactive, "boolean", "nil")
299 if id:find("/") ~= nil or id:find(":") ~= nil then id = id:match("^([0-9A-Fa-f]+)") end
300 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
301 local headers = {}
302 if not requestAuth(headers, interactive) then return false, "Authentication required" end
303 if interactive then write("Connecting to api.github.com... ") end
304 local handle = http.post{ url = "https://api.github.com/gists/" .. id, headers = headers, method = "DELETE" }
305 if handle == nil then if interactive then print("Failed.") end return false, "Could not connect" end
306 if handle.getResponseCode() ~= 204 then
307 local resp = textutils.unserializeJSON(handle.readAll())
308 if interactive then print("Failed: " .. handle.getResponseCode() .. ": " .. (resp and resp.message or "Unknown error")) end
309 handle.close()
310 return false, "Failed: " .. handle.getResponseCode() .. ": " .. (resp and resp.message or "Unknown error")
311 end
312 handle.close()
313 if interactive then print("Success.") end
314 return true
315end
316
317end
318
319local args = { ... }
320
321local function readFile(filename, files, isEditing)
322 if fs.isDir(shell.resolve(filename)) then
323 for _, v in ipairs(fs.list(shell.resolve(filename))) do if readFile(fs.combine(filename, v), files, isEditing) then return true end end
324 else
325 if files[fs.getName(filename)] then print("Cannot upload files with duplicate names.") return true end
326 local file = fs.open(shell.resolve(filename), "rb")
327 if file == nil then
328 if not isEditing then print("Could not read " .. filename .. ".") return true
329 else files[fs.getName(filename)] = textutils.json_null end
330 else
331 files[fs.getName(filename)] = file.readAll()
332 file.close()
333 end
334 end
335end
336
337local function getFiles(isEditing)
338 local files = {}
339 local i = isEditing and 3 or 2
340 while args[i] ~= nil and args[i] ~= "--" do
341 if readFile(args[i], files, isEditing) then return nil end
342 i = i + 1
343 end
344 if args[i] == "--" then return files, table.concat({ table.unpack(args, i + 1) }, " ") end
345 return files
346end
347
348local 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
349
350local helpstr = "Usages:\ngist put <files...> [-- description...]\ngist edit <id> <files...> [-- description]\ngist delete <id>\ngist get <id> <filename>\ngist run <id> [arguments...]\ngist info <id>"
351
352if #args < 2 then
353 print(helpstr)
354 return 1
355end
356
357if args[1] == "get" then
358 if #args < 3 then print(helpstr) return 1 end
359 if args[3]:sub(#args[3]) == "/" or fs.isDir(shell.resolve(args[3])) then
360 fs.makeDir(shell.resolve(args[3]))
361 local files, err = gist.getAll(args[2], write)
362 if files == nil then printError(err) return 3 end
363 for k, v in pairs(files) do
364 local file = fs.open(shell.resolve(fs.combine(args[3], k)), "wb")
365 file.write(v)
366 file.close()
367 end
368 print("Downloaded all files to " .. shell.resolve(args[3]))
369 else
370 local data, err = gist.get(args[2], write)
371 if data == nil then printError(err) return 3 end
372 local file = fs.open(shell.resolve(args[3]), "wb")
373 file.write(data)
374 file.close()
375 print("Downloaded as " .. shell.resolve(args[3]))
376 end
377elseif args[1] == "run" then
378 return gist.run(args[2], write, table.unpack(args, 3))
379elseif args[1] == "put" then
380 local files, description = getFiles(false)
381 if files == nil then return end
382 local id, html_url = gist.put(files, description, nil, true)
383 if id ~= nil then print("Uploaded as " .. html_url .. "\nRun 'gist get " .. id .. "' to download anywhere")
384 else printError(html_url) return 3 end
385elseif args[1] == "info" then
386 local tab, err = gist.info(args[2], write)
387 if tab == nil then printError(err) return 3 end
388 setTextColor(colors.yellow)
389 write("Description: ")
390 setTextColor(colors.white)
391 print(tab.description)
392 setTextColor(colors.yellow)
393 write("Author: ")
394 setTextColor(colors.white)
395 print(tab.author)
396 setTextColor(colors.yellow)
397 write("Revisions: ")
398 setTextColor(colors.white)
399 print(tab.revisionCount)
400 setTextColor(colors.yellow)
401 print("Files in this Gist:")
402 setTextColor(colors.white)
403 textutils.tabulate(tab.files)
404elseif args[1] == "edit" then
405 if #args < 3 then print(helpstr) return 1 end
406 local files, description = getFiles(true)
407 if files == nil then return 2 end
408 if not description then description = gist.info(args[2], write).description end
409 local id, html_url = gist.put(files, description, args[2], true)
410 if id then print("Uploaded as " .. html_url .. "\nRun 'gist get " .. args[2] .. "' to download anywhere")
411 else printError(html_url) return 3 end
412elseif args[1] == "delete" then
413 local ok, err = gist.delete(args[2], true)
414 if ok then print("The requested Gist has been deleted.") else printError(err) return 3 end
415else print(helpstr) return 1 end