update TODO
[gemini.koplugin.git] / main.lua
blob6d4975ffb302228d73674f0b7cfd5c8ddeb1e338
1 local BD = require("ui/bidi")
2 local WidgetContainer = require("ui/widget/container/widgetcontainer")
3 local Event = require("ui/event")
4 local Device = require("device")
5 local UIManager = require("ui/uimanager")
6 local ConfirmBox = require("ui/widget/confirmbox")
7 local InputDialog = require("ui/widget/inputdialog")
8 local MultiInputDialog = require("ui/widget/multiinputdialog")
9 local FileManager = require("apps/filemanager/filemanager")
10 local DocSettings = require("docsettings")
11 local DataStorage = require("datastorage")
12 local ReadHistory = require("readhistory")
13 local Trapper = require("ui/trapper")
14 local InfoMessage = require("ui/widget/infomessage")
15 local ButtonDialogTitle = require("ui/widget/buttondialogtitle")
16 local CheckButton = require("ui/widget/checkbutton")
17 local MultiConfirmBox = require("ui/widget/multiconfirmbox")
18 local SpinWidget = require("ui/widget/spinwidget")
19 local Menu = require("ui/widget/menu")
20 local Persist = require("persist")
21 local NetworkMgr = require("ui/network/manager")
22 local DocumentRegistry = require("document/documentregistry")
23 local Font = require("ui/font")
24 local Dispatcher = require("dispatcher")
25 local TextViewer = require("ui/widget/textviewer")
26 local KeyValuePage = require("ui/widget/keyvaluepage")
27 local Screen = require("device").screen
28 local Version = require("version")
29 local filemanagerutil = require("apps/filemanager/filemanagerutil")
30 local lfs = require("libs/libkoreader-lfs")
31 local url = require("socket.url")
32 local sha256 = require("ffi/sha2").sha256
33 local util = require("util")
34 local ffiutil = require("ffi/util")
35 local _ = require("gettext")
36 local T = require("ffi/util").template
37 local gemini = require("gemini")
39 local gemini_dir = DataStorage:getDataDir() .. "/gemini"
40 local ids_dir = gemini_dir .. "/identities"
41 local history_dir = "/tmp/gemini-history"
42 local queue_dir = "/tmp/gemini-queue"
43 local marks_path = gemini_dir .. "/bookmarks.gmi"
45 --local cafile_path = DataStorage:getDataDir() .. "/data/ca-bundle.crt"
47 local active_identities_persist = Persist:new{ path = gemini_dir .. "/identities.lua" }
48 local queue_persist = Persist:new{ path = gemini_dir .. "/queue.lua" }
49 local trust_store_persist = Persist:new{ path = gemini_dir .. "/known_hosts.lua" }
50 local active_identities = active_identities_persist:load() or {}
51 local queue = queue_persist:load() or {}
52 local trust_store = trust_store_persist:load() or {}
54 local history = {}
55 local unhistory = {}
56 local trust_overrides = {}
58 local default_max_cache_history_items = 20
59 local max_cache_history_items = G_reader_settings:readSetting("gemini_max_cache_history_items") or default_max_cache_history_items
61 local function getDefaultSavesDir()
62 local dir = G_reader_settings:readSetting("home_dir") or filemanagerutil.getDefaultDir()
63 if dir:sub(-1) ~= "/" then
64 dir = dir .. "/"
65 end
66 return dir .. "downloaded"
67 end
68 local save_dir = G_reader_settings:readSetting("gemini_save_dir") or getDefaultSavesDir()
70 local GeminiPlugin = WidgetContainer:extend{
71 name = "gemini",
72 fullname = _("Gemini plugin"),
73 active = false,
74 hold_pan = false,
77 -- Parsed URL of current item
78 function GeminiPlugin:purl()
79 if #history > 0 then
80 return history[1].purl
81 end
82 end
84 local done_setup = false
85 function GeminiPlugin:setup()
86 util.makePath(ids_dir)
87 self:onDispatcherRegisterActions()
88 if not done_setup then
89 done_setup = true
90 local GeminiDocument = require("geminidocument")
91 DocumentRegistry:addProvider("gmi", "text/gemini", GeminiDocument, 100)
92 end
93 end
95 local activating = false
96 local repl_purl
97 function GeminiPlugin:init()
98 self:setup()
100 self.active = activating
101 if self.active then
102 assert(self:purl())
103 local postcb = self.ui.registerPostReaderReadyCallback
104 if not postcb then
105 -- Name for the callback in versions <= 2024.04
106 postcb = self.ui.registerPostReadyCallback
108 if postcb then
109 if self.ui.document.file then
110 -- Keep gemini history files out of reader history
111 postcb(self.ui, function ()
112 ReadHistory:removeItemByPath(self.ui.document.file)
113 end)
115 if repl_purl then
116 local local_repl_purl = repl_purl
117 postcb(self.ui, function ()
118 -- XXX: Input widget painted over without this delay. Better way?
119 UIManager:scheduleIn(0.1, function ()
120 self:promptInput(local_repl_purl, "[Repeating]", false, true)
121 end)
122 end)
126 activating = false
127 repl_purl = nil
129 if self.ui and self.ui.link then
130 if self.ui.link.registerScheme then
131 self.ui.link:registerScheme("gemini")
132 self.ui.link:registerScheme("about")
133 if self.active then
134 self.ui.link:registerScheme("")
137 self.ui.link:addToExternalLinkDialog("23_gemini", function(this, link_url)
138 return {
139 text = _("Open via Gemini"),
140 callback = function()
141 UIManager:close(this.external_link_dialog)
142 this.ui:handleEvent(Event:new("FollowGeminiLink", link_url))
143 end,
144 show_in_dialog_func = function(u)
145 local scheme = u:match("^(%w[%w+%-.]*):") or ""
146 return scheme == "gemini" or scheme == "about" or
147 (scheme == "" and self.active)
148 end,
150 end)
153 self.ui.menu:registerToMainMenu(self)
155 if self.ui and self.ui.highlight then
156 self.ui.highlight:addToHighlightDialog("20_queue_links", function(this)
157 return {
158 text = _("Add links to queue"),
159 show_in_highlight_dialog_func = function()
160 return self.active
161 end,
162 callback = function()
163 self:queueLinksInSelected(this.selected_text)
164 this:onClose()
165 end,
167 end)
170 if Device:isTouchDevice() then
171 self.ui:registerTouchZones({
173 id = "tap_link_gemini",
174 ges = "tap",
175 screen_zone = {
176 ratio_x = 0, ratio_y = 0,
177 ratio_w = 1, ratio_h = 1,
179 overrides = {
180 -- Tap on gemini links has priority over everything
181 "tap_link",
182 "readerhighlight_tap",
183 "tap_top_left_corner",
184 "tap_top_right_corner",
185 "tap_left_bottom_corner",
186 "tap_right_bottom_corner",
187 "readerfooter_tap",
188 "readerconfigmenu_ext_tap",
189 "readerconfigmenu_tap",
190 "readermenu_ext_tap",
191 "readermenu_tap",
192 "tap_forward",
193 "tap_backward",
195 handler = function(ges) return self:onTap(nil, ges) end,
198 id = "hold_release_link_gemini",
199 ges = "hold_release",
200 screen_zone = {
201 ratio_x = 0, ratio_y = 0,
202 ratio_w = 1, ratio_h = 1,
204 overrides = {
205 "readerhighlight_hold_release",
207 handler = function(ges) return self:onHoldRelease(nil, ges) end,
210 id = "hold_pan_link_gemini",
211 ges = "hold_pan",
212 screen_zone = {
213 ratio_x = 0, ratio_y = 0,
214 ratio_w = 1, ratio_h = 1,
216 overrides = {
217 "readerhighlight_hold_pan",
219 handler = function(ges) return self:onHoldPan(nil, ges) end,
222 id = "double_tap_link_gemini",
223 ges = "double_tap",
224 screen_zone = {
225 ratio_x = 0, ratio_y = 0,
226 ratio_w = 1, ratio_h = 1,
228 overrides = {
229 "double_tap_top_left_corner",
230 "double_tap_top_right_corner",
231 "double_tap_bottom_left_corner",
232 "double_tap_bottom_right_corner",
233 "double_tap_left_side",
234 "double_tap_right_side",
236 handler = function(ges) return self:onDoubleTap(nil, ges) end,
239 id = "swipe_gemini",
240 ges = "swipe",
241 screen_zone = {
242 ratio_x = 0, ratio_y = 0,
243 ratio_w = 1, ratio_h = 1,
245 handler = function(ges) return self:onSwipe(nil, ges) end,
251 function GeminiPlugin:mimeToExt(mimetype)
252 return (mimetype == "text/plain" and "txt")
253 or DocumentRegistry:mimeToExt(mimetype)
254 or (mimetype:find("^text/") and "txt")
257 local function writeBodyToFile(body, path)
258 local o = io.open(path, "w")
259 if o then
260 if type(body) == "string" then
261 o:write(body)
262 o:close()
263 return true
264 else
265 local chunk, aborted = body:read(256)
266 while chunk and chunk ~= "" do
267 o:write(chunk)
268 chunk, aborted = body:read(256)
270 body:close()
271 o:close()
272 return not aborted
274 else
275 return false
279 function GeminiPlugin:saveBody(body, mimetype, purl)
280 self:getSavePath(purl, mimetype, function(path)
281 if not writeBodyToFile(body, path) then
282 -- clear up partial write
283 FileManager:deleteFile(path, true)
285 end)
288 function GeminiPlugin:openBody(body, mimetype, purl, cert_info, replace_history)
289 util.makePath(history_dir)
291 local function get_ext(p)
292 if p.path then
293 local ext, m = p.path:gsub(".*%.","",1)
294 if m == 1 then
295 return ext
299 local hn = #history
300 if replace_history then
301 hn = hn - 1
303 local tn = history_dir .. "/Gemini " .. hn
304 local ext = self:mimeToExt(mimetype) or get_ext(purl)
305 if ext then
306 tn = tn .. "." .. ext
309 if not DocumentRegistry:hasProvider(tn) then
310 UIManager:show(ConfirmBox:new{
311 text = T(_("Can't view file (%1). Save it instead?"), mimetype),
312 ok_text = _("Save file"),
313 cancel_text = _("Cancel"),
314 ok_callback = function()
315 self:saveBody(body, mimetype, purl)
316 end,
318 return
321 if not replace_history then
322 -- Delete history tail
323 local ok, iter, dir_obj = pcall(lfs.dir, history_dir)
324 if not ok then
325 return
328 for f in iter, dir_obj do
329 local path = history_dir.."/"..f
330 local attr = lfs.attributes(path) or {}
331 if attr.mode == "file" or attr.mode == "link" then
332 local n = tonumber(f:match("^Gemini (%d+)%f[^%d]"))
333 if n and (n >= #history or n <= #history - max_cache_history_items) then
334 FileManager:deleteFile(path, true)
335 local h = history[#history - n]
336 if h and h.path == path then
337 h.path = nil
342 while table.remove(unhistory) do end
345 if not writeBodyToFile(body, tn) then
346 return
349 local history_item = { purl = purl, path = tn, mimetype = mimetype, cert_info = cert_info }
350 if replace_history then
351 history[1] = history_item
352 else
353 table.insert(history, 1, history_item)
356 self:openCurrent()
359 function GeminiPlugin:openCurrent()
360 if not history[1].path then
361 return self:openUrl(history[1].purl, { replace_history = true })
364 -- as in ReaderUI:switchDocument, but with seamless option
365 local function switchDocumentSeamlessly(new_file)
366 -- Mimic onShowingReader's refresh optimizations
367 self.ui.tearing_down = true
368 self.ui.dithered = nil
370 self.ui:handleEvent(Event:new("CloseReaderMenu"))
371 self.ui:handleEvent(Event:new("CloseConfigMenu"))
372 self.ui.highlight:onClose() -- close highlight dialog if any
373 self.ui:onClose(false)
375 self.ui:showReader(new_file, nil, true)
378 --self.ui:switchDocument(history[1].path)
379 local open_msg = InfoMessage:new{
380 text = T(_("%1\nOpening..."), gemini.showUrl(history[1].purl, true)),
382 UIManager:show(open_msg)
384 -- Tell new GeminiPlugin instance to consider itself active
385 activating = true
387 if self.ui.name == "ReaderUI" then
388 --self.ui:switchDocument(history[1].path)
389 switchDocumentSeamlessly(history[1].path)
390 else
391 local ReaderUI = require("apps/reader/readerui")
392 ReaderUI:showReader(history[1].path, nil, true)
394 UIManager:close(open_msg)
397 local function normaliseIdentUrl(u)
398 local purl = url.parse(u, {scheme = "gemini", port = "1965"})
399 if purl == nil or purl.scheme ~= "gemini" then
400 return nil
402 purl.query = nil
403 return url.build(purl)
406 function GeminiPlugin:setIdentity(at_url, identity)
407 local n = normaliseIdentUrl(at_url)
408 if n then
409 active_identities[n] = identity
410 active_identities_persist:save(active_identities)
414 -- Return first identity at or above at_url, if any
415 function GeminiPlugin:getIdentity(at_url)
416 local n = normaliseIdentUrl(at_url)
417 if n == nil then
418 return nil
421 for u,id in pairs(active_identities) do
422 if u == n then
423 return id,u
427 local up = url.absolute(at_url, "./")
428 if up == at_url then
429 up = url.absolute(at_url, "../")
431 if up ~= at_url then
432 return self:getIdentity(up)
436 function GeminiPlugin:writeDefaultBookmarks()
437 if not util.fileExists(marks_path) then
438 local f = io.open(marks_path, "w")
439 if f then
440 f:write(require("staticgemtexts").default_bookmarks)
441 f:close()
446 function GeminiPlugin:openUrl(article_url, opts)
447 if type(article_url) ~= "string" then
448 article_url = url.build(article_url)
450 opts = opts or {}
451 local bodyCallback = opts.bodyCallback or function(f, mimetype, p, cert_info)
452 self:openBody(f, mimetype, p, cert_info, opts.replace_history)
454 if self:purl() then
455 article_url = url.absolute(self:purl(), article_url)
458 local purl = url.parse(article_url, {port = "1965"})
460 if purl and purl.scheme == "about" then
461 local body
462 if purl.path == "bookmarks" then
463 self:writeDefaultBookmarks()
464 body = io.open(marks_path, "r")
465 else
466 body = require("staticgemtexts")[purl.path]
468 if body then
469 bodyCallback(body, "text/gemini", purl)
470 else
471 UIManager:show(InfoMessage:new{text = _("Unknown \"about:\" URL: " .. article_url)})
473 return
476 if not purl or not purl.host or purl.scheme ~= "gemini" then
477 UIManager:show(InfoMessage:new{text = _("Invalid gemini URL: " .. article_url)})
478 return
481 if NetworkMgr:willRerunWhenConnected(function() self:openUrl(article_url, opts) end) then
482 -- Not connected yet, nothing more to do here, NetworkMgr will forward the callback and run it once connected!
483 return
486 local id = self:getIdentity(article_url)
487 local id_path = id and ids_dir.."/"..id
488 local function success_cb(f, p, mimetype, params, cert_info)
489 if opts.repl_purl then
490 repl_purl = opts.repl_purl
492 bodyCallback(f, mimetype, p, cert_info)
494 local function error_cb(msg, p, major, minor, server_msg)
495 if major then
496 msg = T(_("Server reports %1: %2"), msg, server_msg)
498 if major == "1" then
499 self:promptInput(p, server_msg, minor == "1")
500 elseif major == "6" then
501 UIManager:show(ConfirmBox:new{
502 text = msg,
503 ok_text = _("Set identity"),
504 cancel_text = _("Cancel"),
505 ok_callback = function()
506 self:confIdentAt(gemini.showUrl(p), function(new_id)
507 if new_id then
508 self:openUrl(p, opts)
510 end)
511 end,
513 else
514 UIManager:show(InfoMessage:new{text = msg})
517 local function check_trust_cb(host, new_fp, old_trusted_times, old_expiry, cb)
518 if trust_overrides[new_fp] and os.time() < trust_overrides[new_fp] then
519 cb("once")
520 else
521 self:promptUnexpectedCert(host, new_fp, old_trusted_times, old_expiry, cb)
524 local function trust_modified_cb()
525 trust_store_persist:save(trust_store)
527 local perm_redir_cb = nil -- TODO: automatically edit bookmarks file?
528 local function info_cb(msg, fast)
529 return Trapper:info(msg, fast)
532 Trapper:wrap(function()
533 Trapper:setPausedText(T(_("Abort connection?")))
534 gemini.makeRequest(gemini.showUrl(purl),
535 id and id_path..".key",
536 id and id_path..".crt",
537 nil, -- disable CA-based verification
538 trust_store,
539 check_trust_cb,
540 trust_modified_cb,
541 success_cb,
542 error_cb,
543 perm_redir_cb,
544 info_cb,
545 G_reader_settings:isTrue("gemini_confirm_tofu"))
546 Trapper:reset()
547 end)
550 -- Prompt user for input. May modify `purl.query`.
551 function GeminiPlugin:promptInput(purl, prompt, is_secret, repl)
552 local input_dialog
553 local repl_button
554 local multiline_button
555 local function submit()
556 purl.query = url.escape(input_dialog:getInputText())
557 if #url.build(purl) > 1024 then
558 UIManager:show(InfoMessage:new{ text =
559 T(_("Input too long (by %1 bytes)"), #url.build(purl) - 1024) })
560 else
561 UIManager:close(input_dialog)
562 self:openUrl(purl, { repl_purl = repl_button.checked and purl })
565 input_dialog = InputDialog:new{
566 title = prompt,
567 input = "",
568 text_type = is_secret and "password",
569 enter_callback = submit,
570 buttons = {
573 text = _("Cancel"),
574 id = "close",
575 callback = function()
576 UIManager:close(input_dialog)
577 end,
580 text = _("Enter"),
581 callback = submit,
587 -- read-eval-print-loop mode: keep presenting input dialog
588 repl_button = CheckButton:new{
589 text = _("Repeat"),
590 checked = repl,
591 parent = input_dialog,
593 multiline_button = CheckButton:new{
594 text = _("Multiline input"),
595 checked = false,
596 callback = function()
597 input_dialog.allow_newline = multiline_button.checked
598 -- FIXME: less hacky way to do this?
599 if multiline_button.checked then
600 input_dialog._input_widget.enter_callback = nil
601 else
602 input_dialog._input_widget.enter_callback = submit
604 end,
605 parent = input_dialog,
607 input_dialog:addWidget(multiline_button)
608 input_dialog:addWidget(repl_button)
610 local y_offset = 0
611 if repl then
612 -- Draw just above keyboard (in vertical mode),
613 -- so we can see as much as possible of the newly loaded page
614 y_offset = Screen:scaleBySize(120)
615 if G_reader_settings:isTrue("keyboard_key_compact") then
616 y_offset = y_offset + 50
620 UIManager:show(input_dialog, nil, nil, nil, y_offset)
621 input_dialog:onShowKeyboard()
622 return true
625 function GeminiPlugin:promptUnexpectedCert(host, new_fp, old_trusted_times, old_expiry, cb)
626 local widget = MultiConfirmBox:new{
627 text = old_trusted_times > 0 and
628 T(_([[
629 The server identity presented by %1 does not match that previously trusted (%2 times).
630 Digest of received certificate: SHA256:%3
631 Previously trusted certificate expiry date: %4]]), host, old_trusted_times, new_fp, old_expiry) or
632 T(_([[
633 No trusted server identity known for %1. Trust provided server identity?
634 Digest of received certificate: SHA256:%2]]), host, new_fp),
635 face = Font:getFace("x_smallinfofont"),
636 choice1_text = _("Trust new certificate"),
637 choice1_callback = function()
638 cb("always")
639 end,
640 choice2_text = _("Connect without trust"),
641 choice2_callback = function()
642 -- persist for 1h
643 trust_overrides[new_fp] = os.time() + 3600
644 cb("once")
645 end,
646 cancel_callback = function()
647 cb()
648 end,
650 UIManager:show(widget)
653 function GeminiPlugin:goBack(n)
654 n = n or 1
655 if n > #history-1 then
656 n = #history-1
657 elseif n < -#unhistory then
658 n = -#unhistory
660 if n == 0 then
661 return false
663 while n > 0 do
664 table.insert(unhistory, 1, table.remove(history, 1))
665 n = n-1
667 while n < 0 do
668 table.insert(history, 1, table.remove(unhistory, 1))
669 n = n+1
671 self:openCurrent()
672 return true
675 function GeminiPlugin:clearHistory()
676 local function delete_item(item)
677 if item.path then
678 FileManager:deleteFile(item.path, true)
682 while #history > 1 do
683 delete_item(table.remove(history, 2))
685 while #unhistory > 0 do
686 delete_item(table.remove(unhistory, 1))
690 function GeminiPlugin:onTap(_, ges)
691 if self.active then
692 return self:followGesLink(ges)
696 function GeminiPlugin:onDoubleTap(_, ges)
697 if self.active then
698 return self:followGesLink(ges, true)
702 function GeminiPlugin:onHoldPan(_, ges)
703 self.hold_pan = true
706 function GeminiPlugin:onHoldRelease(_, ges)
707 if self.active and not self.hold_pan then
708 if self:followGesLink(ges, true) then
709 if self.ui.highlight then
710 self.ui.highlight:clear()
712 return true
715 self.hold_pan = false
718 function GeminiPlugin:onSwipe(_, ges)
719 if self.active then
720 local direction = BD.flipDirectionIfMirroredUILayout(ges.direction)
721 if direction == "south" then
722 return self:goBack()
723 elseif direction == "north" then
724 self:showNav()
725 return true
730 function GeminiPlugin:followGesLink(ges, nav)
731 local link = self.ui.link:getLinkFromGes(ges)
732 if link and link.xpointer then
733 local scheme = link.xpointer:match("^(%w[%w+%-.]*):") or ""
734 if scheme == "gemini" or scheme == "about"
735 or (scheme == "" and self.active) then
736 if nav then
737 self:showNav(link.xpointer)
738 else
739 self:openUrl(link.xpointer)
741 return true
746 function GeminiPlugin:onFollowGeminiLink(u)
747 return self:showNav(u)
750 function GeminiPlugin:onEndOfBook()
751 -- TODO: seems we can't override the usual reader onEndOfBook handling.
752 -- Leaving this as a hidden option for now.
753 if G_reader_settings:isTrue("gemini_next_on_end") then
754 if self.active and #queue > 0 then
755 self:openQueueItem()
756 return true
761 function GeminiPlugin:queueLinksInSelected(selected)
762 local html = self.ui.document:getHTMLFromXPointers(selected.pos0, selected.pos1, nil, true)
763 if html then
764 -- Following pattern isn't strictly correct in general,
765 -- but is for the html generated from a gemini document.
766 local n = 0
767 for u in html:gmatch('<a[^>]*href="([^"]*)"') do
768 self:queueLink(u)
769 n = n + 1
771 UIManager:show(InfoMessage:new{ text =
772 n == 0 and _("No links found in selected text.") or
773 T(_("Added %1 links to queue."), n)
778 function GeminiPlugin:queueBody(body, u, mimetype, cert_info, existing_item, prepend)
779 util.makePath(queue_dir)
780 local path = queue_dir.."/"..sha256(u)
781 if writeBodyToFile(body, path) then
782 if existing_item then
783 existing_item.path = path
784 existing_item.mimetype = mimetype
785 existing_item.cert_info = cert_info
786 else
787 self:queueItem({ url = u, path = path, mimetype = mimetype, cert_info = cert_info }, prepend)
789 elseif not existing_item then
790 self:queueItem({ url = u }, prepend)
794 function GeminiPlugin:queueCachedHistoryItem(h, prepend)
795 local body = io.open(h.path, "r")
796 if body then
797 self:queueBody(body, gemini.showUrl(h.purl), h.mimetype, h.cert_info, nil, prepend)
801 function GeminiPlugin:fetchLink(u, item, prepend)
802 self:openUrl(u, { bodyCallback = function(body, mimetype, purl, cert_info)
803 self:queueBody(body, gemini.showUrl(purl), mimetype, cert_info, item, prepend)
804 end})
807 function GeminiPlugin:fetchQueue()
808 for _n,item in ipairs(queue) do
809 if not item.path then
810 self:fetchLink(item.url, item)
815 function GeminiPlugin:queueLink(u, prepend)
816 local purl = url.parse(u)
817 if purl and purl.scheme ~= "about" and
818 not G_reader_settings:isTrue("gemini_no_fetch_on_add") and NetworkMgr:isConnected() then
819 self:fetchLink(u, nil, prepend)
820 else
821 self:queueItem({ url = u }, prepend)
825 function GeminiPlugin:queueItem(item, prepend)
826 for k = #queue,1,-1 do
827 if queue[k].url == item.url then
828 table.remove(queue,k)
831 if prepend then
832 table.insert(queue, 1, item)
833 else
834 table.insert(queue, item)
836 queue_persist:save(queue)
839 function GeminiPlugin:openQueueItem(n)
840 n = n or 1
841 local item = queue[n]
842 if item then
843 if item.path then
844 local f = io.open(item.path, "r")
845 if not f then
846 UIManager:show(InfoMessage:new{text = T(_("Failed to open %1 for reading."), item.path)})
847 else
848 self:openBody(f, item.mimetype, url.parse(item.url), item.cert_info)
849 FileManager:deleteFile(item.path, true)
850 self:popQueue(n)
852 elseif item.url:match("^about:") or NetworkMgr:isConnected() then
853 self:openUrl(item.url)
854 self:popQueue(n)
855 else
856 UIManager:show(InfoMessage:new{text = T(_("Need network connection to fetch %1"), item.url)})
861 function GeminiPlugin:popQueue(n)
862 n = n or 1
863 local item = table.remove(queue, n)
864 queue_persist:save(queue)
865 return item
868 function GeminiPlugin:clearQueue()
869 while #queue > 0 do
870 local item = table.remove(queue, 1)
871 if item.path then
872 FileManager:deleteFile(item.path, true)
875 queue_persist:save(queue)
878 function GeminiPlugin:getSavePath(purl, mimetype, cb)
879 local basename = ""
880 local add_ext = false
881 if purl.path then
882 basename = purl.path:gsub("/+$","",1):gsub(".*/","",1)
883 if basename == "" and purl.host then
884 basename = purl.host
885 add_ext = true
887 if add_ext or not basename:match(".+%..+") then
888 local ext = self:mimeToExt(mimetype)
889 if ext then
890 basename = basename.."."..ext
895 local widget
897 local function do_save()
898 local fields = widget:getFields()
899 local dir = fields[2]
900 local bn = fields[1]
901 if bn ~= "" then
902 local path = dir.."/"..bn
903 local tp = lfs.attributes(path, "mode")
904 if tp == "directory" then
905 UIManager:show(InfoMessage:new{text = _("Path is a directory")})
906 elseif tp ~= nil then
907 UIManager:show(ConfirmBox:new{
908 text = _("File exists. Overwrite?"),
909 ok_text = _("Overwrite"),
910 cancel_text = _("Cancel"),
911 ok_callback = function()
912 UIManager:close(widget)
913 cb(path)
914 end,
916 else
917 UIManager:close(widget)
918 util.makePath(dir)
919 cb(path)
924 widget = MultiInputDialog:new{
925 title = _("Save as"),
926 fields = {
928 description = _("Filename"),
929 text = basename,
932 description = _("Directory to save under"),
933 text = save_dir,
936 buttons = {
939 text = _("Cancel"),
940 id = "close",
941 callback = function()
942 UIManager:close(widget)
943 end,
946 text = _("Save"),
947 is_enter_default = true,
948 callback = do_save,
953 widget.input_field[1].enter_callback = do_save
954 widget.input_field[2].enter_callback = do_save
955 UIManager:show(widget)
956 widget:onShowKeyboard()
959 function GeminiPlugin:saveCurrent()
960 self:getSavePath(history[1].purl, history[1].mimetype, function(path)
961 ffiutil.copyFile(history[1].path, path)
962 self.ui:saveSettings()
963 if DocSettings.updateLocation then
964 DocSettings.updateLocation(history[1].path, path, true)
966 end)
969 function GeminiPlugin:confIdentAt(uri, cb)
970 local n = normaliseIdentUrl(uri)
971 if n == nil then
972 return
975 local widget
976 local id = self:getIdentity(n)
977 local function set_id(new_id)
978 if new_id then
979 self:setIdentity(n, new_id)
980 id = new_id
981 UIManager:close(widget)
982 if cb then
983 cb(id)
984 else
985 self:confIdentAt(n)
989 widget = ButtonDialogTitle:new{
990 title = T(_("Identity at %1"), gemini.showUrl(n)),
991 buttons = {
994 text = id and T(_("Stop using identity %1"), id) or _("No identity in use"),
995 enabled = id ~= nil,
996 callback = function()
997 local delId
998 delId = function()
999 local c_id, at_url = self:getIdentity(n)
1000 if c_id then
1001 self:setIdentity(at_url, nil)
1002 delId()
1005 delId()
1006 UIManager:close(widget)
1007 if cb then
1008 cb(nil)
1010 end,
1015 text = _("Cancel"),
1016 id = "close",
1017 callback = function()
1018 UIManager:close(widget)
1022 text = _("Choose identity"),
1023 callback = function()
1024 self:chooseIdentity(set_id)
1025 end,
1028 text = _("Create identity"),
1029 enabled = os.execute("openssl version >& /dev/null"),
1030 callback = function()
1031 self:createIdentInteractive(set_id)
1032 end,
1037 UIManager:show(widget)
1040 function GeminiPlugin:getIds()
1041 local ids = {}
1042 util.findFiles(ids_dir, function(path,crt)
1043 if crt:find("%.crt$") then
1044 table.insert(ids, crt:sub(0,-5))
1046 end)
1047 table.sort(ids)
1048 return ids
1051 function GeminiPlugin:chooseIdentity(callback)
1052 local ids = self:getIds()
1054 local widget
1055 local items = {}
1056 for _i,id in ipairs(ids) do
1057 table.insert(items,
1059 text = id,
1060 callback = function()
1061 callback(id)
1062 UIManager:close(widget)
1063 end,
1066 widget = Menu:new{
1067 title = _("Choose identity"),
1068 item_table = items,
1069 width = Screen:getWidth(), -- backwards compatibility;
1070 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1072 UIManager:show(widget)
1075 function GeminiPlugin:createIdentInteractive(callback)
1076 local widget
1077 local rsa_button
1078 local function createId(id, common_name, rsa)
1079 local path = ids_dir.."/"..id
1080 local shell_quoted_name = common_name:gsub("'","'\\''")
1081 local subj = shell_quoted_name == "" and "/" or "/CN="..shell_quoted_name
1082 if not rsa then
1083 os.execute("openssl ecparam -genkey -name prime256v1 > "..path..".key")
1084 os.execute("openssl req -x509 -new -key "..path..".key -sha256 -out "..path..".crt -days 2000000 -subj '"..subj.."'")
1085 else
1086 os.execute("openssl req -x509 -newkey rsa:2048 -keyout "..path..".key -sha256 -out "..path..".crt -days 2000000 -nodes -subj '"..subj.."'")
1088 UIManager:close(widget)
1089 callback(id)
1091 local function create_cb()
1092 local fields = widget:getFields()
1093 if fields[1] == "" then
1094 UIManager:show(InfoMessage:new{text = _("Enter a petname for this identity, to be used in this client to refer to the identity.")})
1095 elseif not fields[1]:match("^[%w_%-]+$") then
1096 UIManager:show(InfoMessage:new{text = _("Punctuation not allowed in petname.")})
1097 elseif fields[1]:len() > 12 then
1098 UIManager:show(InfoMessage:new{text = _("Petname too long.")})
1099 elseif util.fileExists(ids_dir.."/"..fields[1]..".crt") then
1100 UIManager:show(ConfirmBox:new{
1101 text = _("Identity already exists. Overwrite?"),
1102 ok_text = _("Destroy existing identity"),
1103 cancel_text = _("Cancel"),
1104 ok_callback = function()
1105 createId(fields[1], fields[2], rsa_button.checked)
1106 end,
1108 else
1109 createId(fields[1], fields[2])
1112 widget = MultiInputDialog:new{
1113 title = _("Create identity"),
1114 fields = {
1116 description = _("Identity petname"),
1119 description = _("Name (optional, sent to server)"),
1122 buttons = {
1125 text = _("Cancel"),
1126 id = "close",
1127 callback = function()
1128 UIManager:close(widget)
1129 end,
1132 text = _("Create"),
1133 callback = function()
1134 create_cb()
1140 widget.input_field[1].enter_callback = create_cb
1141 widget.input_field[2].enter_callback = create_cb
1143 -- FIXME: Seems checkbuttons added to MultiInputDialog don't appear...
1144 rsa_button = CheckButton:new{
1145 text = _("Use RSA instead of ECDSA"),
1146 checked = false,
1147 parent = widget,
1149 widget:addWidget(rsa_button)
1151 UIManager:show(widget)
1152 widget:onShowKeyboard()
1155 function GeminiPlugin:addMark(u, desc)
1156 if url.parse(u) then
1157 self:writeDefaultBookmarks()
1158 local line = "=> " .. u
1159 if desc and desc ~= "" then
1160 line = line .. " " .. desc
1162 line = line .. "\n"
1163 local f = io.open(marks_path, "a")
1164 if f then
1165 f:write(line)
1166 f:close()
1167 return true
1172 function GeminiPlugin:addMarkInteractive(uri)
1173 local widget
1174 local function add_mark()
1175 local fields = widget:getFields()
1176 if self:addMark(fields[2], fields[1]) then
1177 UIManager:close(widget)
1180 widget = MultiInputDialog:new{
1181 title = _("Add bookmark"),
1182 fields = {
1184 description = _("Description (optional)"),
1187 description = _("URL"),
1188 text = gemini.showUrl(uri),
1191 buttons = {
1194 text = _("Cancel"),
1195 id = "close",
1196 callback = function()
1197 UIManager:close(widget)
1198 end,
1201 text = _("Add"),
1202 is_enter_default = true,
1203 callback = add_mark,
1208 widget.input_field[1].enter_callback = add_mark
1209 widget.input_field[2].enter_callback = add_mark
1210 UIManager:show(widget)
1211 widget:onShowKeyboard()
1214 function GeminiPlugin:showHistoryMenu(cb)
1215 cb = cb or function(n) self:goBack(n) end
1216 local menu
1217 local history_items = {}
1218 local function show_history_item(h)
1219 return gemini.showUrl(h.purl) ..
1220 (h.path and " " .. _("(fetched)") or "")
1222 for n,h in ipairs(history) do
1223 table.insert(history_items, {
1224 text = T("%1 %2", n-1, show_history_item(h)),
1225 callback = function()
1226 cb(n-1)
1227 UIManager:close(menu)
1228 end,
1229 hold_callback = function()
1230 UIManager:close(menu)
1231 self:showNav(h.purl)
1232 end,
1235 for n,h in ipairs(unhistory) do
1236 table.insert(history_items, 1, {
1237 text = T("%1 %2", -n, show_history_item(h)),
1238 callback = function()
1239 cb(-n)
1240 UIManager:close(menu)
1244 if #history_items > 1 then
1245 table.insert(history_items, {
1246 text = _("Clear all history"),
1247 callback = function()
1248 UIManager:show(ConfirmBox:new{
1249 text = T(_("Clear %1 history items?"), #history_items-1),
1250 ok_text = _("Clear history"),
1251 cancel_text = _("Cancel"),
1252 ok_callback = function()
1253 self:clearHistory()
1254 UIManager:close(menu)
1255 end,
1260 menu = Menu:new{
1261 title = _("History"),
1262 item_table = history_items,
1263 onMenuHold = function(_, item)
1264 if item.hold_callback then
1265 item.hold_callback()
1267 end,
1268 width = Screen:getWidth(), -- backwards compatibility;
1269 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1271 UIManager:show(menu)
1274 function GeminiPlugin:viewCurrentAsText()
1275 local h = history[1]
1276 local f = io.open(h.path,"r")
1277 UIManager:show(TextViewer:new{
1278 title = gemini.showUrl(h.purl),
1279 text = f and f:read("a") or "[Error reading file]"
1281 f:close()
1284 function GeminiPlugin:showCurrentInfo()
1285 local h = history[1]
1286 local kv_pairs = {
1287 { _("URL"), gemini.showUrl(h.purl) },
1288 { _("Mimetype"), h.mimetype }
1290 local widget
1291 if h.cert_info then
1292 table.insert(kv_pairs, "----")
1293 local cert_info = history[1].cert_info
1294 if cert_info.ca then
1295 table.insert(kv_pairs, { _("Trust type"), _("Chain to Certificate Authority") })
1296 for k, v in ipairs(cert_info.ca) do
1297 table.insert(kv_pairs, { v.name, v.value })
1299 else
1300 if cert_info.trusted_times > 0 then
1301 table.insert(kv_pairs, { _("Trust type"), _("Trust On First Use"), callback = function()
1302 UIManager:close(widget)
1303 self:openUrl("about:tofu")
1304 end })
1305 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1306 table.insert(kv_pairs, { _("Times seen"), cert_info.trusted_times })
1307 else
1308 table.insert(kv_pairs, { _("Trust type"), _("Temporarily accepted") })
1309 table.insert(kv_pairs, { _("SHA256 digest"), cert_info.fp })
1311 table.insert(kv_pairs, { _("Expiry date"), cert_info.expiry })
1314 table.insert(kv_pairs, "----")
1315 table.insert(kv_pairs, { "Source", _("Select to view page as text"), callback = function()
1316 self:viewCurrentAsText()
1317 end })
1318 widget = KeyValuePage:new{
1319 title = _("Page info"),
1320 kv_pairs = kv_pairs,
1322 UIManager:show(widget)
1325 function GeminiPlugin:editQueue()
1326 local menu
1327 local items = {}
1328 local function show_queue_item(item)
1329 return gemini.showUrl(item.url) ..
1330 (item.path and " " .. _("(fetched)") or "")
1332 local unfetched = 0
1333 for n,item in ipairs(queue) do
1334 table.insert(items, {
1335 text = n .. " " .. show_queue_item(item),
1336 callback = function()
1337 UIManager:close(menu)
1338 self:openQueueItem(n)
1339 end,
1340 hold_callback = function()
1341 UIManager:close(menu)
1342 self:showNav(item.url)
1343 end,
1345 if not item.path then
1346 unfetched = unfetched + 1
1349 if unfetched > 0 then
1350 table.insert(items, {
1351 text = T(_("Fetch %1 unfetched items"), unfetched),
1352 callback = function()
1353 self:fetchQueue()
1354 UIManager:close(menu)
1355 self:editQueue()
1359 if #items > 0 then
1360 table.insert(items, {
1361 text = _("Clear queue"),
1362 callback = function()
1363 UIManager:show(ConfirmBox:new{
1364 text = T(_("Clear %1 items from queue?"), #queue),
1365 ok_text = _("Clear queue"),
1366 cancel_text = _("Cancel"),
1367 ok_callback = function()
1368 self:clearQueue()
1369 UIManager:close(menu)
1370 end,
1375 menu = Menu:new{
1376 title = _("Queue"),
1377 item_table = items,
1378 onMenuHold = function(_, item)
1379 if item.hold_callback then
1380 item.hold_callback()
1382 end,
1383 width = Screen:getWidth(), -- backwards compatibility;
1384 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1386 UIManager:show(menu)
1389 function GeminiPlugin:editActiveIdentities()
1390 local menu
1391 local items = {}
1392 for u,id in pairs(active_identities) do
1393 local show_u = gemini.showUrl(u)
1394 table.insert(items, {
1395 text = T("%1: %2", id, show_u),
1396 callback = function()
1397 UIManager:show(ConfirmBox:new{
1398 text = T(_("Stop using identity %1 at %2?"), id, show_u),
1399 ok_text = _("Stop"),
1400 cancel_text = _("Cancel"),
1401 ok_callback = function()
1402 UIManager:close(menu)
1403 self:setIdentity(u, nil)
1404 self:editActiveIdentities()
1405 end,
1407 end,
1410 table.sort(items, function(i1,i2) return i1.text < i2.text end)
1411 menu = Menu:new{
1412 title = _("Active identities"),
1413 item_table = items,
1414 width = Screen:getWidth(), -- backwards compatibility;
1415 height = Screen:getHeight(), -- can delete for KOReader >= 2023.04
1417 UIManager:show(menu)
1420 function GeminiPlugin:showNav(uri)
1421 if uri and type(uri) ~= "string" then
1422 uri = url.build(uri)
1424 local showKbd = not uri or uri == ""
1425 if not uri then
1426 uri = gemini.showUrl(self:purl())
1427 elseif self:purl() and uri ~= "" then
1428 uri = url.absolute(self:purl(), uri)
1431 local nav
1432 local advanced = false
1433 local function current_nav_url()
1434 local u = nav:getInputText()
1435 if u:match("^[./?]") then
1436 -- explicitly relative url
1437 if self:purl() then
1438 u = url.absolute(self:purl(), u)
1440 else
1441 -- absolutise if necessary
1442 local purl = url.parse(u)
1443 if purl and purl.scheme == nil and purl.host == nil then
1444 u = "gemini://" .. u
1447 return u
1449 local function current_input_nonempty()
1450 local purl = url.parse(current_nav_url())
1451 return purl and (purl.host or purl.path)
1453 local function close_nav_keyboard()
1454 if nav.onCloseKeyboard then
1455 nav:onCloseKeyboard()
1456 elseif Version:getNormalizedCurrentVersion() < 202309010000 then
1457 -- backwards compatibility
1458 if nav._input_widget.onCloseKeyboard then
1459 nav._input_widget:onCloseKeyboard()
1463 local function show_hist()
1464 close_nav_keyboard()
1465 self:showHistoryMenu(function(n)
1466 UIManager:close(nav)
1467 self:goBack(n)
1468 end)
1470 local function queue_nav_url(prepend)
1471 if current_input_nonempty() then
1472 local u = current_nav_url()
1473 if u == gemini.showUrl(self:purl()) and history[1].path then
1474 self:queueCachedHistoryItem(history[1], prepend)
1475 else
1476 self:queueLink(u, prepend)
1478 UIManager:close(nav)
1481 local function update_buttons()
1482 local u = current_nav_url()
1483 local purl = url.parse(u)
1484 local id = self:getIdentity(u)
1485 local text = T(_("Identity: %1"), id or _("[none]"))
1486 local id_button = nav.button_table:getButtonById("ident")
1487 if not advanced then
1488 id_button:setText(text, id_button.width)
1490 id_button:enableDisable(advanced or (purl and purl.scheme == "gemini" and purl.host ~= ""))
1491 UIManager:setDirty(id_button, "ui")
1493 local save_button = nav.button_table:getButtonById("save")
1494 save_button:enableDisable(purl and purl.scheme and purl.scheme ~= "about")
1495 UIManager:setDirty(save_button, "ui")
1497 local info_button = nav.button_table:getButtonById("info")
1498 info_button:enableDisable(u == gemini.showUrl(self:purl()))
1499 UIManager:setDirty(info_button, "ui")
1501 local function toggle_advanced()
1502 advanced = not advanced
1503 for _,row in ipairs(nav.button_table.buttons_layout) do
1504 for _,button in ipairs(row) do
1505 if button.text_func and button.hold_callback then
1506 button:setText(button.text_func(), button.width)
1507 button.callback, button.hold_callback = button.hold_callback, button.callback
1511 update_buttons()
1512 UIManager:setDirty(nav, "ui")
1515 nav = InputDialog:new{
1516 title = _("Gemini navigation"),
1517 width = Screen:scaleBySize(550), -- in pixels
1518 input_type = "text",
1519 input = uri and gemini.showUrl(uri) or "gemini://",
1520 buttons = {
1523 text_func = function() return advanced and _("Edit identity URLs") or _("Identity") end,
1524 id = "ident",
1525 callback = function()
1526 close_nav_keyboard()
1527 self:confIdentAt(current_nav_url(), function()
1528 update_buttons()
1529 end)
1530 end,
1531 hold_callback = function()
1532 close_nav_keyboard()
1533 self:editActiveIdentities()
1534 end,
1537 text_func = function() return advanced and _("View as text") or _("Page info") end,
1538 id = "info",
1539 callback = function()
1540 UIManager:close(nav)
1541 self:showCurrentInfo()
1542 end,
1543 hold_callback = function()
1544 UIManager:close(nav)
1545 self:viewCurrentAsText()
1546 end,
1551 text_func = function() return advanced and _("History") or _("Back") end,
1552 enabled = #history > 1,
1553 callback = function()
1554 UIManager:close(nav)
1555 self:goBack()
1556 end,
1557 hold_callback = show_hist,
1560 text_func = function() return advanced and _("History") or _("Unback") end,
1561 enabled = #unhistory > 0,
1562 callback = function()
1563 UIManager:close(nav)
1564 self:goBack(-1)
1565 end,
1566 hold_callback = show_hist,
1569 text_func = function() return advanced and _("Edit queue") or _("Next") end,
1570 enabled = #queue > 0,
1571 callback = function()
1572 UIManager:close(nav)
1573 self:openQueueItem()
1574 end,
1575 hold_callback = function()
1576 UIManager:close(nav)
1577 self:editQueue()
1578 end,
1581 text_func = function() return advanced and _("Edit marks") or _("Bookmarks") end,
1582 callback = function()
1583 UIManager:close(nav)
1584 self:openUrl("about:bookmarks")
1585 end,
1586 hold_callback = function()
1587 if self.ui.texteditor and self.ui.texteditor.quickEditFile then
1588 UIManager:close(nav)
1589 self:writeDefaultBookmarks()
1590 local function done_cb()
1591 if self:purl() and url.build(self:purl()) == "about:bookmarks" then
1592 self:openUrl("about:bookmarks", { replace_history = true })
1595 self.ui.texteditor:quickEditFile(marks_path, done_cb, true)
1596 else
1597 UIManager:show(InfoMessage:new{text = T(_([[
1598 Can't load TextEditor: Plugin disabled or incompatible.
1599 To edit bookmarks, please edit the file %1 in the koreader directory manually.
1600 ]]), marks_path)})
1602 end,
1607 text_func = function() return advanced and _("Root") or _("Up") end,
1608 callback = function()
1609 local u = current_nav_url()
1610 local up = url.absolute(u, "./")
1611 if up == u then
1612 up = url.absolute(u, "../")
1614 nav:setInputText(up)
1615 update_buttons()
1616 end,
1617 hold_callback = function()
1618 nav:setInputText(url.absolute(current_nav_url(), "/"))
1619 update_buttons()
1620 end,
1623 text = _("Save"),
1624 id = "save",
1625 callback = function()
1626 local u = current_nav_url()
1627 local purl = url.parse(u)
1628 if purl and purl.scheme and purl.scheme == "about" then
1629 UIManager:show(InfoMessage:new{text = _("Can't save about: pages")})
1630 elseif u == gemini.showUrl(self:purl()) then
1631 UIManager:close(nav)
1632 self:saveCurrent()
1633 else
1634 UIManager:close(nav)
1635 self:openUrl(u, { bodyCallback = function(f, mimetype, p2)
1636 self:saveBody(f, mimetype, p2)
1637 end })
1639 end,
1642 text_func = function() return advanced and _("Prepend") or _("Add") end,
1643 callback = queue_nav_url,
1644 hold_callback = function() queue_nav_url(true) end,
1647 text_func = function() return advanced and _("Quick mark") or _("Mark") end,
1648 callback = function()
1649 if current_input_nonempty() then
1650 self:addMarkInteractive(current_nav_url())
1651 UIManager:close(nav)
1653 end,
1654 hold_callback = function()
1655 if current_input_nonempty()
1656 and self:addMark(current_nav_url()) then
1657 UIManager:close(nav)
1659 end,
1664 text = _("Cancel"),
1665 id = "close",
1666 callback = function()
1667 UIManager:close(nav)
1668 end,
1671 text_func = function() return advanced and _("Input") or _("Go") end,
1672 is_enter_default = true,
1673 callback = function()
1674 UIManager:close(nav)
1675 self:openUrl(current_nav_url())
1676 end,
1677 hold_callback = function()
1678 local purl = url.parse(current_nav_url())
1679 if purl then
1680 self:promptInput(purl, "[Input]")
1681 UIManager:close(nav)
1683 end,
1688 update_buttons()
1689 -- FIXME: less hacky way to do this?
1690 nav._input_widget.edit_callback = function(edited)
1691 if edited then
1692 update_buttons()
1696 nav.title_bar.right_icon = "appbar.settings"
1697 nav.title_bar.right_icon_tap_callback = toggle_advanced
1698 nav.title_bar:init()
1700 UIManager:show(nav)
1701 if showKbd then
1702 nav:onShowKeyboard()
1706 function GeminiPlugin:onDispatcherRegisterActions()
1707 Dispatcher:registerAction("browse_gemini", {category = "none", event = "BrowseGemini", title = _("Browse Gemini"), general = true, separator = true })
1708 Dispatcher:registerAction("gemini_back", {category = "none", event = "GeminiBack", title = _("Gemini: Back"), reader = true })
1709 Dispatcher:registerAction("gemini_unback", {category = "none", event = "GeminiUnback", title = _("Gemini: Unback"), reader = true })
1710 Dispatcher:registerAction("gemini_history", {category = "none", event = "GeminiHistory", title = _("Gemini: History"), reader = true })
1711 Dispatcher:registerAction("gemini_bookmarks", {category = "none", event = "GeminiBookmarks", title = _("Gemini: Bookmarks"), reader = true })
1712 Dispatcher:registerAction("gemini_mark", {category = "none", event = "GeminiMark", title = _("Gemini: Mark"), reader = true })
1713 Dispatcher:registerAction("gemini_next", {category = "none", event = "GeminiNext", title = _("Gemini: Next"), reader = true })
1714 Dispatcher:registerAction("gemini_add", {category = "none", event = "GeminiAdd", title = _("Gemini: Add"), reader = true })
1715 Dispatcher:registerAction("gemini_nav", {category = "none", event = "GeminiNav", title = _("Gemini: Open nav"), reader = true })
1716 Dispatcher:registerAction("gemini_reload", {category = "none", event = "GeminiReload", title = _("Gemini: Reload"), reader = true })
1717 Dispatcher:registerAction("gemini_up", {category = "none", event = "GeminiUp", title = _("Gemini: Up"), reader = true })
1718 Dispatcher:registerAction("gemini_goNew", {category = "none", event = "GeminiGoNew", title = _("Gemini: Enter URL"), reader = true, separator = true })
1721 function GeminiPlugin:onBrowseGemini()
1722 if self.active then
1723 self:showNav()
1724 elseif #history > 0 then
1725 self:openCurrent()
1726 else
1727 self:openUrl("about:bookmarks")
1729 return true
1732 function GeminiPlugin:onGeminiBack()
1733 if self.active then
1734 self:goBack()
1735 return true
1738 function GeminiPlugin:onGeminiUnback()
1739 if self.active then
1740 self:goBack(-1)
1741 return true
1744 function GeminiPlugin:onGeminiHistory()
1745 if self.active then
1746 self:showHistoryMenu()
1747 return true
1750 function GeminiPlugin:onGeminiBookmarks()
1751 self:openUrl("about:bookmarks")
1752 return true
1754 function GeminiPlugin:onGeminiMark()
1755 if self.active then
1756 self:addMarkInteractive(gemini.showUrl(self:purl()))
1757 return true
1760 function GeminiPlugin:onGeminiNext()
1761 self:openQueueItem()
1762 return true
1764 function GeminiPlugin:onGeminiAdd()
1765 if self.active then
1766 self:queueCachedHistoryItem(history[1])
1767 return true
1770 function GeminiPlugin:onGeminiReload()
1771 if self.active then
1772 self:openUrl(history[1].purl, { replace_history = true })
1773 return true
1776 function GeminiPlugin:onGeminiUp()
1777 if self.active then
1778 local u = gemini.showUrl(self:purl())
1779 local up = url.absolute(u, "./")
1780 if up == u then
1781 up = url.absolute(u, "../")
1783 if up ~= u then
1784 self:openUrl(up)
1786 return true
1789 function GeminiPlugin:onGeminiGoNew()
1790 self:showNav("")
1791 return true
1793 function GeminiPlugin:onGeminiNav()
1794 self:showNav()
1795 return true
1798 function GeminiPlugin:addToMainMenu(menu_items)
1799 menu_items.gemini = {
1800 sorting_hint = "search",
1801 text = _("Browse Gemini"),
1802 callback = function()
1803 self:onBrowseGemini()
1804 end,
1806 local hint = "search_settings"
1807 if Version:getNormalizedCurrentVersion() < 202305180000 then
1808 -- backwards compatibility
1809 hint = "search"
1811 menu_items.gemini_settings = {
1812 text = _("Gemini settings"),
1813 sorting_hint = hint,
1814 sub_item_table = {
1816 text = _("Show help"),
1817 callback = function()
1818 self:openUrl("about:help")
1819 end,
1822 text = T(_("Max cached history items: %1"), max_cache_history_items),
1823 help_text = _("History items up to this limit will be stored on the filesystem and can be accessed offline with Back."),
1824 keep_menu_open = true,
1825 callback = function(touchmenu_instance)
1826 local widget = SpinWidget:new{
1827 title_text = _("Max cached history items"),
1828 value = max_cache_history_items,
1829 value_min = 0,
1830 value_max = 200,
1831 default_value = default_max_cache_history_items,
1832 callback = function(spin)
1833 max_cache_history_items = spin.value
1834 G_reader_settings:saveSetting("gemini_max_cache_history_items", spin.value)
1835 touchmenu_instance:updateItems()
1836 end,
1838 UIManager:show(widget)
1839 end,
1842 text = _("Set directory for saved documents"),
1843 keep_menu_open = true,
1844 callback = function()
1845 local title_header = _("Current directory for saved gemini documents:")
1846 local current_path = save_dir
1847 local default_path = getDefaultSavesDir()
1848 local function caller_callback(path)
1849 save_dir = path
1850 G_reader_settings:saveSetting("gemini_save_dir", path)
1851 if not util.pathExists(path) then
1852 lfs.mkdir(path)
1855 filemanagerutil.showChooseDialog(title_header, caller_callback, current_path, default_path)
1856 end,
1859 text = _("Disable fetch on add"),
1860 help_text = _("Disables immediately fetching URLs added to the queue when connected."),
1861 checked_func = function()
1862 return G_reader_settings:isTrue("gemini_no_fetch_on_add")
1863 end,
1864 callback = function()
1865 G_reader_settings:flipNilOrFalse("gemini_no_fetch_on_add")
1866 end,
1869 text = _("Confirm certificates for new hosts"),
1870 help_text = _("Overrides the default behaviour of silently trusting the first server identity seen for a host, allowing you to confirm the certificate hash out-of-band."),
1871 checked_func = function()
1872 return G_reader_settings:isTrue("gemini_confirm_tofu")
1873 end,
1874 callback = function()
1875 G_reader_settings:flipNilOrFalse("gemini_confirm_tofu")
1876 end,
1882 return GeminiPlugin