local BD = require("ui/bidi") local CenterContainer = require("ui/widget/container/centercontainer") local ConfirmBox = require("ui/widget/confirmbox") local Device = require("device") local Event = require("ui/event") local Font = require("ui/font") local Geom = require("ui/geometry") local GestureRange = require("ui/gesturerange") local InputContainer = require("ui/widget/container/inputcontainer") local InputDialog = require("ui/widget/inputdialog") local Menu = require("ui/widget/menu") local TextViewer = require("ui/widget/textviewer") local UIManager = require("ui/uimanager") local logger = require("logger") local _ = require("gettext") local Screen = require("device").screen local T = require("ffi/util").template local ReaderBookmark = InputContainer:new{ bm_menu_title = _("Bookmarks"), bbm_menu_title = _("Bookmark browsing mode"), bookmarks_items_per_page_default = 14, bookmarks = nil, } function ReaderBookmark:init() if Device:hasKeyboard() then self.key_events = { ShowBookmark = { { "B" }, doc = "show bookmarks" }, } end if G_reader_settings:hasNot("bookmarks_items_per_page") then -- The Bookmarks items per page and items' font size can now be -- configured. Previously, the ones set for the file browser -- were used. Initialize them from these ones. local items_per_page = G_reader_settings:readSetting("items_per_page") or self.bookmarks_items_per_page_default G_reader_settings:saveSetting("bookmarks_items_per_page", items_per_page) local items_font_size = G_reader_settings:readSetting("items_font_size") if items_font_size and items_font_size ~= Menu.getItemFontSize(items_per_page) then -- Keep the user items font size if it's not the default for items_per_page G_reader_settings:saveSetting("bookmarks_items_font_size", items_font_size) end end self.ui.menu:registerToMainMenu(self) end function ReaderBookmark:addToMainMenu(menu_items) -- insert table to main reader menu menu_items.bookmarks = { text = self.bm_menu_title, callback = function() self:onShowBookmark() end, } if not Device:isTouchDevice() then menu_items.toggle_bookmark = { text_func = function() return self:isCurrentPageBookmarked() and _("Remove bookmark for current page") or _("Bookmark current page") end, callback = function() self:onToggleBookmark() end, } end if self.ui.document.info.has_pages then menu_items.bookmark_browsing_mode = { text = self.bbm_menu_title, checked_func = function() return self.ui.paging.bookmark_flipping_mode end, callback = function(touchmenu_instance) self:enableBookmarkBrowsingMode() touchmenu_instance:closeMenu() end, } end menu_items.bookmarks_items_per_page = { text = _("Bookmarks per page"), keep_menu_open = true, callback = function() local SpinWidget = require("ui/widget/spinwidget") local curr_perpage = G_reader_settings:readSetting("bookmarks_items_per_page") or self.bookmarks_items_per_page_default local items = SpinWidget:new{ width = math.floor(Screen:getWidth() * 0.6), value = curr_perpage, value_min = 6, value_max = 24, default_value = self.bookmarks_items_per_page_default, title_text = _("Bookmarks per page"), callback = function(spin) G_reader_settings:saveSetting("bookmarks_items_per_page", spin.value) end } UIManager:show(items) end } menu_items.bookmarks_items_font_size = { text = _("Bookmark font size"), keep_menu_open = true, callback = function() local SpinWidget = require("ui/widget/spinwidget") local curr_perpage = G_reader_settings:readSetting("bookmarks_items_per_page") or self.bookmarks_items_per_page_default local default_font_size = Menu.getItemFontSize(curr_perpage) local curr_font_size = G_reader_settings:readSetting("bookmarks_items_font_size") or default_font_size local items_font = SpinWidget:new{ width = math.floor(Screen:getWidth() * 0.6), value = curr_font_size, value_min = 10, value_max = 72, default_value = default_font_size, title_text = _("Bookmark font size"), callback = function(spin) G_reader_settings:saveSetting("bookmarks_items_font_size", spin.value) end } UIManager:show(items_font) end, } menu_items.bookmarks_items_show_more_text = { text = _("Shrink bookmark font size to fit more text"), keep_menu_open = true, checked_func = function() return G_reader_settings:isTrue("bookmarks_items_multilines_show_more_text") end, callback = function() G_reader_settings:flipNilOrFalse("bookmarks_items_multilines_show_more_text") end } end function ReaderBookmark:enableBookmarkBrowsingMode() self.ui:handleEvent(Event:new("ToggleBookmarkFlipping")) end function ReaderBookmark:isBookmarkInTimeOrder(a, b) return a.datetime > b.datetime end function ReaderBookmark:isBookmarkInPageOrder(a, b) if self.ui.document.info.has_pages then if a.page == b.page then -- have bookmarks before highlights return a.highlighted end return a.page > b.page else local a_page = self.ui.document:getPageFromXPointer(a.page) local b_page = self.ui.document:getPageFromXPointer(b.page) if a_page == b_page then -- have bookmarks before highlights return a.highlighted end return a_page > b_page end end function ReaderBookmark:isBookmarkInReversePageOrder(a, b) -- The way this is used (by getNextBookmarkedPage(), iterating bookmarks -- in reverse order), we want to skip highlights, but also the current -- page: so we do not do any "a.page == b.page" check (not even with -- a reverse logic than the one from above function). if self.ui.document.info.has_pages then return a.page < b.page else local a_page = self.ui.document:getPageFromXPointer(a.page) local b_page = self.ui.document:getPageFromXPointer(b.page) return a_page < b_page end end function ReaderBookmark:isBookmarkPageInPageOrder(a, b) if self.ui.document.info.has_pages then return a > b.page else return a > self.ui.document:getPageFromXPointer(b.page) end end function ReaderBookmark:isBookmarkPageInReversePageOrder(a, b) if self.ui.document.info.has_pages then return a < b.page else return a < self.ui.document:getPageFromXPointer(b.page) end end function ReaderBookmark:fixBookmarkSort(config) -- for backward compatibility, since previously bookmarks for credocuments -- are not well sorted. We need to do a whole sorting for at least once. if config:hasNot("bookmarks_sorted") then table.sort(self.bookmarks, function(a, b) return self:isBookmarkInPageOrder(a, b) end) end end function ReaderBookmark:importSavedHighlight(config) local textmarks = config:readSetting("highlight") or {} -- import saved highlight once, because from now on highlight are added to -- bookmarks when they are created. if config:hasNot("highlights_imported") then for page, marks in pairs(textmarks) do for _, mark in ipairs(marks) do page = self.ui.document.info.has_pages and page or mark.pos0 -- highlights saved by some old versions don't have pos0 field -- we just ignore those highlights if page then self:addBookmark({ page = page, datetime = mark.datetime, notes = mark.text, highlighted = true, }) end end end end end function ReaderBookmark:onReadSettings(config) self.bookmarks = config:readSetting("bookmarks") or {} -- need to do this after initialization because checking xpointer -- may cause segfaults before credocuments are inited. self.ui:registerPostInitCallback(function() self:fixBookmarkSort(config) self:importSavedHighlight(config) end) end function ReaderBookmark:onSaveSettings() self.ui.doc_settings:saveSetting("bookmarks", self.bookmarks) self.ui.doc_settings:makeTrue("bookmarks_sorted") self.ui.doc_settings:makeTrue("highlights_imported") end function ReaderBookmark:isCurrentPageBookmarked() local pn_or_xp if self.ui.document.info.has_pages then pn_or_xp = self.view.state.page else pn_or_xp = self.ui.document:getXPointer() end return self:getDogearBookmarkIndex(pn_or_xp) and true or false end function ReaderBookmark:onToggleBookmark() local pn_or_xp if self.ui.document.info.has_pages then pn_or_xp = self.view.state.page else pn_or_xp = self.ui.document:getXPointer() end self:toggleBookmark(pn_or_xp) self.view.footer:onUpdateFooter(self.view.footer_visible) self.ui:handleEvent(Event:new("SetDogearVisibility", not self.view.dogear_visible)) UIManager:setDirty(self.view.dialog, "ui") return true end function ReaderBookmark:setDogearVisibility(pn_or_xp) if self:getDogearBookmarkIndex(pn_or_xp) then self.ui:handleEvent(Event:new("SetDogearVisibility", true)) else self.ui:handleEvent(Event:new("SetDogearVisibility", false)) end end function ReaderBookmark:onPageUpdate(pageno) if self.ui.document.info.has_pages then self:setDogearVisibility(pageno) else self:setDogearVisibility(self.ui.document:getXPointer()) end end function ReaderBookmark:onPosUpdate(pos) self:setDogearVisibility(self.ui.document:getXPointer()) end function ReaderBookmark:gotoBookmark(pn_or_xp, marker_xp) if pn_or_xp then local event = self.ui.document.info.has_pages and "GotoPage" or "GotoXPointer" self.ui:handleEvent(Event:new(event, pn_or_xp, marker_xp)) end end -- This function adds "chapter" property to highlights already saved in the document function ReaderBookmark:updateHighlightsIfNeeded() local version = self.ui.doc_settings:readSetting("bookmarks_version") or 0 if version >= 20200615 then return end for page, highlights in pairs(self.view.highlight.saved) do for _, highlight in pairs(highlights) do local pg_or_xp = self.ui.document.info.has_pages and page or highlight.pos0 local chapter_name = self.ui.toc:getTocTitleByPage(pg_or_xp) highlight.chapter = chapter_name end end for _, bookmark in ipairs(self.bookmarks) do if bookmark.pos0 then local pg_or_xp = self.ui.document.info.has_pages and bookmark.page or bookmark.pos0 local chapter_name = self.ui.toc:getTocTitleByPage(pg_or_xp) bookmark.chapter = chapter_name elseif bookmark.page then -- dogear bookmark local chapter_name = self.ui.toc:getTocTitleByPage(bookmark.page) bookmark.chapter = chapter_name end end self.ui.doc_settings:saveSetting("bookmarks_version", 20200615) end function ReaderBookmark:onShowBookmark() self:updateHighlightsIfNeeded() -- build up item_table for k, v in ipairs(self.bookmarks) do local page = v.page -- for CREngine, bookmark page is xpointer if not self.ui.document.info.has_pages then if self.ui.pagemap and self.ui.pagemap:wantsPageLabels() then page = self.ui.pagemap:getXPointerPageLabel(page, true) else page = self.ui.document:getPageFromXPointer(page) if self.ui.document:hasHiddenFlows() then local flow = self.ui.document:getPageFlow(page) page = self.ui.document:getPageNumberInFlow(page) if flow > 0 then page = T("[%1]%2", page, flow) end end end end if v.text == nil or v.text == "" then v.text = T(_("Page %1 %2 @ %3"), page, v.notes, v.datetime) end end local items_per_page = G_reader_settings:readSetting("bookmarks_items_per_page") or self.bookmarks_items_per_page_default local items_font_size = G_reader_settings:readSetting("bookmarks_items_font_size") or Menu.getItemFontSize(items_per_page) local multilines_show_more_text = G_reader_settings:isTrue("bookmarks_items_multilines_show_more_text") local bm_menu = Menu:new{ title = _("Bookmarks"), item_table = self.bookmarks, is_borderless = true, is_popout = false, width = Screen:getWidth(), height = Screen:getHeight(), cface = Font:getFace("x_smallinfofont"), items_per_page = items_per_page, items_font_size = items_font_size, multilines_show_more_text = multilines_show_more_text, line_color = require("ffi/blitbuffer").COLOR_WHITE, on_close_ges = { GestureRange:new{ ges = "two_finger_swipe", range = Geom:new{ x = 0, y = 0, w = Screen:getWidth(), h = Screen:getHeight(), }, direction = BD.flipDirectionIfMirroredUILayout("east") } } } self.bookmark_menu = CenterContainer:new{ dimen = Screen:getSize(), covers_fullscreen = true, -- hint for UIManager:_repaint() bm_menu, } -- buid up menu widget method as closure local bookmark = self function bm_menu:onMenuChoice(item) bookmark.ui.link:addCurrentLocationToStack() bookmark:gotoBookmark(item.page, item.pos0) end function bm_menu:onMenuHold(item) self.textviewer = TextViewer:new{ title = _("Bookmark details"), text = item.notes, width = self.textviewer_width, height = self.textviewer_height, buttons_table = { { { text = _("Rename this bookmark"), callback = function() bookmark:renameBookmark(item) UIManager:close(self.textviewer) end, }, { text = _("Remove this bookmark"), callback = function() UIManager:show(ConfirmBox:new{ text = _("Remove this bookmark?"), cancel_text = _("Cancel"), cancel_callback = function() return end, ok_text = _("Remove"), ok_callback = function() bookmark:removeHighlight(item) bm_menu:switchItemTable(nil, bookmark.bookmarks, -1) UIManager:close(self.textviewer) end, }) end, }, }, { { text = _("Close"), is_enter_default = true, callback = function() UIManager:close(self.textviewer) end, }, }, } } UIManager:show(self.textviewer) return true end bm_menu.close_callback = function() UIManager:close(self.bookmark_menu) end bm_menu.show_parent = self.bookmark_menu self.refresh = function() bm_menu:updateItems() self:onSaveSettings() end UIManager:show(self.bookmark_menu) return true end function ReaderBookmark:isBookmarkMatch(item, pn_or_xp) -- this is not correct, but previous commit temporarily -- reverted, see #2395 & #2394 if self.ui.document.info.has_pages then return item.page == pn_or_xp else return self.ui.document:isXPointerInCurrentPage(item.page) end end function ReaderBookmark:getDogearBookmarkIndex(pn_or_xp) local _middle local _start, _end = 1, #self.bookmarks while _start <= _end do _middle = math.floor((_start + _end)/2) local v = self.bookmarks[_middle] if not v.highlighted and self:isBookmarkMatch(v, pn_or_xp) then return _middle elseif self:isBookmarkInPageOrder({page = pn_or_xp}, v) then _end = _middle - 1 else _start = _middle + 1 end end end function ReaderBookmark:isBookmarkSame(item1, item2) if item1.notes ~= item2.notes then return false end if self.ui.document.info.has_pages then return item1.pos0 and item1.pos1 and item2.pos0 and item2.pos1 and item1.pos0.page == item2.pos0.page and item1.pos0.x == item2.pos0.x and item1.pos0.y == item2.pos0.y and item1.pos1.x == item2.pos1.x and item1.pos1.y == item2.pos1.y else return item1.page == item2.page and item1.pos0 == item2.pos0 and item1.pos1 == item2.pos1 end end -- binary insert of sorted bookmarks function ReaderBookmark:addBookmark(item) local _start, _middle, _end, direction = 1, 1, #self.bookmarks, 0 while _start <= _end do _middle = math.floor((_start + _end)/2) -- won't add duplicated bookmarks if self:isBookmarkSame(item, self.bookmarks[_middle]) then logger.warn("skip adding duplicated bookmark") return end if self:isBookmarkInPageOrder(item, self.bookmarks[_middle]) then _end, direction = _middle - 1, 0 else _start, direction = _middle + 1, 1 end end table.insert(self.bookmarks, _middle + direction, item) self.view.footer:onUpdateFooter(self.view.footer_visible) end -- binary search of sorted bookmarks function ReaderBookmark:isBookmarkAdded(item) local _middle local _start, _end = 1, #self.bookmarks while _start <= _end do _middle = math.floor((_start + _end)/2) if self:isBookmarkSame(item, self.bookmarks[_middle]) then return true end if self:isBookmarkInPageOrder(item, self.bookmarks[_middle]) then _end = _middle - 1 else _start = _middle + 1 end end return false end function ReaderBookmark:removeHighlight(item) if item.pos0 then self.ui:handleEvent(Event:new("Unhighlight", item)) else self:removeBookmark(item) -- Update dogear in case we removed a bookmark for current page if self.ui.document.info.has_pages then self:setDogearVisibility(self.view.state.page) else self:setDogearVisibility(self.ui.document:getXPointer()) end end end -- binary search to remove bookmark function ReaderBookmark:removeBookmark(item) local _middle local _start, _end = 1, #self.bookmarks while _start <= _end do _middle = math.floor((_start + _end)/2) local v = self.bookmarks[_middle] if item.datetime == v.datetime and item.page == v.page then table.remove(self.bookmarks, _middle) self.view.footer:onUpdateFooter(self.view.footer_visible) return elseif self:isBookmarkInPageOrder(item, v) then _end = _middle - 1 else _start = _middle + 1 end end -- If we haven't found item, it may be because there are multiple -- bookmarks on the same page, and the above binary search decided to -- not search on one side of one it found on page, where item could be. -- Fallback to do a full scan. logger.dbg("removeBookmark: binary search didn't find bookmark, doing full scan") for i=1, #self.bookmarks do local v = self.bookmarks[i] if item.datetime == v.datetime and item.page == v.page then table.remove(self.bookmarks, i) self.view.footer:onUpdateFooter(self.view.footer_visible) return end end logger.warn("removeBookmark: full scan search didn't find bookmark") end function ReaderBookmark:updateBookmark(item) for i=1, #self.bookmarks do if item.datetime == self.bookmarks[i].datetime and item.page == self.bookmarks[i].page then local page = self.ui.document:getPageFromXPointer(item.updated_highlight.pos0) if self.ui.document:hasHiddenFlows() then local flow = self.ui.document:getPageFlow(page) page = self.ui.document:getPageNumberInFlow(page) if flow > 0 then page = T("[%1]%2", page, flow) end end local new_text = item.updated_highlight.text self.bookmarks[i].page = item.updated_highlight.pos0 self.bookmarks[i].pos0 = item.updated_highlight.pos0 self.bookmarks[i].pos1 = item.updated_highlight.pos1 self.bookmarks[i].notes = item.updated_highlight.text self.bookmarks[i].text = T(_("Page %1 %2 @ %3"), page, new_text, item.updated_highlight.datetime) self.bookmarks[i].datetime = item.updated_highlight.datetime self.bookmarks[i].chapter = item.updated_highlight.chapter self:onSaveSettings() end end end function ReaderBookmark:renameBookmark(item, from_highlight) if from_highlight then -- Called by ReaderHighlight:editHighlight, we need to find the bookmark for i=1, #self.bookmarks do if item.datetime == self.bookmarks[i].datetime and item.page == self.bookmarks[i].page then item = self.bookmarks[i] if item.text == nil or item.text == "" then -- Make up bookmark text as done in onShowBookmark local page = item.page if not self.ui.document.info.has_pages then page = self.ui.document:getPageFromXPointer(page) if self.ui.document:hasHiddenFlows() then local flow = self.ui.document:getPageFlow(page) page = self.ui.document:getPageNumberInFlow(page) if flow > 0 then page = T("[%1]%2", page, flow) end end end item.text = T(_("Page %1 %2 @ %3"), page, item.notes, item.datetime) end break end end if item.text == nil then -- bookmark not found return end end self.input = InputDialog:new{ title = _("Rename bookmark"), input = item.text, input_type = "text", allow_newline = true, cursor_at_end = false, add_scroll_buttons = true, buttons = { { { text = _("Cancel"), is_enter_default = true, callback = function() UIManager:close(self.input) end, }, { text = _("Rename"), callback = function() local value = self.input:getInputValue() if value ~= "" then for i=1, #self.bookmarks do if item.text == self.bookmarks[i].text and item.pos0 == self.bookmarks[i].pos0 and item.pos1 == self.bookmarks[i].pos1 and item.page == self.bookmarks[i].page then self.bookmarks[i].text = value UIManager:close(self.input) if not from_highlight then self.refresh() end break end end end UIManager:close(self.input) end, }, } }, } UIManager:show(self.input) self.input:onShowKeyboard() end function ReaderBookmark:toggleBookmark(pn_or_xp) local index = self:getDogearBookmarkIndex(pn_or_xp) if index then table.remove(self.bookmarks, index) else -- build notes from TOC local notes = self.ui.toc:getTocTitleByPage(pn_or_xp) local chapter_name = notes if notes ~= "" then -- @translators In which chapter title (%1) a note is found. notes = T(_("in %1"), notes) end self:addBookmark({ page = pn_or_xp, datetime = os.date("%Y-%m-%d %H:%M:%S"), notes = notes, chapter = chapter_name }) end end function ReaderBookmark:getPreviousBookmarkedPage(pn_or_xp) logger.dbg("go to next bookmark from", pn_or_xp) for i = 1, #self.bookmarks do if self:isBookmarkInPageOrder({page = pn_or_xp}, self.bookmarks[i]) then return self.bookmarks[i].page end end end function ReaderBookmark:getNextBookmarkedPage(pn_or_xp) logger.dbg("go to next bookmark from", pn_or_xp) for i = #self.bookmarks, 1, -1 do if self:isBookmarkInReversePageOrder({page = pn_or_xp}, self.bookmarks[i]) then return self.bookmarks[i].page end end end function ReaderBookmark:getPreviousBookmarkedPageFromPage(pn_or_xp) logger.dbg("go to next bookmark from", pn_or_xp) for i = 1, #self.bookmarks do if self:isBookmarkPageInPageOrder(pn_or_xp, self.bookmarks[i]) then return self.bookmarks[i].page end end end function ReaderBookmark:getNextBookmarkedPageFromPage(pn_or_xp) logger.dbg("go to next bookmark from", pn_or_xp) for i = #self.bookmarks, 1, -1 do if self:isBookmarkPageInReversePageOrder(pn_or_xp, self.bookmarks[i]) then return self.bookmarks[i].page end end end function ReaderBookmark:getFirstBookmarkedPageFromPage(pn_or_xp) if #self.bookmarks > 0 then local first = #self.bookmarks if self:isBookmarkPageInPageOrder(pn_or_xp, self.bookmarks[first]) then return self.bookmarks[first].page end end end function ReaderBookmark:getLastBookmarkedPageFromPage(pn_or_xp) if #self.bookmarks > 0 then local last = 1 if self:isBookmarkPageInReversePageOrder(pn_or_xp, self.bookmarks[last]) then return self.bookmarks[last].page end end end function ReaderBookmark:onGotoPreviousBookmark(pn_or_xp) self:gotoBookmark(self:getPreviousBookmarkedPage(pn_or_xp)) return true end function ReaderBookmark:onGotoNextBookmark(pn_or_xp) self:gotoBookmark(self:getNextBookmarkedPage(pn_or_xp)) return true end function ReaderBookmark:onGotoNextBookmarkFromPage(add_current_location_to_stack) if add_current_location_to_stack ~= false then -- nil or true self.ui.link:addCurrentLocationToStack() end self:gotoBookmark(self:getNextBookmarkedPageFromPage(self.ui:getCurrentPage())) return true end function ReaderBookmark:onGotoPreviousBookmarkFromPage(add_current_location_to_stack) if add_current_location_to_stack ~= false then -- nil or true self.ui.link:addCurrentLocationToStack() end self:gotoBookmark(self:getPreviousBookmarkedPageFromPage(self.ui:getCurrentPage())) return true end function ReaderBookmark:getLatestBookmark() local latest_bookmark = nil local latest_bookmark_datetime = "0" for i = 1, #self.bookmarks do if self.bookmarks[i].datetime > latest_bookmark_datetime then latest_bookmark_datetime = self.bookmarks[i].datetime latest_bookmark = self.bookmarks[i] end end return latest_bookmark end function ReaderBookmark:hasBookmarks() return self.bookmarks and #self.bookmarks > 0 end function ReaderBookmark:getNumberOfBookmarks() return self.bookmarks and #self.bookmarks or 0 end function ReaderBookmark:getNumberOfHighlightsAndNotes() local highlights = 0 local notes = 0 for i = 1, #self.bookmarks do if self.bookmarks[i].highlighted then highlights = highlights + 1 -- No real way currently to know which highlights -- have been edited and became "notes". Editing them -- adds this 'text' field, but just showing bookmarks -- do that as well... if self.bookmarks[i].text then notes = notes + 1 end end end return highlights, notes end return ReaderBookmark