├── .tool-versions ├── hardcover_version.lua ├── .github ├── FUNDING.yml ├── workflows │ ├── test.yml │ └── release.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── hardcover_config.example.lua ├── .gitignore ├── _meta.lua ├── hardcover ├── lib │ ├── constants │ │ ├── hardcover.lua │ │ ├── icons.lua │ │ └── settings.lua │ ├── user.lua │ ├── ui │ │ ├── search_menu.lua │ │ ├── image_loader.lua │ │ ├── dialog_manager.lua │ │ ├── journal_dialog.lua │ │ ├── search_dialog.lua │ │ ├── update_double_spin_widget.lua │ │ └── hardcover_menu.lua │ ├── cache.lua │ ├── github.lua │ ├── debounce.lua │ ├── scheduler.lua │ ├── book.lua │ ├── throttle.lua │ ├── table_util.lua │ ├── auto_wifi.lua │ ├── page_mapper.lua │ ├── hardcover_settings.lua │ ├── hardcover.lua │ └── hardcover_api.lua └── vendor │ ├── url_content.lua │ └── covermenu.lua ├── LICENSE ├── spec └── lib │ ├── book_spec.lua │ ├── table_util_spec.lua │ └── page_mapper_spec.lua ├── CHANGELOG.md ├── README.md └── main.lua /.tool-versions: -------------------------------------------------------------------------------- 1 | lua 5.1 2 | -------------------------------------------------------------------------------- /hardcover_version.lua: -------------------------------------------------------------------------------- 1 | return { 0, 2, 0 } 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: billiamthesecond 2 | github: Billiam 3 | -------------------------------------------------------------------------------- /hardcover_config.example.lua: -------------------------------------------------------------------------------- 1 | return { 2 | token = 'your token here' 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | hardcover_config.lua 2 | /luarocks 3 | /lua 4 | /lua_modules 5 | /.luarocks 6 | .DS_STORE 7 | -------------------------------------------------------------------------------- /_meta.lua: -------------------------------------------------------------------------------- 1 | local _ = require("gettext") 2 | return { 3 | name = "hardcoverapp", 4 | fullname = _("Hardcover"), 5 | description = _([[Synchronize reading progress to Hardcover.app]]), 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | busted: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | 12 | - name: Run Busted 13 | uses: lunarmodules/busted@v2.2.0 14 | 15 | -------------------------------------------------------------------------------- /hardcover/lib/constants/hardcover.lua: -------------------------------------------------------------------------------- 1 | local Hardcover = { 2 | STATUS = { 3 | TO_READ = 1, 4 | READING = 2, 5 | FINISHED = 3, 6 | DNF = 5, 7 | }, 8 | PRIVACY = { 9 | PUBLIC = 1, 10 | FOLLOWS = 2, 11 | PRIVATE = 3, 12 | }, 13 | CATEGORY = { 14 | TAG = "Tag", 15 | }, 16 | ERROR = { 17 | JWT = "invalid-jwt", 18 | TOKEN = "Unable to verify token", 19 | } 20 | } 21 | 22 | return Hardcover 23 | -------------------------------------------------------------------------------- /hardcover/lib/user.lua: -------------------------------------------------------------------------------- 1 | local SETTING = require("hardcover/lib/constants/settings") 2 | local Api = require("hardcover/lib/hardcover_api") 3 | 4 | local User = {} 5 | 6 | function User:getId() 7 | local user_id = self.settings:readSetting(SETTING.USER_ID) 8 | if not user_id then 9 | local me = Api:me() 10 | user_id = me.id 11 | self.settings:updateSetting(SETTING.USER_ID, user_id) 12 | end 13 | 14 | return user_id 15 | end 16 | 17 | return User 18 | -------------------------------------------------------------------------------- /hardcover/lib/constants/icons.lua: -------------------------------------------------------------------------------- 1 | return { 2 | -- nf-fa-book 3 | PHYSICAL_BOOK = "\u{F02D}", 4 | -- nf-fa-tablet 5 | TABLET = "\u{F10A}", 6 | -- nf-fa-headphones 7 | HEADPHONES = "\u{F025}", 8 | -- nf-fa-bookmark_o 9 | BOOKMARK = "\u{f097}", 10 | -- nf-fae-book_open_o 11 | OPEN_BOOK = "\u{E28B}", 12 | -- nf-oct-check 13 | CHECKMARK = "\u{F42E}", 14 | -- nf-fa-stop_circle 15 | STOP_CIRCLE = "\u{F28D}", 16 | -- nf-fa-trash_can 17 | TRASH = "\u{F014}", 18 | -- nf-fa-star 19 | STAR = "\u{F005}", 20 | -- nf-fa-star_half 21 | HALF_STAR = "\u{F089}", 22 | -- nf-fa-link 23 | LINK = "\u{F0C1}", 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /hardcover/lib/constants/settings.lua: -------------------------------------------------------------------------------- 1 | local Settings = { 2 | ALWAYS_SYNC = "always_sync", 3 | BOOKS = "books", 4 | COMPATIBILITY_MODE = "compatibility_mode", 5 | ENABLE_WIFI = "enable_wifi", 6 | LINK_BY_HARDCOVER = "link_by_hardcover", 7 | LINK_BY_ISBN = "link_by_isbn", 8 | LINK_BY_TITLE = "link_by_title", 9 | SYNC = "sync", 10 | TRACK_FREQUENCY = "track_frequency", 11 | TRACK_METHOD = "track_method", 12 | TRACK_PERCENTAGE = "track_percentage", 13 | TRACK = { 14 | FREQUENCY = "frequency", 15 | PROGRESS = "progress", 16 | }, 17 | USER_ID = "user_id", 18 | } 19 | 20 | Settings.AUTOLINK_OPTIONS = { Settings.LINK_BY_HARDCOVER, Settings.LINK_BY_ISBN, Settings.LINK_BY_TITLE } 21 | 22 | return Settings 23 | -------------------------------------------------------------------------------- /hardcover/lib/ui/search_menu.lua: -------------------------------------------------------------------------------- 1 | local Menu = require("ui/widget/menu") 2 | local ListMenu = require("hardcover/vendor/listmenu") 3 | local CoverMenu = require("hardcover/vendor/covermenu") 4 | 5 | local SearchMenu = Menu:extend { 6 | font_size = 22 7 | } 8 | 9 | SearchMenu.updateItems = CoverMenu.updateItems 10 | SearchMenu.updateCache = CoverMenu.updateCache 11 | SearchMenu.onCloseWidget = CoverMenu.onCloseWidget 12 | 13 | SearchMenu._recalculateDimen = ListMenu._recalculateDimen 14 | SearchMenu._updateItemsBuildUI = ListMenu._updateItemsBuildUI 15 | -- Set ListMenu behaviour: 16 | SearchMenu._do_cover_images = true 17 | SearchMenu._do_filename_only = false 18 | SearchMenu._do_hint_opened = false -- dogear at bottom 19 | 20 | 21 | return SearchMenu 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Run Busted 19 | uses: lunarmodules/busted@v2.2.0 20 | 21 | - name: Zip release 22 | run: | 23 | cd .. 24 | zip -r hardcoverapp.koplugin.zip hardcoverapp.koplugin -x '*/*.git*' '*/spec/*' '*/lua_modules/*' '*/.luarocks/*' '*/lua' '*/luarocks' '*/.tool-versions' '*/*.rockspec' 25 | 26 | - name: Release 27 | uses: softprops/action-gh-release@v2 28 | with: 29 | files: ../hardcoverapp.koplugin.zip 30 | -------------------------------------------------------------------------------- /hardcover/lib/cache.lua: -------------------------------------------------------------------------------- 1 | local Api = require("hardcover/lib/hardcover_api") 2 | local User = require("hardcover/lib/user") 3 | 4 | local Cache = {} 5 | Cache.__index = Cache 6 | 7 | function Cache:new(o) 8 | return setmetatable(o, self) 9 | end 10 | 11 | function Cache:updateBookStatus(filename, status, privacy_setting_id) 12 | local settings = self.settings:readBookSettings(filename) 13 | local book_id = settings.book_id 14 | local edition_id = settings.edition_id 15 | 16 | self.state.book_status = Api:updateUserBook(book_id, status, privacy_setting_id, edition_id) or {} 17 | end 18 | 19 | function Cache:cacheUserBook() 20 | local status, errors = Api:findUserBook(self.settings:getLinkedBookId(), User:getId()) 21 | self.state.book_status = status or {} 22 | 23 | return errors 24 | end 25 | 26 | return Cache 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | * hardcoverapp.koplugin version: 11 | * KOReader version: 12 | * Device: 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **`crash.log` (if applicable)** 18 | `crash.log` is a file that is automatically created when KOReader crashes. It can normally be found in the KOReader directory: 19 | 20 | * `/mnt/private/koreader` for Cervantes 21 | * `koreader/` directory for Kindle 22 | * `.adds/koreader/` directory for Kobo 23 | * `applications/koreader/` directory for Pocketbook 24 | 25 | Android logs are kept in memory. Please go to [Menu] → Help → Bug Report to save these logs to a file. 26 | 27 | Please try to include the relevant sections in your issue description. 28 | You can upload the whole `crash.log` file (zipped if necessary) on GitHub by dragging and dropping it onto this textbox. 29 | -------------------------------------------------------------------------------- /hardcover/lib/github.lua: -------------------------------------------------------------------------------- 1 | local http = require("socket.http") 2 | local json = require("json") 3 | local ltn12 = require("ltn12") 4 | 5 | local VERSION = require("hardcover_version") 6 | 7 | local RELEASE_API = "https://api.github.com/repos/billiam/hardcoverapp.koplugin/releases?per_page=1" 8 | 9 | local Github = {} 10 | 11 | function Github:newestRelease() 12 | local responseBody = {} 13 | local res, code, responseHeaders = http.request { 14 | url = RELEASE_API, 15 | sink = ltn12.sink.table(responseBody), 16 | } 17 | 18 | if code == 200 or code == 304 then 19 | local data = json.decode(table.concat(responseBody), json.decode.simple) 20 | if data and #data > 0 then 21 | local tag = data[1].tag_name 22 | local index = 1 23 | for str in string.gmatch(tag, "([^.]+)") do 24 | local part = tonumber(str) 25 | 26 | if part < VERSION[index] then 27 | return nil 28 | elseif part > VERSION[index] then 29 | return tag 30 | end 31 | index = index + 1 32 | end 33 | end 34 | end 35 | end 36 | 37 | return Github 38 | -------------------------------------------------------------------------------- /hardcover/lib/debounce.lua: -------------------------------------------------------------------------------- 1 | local time = require("ui/time") 2 | local UIManager = require("ui/uimanager") 3 | 4 | local debounce = function(seconds, action) 5 | local args = nil 6 | local previous_call_at = nil 7 | 8 | local scheduled_action 9 | 10 | local execute = function() 11 | action(table.unpack(args, 1, args.n)) 12 | end 13 | 14 | scheduled_action = function() 15 | -- handle timer triggering early 16 | local now = time:now() 17 | local next_execute = previous_call_at + seconds 18 | if next_execute > now then 19 | UIManager:scheduleIn(next_execute - now, scheduled_action) 20 | else 21 | execute() 22 | args = nil 23 | end 24 | end 25 | 26 | local debounced_action_wrapper = function(...) 27 | previous_call_at = time:now() 28 | 29 | args = table.pack(...) 30 | UIManager:unschedule(scheduled_action) 31 | UIManager:scheduleIn(seconds, scheduled_action) 32 | end 33 | 34 | local cancel = function() 35 | return UIManager:unschedule(scheduled_action) 36 | end 37 | 38 | return debounced_action_wrapper, cancel 39 | end 40 | 41 | return debounce 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Billiam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /hardcover/lib/scheduler.lua: -------------------------------------------------------------------------------- 1 | local UIManager = require("ui/uimanager") 2 | local logger = require("logger") 3 | 4 | local Scheduler = { 5 | retries = {} 6 | } 7 | 8 | function Scheduler:clear() 9 | for fn,_ in pairs(self.retries) do 10 | UIManager:unschedule(fn) 11 | self.retries[fn] = nil 12 | end 13 | end 14 | 15 | function Scheduler:withRetries(limit, time_exponent, callback, success_callback, fail_callback) 16 | time_exponent = time_exponent or 2 17 | 18 | local scheduled_job 19 | 20 | local tries = 0 21 | 22 | local success = function() 23 | self.retries[callback] = nil 24 | if success_callback then 25 | success_callback() 26 | end 27 | end 28 | 29 | local fail = function() 30 | tries = tries + 1 31 | 32 | if tries < limit then 33 | UIManager:scheduleIn(2 ^ (time_exponent + tries), scheduled_job) 34 | else 35 | if fail_callback then 36 | fail_callback() 37 | end 38 | end 39 | end 40 | 41 | scheduled_job = function() 42 | callback(success, fail) 43 | end 44 | 45 | local cancel = function() 46 | UIManager:unschedule(scheduled_job) 47 | end 48 | 49 | UIManager:nextTick(scheduled_job) 50 | 51 | return cancel 52 | end 53 | 54 | return Scheduler 55 | -------------------------------------------------------------------------------- /hardcover/lib/book.lua: -------------------------------------------------------------------------------- 1 | local Book = {} 2 | local reading_format_labels = { 3 | "Physical Book", 4 | "Audiobook", 5 | nil, 6 | "E-Book" 7 | } 8 | 9 | function Book:readingFormat(format_id) 10 | if not format_id then 11 | return 12 | end 13 | 14 | return reading_format_labels[format_id] 15 | end 16 | 17 | function Book:editionFormatName(edition_format, format_id) 18 | if edition_format and edition_format ~= "" then 19 | return edition_format 20 | end 21 | 22 | return self:readingFormat(format_id) 23 | end 24 | 25 | function Book:parseIdentifiers(identifiers) 26 | local result = {} 27 | 28 | if not identifiers then 29 | return result 30 | end 31 | 32 | for line in string.lower(identifiers):gmatch("%s*([^%s]+)%s*") do 33 | -- check for hardcover:, hardcover-slug: and hardcover-edition: 34 | local hc = string.match(line, "hardcover:([%w_-]+)") or string.match(line, "hardcover%-slug:([%w_-]+)") 35 | if hc then 36 | result.book_slug = hc 37 | end 38 | 39 | local hc_edition = string.match(line, "hardcover%-edition:(%d+)") 40 | 41 | if hc_edition then 42 | result.edition_id = hc_edition 43 | end 44 | 45 | if not hc and not hc_edition then 46 | -- strip prefix 47 | local str = string.gsub(line, "^[^%s]+%s*:%s*", "") 48 | str = string.gsub(str, "-", "") 49 | 50 | if str and string.find(str, "^%d+$") then 51 | local len = #str 52 | 53 | if len == 13 then 54 | result.isbn_13 = str 55 | elseif len == 10 then 56 | result.isbn_10 = str 57 | end 58 | end 59 | end 60 | end 61 | return result 62 | end 63 | 64 | return Book 65 | -------------------------------------------------------------------------------- /hardcover/lib/throttle.lua: -------------------------------------------------------------------------------- 1 | local time = require("ui/time") 2 | local UIManager = require("ui/uimanager") 3 | 4 | local throttle = function(seconds, action) 5 | local args = nil 6 | local previous_execute_at = nil 7 | local is_scheduled = false 8 | local result = nil 9 | local request_trailing = false 10 | 11 | local scheduled_action 12 | 13 | local execute = function() 14 | previous_execute_at = time:now() 15 | result = action(table.unpack(args, 1, args.n)) 16 | 17 | is_scheduled = true 18 | UIManager:scheduleIn(seconds, scheduled_action) 19 | end 20 | 21 | scheduled_action = function() 22 | -- handle timer triggering early 23 | 24 | local now = time:now() 25 | local next_execute = previous_execute_at + seconds 26 | if next_execute > now then 27 | UIManager:scheduleIn(next_execute - now, scheduled_action) 28 | is_scheduled = true 29 | else 30 | is_scheduled = false 31 | 32 | if request_trailing then 33 | request_trailing = false 34 | execute() 35 | end 36 | 37 | if not is_scheduled then 38 | -- This check is needed because action can recursively call debounced_action_wrapper 39 | args = nil 40 | end 41 | end 42 | end 43 | 44 | local throttled_action_wrapper = function(...) 45 | args = table.pack(...) 46 | if is_scheduled then 47 | request_trailing = true 48 | else 49 | execute() 50 | end 51 | return result 52 | end 53 | 54 | local cancel = function() 55 | is_scheduled = false 56 | return UIManager:unschedule(scheduled_action) 57 | end 58 | 59 | return throttled_action_wrapper, cancel 60 | end 61 | 62 | return throttle 63 | -------------------------------------------------------------------------------- /spec/lib/book_spec.lua: -------------------------------------------------------------------------------- 1 | local Book = require("hardcover/lib/book") 2 | 3 | describe("Book", function() 4 | describe("parseIdentifiers", function() 5 | it("parses 10 character strings as isbn10", function() 6 | local identifiers = "asin:1234567890" 7 | local expected = { 8 | isbn_10 = "1234567890" 9 | } 10 | assert.are.same(expected, Book:parseIdentifiers(identifiers)) 11 | end) 12 | 13 | it("parses 13 character strings as isbn13", function() 14 | local identifiers = "asin 13:1234567890123" 15 | local expected = { 16 | isbn_13 = "1234567890123" 17 | } 18 | assert.are.same(expected, Book:parseIdentifiers(identifiers)) 19 | end) 20 | 21 | it("parses hardcover book and editions", function() 22 | local identifiers = [[ 23 | HARDCOVER:the-hobbit 24 | HARDCOVER-EDITION:16193290 25 | ]] 26 | 27 | local expected = { 28 | book_slug = "the-hobbit", 29 | edition_id = "16193290" 30 | } 31 | assert.are.same(expected, Book:parseIdentifiers(identifiers)) 32 | end) 33 | 34 | it("parses hardcover-slug", function() 35 | local identifiers = "HARDCOVER-SLUG:1984" 36 | local expected = { 37 | book_slug = "1984" 38 | } 39 | assert.are.same(expected, Book:parseIdentifiers(identifiers)) 40 | end) 41 | 42 | it("prioritizes hardcover editions over isbn", function() 43 | local identifiers = [[ 44 | HARDCOVER:1234567890 45 | HARDCOVER-EDITION:1234567890123 46 | ]] 47 | 48 | local expected = { 49 | book_slug = "1234567890", 50 | edition_id = "1234567890123" 51 | } 52 | assert.are.same(expected, Book:parseIdentifiers(identifiers)) 53 | end) 54 | end) 55 | end) 56 | -------------------------------------------------------------------------------- /hardcover/lib/table_util.lua: -------------------------------------------------------------------------------- 1 | local TableUtil = {} 2 | 3 | function TableUtil.dig(t, ...) 4 | local result = t 5 | 6 | for _, k in ipairs({ ... }) do 7 | result = result[k] 8 | if result == nil then 9 | return nil 10 | end 11 | end 12 | 13 | return result 14 | end 15 | 16 | function TableUtil.map(t, cb) 17 | local result = {} 18 | for i, v in ipairs(t) do 19 | result[i] = cb(v, i) 20 | end 21 | return result 22 | end 23 | 24 | function TableUtil.filter(t, cb) 25 | local result = {} 26 | for i, v in ipairs(t) do 27 | if cb(v, i) then 28 | table.insert(result, v) 29 | end 30 | end 31 | return result 32 | end 33 | 34 | function TableUtil.shuffle(t) 35 | for i = #t, 2, -1 do 36 | local j = math.random(i) 37 | t[i], t[j] = t[j], t[i] 38 | end 39 | end 40 | 41 | function TableUtil.slice(t, from, to) 42 | local result = {} 43 | local max = (not to or to > #t) and #t or to 44 | 45 | for i = from, max do 46 | result[i - from + 1] = t[i] 47 | end 48 | return result 49 | end 50 | 51 | function TableUtil.contains(t, value) 52 | if not t then 53 | return false 54 | end 55 | 56 | for _, v in ipairs(t) do 57 | if v == value then 58 | return true 59 | end 60 | end 61 | 62 | return false 63 | end 64 | 65 | function TableUtil.binSearch(t, value) 66 | local start_i = 1 67 | local len = #t 68 | local end_i = len 69 | 70 | if end_i == 0 then 71 | return 72 | end 73 | 74 | while start_i <= end_i do 75 | local mid_i = math.floor((start_i + end_i) / 2) 76 | local mid_val = t[mid_i] 77 | 78 | if mid_val == value then 79 | while t[mid_i] == value do 80 | mid_i = mid_i - 1 81 | end 82 | 83 | return mid_i + 1 84 | end 85 | 86 | if mid_val > value then 87 | end_i = mid_i - 1 88 | else 89 | start_i = mid_i + 1 90 | end 91 | end 92 | 93 | if start_i <= len then 94 | return start_i 95 | end 96 | end 97 | 98 | return TableUtil 99 | -------------------------------------------------------------------------------- /hardcover/lib/ui/image_loader.lua: -------------------------------------------------------------------------------- 1 | --local HTTPClient = require("httpclient") 2 | local logger = require("logger") 3 | local getUrlContent = require("hardcover/vendor/url_content") 4 | local UIManager = require("ui/uimanager") 5 | local Trapper = require("ui/trapper") 6 | 7 | local ImageLoader = { 8 | url_map = {} 9 | } 10 | 11 | function ImageLoader:isLoading() 12 | return self.loading == true 13 | end 14 | 15 | local Batch = { 16 | load_count = nil, 17 | loading = false, 18 | url_map = {}, 19 | callback = nil 20 | } 21 | Batch.__index = Batch 22 | 23 | function Batch:new(o) 24 | return setmetatable(o or {}, self) 25 | end 26 | 27 | function Batch:data(url) 28 | return self.url_map[url] 29 | end 30 | 31 | function Batch:loadImages(urls) 32 | if self.loading then 33 | error("batch already in progress") 34 | end 35 | 36 | self.loading = true 37 | 38 | local url_queue = { table.unpack(urls) } 39 | local run_image 40 | local stop_loading = false 41 | 42 | run_image = function() 43 | Trapper:wrap(function() 44 | if stop_loading then return end 45 | 46 | local url = table.remove(url_queue, 1) 47 | 48 | local completed, success, content = Trapper:dismissableRunInSubprocess(function() 49 | return getUrlContent(url, 10, 30) 50 | end) 51 | 52 | --if not completed then 53 | -- logger.warn("Aborted") 54 | --end 55 | 56 | if completed and success then 57 | self.callback(url, content) 58 | end 59 | 60 | if #url_queue > 0 then 61 | UIManager:scheduleIn(0.2, run_image) 62 | else 63 | self.loading = false 64 | end 65 | end) 66 | end 67 | 68 | if #urls == 0 then 69 | self.loading = false 70 | end 71 | 72 | UIManager:nextTick(run_image) 73 | 74 | local halt = function() 75 | stop_loading = true 76 | UIManager:unschedule(run_image) 77 | end 78 | 79 | return halt 80 | end 81 | 82 | function ImageLoader:loadImages(urls, callback) 83 | local batch = Batch:new() 84 | batch.callback = callback 85 | local halt = batch:loadImages(urls) 86 | return batch, halt 87 | end 88 | 89 | return ImageLoader 90 | -------------------------------------------------------------------------------- /hardcover/vendor/url_content.lua: -------------------------------------------------------------------------------- 1 | -- vendored from frontend/ui/wikipedia 2 | local logger = require("logger") 3 | 4 | local function getUrlContent(url, timeout, maxtime) 5 | local http = require("socket.http") 6 | local ltn12 = require("ltn12") 7 | local socket = require("socket") 8 | local socketutil = require("socketutil") 9 | local socket_url = require("socket.url") 10 | 11 | local parsed = socket_url.parse(url) 12 | if parsed.scheme ~= "http" and parsed.scheme ~= "https" then 13 | return false, "Unsupported protocol" 14 | end 15 | if not timeout then timeout = 10 end 16 | 17 | local sink = {} 18 | socketutil:set_timeout(timeout, maxtime or 30) 19 | local request = { 20 | url = url, 21 | method = "GET", 22 | sink = maxtime and socketutil.table_sink(sink) or ltn12.sink.table(sink), 23 | } 24 | 25 | local code, headers, status = socket.skip(1, http.request(request)) 26 | socketutil:reset_timeout() 27 | local content = table.concat(sink) -- empty or content accumulated till now 28 | -- logger.dbg("code:", code) 29 | -- logger.dbg("headers:", headers) 30 | -- logger.dbg("status:", status) 31 | -- logger.dbg("#content:", #content) 32 | 33 | if code == socketutil.TIMEOUT_CODE or 34 | code == socketutil.SSL_HANDSHAKE_CODE or 35 | code == socketutil.SINK_TIMEOUT_CODE 36 | then 37 | logger.warn("request interrupted:", code) 38 | return false, code 39 | end 40 | if headers == nil then 41 | logger.warn("No HTTP headers:", status or code or "network unreachable") 42 | return false, "Network or remote server unavailable" 43 | end 44 | if not code or code < 200 or code > 299 then -- all 200..299 HTTP codes are OK 45 | logger.warn("HTTP status not okay:", status or code or "network unreachable") 46 | logger.dbg("Response headers:", headers) 47 | return false, "Remote server error or unavailable" 48 | end 49 | if headers and headers["content-length"] then 50 | -- Check we really got the announced content size 51 | local content_length = tonumber(headers["content-length"]) 52 | if #content ~= content_length then 53 | return false, "Incomplete content received" 54 | end 55 | end 56 | 57 | return true, content 58 | end 59 | 60 | return getUrlContent 61 | -------------------------------------------------------------------------------- /hardcover/lib/auto_wifi.lua: -------------------------------------------------------------------------------- 1 | local SETTING = require("hardcover/lib/constants/settings") 2 | 3 | local Device = require("device") 4 | local logger = require("logger") 5 | 6 | local NetworkMgr = require("ui/network/manager") 7 | 8 | local AutoWifi = { 9 | connection_pending = false 10 | } 11 | AutoWifi.__index = AutoWifi 12 | 13 | function AutoWifi:new(o) 14 | return setmetatable(o, self) 15 | end 16 | 17 | function AutoWifi:withWifi(callback) 18 | if NetworkMgr:isWifiOn() then 19 | callback(false) 20 | return 21 | end 22 | 23 | if self.settings:readSetting(SETTING.ENABLE_WIFI) 24 | and not NetworkMgr.pending_connection 25 | and Device:hasWifiRestore() 26 | and G_reader_settings:nilOrFalse("airplanemode") then 27 | --logger.warn("HARDCOVER enabling wifi") 28 | 29 | local original_on = NetworkMgr.wifi_was_on 30 | 31 | NetworkMgr:restoreWifiAsync() 32 | NetworkMgr:scheduleConnectivityCheck(function() 33 | -- restore original "was on" state to prevent wifi being restored automatically after suspend 34 | NetworkMgr.wifi_was_on = original_on 35 | G_reader_settings:saveSetting("wifi_was_on", original_on) 36 | 37 | self.connection_pending = false 38 | --logger.warn("HARDCOVER wifi enabled") 39 | 40 | callback(true) 41 | 42 | -- TODO: schedule turn off wifi, debounce 43 | self:wifiDisableSilent() 44 | end) 45 | end 46 | end 47 | 48 | function AutoWifi:wifiDisableSilent() 49 | NetworkMgr:turnOffWifi(function() 50 | -- explicitly disable wifi was on 51 | NetworkMgr.wifi_was_on = false 52 | G_reader_settings:saveSetting("wifi_was_on", false) 53 | --logger.warn("HARDCOVER disabling wifi") 54 | end) 55 | end 56 | 57 | function AutoWifi:wifiPrompt(callback) 58 | if NetworkMgr:isWifiOn() then 59 | if callback then 60 | callback(false) 61 | end 62 | 63 | return 64 | end 65 | 66 | if G_reader_settings:isTrue("airplanemode") then 67 | return 68 | end 69 | 70 | local network_callback = callback and function() callback(true) end or nil 71 | 72 | if self.settings:readSetting(SETTING.ENABLE_WIFI) then 73 | NetworkMgr:turnOnWifiAndWaitForConnection(network_callback) 74 | else 75 | NetworkMgr:promptWifiOn(network_callback) 76 | end 77 | end 78 | 79 | function AutoWifi:wifiDisablePrompt() 80 | if self.settings:readSetting(SETTING.ENABLE_WIFI) and Device:hasWifiRestore() then 81 | self:wifiDisableSilent() 82 | else 83 | NetworkMgr:toggleWifiOff() 84 | end 85 | end 86 | 87 | return AutoWifi 88 | -------------------------------------------------------------------------------- /hardcover/lib/page_mapper.lua: -------------------------------------------------------------------------------- 1 | local _t = require("hardcover/lib/table_util") 2 | 3 | local PageMapper = {} 4 | PageMapper.__index = PageMapper 5 | 6 | function PageMapper:new(o) 7 | return setmetatable(o or {}, self) 8 | end 9 | 10 | function PageMapper:getUnmappedPage(remote_page, document_pages, remote_pages) 11 | self:checkIgnorePagemap() 12 | 13 | local document_page = self.state.page_map and _t.binSearch(self.state.page_map, remote_page) 14 | 15 | if not document_page then 16 | document_page = math.floor((remote_page / remote_pages) * document_pages) 17 | end 18 | 19 | return document_page 20 | end 21 | 22 | function PageMapper:getMappedPage(raw_page, document_pages, remote_pages) 23 | self:checkIgnorePagemap() 24 | 25 | if self.state.page_map then 26 | local mapped_page = self.state.page_map[raw_page] 27 | if mapped_page then 28 | return mapped_page 29 | elseif raw_page > self.state.page_map_range.last_page then 30 | return remote_pages or self.state.page_map_range.real_page 31 | end 32 | end 33 | 34 | if remote_pages and document_pages then 35 | return math.floor((raw_page / document_pages) * remote_pages) 36 | end 37 | 38 | return raw_page 39 | end 40 | 41 | function PageMapper:usePageMap() 42 | return self.ui.pagemap:wantsPageLabels() and not self.ui.pagemap.chars_per_synthetic_page 43 | end 44 | 45 | function PageMapper:checkIgnorePagemap() 46 | local current_page_labels = self:usePageMap() 47 | 48 | if current_page_labels == self.use_page_map then 49 | return 50 | end 51 | 52 | self.use_page_map = current_page_labels 53 | 54 | if current_page_labels then 55 | self:cachePageMap() 56 | else 57 | self.state.page_map = nil 58 | end 59 | end 60 | 61 | local toInteger = function(number) 62 | local as_number = tonumber(number) 63 | if as_number then 64 | return math.floor(as_number) 65 | end 66 | end 67 | 68 | function PageMapper:cachePageMap() 69 | if not self:usePageMap() then 70 | return 71 | end 72 | local page_map = self.ui.document:getPageMap() 73 | 74 | local lookup = {} 75 | local page_label = 1 76 | local last_page_label = 1 77 | local last_page = 1 78 | local max_page_label = 1 79 | 80 | for _, v in ipairs(page_map) do 81 | page_label = toInteger(v.label) or page_label 82 | 83 | for i = last_page, v.page, 1 do 84 | lookup[i] = last_page_label 85 | end 86 | 87 | lookup[v.page] = page_label 88 | last_page = v.page 89 | max_page_label = page_label > max_page_label and page_label or max_page_label 90 | last_page_label = page_label 91 | end 92 | 93 | self.state.page_map_range = { 94 | real_page = max_page_label, 95 | last_page = last_page, 96 | } 97 | self.state.page_map = lookup 98 | end 99 | 100 | -- Used to decide whether a reading threshold has been crossed 101 | function PageMapper:getRemotePagePercent(raw_page, document_pages, remote_pages) 102 | self:checkIgnorePagemap() 103 | 104 | local local_percent = nil 105 | local mapped_page = nil 106 | 107 | if self.state.page_map then 108 | mapped_page = self.state.page_map[raw_page] 109 | 110 | if remote_pages then 111 | -- return labeled page divided by edition page count to maintain correct page/percentage in journal entries 112 | return math.min(1.0, mapped_page / remote_pages), mapped_page 113 | elseif self.state.page_map_range and self.state.page_map_range.real_page then 114 | local_percent = mapped_page / self.state.page_map_range.real_page 115 | end 116 | end 117 | 118 | if not local_percent and document_pages then 119 | local_percent = raw_page / document_pages 120 | end 121 | 122 | if local_percent then 123 | local total_pages = remote_pages or document_pages 124 | 125 | local remote_page = math.floor(local_percent * total_pages) 126 | return remote_page / total_pages, mapped_page or remote_page 127 | end 128 | 129 | return 0 130 | end 131 | 132 | return PageMapper 133 | -------------------------------------------------------------------------------- /spec/lib/table_util_spec.lua: -------------------------------------------------------------------------------- 1 | local table_util = require("hardcover/lib/table_util") 2 | describe("table_util", function() 3 | describe("dig", function() 4 | it("fetches nested table values", function() 5 | local t = { 6 | a = { 7 | b = { 8 | 10, 9 | 20, 10 | 30, 11 | } 12 | } 13 | } 14 | assert.are.equal(30, table_util.dig(t, "a", "b", 3)) 15 | end) 16 | 17 | it("returns nil for missing values", function() 18 | local t = { 19 | a = { 20 | b = { 21 | 10, 22 | 20, 23 | 30, 24 | } 25 | } 26 | } 27 | assert.is_nil(table_util.dig(t, "a", "c", 3)) 28 | end) 29 | end) 30 | 31 | describe("map", function() 32 | it("returns table containing callback result", function() 33 | local input = { 1, 2, 3, 4 } 34 | local result = table_util.map(input, function(el) return el * 2 end) 35 | 36 | assert.are.same(result, { 2, 4, 6, 8 }) 37 | end) 38 | 39 | it("passes table index to map callbacks", function() 40 | local input = { "a", "b", "c" } 41 | 42 | local result = table_util.map(input, function(el, index) return index end) 43 | 44 | assert.are.same(result, { 1, 2, 3 }) 45 | end) 46 | end) 47 | 48 | describe("contains", function() 49 | it("compares object equality", function() 50 | local subtable = { 1, 2, 3 } 51 | local t = { "a", "b", subtable } 52 | 53 | assert.is_true(table_util.contains(t, "b")) 54 | assert.is_true(table_util.contains(t, subtable)) 55 | assert.is_false(table_util.contains(t, "c")) 56 | assert.is_false(table_util.contains(t, { 1, 2, 3 })) 57 | end) 58 | end) 59 | 60 | describe("filter", function() 61 | it("selects table elements by callback", function() 62 | local input = { 1, 2, 3, 4, 5, 6 } 63 | local result = table_util.filter(input, function(v, k) return v % 2 == 0 end) 64 | assert.are.same(result, { 2, 4, 6 }) 65 | end) 66 | 67 | it("selects table elements by index", function() 68 | local input = { 10, 20, 30, 40, 50, 60 } 69 | local result = table_util.filter(input, function(v, k) return k % 2 == 0 end) 70 | assert.are.same(result, { 20, 40, 60 }) 71 | end) 72 | end) 73 | 74 | describe("slice", function() 75 | it("returns limited result", function() 76 | local input = { 1, 2, 3, 4, 5, 6 } 77 | local result = table_util.slice(input, 4) 78 | 79 | assert.are.same(result, { 4, 5, 6 }) 80 | end) 81 | 82 | it("returns offset result", function() 83 | local input = { 1, 2, 3, 4, 5, 6 } 84 | local result = table_util.slice(input, 1, 3) 85 | 86 | assert.are.same(result, { 1, 2, 3 }) 87 | end) 88 | 89 | it("limits end to table length", function() 90 | local input = { 1, 2, 3 } 91 | local result = table_util.slice(input, 4) 92 | 93 | assert.are.same(result, {}) 94 | end) 95 | 96 | it("returns midsection of input", function() 97 | local input = { 1, 2, 3, 4, 5, 6 } 98 | local result = table_util.slice(input, 3, 4) 99 | 100 | assert.are.same(result, { 3, 4 }) 101 | end) 102 | end) 103 | 104 | describe("binSearch", function() 105 | it("returns nil for an empty table", function() 106 | assert.is_nil(table_util.binSearch({}, 5)) 107 | end) 108 | 109 | it("returns first index over search value for 1 element table", function() 110 | local t = { 5 } 111 | assert.is_equal(1, table_util.binSearch(t, 2)) 112 | end) 113 | 114 | it("returns first index over search value for 2 element table", function() 115 | local t = { 5, 10 } 116 | assert.is_equal(2, table_util.binSearch(t, 7)) 117 | assert.is_equal(1, table_util.binSearch(t, 2)) 118 | end) 119 | 120 | it("returns nil if all values are below the search value", function() 121 | local t = { 5, 10, 15 } 122 | assert.is_nil(table_util.binSearch(t, 20)) 123 | end) 124 | end) 125 | end) 126 | -------------------------------------------------------------------------------- /hardcover/lib/hardcover_settings.lua: -------------------------------------------------------------------------------- 1 | local KoreaderVersion = require("version") 2 | local LuaSettings = require("luasettings") 3 | 4 | local _t = require("hardcover/lib/table_util") 5 | local SETTING = require("hardcover/lib/constants/settings") 6 | 7 | local HardcoverSettings = {} 8 | HardcoverSettings.__index = HardcoverSettings 9 | 10 | function HardcoverSettings:new(path, ui) 11 | local o = {} 12 | setmetatable(o, self) 13 | 14 | self.settings = LuaSettings:open(path) 15 | self.ui = ui 16 | self.subscribers = {} 17 | 18 | if KoreaderVersion:getNormalizedCurrentVersion() < 202403010000 then 19 | if self.settings:readSetting(SETTING.COMPATIBILITY_MODE) == nil then 20 | self:updateSetting(SETTING.COMPATIBILITY_MODE, true) 21 | end 22 | end 23 | 24 | return o 25 | end 26 | 27 | function HardcoverSettings:readSetting(key) 28 | return self.settings:readSetting(key) 29 | end 30 | 31 | function HardcoverSettings:readBookSettings(filename) 32 | local books = self.settings:readSetting("books") 33 | if not books then 34 | return {} 35 | end 36 | 37 | return books[filename] 38 | end 39 | 40 | function HardcoverSettings:readBookSetting(filename, key) 41 | if not filename then 42 | return 43 | end 44 | 45 | local settings = self:readBookSettings(filename) 46 | if settings then 47 | return settings[key] 48 | end 49 | end 50 | 51 | function HardcoverSettings:updateBookSetting(filename, config) 52 | local books = self.settings:readSetting("books", {}) 53 | if not books[filename] then 54 | books[filename] = {} 55 | end 56 | local book_setting = books[filename] 57 | local original_value = { table.unpack(book_setting) } 58 | for k, v in pairs(config) do 59 | if k == "_delete" then 60 | for _, name in ipairs(v) do 61 | book_setting[name] = nil 62 | end 63 | else 64 | book_setting[k] = v 65 | end 66 | end 67 | 68 | self.settings:flush() 69 | 70 | self:notify(SETTING.BOOKS, { filename = filename, config = config }, original_value) 71 | end 72 | 73 | function HardcoverSettings:updateSetting(key, value) 74 | local original_value = self.settings:readSetting(key) 75 | self.settings:saveSetting(key, value) 76 | 77 | self.settings:flush() 78 | 79 | self:notify(key, value, original_value) 80 | end 81 | 82 | function HardcoverSettings:notify(key, value, original_value) 83 | for _, cb in ipairs(self.subscribers) do 84 | cb(key, value, original_value) 85 | end 86 | end 87 | 88 | function HardcoverSettings:subscribe(cb) 89 | table.insert(self.subscribers, cb) 90 | end 91 | 92 | function HardcoverSettings:unsubscribe(cb) 93 | local new_subscribers = {} 94 | for _, original_cb in ipairs(self.subscribers) do 95 | if original_cb ~= cb then 96 | table.insert(new_subscribers, original_cb) 97 | end 98 | end 99 | self.subscribers = new_subscribers 100 | end 101 | 102 | function HardcoverSettings:setSync(value) 103 | self:updateBookSetting(self.ui.document.file, { sync = value == true }) 104 | end 105 | 106 | function HardcoverSettings:setTrackMethod(method) 107 | self:updateSetting(SETTING.TRACK_METHOD, method) 108 | end 109 | 110 | function HardcoverSettings:editionLinked() 111 | return self:getLinkedEditionId() ~= nil 112 | end 113 | 114 | function HardcoverSettings:readLinked() 115 | return self:readBookSetting(self.ui.document.file, "read_id") ~= nil 116 | end 117 | 118 | function HardcoverSettings:bookLinked() 119 | return self:getLinkedBookId() ~= nil 120 | end 121 | 122 | function HardcoverSettings:getFilePath() 123 | return _t.dig(self, "ui", "document", "file") 124 | end 125 | 126 | function HardcoverSettings:getLinkedTitle() 127 | return self:readBookSetting(self:getFilePath(), "title") 128 | end 129 | 130 | function HardcoverSettings:getLinkedBookId() 131 | return self:readBookSetting(self:getFilePath(), "book_id") 132 | end 133 | 134 | function HardcoverSettings:getLinkedEditionFormat() 135 | return self:readBookSetting(self:getFilePath(), "edition_format") 136 | end 137 | 138 | function HardcoverSettings:getLinkedEditionId() 139 | return self:readBookSetting(self:getFilePath(), "edition_id") 140 | end 141 | 142 | function HardcoverSettings:fileSyncEnabled(file) 143 | if not file then 144 | return false 145 | end 146 | 147 | local sync_value = self:readBookSetting(file, "sync") 148 | if sync_value == nil then 149 | sync_value = self.settings:readSetting(SETTING.ALWAYS_SYNC) 150 | end 151 | return sync_value == true 152 | end 153 | 154 | function HardcoverSettings:syncEnabled() 155 | return self:fileSyncEnabled(self:getFilePath()) 156 | end 157 | 158 | function HardcoverSettings:autolinkEnabled() 159 | for _, setting in ipairs(SETTING.AUTOLINK_OPTIONS) do 160 | if self.settings:readSetting(setting) then 161 | return true 162 | end 163 | end 164 | 165 | return false 166 | end 167 | 168 | function HardcoverSettings:pages() 169 | return self:readBookSetting(self:getFilePath(), "pages") 170 | end 171 | 172 | function HardcoverSettings:trackFrequency() 173 | return self.settings:readSetting(SETTING.TRACK_FREQUENCY) or 5 174 | end 175 | 176 | function HardcoverSettings:trackPercentageInterval() 177 | return self.settings:readSetting(SETTING.TRACK_PERCENTAGE) or 10 178 | end 179 | 180 | function HardcoverSettings:trackByTime() 181 | local setting = self.settings:readSetting(SETTING.TRACK_METHOD) 182 | return setting == nil or setting == SETTING.TRACK.FREQUENCY 183 | end 184 | 185 | function HardcoverSettings:trackByProgress() 186 | return self.settings:readSetting(SETTING.TRACK_METHOD) == SETTING.TRACK.PROGRESS 187 | end 188 | 189 | function HardcoverSettings:changeTrackPercentageInterval(percent) 190 | self:updateSetting(SETTING.TRACK_PERCENTAGE, percent) 191 | end 192 | 193 | function HardcoverSettings:compatibilityMode() 194 | return self.settings:readSetting(SETTING.COMPATIBILITY_MODE) == true 195 | end 196 | 197 | return HardcoverSettings 198 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ### Added 6 | 7 | * Plugin will now consider the `hardcover-slug` ebook identifier in addition to `hardcover` identifier (by [@yd4dev](https://github.com/yd4dev)) 8 | 9 | ## 0.2.0 (2025-11-22) 10 | 11 | ### Added 12 | 13 | * Publisher page labels will now be used for reading progress without translation 14 | * Percentage calculation used to determine when to create new journal entries now uses the page 15 | label divided by edition page count. This is weird but ensures that journal reading percentage is close to the 16 | selected interval 17 | * Plugin will now ignore page mapping if publisher page labels are disabled in KOReader 18 | 19 | ### Fixes 20 | 21 | * Switch to `socket.http` implementation to better support proxy usage 22 | 23 | ### Chores 24 | 25 | * Fix zip release directory structure 26 | 27 | ## 0.1.3 (2025-09-10) 28 | 29 | ### Added 30 | 31 | * Register action to immediately update reading 32 | progress [#19](https://github.com/Billiam/hardcoverapp.koplugin/issues/19) 33 | * Add event to open journal entry dialog [#27](https://github.com/Billiam/hardcoverapp.koplugin/issues/27) 34 | 35 | ## 0.1.2 (2025-05-13) 36 | 37 | ### Added 38 | 39 | * Hardcover menu now visible in both reading view and file manager view 40 | * Sort ebooks above physical books in edition list 41 | * Support [airplane mode plugin](https://github.com/kodermike/airplanemode.koplugin) 42 | 43 | ### Fixes 44 | 45 | * Remove 50 book limit from edition selection 46 | 47 | ## 0.1.1 (2025-04-12) 48 | 49 | ### Added 50 | 51 | * Reduce data requested from search API endpoint 52 | 53 | ### Fixes 54 | 55 | * Fix missing invalid API key warning after request failure 56 | 57 | ### Chores 58 | 59 | * Remove dependency on coverbrowser plugin 60 | 61 | ## 0.1.0 (2025-03-21) 62 | 63 | ### Added 64 | 65 | * Prompt to enable wifi if needed before opening journal dialog 66 | 67 | ### Chores 68 | 69 | * Include user agent in requests to hardcover API 70 | 71 | ### Fixes 72 | 73 | * Always show book format and reader count in list view 74 | * Fix potential crash when document is no longer available when fetching book cache 75 | * Fix crash at some font sizes when using compatibility mode and searching for books with long author names 76 | * Fix compatibility mode not displaying authors at some font sizes 77 | * Fix compatibility mode not displaying edition type at some font sizes 78 | 79 | ## 0.0.8 (2025-01-16) 80 | 81 | ### Added 82 | 83 | * Display edition language and series in book searches 84 | * Add option to turn on/off wifi automatically for background updates on some devices where possible 85 | * Changed manual page update dialog to allow updating by document page or hardcover page with synchronized display 86 | 87 | ### Fixes 88 | 89 | * Fix crash when navigating to previous page in search menu after images have loaded 90 | * Fix crash related to book settings when active document has been closed 91 | * Fall back to less specific reading format when edition format is unavailable 92 | * Fix ISBN values with hyphens being ignored by automatic book linking 93 | * Fix pages exceeding a document's page map being treated as lower numbers than previous page 94 | 95 | ### Chores 96 | 97 | * Renamed lib directory and config.lua to prevent conflicts with other plugins 98 | 99 | ### ⚠️ Upgrading 100 | 101 | The plugin now looks for `hardcover_config.lua` instead of `config.lua`. Rename this file (which contains your API key) 102 | on your device. 103 | 104 | ## 0.0.7 (2024-12-30) 105 | 106 | ### Added 107 | 108 | * Added option to update books by percentage completed rather than timed updates 109 | * Display error and disable some functionality when Hardcover API indicates that API key is not valid, in preparation 110 | for [upcoming API key reset](https://github.com/Billiam/hardcoverapp.koplugin/issues/6) 111 | * Allow linking books, enabling/disabling book tracking from KOReader's gesture manager 112 | 113 | ### Fixes 114 | 115 | * Fix crash when linking book from hardcover menu 116 | * Fix automatic book linking not working unless track progress (or always track progress) already set 117 | * Fix manual and automatic book linking not working for hardcover identifiers 118 | * Fix failure to mark book as read when end of book action displays a dialog 119 | * Fix a crash when searching without an internet connection 120 | * Fix page update tracking not working correctly when using "always track progress" setting 121 | * Fix off-by-one page number issue when document contains a page map 122 | * Fix unable to set edition if that edition already set in hardcover 123 | 124 | ### Chores 125 | 126 | * Update default edition selection in journal dialog to use multiple API calls instead of one due to upcoming Hardcover 127 | API limits 128 | * Fetch book authors from cached column in Hardcover API 129 | 130 | ## 0.0.6 (2024-12-10) 131 | 132 | ### Added 133 | 134 | * Added compatibility mode with reduced detail in search dialog for incompatible versions of KOReader 135 | 136 | ### Fixes 137 | 138 | * Fix crash when selecting specific edition in journal dialog 139 | 140 | ## 0.0.5 (2024-12-04) 141 | 142 | ### Fixes 143 | 144 | * Fix failed identifier parsing by Hardcover slug 145 | * Fix error when searching for books by Hardcover identifiers 146 | * Fix note content not saving depending on last focused field 147 | * Fix note failing to save without tags 148 | 149 | ## 0.0.4 (2024-12-01) 150 | 151 | ### Fixes 152 | 153 | * Fix error when sorting books in Hardcover search 154 | 155 | ## 0.0.3 (2024-11-29) 156 | 157 | ### Fixes 158 | 159 | * Fixed autolink failing for Hardcover identifiers and title 160 | * Fixed autolink not displaying success notification 161 | 162 | ## 0.0.2 (2024-11-27) 163 | 164 | ### Fixes 165 | 166 | * Increased default tracking frequency to every 5 minutes 167 | * Skip book data caching if not currently viewing a document 168 | * Fix syntax error in suspense listener 169 | * Only eager cache book data when book tracking enabled (for page updates) 170 | * Fix errors when device resumed without an active document 171 | 172 | ## 0.0.1 (2024-11-24) 173 | 174 | Initial release 175 | -------------------------------------------------------------------------------- /hardcover/lib/hardcover.lua: -------------------------------------------------------------------------------- 1 | -- wrapper around hardcover_api to add higher level methods 2 | local _ = require("gettext") 3 | local logger = require("logger") 4 | local util = require("util") 5 | 6 | local UIManager = require("ui/uimanager") 7 | 8 | local Notification = require("ui/widget/notification") 9 | 10 | local Api = require("hardcover/lib/hardcover_api") 11 | local Book = require("hardcover/lib/book") 12 | local User = require("hardcover/lib/user") 13 | 14 | local SETTING = require("hardcover/lib/constants/settings") 15 | 16 | local cache = {} 17 | 18 | local Hardcover = {} 19 | Hardcover.__index = Hardcover 20 | 21 | function Hardcover:new(o) 22 | return setmetatable(o, self) 23 | end 24 | 25 | function Hardcover:showLinkBookDialog(force_search, link_callback) 26 | local search_value, books, err = self:findBookOptions(force_search) 27 | 28 | if err then 29 | logger.err(err) 30 | return 31 | end 32 | 33 | self.dialog_manager:buildSearchDialog( 34 | "Select book", 35 | books, 36 | { 37 | book_id = self.settings:getLinkedBookId() 38 | }, 39 | function(book) 40 | self:linkBook(book) 41 | link_callback(book) 42 | end, 43 | function(search) 44 | self.dialog_manager:updateSearchResults(search) 45 | return true 46 | end, 47 | search_value 48 | ) 49 | end 50 | 51 | function Hardcover:updateCurrentBookStatus(status, privacy_setting_id) 52 | self.cache:updateBookStatus(self.ui.document.file, status, privacy_setting_id) 53 | if not self.state.book_status.id then 54 | self.dialog_manager:showError("Book status could not be updated") 55 | end 56 | end 57 | 58 | function Hardcover:changeBookVisibility(visibility) 59 | self.cache:cacheUserBook() 60 | 61 | if self.state.book_status.id then 62 | self:updateCurrentBookStatus(self.state.book_status.status_id, visibility) 63 | end 64 | end 65 | 66 | function Hardcover:linkBook(book) 67 | local filename = self.ui.document.file 68 | 69 | local delete = {} 70 | local clear_keys = { "book_id", "edition_id", "edition_format", "pages", "title" } 71 | for _, key in ipairs(clear_keys) do 72 | if book[key] == nil then 73 | table.insert(delete, key) 74 | end 75 | end 76 | 77 | local new_settings = { 78 | book_id = book.book_id, 79 | edition_id = book.edition_id, 80 | edition_format = Book:editionFormatName(book.edition_format, book.reading_format_id), 81 | pages = book.pages, 82 | title = book.title, 83 | _delete = delete 84 | } 85 | 86 | self.settings:updateBookSetting(filename, new_settings) 87 | self.cache:cacheUserBook() 88 | 89 | if book.book_id and self.state.book_status.id then 90 | if new_settings.edition_id and new_settings.edition_id ~= self.state.book_status.edition_id then 91 | -- update edition 92 | self.state.book_status = Api:updateUserBook( 93 | new_settings.book_id, 94 | self.state.book_status.status_id, 95 | self.state.book_status.privacy_setting_id, 96 | new_settings.edition_id 97 | ) or {} 98 | end 99 | end 100 | 101 | return true 102 | end 103 | 104 | -- could be moved to book search model 105 | function Hardcover:findBookOptions(force_search) 106 | local props = self.ui.document:getProps() 107 | local identifiers = Book:parseIdentifiers(props.identifiers) 108 | local user_id = User:getId() 109 | 110 | if not force_search then 111 | local book_lookup = Api:findBookByIdentifiers(identifiers, user_id) 112 | if book_lookup then 113 | return nil, { book_lookup } 114 | end 115 | end 116 | 117 | local title = props.title 118 | if not title or title == "" then 119 | local _dir, path = util.splitFilePathName(self.ui.document.file) 120 | local filename, _suffix = util.splitFileNameSuffix(path) 121 | 122 | title = filename:gsub("_", " ") 123 | end 124 | local result, err = Api:findBooks(title, props.authors, user_id) 125 | return title, result, err 126 | end 127 | 128 | function Hardcover:autolinkBook(book) 129 | if not book then 130 | return 131 | end 132 | 133 | local linked = self:linkBook(book) 134 | if linked then 135 | UIManager:show(Notification:new { 136 | text = _("Linked to: " .. book.title), 137 | }) 138 | end 139 | end 140 | 141 | function Hardcover:linkBookByIsbn(identifiers) 142 | if identifiers.isbn_10 or identifiers.isbn_13 then 143 | local user_id = User:getId() 144 | local book_lookup = Api:findBookByIdentifiers({ 145 | isbn_10 = identifiers.isbn_10, 146 | isbn_13 = identifiers.isbn_13 147 | }, 148 | user_id 149 | ) 150 | if book_lookup then 151 | self:autolinkBook(book_lookup) 152 | return true 153 | end 154 | end 155 | end 156 | 157 | function Hardcover:linkBookByHardcover(identifiers) 158 | if identifiers.book_slug or identifiers.edition_id then 159 | local user_id = User:getId() 160 | local book_lookup = Api:findBookByIdentifiers( 161 | { book_slug = identifiers.book_slug, edition_id = identifiers.edition_id }, user_id) 162 | if book_lookup then 163 | self:autolinkBook(book_lookup) 164 | return true 165 | end 166 | end 167 | end 168 | 169 | function Hardcover:linkBookByTitle() 170 | local props = self.ui.document:getProps() 171 | 172 | local results = Api:findBooks(props.title, props.authors, User:getId()) 173 | if results and #results > 0 then 174 | self:autolinkBook(results[1]) 175 | return true 176 | end 177 | end 178 | 179 | function Hardcover:tryAutolink() 180 | if self.settings:bookLinked() then 181 | return 182 | end 183 | 184 | local props = self.ui.document:getProps() 185 | 186 | local identifiers = Book:parseIdentifiers(props.identifiers) 187 | if ((identifiers.isbn_10 or identifiers.isbn_13) and self.settings:readSetting(SETTING.LINK_BY_ISBN)) 188 | or ((identifiers.book_slug or identifiers.edition_id) and self.settings:readSetting(SETTING.LINK_BY_HARDCOVER)) 189 | or (props.title and self.settings:readSetting(SETTING.LINK_BY_TITLE)) then 190 | self.wifi:withWifi(function() 191 | self:_runAutolink(identifiers) 192 | end) 193 | end 194 | end 195 | 196 | function Hardcover:_runAutolink(identifiers) 197 | local linked = false 198 | if self.settings:readSetting(SETTING.LINK_BY_ISBN) then 199 | linked = self:linkBookByIsbn(identifiers) 200 | end 201 | 202 | if not linked and self.settings:readSetting(SETTING.LINK_BY_HARDCOVER) then 203 | linked = self:linkBookByHardcover(identifiers) 204 | end 205 | 206 | if not linked and self.settings:readSetting(SETTING.LINK_BY_TITLE) then 207 | self:linkBookByTitle() 208 | end 209 | end 210 | 211 | return Hardcover 212 | -------------------------------------------------------------------------------- /hardcover/lib/ui/dialog_manager.lua: -------------------------------------------------------------------------------- 1 | local _ = require("gettext") 2 | local json = require("json") 3 | 4 | local UIManager = require("ui/uimanager") 5 | 6 | local InfoMessage = require("ui/widget/infomessage") 7 | 8 | local Api = require("hardcover/lib/hardcover_api") 9 | local Book = require("hardcover/lib/book") 10 | local User = require("hardcover/lib/user") 11 | 12 | local HARDCOVER = require("hardcover/lib/constants/hardcover") 13 | 14 | local JournalDialog = require("hardcover/lib/ui/journal_dialog") 15 | local SearchDialog = require("hardcover/lib/ui/search_dialog") 16 | 17 | local DialogManager = {} 18 | DialogManager.__index = DialogManager 19 | 20 | function DialogManager:new(o) 21 | return setmetatable(o or {}, self) 22 | end 23 | 24 | local function mapJournalData(data) 25 | local result = { 26 | book_id = data.book_id, 27 | event = data.event_type, 28 | entry = data.text, 29 | edition_id = data.edition_id, 30 | privacy_setting_id = data.privacy_setting_id, 31 | tags = json.util.InitArray({}) 32 | } 33 | 34 | if #data.tags > 0 then 35 | for _, tag in ipairs(data.tags) do 36 | table.insert(result.tags, { category = HARDCOVER.CATEGORY.TAG, tag = tag, spoiler = false }) 37 | end 38 | end 39 | if #data.hidden_tags > 0 then 40 | for _, tag in ipairs(data.hidden_tags) do 41 | table.insert(result.tags, { category = HARDCOVER.CATEGORY.TAG, tag = tag, spoiler = true }) 42 | end 43 | end 44 | 45 | if data.page then 46 | result.metadata = { 47 | position = { 48 | type = "pages", 49 | value = data.page, 50 | possible = data.pages 51 | } 52 | } 53 | end 54 | 55 | return result 56 | end 57 | 58 | function DialogManager:buildSearchDialog(title, items, active_item, book_callback, search_callback, search) 59 | local callback = function(book) 60 | self.search_dialog:onClose() 61 | book_callback(book) 62 | end 63 | 64 | if self.search_dialog then 65 | self.search_dialog:free() 66 | end 67 | 68 | self.search_dialog = SearchDialog:new { 69 | compatibility_mode = self.settings:compatibilityMode(), 70 | title = title, 71 | items = items, 72 | active_item = active_item, 73 | select_book_cb = callback, 74 | search_callback = search_callback, 75 | search_value = search 76 | } 77 | 78 | UIManager:show(self.search_dialog) 79 | end 80 | 81 | function DialogManager:buildBookListDialog(title, items, icon_callback, disable_wifi_after) 82 | if self.search_dialog then 83 | self.search_dialog:free() 84 | end 85 | 86 | self.search_dialog = SearchDialog:new { 87 | compatibility_mode = self.settings:compatibilityMode(), 88 | title = title, 89 | items = items, 90 | left_icon_callback = icon_callback, 91 | left_icon = "cre.render.reload", 92 | close_callback = function() 93 | if disable_wifi_after then 94 | UIManager:nextTick(function() 95 | self.wifi:wifiDisablePrompt() 96 | end) 97 | end 98 | end 99 | } 100 | 101 | UIManager:show(self.search_dialog) 102 | end 103 | 104 | function DialogManager:updateSearchResults(search) 105 | local books, error = Api:findBooks(search, nil, User:getId()) 106 | if error then 107 | if not Api.enabled then 108 | UIManager:close(self.search_dialog) 109 | end 110 | 111 | return 112 | end 113 | 114 | self.search_dialog:setItems(self.search_dialog.title, books, self.search_dialog.active_item) 115 | self.search_dialog.search_value = search 116 | end 117 | 118 | function DialogManager:journalEntryForm(text, document, page, remote_pages, mapped_page, event_type) 119 | local settings = self.settings:readBookSettings(document.file) or {} 120 | local edition_id = settings.edition_id 121 | local edition_format = settings.edition_format 122 | 123 | if not edition_id then 124 | local edition = Api:findDefaultEdition(settings.book_id, User:getId()) 125 | if edition then 126 | edition_id = edition.id 127 | edition_format = Book:editionFormatName(edition.edition_format, edition.reading_format_id) 128 | remote_pages = edition.pages 129 | end 130 | end 131 | 132 | mapped_page = mapped_page or self.page_mapper:getMappedPage(page, document:getPageCount(), remote_pages) 133 | local wifi_was_off = false 134 | local dialog 135 | dialog = JournalDialog:new { 136 | input = text, 137 | event_type = event_type or "note", 138 | book_id = settings.book_id, 139 | edition_id = edition_id, 140 | edition_format = edition_format, 141 | page = mapped_page, 142 | pages = remote_pages, 143 | save_dialog_callback = function(book_data) 144 | local api_data = mapJournalData(book_data) 145 | local result = Api:createJournalEntry(api_data) 146 | if result then 147 | UIManager:nextTick(function() 148 | UIManager:close(dialog) 149 | 150 | if wifi_was_off then 151 | UIManager:nextTick(function() 152 | self.wifi:wifiDisablePrompt() 153 | end) 154 | end 155 | end) 156 | 157 | return true, _(event_type .. " saved") 158 | else 159 | return false, _(event_type .. " could not be saved") 160 | end 161 | end, 162 | select_edition_callback = function() 163 | -- TODO: could be moved into child dialog but needs access to build dialog, which needs dialog again 164 | dialog:onCloseKeyboard() 165 | 166 | local editions = Api:findEditions(self.settings:getLinkedBookId(), User:getId()) 167 | self:buildSearchDialog( 168 | "Select edition", 169 | editions, 170 | { edition_id = dialog.edition_id }, 171 | function(edition) 172 | if not edition then 173 | return 174 | end 175 | 176 | dialog:setEdition( 177 | edition.edition_id, 178 | Book:editionFormatName(edition.edition_format, edition.reading_format_id), 179 | edition.pages 180 | ) 181 | end 182 | ) 183 | end, 184 | 185 | close_callback = function() 186 | if wifi_was_off then 187 | UIManager:nextTick(function() 188 | self.wifi:wifiDisablePrompt() 189 | end) 190 | end 191 | end 192 | } 193 | -- scroll to the bottom instead of overscroll displayed 194 | dialog._input_widget:scrollToBottom() 195 | 196 | self.wifi:wifiPrompt(function(wifi_enabled) 197 | wifi_was_off = wifi_enabled 198 | 199 | UIManager:show(dialog) 200 | dialog:onShowKeyboard() 201 | end) 202 | end 203 | 204 | function DialogManager:showError(err) 205 | UIManager:show(InfoMessage:new { 206 | text = err, 207 | icon = "notice-warning", 208 | timeout = 2 209 | }) 210 | end 211 | 212 | return DialogManager 213 | -------------------------------------------------------------------------------- /hardcover/lib/ui/journal_dialog.lua: -------------------------------------------------------------------------------- 1 | local Button = require("ui/widget/button") 2 | local Device = require("device") 3 | local Font = require("ui/font") 4 | local FrameContainer = require("ui/widget/container/framecontainer") 5 | local HorizontalGroup = require("ui/widget/horizontalgroup") 6 | local HorizontalSpan = require("ui/widget/horizontalspan") 7 | local InputDialog = require("ui/widget/inputdialog") 8 | local InputText = require("ui/widget/inputtext") 9 | local Size = require("ui/size") 10 | local SpinWidget = require("ui/widget/spinwidget") 11 | local TextWidget = require("ui/widget/textwidget") 12 | local ToggleSwitch = require("ui/widget/toggleswitch") 13 | local TextBoxWidget = require("ui/widget/textboxwidget") 14 | local UIManager = require("ui/uimanager") 15 | local _ = require("gettext") 16 | local logger = require("logger") 17 | 18 | local JOURNAL_NOTE = "note" 19 | local JOURNAL_QUOTE = "quote" 20 | 21 | local JournalDialog = InputDialog:extend { 22 | allow_newline = true, 23 | results = {}, 24 | title = "Create journal entry", 25 | padding = 10, 26 | 27 | event_type = JOURNAL_NOTE, 28 | pages = _("???"), 29 | page = nil, 30 | edition_id = nil, 31 | edition_type = nil, 32 | tags = {}, 33 | hidden_tags = {}, 34 | privacy_setting_id = 1, 35 | select_edition_callback = nil 36 | } 37 | 38 | local function comma_split(text) 39 | local result = {} 40 | for str in string.gmatch(text, "([^,]+)") do 41 | local trimmed = str:match("^%s*(.-)%s*$") 42 | if trimmed ~= "" then 43 | table.insert(result, trimmed) 44 | end 45 | end 46 | 47 | return result 48 | end 49 | 50 | function JournalDialog:init() 51 | self:setModified() 52 | 53 | local text_widget = TextBoxWidget:new { 54 | text = "I\n\nj", 55 | face = Font:getFace("smallinfofont"), 56 | for_measurement_only = true, 57 | } 58 | self.text_height = text_widget:getTextHeight() 59 | 60 | self.save_callback = function() 61 | return self.save_dialog_callback({ 62 | book_id = self.book_id, 63 | edition_id = self.edition_id, 64 | text = self.note_input:getText(), 65 | page = self.page, 66 | pages = self.pages, 67 | event_type = self.event_type, 68 | privacy_setting_id = self.privacy_setting_id, 69 | tags = comma_split(self.tag_field.text), 70 | hidden_tags = comma_split(self.hidden_tag_field.text) 71 | }) 72 | end 73 | 74 | InputDialog.init(self) 75 | self.note_input = self._input_widget 76 | 77 | local journal_type 78 | journal_type = ToggleSwitch:new { 79 | width = self.width - 30, 80 | margin = 10, 81 | margin_bottom = 20, 82 | alternate = false, 83 | 84 | toggle = { _("Note"), _("Quote") }, 85 | values = { JOURNAL_NOTE, JOURNAL_QUOTE }, 86 | config = self, 87 | callback = function(position) 88 | self.event_type = position == 1 and JOURNAL_NOTE or JOURNAL_QUOTE 89 | end 90 | } 91 | journal_type:setPosition(self.event_type == JOURNAL_NOTE and 1 or 2) 92 | 93 | local privacy_label = TextWidget:new { 94 | text = "Privacy: ", 95 | face = Font:getFace("cfont", 16) 96 | } 97 | 98 | local privacy_switch 99 | privacy_switch = ToggleSwitch:new { 100 | width = self.width - 40 - privacy_label:getWidth(), 101 | toggle = { _("Public"), _("Follows"), _("Private") }, 102 | values = { 1, 2, 3 }, 103 | alternate = false, 104 | config = self, 105 | callback = function(position) 106 | self.privacy_setting_id = position 107 | end 108 | } 109 | privacy_switch:setPosition(self.privacy_setting_id) 110 | 111 | local privacy_row = HorizontalGroup:new { 112 | privacy_label, 113 | HorizontalSpan:new { 114 | width = 10 115 | }, 116 | privacy_switch 117 | } 118 | 119 | self.tag_field = InputText:new { 120 | width = self.width - Size.padding.default - Size.border.inputtext - 30, 121 | input = table.concat(self.tags, ", "), 122 | focused = false, 123 | show_parent = self, 124 | parent = self, 125 | hint = _("Tags (comma separated)"), 126 | face = Font:getFace("cfont", 16), 127 | } 128 | self.hidden_tag_field = InputText:new { 129 | width = self.width - Size.padding.default - Size.border.inputtext - 30, 130 | input = table.concat(self.hidden_tags, ", "), 131 | focused = false, 132 | show_parent = self, 133 | parent = self, 134 | hint = _("Hidden tags (comma separated)"), 135 | face = Font:getFace("cfont", 16), 136 | } 137 | 138 | self.page_button = Button:new { 139 | text = "page", 140 | text_func = function() 141 | return _("page " .. self.page .. " of " .. self.pages) 142 | end, 143 | width = (self.width - 10 - 30) / 2, 144 | text_font_size = 16, 145 | bordersize = Size.border.thin, 146 | callback = function() 147 | local spinner = SpinWidget:new { 148 | value = self.page, 149 | value_min = 0, 150 | value_max = self.pages, 151 | value_step = 1, 152 | value_hold_step = 20, 153 | ok_text = _("Set page"), 154 | title_text = _("Set current page"), 155 | callback = function(spin) 156 | self.page = spin.value 157 | self.page_button:setText(self.page_button.text_func(self), self.page_button.width) 158 | end 159 | } 160 | self:onCloseKeyboard() 161 | UIManager:show(spinner) 162 | end 163 | } 164 | 165 | self.edition_button = Button:new { 166 | text = "edition", 167 | text_func = function() 168 | return self.edition_format or "Physical Book" 169 | end, 170 | width = (self.width - 10 - 30) / 2, 171 | text_font_size = 16, 172 | bordersize = Size.border.thin, 173 | callback = self.select_edition_callback 174 | } 175 | 176 | local edition_row = FrameContainer:new { 177 | padding_top = 10, 178 | padding_bottom = 8, 179 | bordersize = 0, 180 | HorizontalGroup:new { 181 | self.edition_button, 182 | HorizontalSpan:new { 183 | width = 10 184 | }, 185 | self.page_button 186 | } 187 | } 188 | 189 | self:addWidget(journal_type) 190 | self:addWidget(edition_row) 191 | self:addWidget(privacy_row) 192 | self:addWidget(self.tag_field) 193 | self:addWidget(self.hidden_tag_field) 194 | end 195 | 196 | function JournalDialog:onConfigChoose(values, name, event, args, position) 197 | UIManager:tickAfterNext(function() 198 | -- TODO regional refresh 199 | UIManager:setDirty(self.dialog, "ui") 200 | end) 201 | end 202 | 203 | function JournalDialog:setEdition(edition_id, edition_format, edition_pages) 204 | self.edition_id = edition_id 205 | self.edition_format = edition_format 206 | self.pages = edition_pages or self.pages 207 | 208 | self.page_button:setText(self.page_button.text_func(), self.page_button.width) 209 | self.edition_button:setText(self.edition_button.text_func(), self.edition_button.width) 210 | end 211 | 212 | function JournalDialog:setModified() 213 | if self.input then 214 | self._text_modified = true 215 | if self.button_table then 216 | self.button_table:getButtonById("save"):enable() 217 | self:refreshButtons() 218 | end 219 | end 220 | end 221 | 222 | -- copied from MultiInputDialog.lua 223 | function JournalDialog:onSwitchFocus(inputbox) 224 | -- unfocus current inputbox 225 | self._input_widget:unfocus() 226 | -- and close its existing keyboard (via InputDialog's thin wrapper around _input_widget's own method) 227 | self:onCloseKeyboard() 228 | 229 | UIManager:setDirty(nil, function() 230 | return "ui", self.dialog_frame.dimen 231 | end) 232 | 233 | -- focus new inputbox 234 | self._input_widget = inputbox 235 | self._input_widget:focus() 236 | self.focused_field_idx = inputbox.idx 237 | 238 | if (Device:hasKeyboard() or Device:hasScreenKB()) and G_reader_settings:isFalse("virtual_keyboard_enabled") then 239 | -- do not load virtual keyboard when user is hiding it. 240 | return 241 | end 242 | -- Otherwise make sure we have a (new) visible keyboard 243 | self:onShowKeyboard() 244 | end 245 | 246 | return JournalDialog 247 | -------------------------------------------------------------------------------- /hardcover/lib/ui/search_dialog.lua: -------------------------------------------------------------------------------- 1 | local CenterContainer = require("ui/widget/container/centercontainer") 2 | local Device = require("device") 3 | local Geom = require("ui/geometry") 4 | local GestureRange = require("ui/gesturerange") 5 | local InputContainer = require("ui/widget/container/inputcontainer") 6 | local InputDialog = require("ui/widget/inputdialog") 7 | local Menu = require("ui/widget/menu") 8 | local SearchMenu = require("hardcover/lib/ui/search_menu") 9 | local Size = require("ui/size") 10 | local UIManager = require("ui/uimanager") 11 | local _ = require("gettext") 12 | local logger = require("logger") 13 | local _t = require("hardcover/lib/table_util") 14 | 15 | local Screen = Device.screen 16 | 17 | local HardcoverSearchDialog = InputContainer:extend { 18 | width = nil, 19 | bordersize = Size.border.window, 20 | items = {}, 21 | active_item = {}, 22 | select_cb = nil, 23 | title = nil, 24 | search_callback = nil, 25 | left_icon_callback = nil, 26 | left_icon = nil, 27 | search_value = nil, 28 | close_callback = nil, 29 | 30 | compatibility_mode = true 31 | } 32 | 33 | function HardcoverSearchDialog:createListItem(book, active_item) 34 | local info = "" 35 | local title = book.title 36 | local authors = {} 37 | 38 | if book.contributions.author then 39 | table.insert(authors, book.contributions.author) 40 | end 41 | 42 | if #book.contributions > 0 then 43 | for _, a in ipairs(book.contributions) do 44 | table.insert(authors, a.author.name) 45 | end 46 | end 47 | 48 | if book.release_year then 49 | title = title .. " (" .. book.release_year .. ")" 50 | end 51 | 52 | if book.users_count then 53 | info = book.users_count .. " readers" 54 | elseif book.users_read_count then 55 | info = book.users_read_count .. " reads" 56 | end 57 | 58 | local active = active_item and ( 59 | (book.edition_id and book.edition_id == active_item.edition_id) or 60 | (book.book_id == active_item.book_id) 61 | ) 62 | 63 | local result = { 64 | title = title, 65 | mandatory = info, 66 | mandatory_dim = true, 67 | file = "hardcover-" .. book.book_id, 68 | book_id = book.book_id, 69 | edition_id = book.edition_id, 70 | edition_format = book.edition_format, 71 | highlight = active, 72 | } 73 | 74 | if not book.edition_id and _t.dig(book, "book_series", 1, "position") then 75 | result.series = book.book_series[1].series.name 76 | if book.book_series[1].position then 77 | result.series = result.series .. " #" .. book.book_series[1].position 78 | end 79 | end 80 | 81 | if book.language and book.language.code2 then 82 | if self.series then 83 | result.series = " - " .. book.language.code2 84 | else 85 | result.series = book.language.language 86 | end 87 | end 88 | 89 | if book.pages then 90 | result.pages = book.pages 91 | end 92 | 93 | if book.book_series.position then 94 | result.series = book.book_series.series.name 95 | result.series_index = book.book_series.position 96 | end 97 | 98 | if #authors > 0 then 99 | result.authors = table.concat(authors, ", ") 100 | end 101 | 102 | if self.compatibility_mode then 103 | result.text = result.title 104 | result.dim = result.highlight 105 | if book.edition_id then 106 | result.text = result.text .. " - " .. book.filetype 107 | else 108 | if result.authors and result.authors ~= "" then 109 | result.text = result.text .. " - " .. result.authors 110 | end 111 | end 112 | end 113 | 114 | if book.filetype then 115 | result.filetype = book.filetype 116 | end 117 | 118 | if book.cached_image.url then 119 | result.cover_url = book.cached_image.url 120 | result.cover_w = book.cached_image.width 121 | result.cover_h = book.cached_image.height 122 | result.lazy_load_cover = true 123 | end 124 | 125 | return result 126 | end 127 | 128 | function HardcoverSearchDialog:init() 129 | if Device:isTouchDevice() then 130 | self.ges_events.Tap = { 131 | GestureRange:new { 132 | ges = "tap", 133 | range = Geom:new { 134 | x = 0, 135 | y = 0, 136 | w = Screen:getWidth(), 137 | h = Screen:getHeight(), 138 | } 139 | } 140 | } 141 | end 142 | 143 | self.width = self.width or Screen:getWidth() - Screen:scaleBySize(50) 144 | self.width = math.min(self.width, Screen:scaleBySize(600)) 145 | self.height = Screen:getHeight() - Screen:scaleBySize(50) 146 | 147 | local left_icon, left_icon_callback 148 | if self.search_callback then 149 | left_icon = "appbar.search" 150 | left_icon_callback = function() self:search() end 151 | elseif self.left_icon_callback then 152 | left_icon = self.left_icon 153 | left_icon_callback = self.left_icon_callback 154 | end 155 | local menu_class = self.compatibility_mode and Menu or SearchMenu 156 | 157 | self.menu = menu_class:new { 158 | single_line = false, 159 | multilines_show_more_text = true, 160 | title = self.title or "Select book", 161 | fullscreen = true, 162 | item_table = self:parseItems(self.items, self.active_item), 163 | width = self.width, 164 | height = self.height, 165 | title_bar_left_icon = left_icon, 166 | onLeftButtonTap = left_icon_callback, 167 | onMenuSelect = function(menu, book) 168 | if self.select_book_cb then 169 | self.select_book_cb(book) 170 | end 171 | end, 172 | close_callback = function() 173 | self:onClose() 174 | end 175 | } 176 | 177 | self.items = nil 178 | 179 | self.container = CenterContainer:new { 180 | dimen = Screen:getSize(), 181 | self.menu, 182 | } 183 | 184 | self.menu.show_parent = self 185 | 186 | self[1] = self.container 187 | end 188 | 189 | function HardcoverSearchDialog:search() 190 | local search_dialog 191 | search_dialog = InputDialog:new { 192 | title = "New search", 193 | input = self.search_value, 194 | save_button_text = "Search", 195 | buttons = { { 196 | { 197 | text = _("Cancel"), 198 | callback = function() 199 | UIManager:close(search_dialog) 200 | end, 201 | }, 202 | { 203 | text = _("Search"), 204 | -- button with is_enter_default set to true will be 205 | -- triggered after user press the enter key from keyboard 206 | is_enter_default = true, 207 | callback = function() 208 | local text = search_dialog:getInputText() 209 | local result = self.search_callback(text) 210 | if result then 211 | UIManager:close(search_dialog) 212 | end 213 | end, 214 | } 215 | } } 216 | } 217 | 218 | UIManager:show(search_dialog) 219 | search_dialog:onShowKeyboard() 220 | end 221 | 222 | function HardcoverSearchDialog:setTitle(title) 223 | self.menu.title = title 224 | end 225 | 226 | function HardcoverSearchDialog:onClose() 227 | UIManager:close(self) 228 | if self.close_callback then 229 | self.close_callback() 230 | end 231 | 232 | return true 233 | end 234 | 235 | function HardcoverSearchDialog:onTapClose(arg, ges) 236 | if ges.pos:notIntersectWith(self.movable.dimen) then 237 | self:onClose() 238 | end 239 | return true 240 | end 241 | 242 | function HardcoverSearchDialog:parseItems(items, active_item) 243 | return _t.map(items, function(book) 244 | return self:createListItem(book, active_item) 245 | end) 246 | end 247 | 248 | function HardcoverSearchDialog:setItems(title, items, active_item) 249 | if self.menu.halt_image_loading then 250 | self.menu.halt_image_loading() 251 | end 252 | 253 | -- hack: Allow reusing menu (and closing more than once) 254 | self.menu._covermenu_onclose_done = false 255 | local new_item_table = self:parseItems(items, active_item) 256 | if self.menu.item_table then 257 | for _, v in ipairs(self.menu.item_table) do 258 | if v.cover_bb then 259 | v.cover_bb:free() 260 | end 261 | end 262 | end 263 | self.menu:switchItemTable(title, new_item_table) 264 | end 265 | 266 | function HardcoverSearchDialog:onTap(_, ges) 267 | if ges.pos:notIntersectWith(self[1][1].dimen) then 268 | -- Tap outside closes widget 269 | self:onClose() 270 | return true 271 | end 272 | end 273 | 274 | return HardcoverSearchDialog 275 | -------------------------------------------------------------------------------- /spec/lib/page_mapper_spec.lua: -------------------------------------------------------------------------------- 1 | local PageMapper = require("hardcover/lib/page_mapper") 2 | 3 | describe("PageMapper", function() 4 | local ui = function(page_map, use_page_map) 5 | use_page_map = use_page_map == nil and true or use_page_map 6 | 7 | return { 8 | document = { 9 | getPageMap = function() 10 | return page_map 11 | end 12 | }, 13 | pagemap = { 14 | wantsPageLabels = function() 15 | return use_page_map 16 | end 17 | } 18 | } 19 | end 20 | 21 | describe("cachePageMap", function() 22 | it("does not translate page map when document has no page map", function() 23 | local map = nil 24 | local state = {} 25 | 26 | local page_map = PageMapper:new { 27 | state = state, 28 | ui = ui(map, false) 29 | } 30 | 31 | page_map:cachePageMap() 32 | assert.is_nil(state.page_map) 33 | end) 34 | 35 | it("create a table of raw page numbers to canonical book page integers", function() 36 | local map = { 37 | { 38 | page = 1, 39 | label = "i", 40 | }, 41 | { 42 | page = 2, 43 | label = "ii", 44 | }, 45 | { 46 | page = 3, 47 | label = "iii" 48 | }, 49 | { 50 | page = 4, 51 | label = "2" 52 | } 53 | } 54 | 55 | local state = {} 56 | 57 | local page_map = PageMapper:new { 58 | state = state, 59 | ui = ui(map) 60 | } 61 | page_map:cachePageMap() 62 | local expected = { 63 | [1] = 1, 64 | [2] = 1, 65 | [3] = 1, 66 | [4] = 2 67 | } 68 | assert.are.same(expected, state.page_map) 69 | end) 70 | 71 | it("fills gaps in raw page numbers", function() 72 | local map = { 73 | { 74 | page = 1, 75 | label = "i", 76 | }, 77 | { 78 | page = 3, 79 | label = "ii", 80 | }, 81 | { 82 | page = 5, 83 | label = "4" 84 | } 85 | } 86 | 87 | local state = {} 88 | 89 | local page_map = PageMapper:new { 90 | state = state, 91 | ui = ui(map) 92 | } 93 | page_map:cachePageMap() 94 | local expected = { 95 | [1] = 1, 96 | [2] = 1, 97 | [3] = 1, 98 | [4] = 1, 99 | [5] = 4 100 | } 101 | assert.are.same(expected, state.page_map) 102 | end) 103 | 104 | it("maps multiple pages to canonical page integers", function() 105 | local map = { 106 | { 107 | page = 1, 108 | label = "1", 109 | }, 110 | { 111 | page = 2, 112 | label = "1", 113 | }, 114 | { 115 | page = 3, 116 | label = "2" 117 | } 118 | } 119 | 120 | local state = {} 121 | 122 | local page_map = PageMapper:new { 123 | state = state, 124 | ui = ui(map) 125 | } 126 | page_map:cachePageMap() 127 | local expected = { 128 | [1] = 1, 129 | [2] = 1, 130 | [3] = 2, 131 | } 132 | assert.are.same(expected, state.page_map) 133 | end) 134 | end) 135 | 136 | describe("getMappedPage", function() 137 | it("returns the page mapped page if available", function() 138 | local page_map = PageMapper:new { 139 | state = { 140 | page_map = { 141 | [1] = 99 142 | } 143 | }, 144 | ui = ui({}) 145 | } 146 | page_map.use_page_map = true 147 | 148 | assert.are.equal(page_map:getMappedPage(1, 100, 50), 99) 149 | end) 150 | 151 | it("translates local pages to canonical pages", function() 152 | local page_map = PageMapper:new { 153 | state = {}, 154 | ui = ui({}) 155 | } 156 | local current_page = 1 157 | local document_pages = 2 158 | local canonical_pages = 20 159 | 160 | local expected = 10 161 | 162 | assert.are.equal(expected, page_map:getMappedPage(current_page, document_pages, canonical_pages)) 163 | end) 164 | 165 | describe("when local page exceeds mapped pages", function() 166 | it("it returns the edition last page", function() 167 | local page_map = PageMapper:new { 168 | state = { 169 | page_map = {}, 170 | page_map_range = { 171 | last_page = 10, 172 | } 173 | }, 174 | ui = ui({}) 175 | } 176 | page_map.use_page_map = true 177 | 178 | assert.are.equal(page_map:getMappedPage(20, 100, 1000), 1000) 179 | end) 180 | 181 | describe("when edition page is not available", function() 182 | it("returns the cached last page", function() 183 | local page_map = PageMapper:new { 184 | state = { 185 | page_map = {}, 186 | page_map_range = { 187 | last_page = 10, 188 | real_page = 500 189 | } 190 | }, 191 | ui = ui({}) 192 | } 193 | page_map.use_page_map = true 194 | 195 | assert.are.equal(page_map:getMappedPage(20, 100, nil), 500) 196 | end) 197 | end) 198 | end) 199 | end) 200 | 201 | describe("getUnmappedPage", function() 202 | describe("when there is a page map", function() 203 | it("finds the first matching page for a canonical page", function() 204 | local page_map = PageMapper:new { 205 | state = { 206 | page_map = { 207 | [1] = 1, 208 | [2] = 5, 209 | [3] = 5, 210 | [4] = 5, 211 | [5] = 8, 212 | [6] = 9, 213 | [7] = 10, 214 | } 215 | }, 216 | ui = ui({}) 217 | } 218 | page_map.use_page_map = true 219 | 220 | assert.are.equal(page_map:getUnmappedPage(5, 100, 100), 2) 221 | end) 222 | 223 | describe("when there is no exact match", function() 224 | it("returns the first page over the canonical page", function() 225 | local page_map = PageMapper:new { 226 | state = { 227 | page_map = { 228 | [1] = 1, 229 | [2] = 2, 230 | [3] = 5, 231 | [4] = 6, 232 | [5] = 7, 233 | } 234 | }, 235 | ui = ui({}) 236 | } 237 | page_map.use_page_map = true 238 | 239 | assert.are.equal(page_map:getUnmappedPage(3, 100, 100), 3) 240 | end) 241 | end) 242 | end) 243 | 244 | describe("when there is no page map", function() 245 | it("returns the page as a percentage of the total local pages", function() 246 | local page_map = PageMapper:new { 247 | state = {}, 248 | ui = ui({}) 249 | } 250 | 251 | assert.are.equal(page_map:getUnmappedPage(50, 1000, 100), 500) 252 | end) 253 | end) 254 | end) 255 | 256 | describe("getRemotePagePercent", function() 257 | it("returns the percent of the equivalent floored remote page", function() 258 | local page_map = PageMapper:new { 259 | state = {}, 260 | ui = ui({}, false) 261 | } 262 | 263 | local percent, page = page_map:getRemotePagePercent(10, 20, 29) 264 | 265 | assert.are.equal(14 / 29, percent) 266 | assert.are.equal(14, page) 267 | end) 268 | 269 | it("returns a simple percentage if remote page is unavailable", function() 270 | local page_map = PageMapper:new { 271 | state = {}, 272 | ui = ui({}, false) 273 | } 274 | 275 | local percent, page = page_map:getRemotePagePercent(10, 20) 276 | 277 | assert.are.equal(0.5, percent) 278 | assert.are.equal(10, page) 279 | end) 280 | describe("when there is a page map", function() 281 | local map = { 282 | { 283 | page = 20, 284 | label = "50", 285 | }, 286 | { 287 | page = 30, 288 | label = "200" 289 | } 290 | } 291 | 292 | it("returns the percent of mapped page to remote page", function() 293 | local page_map = PageMapper:new { 294 | state = {}, 295 | ui = ui(map) 296 | } 297 | 298 | local percent, page = page_map:getRemotePagePercent(20, 20, 100) 299 | 300 | assert.are.equal(0.5, percent) 301 | assert.are.equal(50, page) 302 | end) 303 | 304 | it("returns the percent of mapped page to document pages if no remote page available", function() 305 | local page_map = PageMapper:new { 306 | state = {}, 307 | ui = ui(map) 308 | } 309 | 310 | local percent, page = page_map:getRemotePagePercent(20, 20) 311 | 312 | assert.are.equal(0.25, percent) 313 | assert.are.equal(50, page) 314 | end) 315 | end) 316 | end) 317 | end) 318 | -------------------------------------------------------------------------------- /hardcover/lib/ui/update_double_spin_widget.lua: -------------------------------------------------------------------------------- 1 | local Blitbuffer = require("ffi/blitbuffer") 2 | local ButtonTable = require("ui/widget/buttontable") 3 | local CenterContainer = require("ui/widget/container/centercontainer") 4 | local Device = require("device") 5 | local FocusManager = require("ui/widget/focusmanager") 6 | local FrameContainer = require("ui/widget/container/framecontainer") 7 | local Geom = require("ui/geometry") 8 | local GestureRange = require("ui/gesturerange") 9 | local Font = require("ui/font") 10 | local HorizontalGroup = require("ui/widget/horizontalgroup") 11 | local MovableContainer = require("ui/widget/container/movablecontainer") 12 | local NumberPickerWidget = require("ui/widget/numberpickerwidget") 13 | local Size = require("ui/size") 14 | local TextWidget = require("ui/widget/textwidget") 15 | local TitleBar = require("ui/widget/titlebar") 16 | local UIManager = require("ui/uimanager") 17 | local VerticalGroup = require("ui/widget/verticalgroup") 18 | local WidgetContainer = require("ui/widget/container/widgetcontainer") 19 | local _ = require("gettext") 20 | local Screen = Device.screen 21 | local T = require("ffi/util").template 22 | 23 | local DoubleSpinWidget = require("ui/widget/doublespinwidget") 24 | local UpdateDoubleSpinWidget = DoubleSpinWidget:extend { 25 | } 26 | function UpdateDoubleSpinWidget:update(numberpicker_left_value, numberpicker_right_value) 27 | local prev_movable_offset = self.movable and self.movable:getMovedOffset() 28 | local prev_movable_alpha = self.movable and self.movable.alpha 29 | self.layout = {} 30 | local left_widget = NumberPickerWidget:new { 31 | show_parent = self, 32 | value = numberpicker_left_value or self.left_value, 33 | value_min = self.left_min, 34 | value_max = self.left_max, 35 | value_step = self.left_step, 36 | value_hold_step = self.left_hold_step, 37 | precision = self.left_precision, 38 | wrap = self.left_wrap, 39 | unit = self.unit, 40 | } 41 | self:mergeLayoutInHorizontal(left_widget) 42 | local right_widget = NumberPickerWidget:new { 43 | show_parent = self, 44 | value = numberpicker_right_value or self.right_value, 45 | value_min = self.right_min, 46 | value_max = self.right_max, 47 | value_step = self.right_step, 48 | value_hold_step = self.right_hold_step, 49 | precision = self.right_precision, 50 | wrap = self.right_wrap, 51 | unit = self.unit, 52 | } 53 | self:mergeLayoutInHorizontal(right_widget) 54 | left_widget.picker_updated_callback = function(value) 55 | -- UPDATED allow callback to modify values 56 | local left_value, right_value = self.update_callback(value, right_widget:getValue(), true) 57 | self:update(left_value, right_value) 58 | end 59 | right_widget.picker_updated_callback = function(value) 60 | -- UPDATED allow callback to modify values 61 | local left_value, right_value = self.update_callback(left_widget:getValue(), value, false) 62 | self:update(left_value, right_value) 63 | end 64 | local separator_widget = TextWidget:new { 65 | text = self.is_range and "–" or "", 66 | face = self.title_face, 67 | bold = true, 68 | } 69 | 70 | local text_max_width = math.floor(0.95 * self.width / 2) 71 | local left_vertical_group = VerticalGroup:new { 72 | align = "center", 73 | left_widget, 74 | } 75 | local separator_vertical_group = VerticalGroup:new { 76 | align = "center", 77 | separator_widget, 78 | } 79 | local right_vertical_group = VerticalGroup:new { 80 | align = "center", 81 | right_widget, 82 | } 83 | 84 | if self.left_text ~= "" or self.right_text ~= "" then 85 | table.insert(left_vertical_group, 1, TextWidget:new { 86 | text = self.left_text, 87 | face = self.title_face, 88 | max_width = text_max_width, 89 | }) 90 | table.insert(separator_vertical_group, 1, TextWidget:new { 91 | text = "", 92 | face = self.title_face, 93 | }) 94 | table.insert(right_vertical_group, 1, TextWidget:new { 95 | text = self.right_text, 96 | face = self.title_face, 97 | max_width = text_max_width, 98 | }) 99 | end 100 | 101 | local widget_group = HorizontalGroup:new { 102 | align = "center", 103 | CenterContainer:new { 104 | dimen = Geom:new { 105 | w = self.width / 2, 106 | h = left_vertical_group:getSize().h, 107 | }, 108 | left_vertical_group, 109 | }, 110 | CenterContainer:new { 111 | dimen = Geom:new(), 112 | separator_vertical_group, 113 | }, 114 | CenterContainer:new { 115 | dimen = Geom:new { 116 | w = self.width / 2, 117 | h = right_vertical_group:getSize().h, 118 | }, 119 | right_vertical_group, 120 | }, 121 | } 122 | 123 | local title_bar = TitleBar:new { 124 | width = self.width, 125 | align = "left", 126 | with_bottom_line = true, 127 | title = self.title_text, 128 | title_shrink_font_to_fit = true, 129 | info_text = self.info_text, 130 | show_parent = self, 131 | } 132 | 133 | local buttons = {} 134 | if self.left_default and self.right_default then 135 | local separator = self.is_range and "–" or "/" 136 | local unit = "" 137 | if self.unit then 138 | if self.unit == "°" then 139 | unit = self.unit 140 | elseif self.unit ~= "" then 141 | unit = "\u{202F}" .. self.unit -- use Narrow No-Break Space (NNBSP) here 142 | end 143 | end 144 | table.insert(buttons, { 145 | { 146 | text = self.default_text or T(_("Default values: %1%3 %4 %2%3"), 147 | self.left_precision and string.format(self.left_precision, self.left_default) or self.left_default, 148 | self.right_precision and string.format(self.right_precision, self.right_default) or self.right_default, 149 | unit, separator), 150 | callback = function() 151 | left_widget.value = self.left_default 152 | right_widget.value = self.right_default 153 | left_widget:update() 154 | right_widget:update() 155 | end, 156 | } 157 | }) 158 | end 159 | if self.extra_text then 160 | table.insert(buttons, { 161 | { 162 | text = self.extra_text, 163 | callback = function() 164 | if self.extra_callback then 165 | self.extra_callback(left_widget:getValue(), right_widget:getValue()) 166 | end 167 | if not self.keep_shown_on_apply then -- assume extra wants it same as ok 168 | self:onClose() 169 | end 170 | end, 171 | }, 172 | }) 173 | end 174 | table.insert(buttons, { 175 | { 176 | text = self.cancel_text, 177 | callback = function() 178 | if self.cancel_callback then 179 | self.cancel_callback() 180 | end 181 | self:onClose() 182 | end, 183 | }, 184 | { 185 | text = self.ok_text, 186 | enabled = self.ok_always_enabled or self.left_value ~= left_widget:getValue() 187 | or self.right_value ~= right_widget:getValue(), 188 | callback = function() 189 | self.left_value = left_widget:getValue() 190 | self.right_value = right_widget:getValue() 191 | if self.callback then 192 | self.callback(self.left_value, self.right_value) 193 | end 194 | if self.keep_shown_on_apply then 195 | self:update() 196 | else 197 | self:onClose() 198 | end 199 | end, 200 | }, 201 | }) 202 | 203 | local button_table = ButtonTable:new { 204 | width = self.width - 2 * Size.padding.default, 205 | buttons = buttons, 206 | zero_sep = true, 207 | show_parent = self, 208 | } 209 | self:mergeLayoutInVertical(button_table) 210 | 211 | self.widget_frame = FrameContainer:new { 212 | radius = Size.radius.window, 213 | padding = 0, 214 | margin = 0, 215 | background = Blitbuffer.COLOR_WHITE, 216 | VerticalGroup:new { 217 | align = "left", 218 | title_bar, 219 | CenterContainer:new { 220 | dimen = Geom:new { 221 | w = self.width, 222 | h = widget_group:getSize().h + 4 * Size.padding.large, 223 | }, 224 | widget_group 225 | }, 226 | CenterContainer:new { 227 | dimen = Geom:new { 228 | w = self.width, 229 | h = button_table:getSize().h, 230 | }, 231 | button_table 232 | } 233 | } 234 | } 235 | self.movable = MovableContainer:new { 236 | alpha = prev_movable_alpha, 237 | self.widget_frame, 238 | } 239 | self[1] = WidgetContainer:new { 240 | align = "center", 241 | dimen = Geom:new { 242 | x = 0, y = 0, 243 | w = self.screen_width, 244 | h = self.screen_height, 245 | }, 246 | self.movable, 247 | } 248 | if prev_movable_offset then 249 | self.movable:setMovedOffset(prev_movable_offset) 250 | end 251 | self:refocusWidget() 252 | UIManager:setDirty(self, function() 253 | return "ui", self.widget_frame.dimen 254 | end) 255 | end 256 | 257 | return UpdateDoubleSpinWidget 258 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hardcover.app for KOReader 2 | 3 | A KOReader plugin to update your [Hardcover.app](https://hardcover.app) reading status 4 | 5 | ## Installation 6 | 7 | 1. Download and extract the latest release: https://github.com/Billiam/hardcoverapp.koplugin/releases/latest 8 | 2. Rename `hardcover_config.example.lua` to `hardcover_config.lua` 9 | 3. Fetch your API key from https://hardcover.app/account/api (just the part after `Bearer `) 10 | 4. Add your API key to the `token` field in `hardcover_config.lua`, between the `''` quotes. For example: 11 | `token = 'abcde...fghij'` 12 | 5. Install plugin by copying the `hardcoverapp.koplugin` folder to the KOReader plugins folder on your device 13 | 14 | ## Usage 15 | 16 | The Hardcover plugin's menu can be found in the Bookmark top menu when a document is active. 17 | 18 | ![Hardcover plugin main menu with the following menu items: the currently linked book (Frankenstein), an option to change the edition specific edition, a checkbox to Automatically track progress, Update status (which opens a submenu), Settings (which opens a submenu), and About](https://github.com/user-attachments/assets/0fd8f6fb-3a61-471f-9450-9a0b3dadc9d1) 19 | 20 | ### Linking a book 21 | 22 | Before updates can be sent to Hardcover, the plugin needs to know which Hardcover book and/or edition your current 23 | document represents. 24 | 25 | You can search for a book by selecting `Link book` from the Hardcover menu. If any books can be found based 26 | on your book's metadata, these will be displayed. 27 | 28 | ![A dismissable window titled "Select book" with a search button displayed as a magnifying glass in the upper left corner, and a close button on the right. A list of 14 possible books is displayed below this, with buttons to change result pages. The book title ande author are displayed if available, as well as the number of reads by hardcover users and the number of pages if available. Some books display cover images](https://github.com/user-attachments/assets/99d16ef0-6dda-41d8-bfdc-32c97ae09d87) 29 | 30 | If you cannot find the book you're looking for, you can tap the magnifying glass icon in the upper left corner and 31 | begin a manual search. 32 | 33 | ![An input dialog titled "New Search" with the text "Frankenstein Mary Shelley". Behind the dialog, a recent book search result dialog appears with two books](https://github.com/user-attachments/assets/73619448-9821-410a-901f-d8fc61185dd3) 34 | 35 | Selecting a Hardcover book or edition will link it to your current document, but will not automatically update your 36 | reading status on Hardcover. This can be done manually from the [update status](#updating-reading-status) menu, or using 37 | the [track progress](#automatically-track-progress) option. 38 | 39 | To clear the currently linked book, tap and hold to the `Linked book` menu item for a moment. 40 | 41 | After selecting a book, you can set a specific edition using the `Change edition` menu item. This will present a list 42 | of available editions for the currently linked book. No manual edition search is available 43 | 44 | ### Updating reading status 45 | 46 | ![A menu to update book status containing: a set of radio buttons for the current book status (Want to read, currently reading, read and did not finish), an item to unset the current status. The following section has an item to update the current page (displaying page 154 of 353), add a note, update rating (displaying the current rating of 4.5 stars), and an item to update the status privacy settings which open in a submenu](https://github.com/user-attachments/assets/55b33a0a-bda8-4ec9-918d-0409266abe3b) 47 | 48 | To change your book status (Want To Read, Currently Reading, Read, Did Not Finish) on Hardcover, you open the 49 | `Update status` menu after [linking your book](#linking-a-book). You can also remove the book from your Hardcover 50 | library using the `Remove` menu item. 51 | 52 | From this menu you can also update your current page and book rating, and add a new journal entry 53 | 54 | Tap and hold the book rating menu item to clear your current rating. 55 | 56 | ### Add a journal entry quote 57 | 58 | Selecting text to quote: 59 | 60 | ![Book text with two sentences highlighted. KOReader's highlight menu popup is displayed in the center with an option at the end for Hardcover quote](https://github.com/user-attachments/assets/5dba19a4-f72a-4894-820c-0cfdcc55bf68) 61 | 62 | ![A form window titled "Create journal entry". The previously selected text appears in an input field at the top. Below that is a toggle for whether the journal entry should be a note or a quote. A button to change the journal edition follows, and one to change the current page. Below those, a toggle to change the journal entry privay with the options Public, Follows and Private. Lastly are two input fields to set journal entry tags and spoiler tags respectively, and then buttons to save the entry or close the window](https://github.com/user-attachments/assets/c386f153-330f-4e1f-afa1-12fdf48a1216) 63 | 64 | After selecting document text in a linked document, choose `Hardcover quote` from the highlight menu to display the 65 | journal entry form, prefilled with the selected text and page. 66 | 67 | ### Automatically track progress 68 | 69 | Automatic progress tracking is optional: book status and reading progress can instead be 70 | [updated manually](#update-reading-status) from the `Update status` menu. 71 | 72 | When track progress is enabled for a book which has been linked ([manually](#linking-a-book) 73 | or [automatically](#automatic-linking)), 74 | page and status updates will automatically be sent to Hardcover for some reading events: 75 | 76 | * Your current read will be updated when paging through the document, no more than once per minute. This frequency 77 | [can be configured](#track-progress-frequency). 78 | * When marking a book as finished from the file browser, the book will be marked as finished in Hardcover 79 | * When reaching the end of the document, if the KOReader settings automatically mark the document as finished, the 80 | book will be marked as finished in Hardcover. If the KOReader setting instead opens a popup, the book status will be 81 | checked 82 | ten seconds later, and if the book has been marked finished, it will be marked as finish in Hardcover. 83 | 84 | For all documents, but in particular for reflowable documents (like epubs), the current page in your reader may not 85 | match that of the original published book. 86 | 87 | Some documents contain information allowing the current page to map to the published book's pages. For these documents, 88 | the mapped page will be sent to Hardcover if possible. 89 | 90 | For documents without these, your progress will be converted to a percentage of the number of pages in the original 91 | published book, with a calculation like: 92 | `round((document_page_number / document_total_pages) * hardcover_edition_total_pages)`. 93 | 94 | In both cases, this may not exactly match the page of the published document, and can even be far off if there 95 | are large differences in the total pages. 96 | 97 | ## Settings 98 | 99 | ![A settings menu containing the following options: Checkboxes for Automatically link by ISBN, Automatically link by Hardcover identifiers and Automatically link by title and author. Below that is an item to change the Track progress frequency showing the current setting (1 minute), and a checkbox to Always track progress by default.](https://github.com/user-attachments/assets/dc8a397b-f36d-49da-b880-d04d47219ed0) 100 | 101 | ### Automatic linking 102 | 103 | With automatic linking enabled, the plugin will attempt to find the matching book and/or edition on Hardcover 104 | when a new document is opened, if no book has been linked already. These options are off by default. 105 | 106 | * **Automatically link by ISBN**: If the document contains ISBN or ISBN13 metadata, try to find a matching edition for 107 | that ISBN 108 | * **Automatically link by Hardcover**: If the document metadata contains a `hardcover` identifier (with a URL slug for 109 | the book) 110 | or a `hardcover-edition` with an edition ID, try to find the matching book or edition. 111 | (see: [RobBrazier/calibre-plugins](https://github.com/RobBrazier/calibre-plugins/tree/main/plugins/hardcover)) 112 | * **Automatically link by title**: If the document metadata contains a title, choose the first book returned from 113 | hardcover search results for that title and document author (if available). 114 | 115 | ### Track progress settings 116 | 117 | By default, (when enabled) updates will be sent to hardcover at a frequency you can select, no more often than once per 118 | minute. If you don't need updates this frequently, and to preserve battery, you can decrease this frequency further. 119 | 120 | You can also choose to update based on your percentage progress through a book. With this option, an update will be sent 121 | when you cross a percentage threshold (for examppe, every 10% completed). 122 | 123 | ### Always track progress by default 124 | 125 | When always track progress is enabled, new documents will have the [track progress](#automatically-track-progress) 126 | option enabled automatically. You can still turn off `Track progress` on a per-document basis when this setting is 127 | enabled. 128 | 129 | Books still must be linked (manually or automatically) to send updates to Hardcover. 130 | 131 | ### Enable wifi on demand 132 | 133 | On some devices, wifi can be enabled on demand without interruption. When this feature is enabled, wifi will be enabled 134 | automatically before some types of background API requests (namely updating the initial application cache, and updating 135 | your reading progress), and then disabled afterward. 136 | 137 | This can improve battery life significantly on some devices, particularly with infrequent page updates. 138 | 139 | This feature is not used for all network requests. If wifi has not been manually enabled the following will not work: 140 | 141 | * fetching or updating your reading status manually 142 | * manually updating your reading progress from the menu 143 | 144 | ### Compatibility mode 145 | 146 | When enabled, book and edition searches will be displayed in a simplified list with minimal data. This mode is the 147 | default for KOReader versions prior to v2024.07 148 | -------------------------------------------------------------------------------- /hardcover/lib/hardcover_api.lua: -------------------------------------------------------------------------------- 1 | local config = require("hardcover_config") 2 | local logger = require("logger") 3 | local http = require("socket.http") 4 | local ltn12 = require("ltn12") 5 | local json = require("json") 6 | local _t = require("hardcover/lib/table_util") 7 | local T = require("ffi/util").template 8 | local Trapper = require("ui/trapper") 9 | local NetworkManager = require("ui/network/manager") 10 | local socketutil = require("socketutil") 11 | 12 | local Book = require("hardcover/lib/book") 13 | local VERSION = require("hardcover_version") 14 | 15 | local api_url = "https://api.hardcover.app/v1/graphql" 16 | 17 | local headers = { 18 | ["Content-Type"] = "application/json", 19 | ["User-Agent"] = T("hardcoverapp.koplugin/%1 (https://github.com/billiam/hardcoverapp.koplugin)", 20 | table.concat(VERSION, ".")), 21 | Authorization = "Bearer " .. config.token 22 | } 23 | 24 | local HardcoverApi = { 25 | enabled = true 26 | } 27 | 28 | local book_fragment = [[ 29 | fragment BookParts on books { 30 | book_id: id 31 | title 32 | release_year 33 | users_read_count 34 | pages 35 | book_series { 36 | position 37 | series { 38 | name 39 | } 40 | } 41 | contributions: cached_contributors 42 | cached_image 43 | user_books(where: { user_id: { _eq: $userId }}) { 44 | id 45 | } 46 | }]] 47 | 48 | local edition_fragment = book_fragment .. [[ 49 | fragment EditionParts on editions { 50 | id 51 | book { 52 | ...BookParts 53 | } 54 | cached_image 55 | edition_format 56 | language { 57 | code2 58 | language 59 | } 60 | pages 61 | publisher { 62 | name 63 | } 64 | release_date 65 | reading_format_id 66 | title 67 | users_count 68 | }]] 69 | 70 | local user_book_fragment = [[ 71 | fragment UserBookParts on user_books { 72 | id 73 | book_id 74 | status_id 75 | edition_id 76 | privacy_setting_id 77 | rating 78 | user_book_reads(order_by: {id: asc}) { 79 | id 80 | started_at 81 | finished_at 82 | progress_pages 83 | edition_id 84 | } 85 | }]] 86 | 87 | function HardcoverApi:me() 88 | local result = self:query([[{ 89 | me { 90 | id 91 | account_privacy_setting_id 92 | } 93 | }]]) 94 | 95 | if result and result.me then 96 | return result.me[1] 97 | end 98 | return {} 99 | end 100 | 101 | function HardcoverApi:query(query, parameters) 102 | if not NetworkManager:isConnected() or not self.enabled then 103 | return 104 | end 105 | 106 | local completed, success, content 107 | 108 | completed, content = Trapper:dismissableRunInSubprocess(function() 109 | return self:_query(query, parameters) 110 | end, true, true) 111 | 112 | if completed and content then 113 | local code, response = string.match(content, "^([^:]*):(.*)") 114 | if string.find(code, "^%d%d%d") then 115 | local data = json.decode(response, json.decode.simple) 116 | if data.data then 117 | return data.data 118 | elseif data.errors or data.error then 119 | local err = data.errors or { data.error } 120 | if self.on_error then 121 | for _, e in ipairs(err) do 122 | self.on_error(e) 123 | end 124 | end 125 | 126 | return nil, { errors = err } 127 | end 128 | else 129 | return nil, { completed = false } 130 | end 131 | else 132 | return nil, { completed = completed } 133 | end 134 | end 135 | 136 | function HardcoverApi:_query(query, parameters) 137 | local requestBody = { 138 | query = query, 139 | variables = parameters 140 | } 141 | 142 | local maxtime = 12 143 | local timeout = 6 144 | 145 | local sink = {} 146 | socketutil:set_timeout(timeout, maxtime or 30) 147 | local request = { 148 | url = api_url, 149 | method = "POST", 150 | headers = headers, 151 | source = ltn12.source.string(json.encode(requestBody)), 152 | sink = socketutil.table_sink(sink), 153 | } 154 | 155 | local _, code, _headers, _status = http.request(request) 156 | socketutil:reset_timeout() 157 | 158 | local content = table.concat(sink) -- empty or content accumulated till now 159 | --logger.warn(requestBody) 160 | if code == socketutil.TIMEOUT_CODE or 161 | code == socketutil.SSL_HANDSHAKE_CODE or 162 | code == socketutil.SINK_TIMEOUT_CODE 163 | then 164 | logger.warn("request interrupted:", code) 165 | return code .. ':' 166 | end 167 | 168 | if type(code) == "string" then 169 | logger.dbg("Request error", code) 170 | end 171 | 172 | if type(code) == "number" and (code < 200 or code > 299) then 173 | logger.dbg("Request error", code, content) 174 | end 175 | 176 | return code .. ':' .. content 177 | end 178 | 179 | function HardcoverApi:hydrateBooks(ids, user_id) 180 | if #ids == 0 then 181 | return {} 182 | end 183 | 184 | -- hydrate ids 185 | local bookQuery = [[ 186 | query ($ids: [Int!], $userId: Int!) { 187 | books(where: { id: { _in: $ids }}) { 188 | ...BookParts 189 | } 190 | } 191 | ]] .. book_fragment 192 | 193 | local books = self:query(bookQuery, { ids = ids, userId = user_id }) 194 | if books then 195 | local list = books.books 196 | 197 | if #list > 1 then 198 | local id_order = {} 199 | 200 | for i, v in ipairs(ids) do 201 | id_order[v] = i 202 | end 203 | 204 | -- sort books by original ID order 205 | table.sort(list, function(a, b) 206 | return id_order[a.book_id] < id_order[b.book_id] 207 | end) 208 | end 209 | 210 | return list 211 | end 212 | end 213 | 214 | function HardcoverApi:hydrateBookFromEdition(edition_id, user_id) 215 | local editionSearch = [[ 216 | query ($id Int!, $userId: Int!) { 217 | editions(where: { id: { _eq: $id }}) { 218 | ...EditionParts 219 | } 220 | }]] .. edition_fragment 221 | 222 | local editions = self:query(editionSearch, { id = edition_id, userId = user_id }) 223 | if editions and editions.editions and #editions.editions > 0 then 224 | return self:normalizedEdition(editions.editions[1]) 225 | end 226 | end 227 | 228 | function HardcoverApi:findBookBySlug(slug, user_id) 229 | local slugSearch = [[ 230 | query ($slug: String!, $userId: Int!) { 231 | books(where: { slug: { _eq: $slug }}) { 232 | ...BookParts 233 | } 234 | }]] .. book_fragment 235 | 236 | local books = self:query(slugSearch, { slug = slug, userId = user_id }) 237 | if books and books.books and #books.books > 0 then 238 | return books.books[1] 239 | end 240 | end 241 | 242 | function HardcoverApi:findEditions(book_id, user_id) 243 | local edition_search = [[ 244 | query ($id: Int!, $userId: Int!) { 245 | editions(where: { book_id: { _eq: $id }, _or: [{reading_format_id: { _is_null: true }}, {reading_format_id: { _neq: 2 }} ]}, 246 | order_by: { users_count: desc_nulls_last }) { 247 | ...EditionParts 248 | } 249 | }]] .. edition_fragment 250 | 251 | local editions = self:query(edition_search, { id = book_id, userId = user_id }) 252 | if not editions or not editions.editions then 253 | return {} 254 | end 255 | local edition_list = editions.editions 256 | 257 | if #edition_list > 1 then 258 | -- prefer editions with user reads 259 | local edition_ids = _t.map(edition_list, function(edition) 260 | return edition.id 261 | end) 262 | 263 | local read_search = [[ 264 | query ($ids: [Int!], $userId: Int!) { 265 | user_books(where: { edition_id: { _in: $ids }, user_id: { _eq: $userId }}) { 266 | edition_id 267 | } 268 | } 269 | ]] 270 | 271 | local read_editions = self:query(read_search, { ids = edition_ids, userId = user_id }) 272 | if not read_editions then 273 | return nil 274 | end 275 | local read_index = {} 276 | for _, read in ipairs(read_editions) do 277 | read_index[read.edition_id] = true 278 | end 279 | 280 | table.sort(edition_list, function(a, b) 281 | -- sort by user reads 282 | local read_a = read_index[a.id] 283 | local read_b = read_index[b.id] 284 | 285 | if read_a ~= read_b then 286 | return read_a == true 287 | end 288 | 289 | if a.reading_format_id ~= b.reading_format_id then 290 | return a.reading_format_id == 4 291 | end 292 | 293 | if a.users_count ~= b.users_count then 294 | return a.users_count > b.users_count 295 | end 296 | end) 297 | end 298 | 299 | return _t.map(edition_list, function(edition) 300 | return self:normalizedEdition(edition) 301 | end) 302 | end 303 | 304 | function HardcoverApi:search(title, author, userId, page) 305 | page = page or 1 306 | local query = [[ 307 | query ($query: String!, $page: Int!) { 308 | search(query: $query, per_page: 25, page: $page, query_type: "Book") { 309 | ids 310 | } 311 | }]] 312 | local search = title .. " " .. (author or "") 313 | local results, error = self:query(query, { query = search, page = page }) 314 | if error then 315 | return nil, error 316 | end 317 | 318 | if not results or not _t.dig(results, "search", "ids") then 319 | return {} 320 | end 321 | 322 | local ids = _t.map(results.search.ids, function(id) return tonumber(id) end) 323 | return self:hydrateBooks(ids, userId) 324 | end 325 | 326 | function HardcoverApi:findBookByIdentifiers(identifiers, user_id) 327 | local isbnKey 328 | 329 | if identifiers.edition_id then 330 | local book = self:hydrateBookFromEdition(identifiers.edition_id, user_id) 331 | if book then 332 | return book 333 | end 334 | end 335 | 336 | if identifiers.book_slug then 337 | local book = self:findBookBySlug(identifiers.book_slug, user_id) 338 | if book then 339 | return book 340 | end 341 | end 342 | 343 | if identifiers.isbn_13 then 344 | isbnKey = 'isbn_13' 345 | elseif identifiers.isbn_10 then 346 | isbnKey = 'isbn_10' 347 | end 348 | 349 | if isbnKey then 350 | local editionSearch = [[ 351 | query ($isbn: String!, $userId: Int!) { 352 | editions(where: { ]] .. isbnKey .. [[: { _eq: $isbn }}) { 353 | ...EditionParts 354 | } 355 | }]] .. edition_fragment 356 | 357 | local editions = self:query(editionSearch, { isbn = tostring(identifiers[isbnKey]), userId = user_id }) 358 | if editions and editions.editions and #editions.editions > 0 then 359 | return self:normalizedEdition(editions.editions[1]) 360 | end 361 | end 362 | end 363 | 364 | function HardcoverApi:normalizedEdition(edition) 365 | local result = edition.book 366 | 367 | result.edition_id = edition.id 368 | result.edition_format = Book:editionFormatName(edition.edition_format, edition.reading_format_id) 369 | 370 | result.cached_image = edition.cached_image 371 | result.publisher = edition.publisher 372 | if edition.release_date then 373 | local year = edition.release_date:match("^(%d%d%d%d)-") 374 | result.release_year = year 375 | else 376 | result.release_year = nil 377 | end 378 | result.language = edition.language 379 | result.title = edition.title 380 | result.reads = edition.reads 381 | result.pages = edition.pages 382 | result.filetype = result.edition_format or "Physical Book" 383 | result.users_count = edition.users_count 384 | 385 | return result 386 | end 387 | 388 | function HardcoverApi:normalizeUserBookRead(user_book_read) 389 | local user_book = user_book_read.user_book 390 | user_book_read.user_book = nil 391 | user_book.user_book_reads = { user_book_read } 392 | return user_book 393 | end 394 | 395 | function HardcoverApi:findBooks(title, author, userId) 396 | if not title or string.match(title, "^%s*$") then 397 | return {} 398 | end 399 | 400 | title = title:gsub(":.+", ""):gsub("^%s+", ""):gsub("%s+$", "") 401 | return self:search(title, author, userId) 402 | end 403 | 404 | function HardcoverApi:findUserBook(book_id, user_id) 405 | -- this may not be adequate, as (it's possible) there could be more than one read in progress? Maybe? 406 | local read_query = [[ 407 | query ($id: Int!, $userId: Int!) { 408 | user_books(where: { book_id: { _eq: $id }, user_id: { _eq: $userId }}) { 409 | ...UserBookParts 410 | } 411 | } 412 | ]] .. user_book_fragment 413 | 414 | local results, err = self:query(read_query, { id = book_id, userId = user_id }) 415 | if not results or not results.user_books then 416 | return {}, err 417 | end 418 | 419 | return results.user_books[1] 420 | end 421 | 422 | function HardcoverApi:findDefaultEdition(book_id, user_id) 423 | -- prefer: 424 | -- 1. most recent matching user read 425 | -- 2. a user book 426 | -- 3. default ebook edition 427 | -- 4. default physical edition 428 | -- 5. most read book edition 429 | local user_edition_fragment = [[ 430 | fragment UserEditionParts on editions { 431 | id 432 | edition_format 433 | reading_format_id 434 | pages 435 | } 436 | ]] 437 | local user_book_query = [[ 438 | query ($bookId: Int!, $userId: Int!) { 439 | user_books(limit: 1, where: { book_id: { _eq: $bookId}, user_id: { _eq: $userId }}) { 440 | edition { 441 | ...UserEditionParts 442 | } 443 | user_book_reads(limit: 1, order_by: {id: asc}) { 444 | edition { 445 | ...UserEditionParts 446 | } 447 | } 448 | } 449 | } 450 | ]] .. user_edition_fragment 451 | 452 | local user_book_results = self:query(user_book_query, { bookId = book_id, userId = user_id }) 453 | if user_book_results then 454 | local user_book = _t.dig(user_book_results, "user_books", 1) 455 | if user_book then 456 | local read_edition = _t.dig(user_book, "user_book_reads", 1, "edition") 457 | if read_edition then 458 | return read_edition 459 | end 460 | return user_book.edition 461 | end 462 | end 463 | 464 | local default_edition_query = [[ 465 | query ($bookId: Int!) { 466 | books_by_pk(id: $bookId) { 467 | default_physical_edition { 468 | ...UserEditionParts 469 | } 470 | default_ebook_edition { 471 | ...UserEditionParts 472 | } 473 | } 474 | } 475 | ]] .. user_edition_fragment 476 | local default_edition_results = self:query(default_edition_query, { bookId = book_id }) 477 | if default_edition_results then 478 | if default_edition_results.books_by_pk.default_ebook_edition then 479 | return default_edition_results.books_by_pk.default_ebook_edition 480 | end 481 | 482 | if default_edition_results.books_by_pk.default_physical_edition then 483 | return default_edition_results.books_by_pk.default_physical_edition 484 | end 485 | end 486 | 487 | local edition_query = [[ 488 | query ($bookId: Int!) { 489 | editions( 490 | limit: 1 491 | where: {book_id: {_eq: $bookId}} 492 | order_by: {users_count: desc_nulls_last} 493 | ) { 494 | ...UserEditionParts 495 | } 496 | } 497 | ]] .. user_edition_fragment 498 | local edition_results = self:query(edition_query, { bookId = book_id }) 499 | if edition_results then 500 | return _t.dig(edition_results, "editions", 1) 501 | end 502 | end 503 | 504 | function HardcoverApi:createRead(user_book_id, edition_id, page, started_at) 505 | local query = [[ 506 | mutation InsertUserBookRead($id: Int!, $pages: Int, $editionId: Int, $startedAt: date) { 507 | insert_user_book_read(user_book_id: $id, user_book_read: { 508 | progress_pages: $pages, 509 | edition_id: $editionId, 510 | started_at: $startedAt, 511 | }) { 512 | error 513 | user_book_read { 514 | id 515 | started_at 516 | finished_at 517 | edition_id 518 | progress_pages 519 | user_book { 520 | id 521 | book_id 522 | status_id 523 | edition_id 524 | privacy_setting_id 525 | rating 526 | } 527 | } 528 | } 529 | } 530 | ]] 531 | 532 | local result = self:query(query, { id = user_book_id, pages = page, editionId = edition_id, startedAt = started_at }) 533 | if result and result.update_user_book_read then 534 | local user_book_read = result.insert_user_book_read.user_book_read 535 | return self:normalizeUserBookRead(user_book_read) 536 | end 537 | end 538 | 539 | function HardcoverApi:updatePage(user_read_id, edition_id, page, started_at) 540 | local query = [[ 541 | mutation UpdateBookProgress($id: Int!, $pages: Int, $editionId: Int, $startedAt: date) { 542 | update_user_book_read(id: $id, object: { 543 | progress_pages: $pages, 544 | edition_id: $editionId, 545 | started_at: $startedAt, 546 | }) { 547 | error 548 | user_book_read { 549 | id 550 | started_at 551 | finished_at 552 | edition_id 553 | progress_pages 554 | user_book { 555 | id 556 | book_id 557 | status_id 558 | edition_id 559 | privacy_setting_id 560 | rating 561 | } 562 | } 563 | } 564 | } 565 | ]] 566 | 567 | local result = self:query(query, { id = user_read_id, pages = page, editionId = edition_id, startedAt = started_at }) 568 | if result and result.update_user_book_read then 569 | return self:normalizeUserBookRead(result.update_user_book_read.user_book_read) 570 | end 571 | end 572 | 573 | function HardcoverApi:updateUserBook(book_id, status_id, privacy_setting_id, edition_id) 574 | if not privacy_setting_id then 575 | local me = self:me() 576 | privacy_setting_id = me.account_privacy_setting_id or 1 577 | end 578 | 579 | local query = [[ 580 | mutation ($object: UserBookCreateInput!) { 581 | insert_user_book(object: $object) { 582 | error 583 | user_book { 584 | ...UserBookParts 585 | } 586 | } 587 | } 588 | ]] .. user_book_fragment 589 | 590 | local update_args = { 591 | book_id = book_id, 592 | privacy_setting_id = privacy_setting_id, 593 | status_id = status_id, 594 | edition_id = edition_id 595 | } 596 | 597 | local result = self:query(query, { object = update_args }) 598 | if result and result.insert_user_book then 599 | return result.insert_user_book.user_book 600 | end 601 | end 602 | 603 | function HardcoverApi:updateRating(user_book_id, rating) 604 | local query = [[ 605 | mutation ($id: Int!, $rating: numeric) { 606 | update_user_book(id: $id, object: { rating: $rating }) { 607 | error 608 | user_book { 609 | ...UserBookParts 610 | } 611 | } 612 | } 613 | ]] .. user_book_fragment 614 | 615 | if rating == 0 or rating == nil then 616 | rating = json.util.null 617 | end 618 | 619 | local result = self:query(query, { id = user_book_id, rating = rating }) 620 | if result and result.update_user_book then 621 | return result.update_user_book.user_book 622 | end 623 | end 624 | 625 | function HardcoverApi:removeRead(user_book_id) 626 | local query = [[ 627 | mutation($id: Int!) { 628 | delete_user_book(id: $id) { 629 | id 630 | } 631 | } 632 | ]] 633 | local result = self:query(query, { id = user_book_id }) 634 | if result then 635 | return result.delete_user_book 636 | end 637 | end 638 | 639 | function HardcoverApi:createJournalEntry(object) 640 | local query = [[ 641 | mutation InsertReadingJournalEntry($object: ReadingJournalCreateType!) { 642 | insert_reading_journal(object: $object) { 643 | reading_journal { 644 | id 645 | } 646 | } 647 | } 648 | ]] 649 | 650 | local result = self:query(query, { object = object }) 651 | if result then 652 | return result.insert_reading_journal.reading_journal 653 | end 654 | end 655 | 656 | return HardcoverApi 657 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | local _ = require("gettext") 2 | local DataStorage = require("datastorage") 3 | local Dispatcher = require("dispatcher") 4 | local DocSettings = require("docsettings") 5 | local logger = require("logger") 6 | local math = require("math") 7 | 8 | local NetworkManager = require("ui/network/manager") 9 | local Trapper = require("ui/trapper") 10 | local UIManager = require("ui/uimanager") 11 | 12 | local InfoMessage = require("ui/widget/infomessage") 13 | local Notification = require("ui/widget/notification") 14 | 15 | local WidgetContainer = require("ui/widget/container/widgetcontainer") 16 | 17 | local _t = require("hardcover/lib/table_util") 18 | local Api = require("hardcover/lib/hardcover_api") 19 | local AutoWifi = require("hardcover/lib/auto_wifi") 20 | local Cache = require("hardcover/lib/cache") 21 | local debounce = require("hardcover/lib/debounce") 22 | local Hardcover = require("hardcover/lib/hardcover") 23 | local HardcoverSettings = require("hardcover/lib/hardcover_settings") 24 | local PageMapper = require("hardcover/lib/page_mapper") 25 | local Scheduler = require("hardcover/lib/scheduler") 26 | local throttle = require("hardcover/lib/throttle") 27 | local User = require("hardcover/lib/user") 28 | 29 | local DialogManager = require("hardcover/lib/ui/dialog_manager") 30 | local HardcoverMenu = require("hardcover/lib/ui/hardcover_menu") 31 | 32 | local HARDCOVER = require("hardcover/lib/constants/hardcover") 33 | local SETTING = require("hardcover/lib/constants/settings") 34 | 35 | local HardcoverApp = WidgetContainer:extend { 36 | name = "hardcoverappsync", 37 | is_doc_only = false, 38 | state = nil, 39 | settings = nil, 40 | width = nil, 41 | enabled = true 42 | } 43 | 44 | local HIGHLIGHT_MENU_NAME = "13_0_make_hardcover_highlight_item" 45 | 46 | function HardcoverApp:onDispatcherRegisterActions() 47 | Dispatcher:registerAction("hardcover_link", { 48 | category = "none", 49 | event = "HardcoverLink", 50 | title = _("Hardcover: Link book"), 51 | general = true, 52 | }) 53 | 54 | Dispatcher:registerAction("hardcover_track", { 55 | category = "none", 56 | event = "HardcoverTrack", 57 | title = _("Hardcover: Track progress"), 58 | general = true, 59 | }) 60 | 61 | Dispatcher:registerAction("hardcover_stop_track", { 62 | category = "none", 63 | event = "HardcoverStopTrack", 64 | title = _("Hardcover: Stop tracking progress"), 65 | general = true, 66 | }) 67 | 68 | Dispatcher:registerAction("hardcover_update_progress", { 69 | category = "none", 70 | event = "HardcoverUpdateProgress", 71 | title = _("Hardcover: Update progress"), 72 | general = true, 73 | }) 74 | end 75 | 76 | function HardcoverApp:init() 77 | self.state = { 78 | page = nil, 79 | pos = nil, 80 | search_results = {}, 81 | book_status = {}, 82 | page_update_pending = false 83 | } 84 | --logger.warn("HARDCOVER app init") 85 | self.settings = HardcoverSettings:new( 86 | ("%s/%s"):format(DataStorage:getSettingsDir(), "hardcoversync_settings.lua"), 87 | self.ui 88 | ) 89 | self.settings:subscribe(function(field, change, original_value) self:onSettingsChanged(field, change, original_value) end) 90 | 91 | User.settings = self.settings 92 | Api.on_error = function(err) 93 | if not err or not self.enabled then 94 | return 95 | end 96 | 97 | if err == HARDCOVER.ERROR.TOKEN or _t.dig(err, "extensions", "code") == HARDCOVER.ERROR.JWT or (err.message and string.find(err.message, "JWT")) then 98 | self:disable() 99 | UIManager:show(InfoMessage:new { 100 | text = "Your Hardcover API key is not valid or has expired. Please update it and restart", 101 | icon = "notice-warning", 102 | }) 103 | end 104 | end 105 | 106 | self.cache = Cache:new { 107 | settings = self.settings, 108 | state = self.state 109 | } 110 | self.page_mapper = PageMapper:new { 111 | state = self.state, 112 | ui = self.ui, 113 | } 114 | self.wifi = AutoWifi:new { 115 | settings = self.settings 116 | } 117 | self.dialog_manager = DialogManager:new { 118 | page_mapper = self.page_mapper, 119 | settings = self.settings, 120 | state = self.state, 121 | ui = self.ui, 122 | wifi = self.wifi 123 | } 124 | self.hardcover = Hardcover:new { 125 | cache = self.cache, 126 | dialog_manager = self.dialog_manager, 127 | settings = self.settings, 128 | state = self.state, 129 | ui = self.ui, 130 | wifi = self.wifi 131 | } 132 | 133 | self.menu = HardcoverMenu:new { 134 | enabled = true, 135 | 136 | cache = self.cache, 137 | dialog_manager = self.dialog_manager, 138 | hardcover = self.hardcover, 139 | page_mapper = self.page_mapper, 140 | settings = self.settings, 141 | state = self.state, 142 | ui = self.ui, 143 | } 144 | 145 | self:onDispatcherRegisterActions() 146 | self:initializePageUpdate() 147 | self.ui.menu:registerToMainMenu(self) 148 | end 149 | 150 | function HardcoverApp:_bookSettingChanged(setting, key) 151 | return setting[key] ~= nil or _t.contains(_t.dig(setting, "_delete"), key) 152 | end 153 | 154 | -- Open note dialog 155 | -- 156 | -- UIManager:broadcastEvent(Event:new("HardcoverNote", note_params)) 157 | -- 158 | -- note_params can contain: 159 | -- text: Value will prepopulate the note section 160 | -- page_number: The local page number 161 | -- remote_page (optional): The mapped page in the linked book edition 162 | -- note_type: one of "quote" or "note" 163 | function HardcoverApp:onHardcoverNote(note_params) 164 | -- open journal dialog 165 | self.dialog_manager:journalEntryForm( 166 | note_params.text, 167 | self.ui.document, 168 | note_params.page_number, 169 | self.settings:pages(), 170 | note_params.remote_page, 171 | note_params.note_type or "quote" 172 | ) 173 | end 174 | 175 | function HardcoverApp:disable() 176 | self.enabled = false 177 | if self.menu then 178 | self.menu.enabled = false 179 | end 180 | self:registerHighlight() 181 | end 182 | 183 | function HardcoverApp:onHardcoverLink() 184 | self.hardcover:showLinkBookDialog(false, function(book) 185 | UIManager:show(Notification:new { 186 | text = _("Linked to: " .. book.title), 187 | }) 188 | end) 189 | end 190 | 191 | function HardcoverApp:onHardcoverTrack() 192 | self.settings:setSync(true) 193 | UIManager:nextTick(function() 194 | UIManager:show(Notification:new { 195 | text = _("Progress tracking enabled") 196 | }) 197 | end) 198 | end 199 | 200 | function HardcoverApp:onHardcoverStopTrack() 201 | self.settings:setSync(false) 202 | UIManager:show(Notification:new { 203 | text = _("Progress tracking disabled") 204 | }) 205 | end 206 | 207 | function HardcoverApp:onHardcoverUpdateProgress() 208 | if self.ui.document and self.settings:bookLinked() then 209 | self:updatePageNow(function(result) 210 | if result then 211 | UIManager:show(Notification:new { 212 | text = _("Progress updated") 213 | }) 214 | else 215 | logger.warn("Unsuccessful updating page progress", self.ui.document.file) 216 | end 217 | end) 218 | else 219 | logger.warn(self.state.book_status) 220 | local error 221 | if not self.ui.document then 222 | error = "No book active" 223 | elseif not self.state.book_status.id then 224 | error = "Book has not been mapped" 225 | end 226 | 227 | local error_message = error and "Unable to update reading progress: " .. error or "Unable to update reading progress" 228 | UIManager:show(InfoMessage:new { 229 | text = error_message, 230 | icon = "notice-warning", 231 | }) 232 | end 233 | end 234 | 235 | function HardcoverApp:onSettingsChanged(field, change, original_value) 236 | if field == SETTING.BOOKS then 237 | local book_settings = change.config 238 | if self:_bookSettingChanged(book_settings, "sync") then 239 | if book_settings.sync then 240 | if not self.state.book_status.id then 241 | self:startReadCache() 242 | end 243 | else 244 | self:cancelPendingUpdates() 245 | end 246 | end 247 | 248 | if self:_bookSettingChanged(book_settings, "book_id") then 249 | self:registerHighlight() 250 | end 251 | elseif field == SETTING.TRACK_METHOD then 252 | self:cancelPendingUpdates() 253 | self:initializePageUpdate() 254 | elseif field == SETTING.LINK_BY_HARDCOVER or field == SETTING.LINK_BY_ISBN or field == SETTING.LINK_BY_TITLE then 255 | if change then 256 | self.hardcover:tryAutolink() 257 | end 258 | end 259 | end 260 | 261 | function HardcoverApp:_handlePageUpdate(filename, mapped_page, immediate, callback) 262 | --logger.warn("HARDCOVER: Throttled page update", mapped_page) 263 | self.page_update_pending = false 264 | 265 | if not self:syncFileUpdates(filename) then 266 | return 267 | end 268 | 269 | if self.state.book_status.status_id ~= HARDCOVER.STATUS.READING then 270 | return 271 | end 272 | 273 | local reads = self.state.book_status.user_book_reads 274 | local current_read = reads and reads[#reads] 275 | if not current_read then 276 | return 277 | end 278 | 279 | local immediate_update = function() 280 | self.wifi:withWifi(function() 281 | local result = Api:updatePage(current_read.id, current_read.edition_id, mapped_page, current_read.started_at) 282 | if result then 283 | self.state.book_status = result 284 | end 285 | if callback then 286 | callback(result) 287 | end 288 | end) 289 | end 290 | 291 | local trapped_update = function() 292 | Trapper:wrap(immediate_update) 293 | end 294 | 295 | if immediate then 296 | immediate_update() 297 | else 298 | UIManager:scheduleIn(1, trapped_update) 299 | end 300 | end 301 | 302 | function HardcoverApp:initializePageUpdate() 303 | local track_frequency = math.max(math.min(self.settings:trackFrequency(), 120), 1) * 60 304 | 305 | HardcoverApp._throttledHandlePageUpdate, HardcoverApp._cancelPageUpdate = throttle( 306 | track_frequency, 307 | HardcoverApp._handlePageUpdate 308 | ) 309 | 310 | HardcoverApp.onPageUpdate, HardcoverApp._cancelPageUpdateEvent = debounce(2, HardcoverApp.pageUpdateEvent) 311 | end 312 | 313 | function HardcoverApp:pageUpdateEvent(page) 314 | self.state.last_page = self.state.page 315 | self.state.page = page 316 | 317 | if not (self.state.book_status.id and self.settings:syncEnabled()) then 318 | return 319 | end 320 | --logger.warn("HARDCOVER page update event pending") 321 | local document_pages = self.ui.document:getPageCount() 322 | local remote_pages = self.settings:pages() 323 | 324 | if self.settings:trackByTime() then 325 | local mapped_page = self.page_mapper:getMappedPage(page, document_pages, remote_pages) 326 | 327 | self:_throttledHandlePageUpdate(self.ui.document.file, mapped_page) 328 | self.page_update_pending = true 329 | elseif self.settings:trackByProgress() and self.state.last_page then 330 | local percent_interval = self.settings:trackPercentageInterval() 331 | 332 | local previous_percent = self.page_mapper:getRemotePagePercent( 333 | self.state.last_page, 334 | document_pages, 335 | remote_pages 336 | ) 337 | 338 | local current_percent, mapped_page = self.page_mapper:getRemotePagePercent( 339 | self.state.page, 340 | document_pages, 341 | remote_pages 342 | ) 343 | 344 | local last_compare = math.floor(previous_percent * 100 / percent_interval) 345 | local current_compare = math.floor(current_percent * 100 / percent_interval) 346 | 347 | if last_compare ~= current_compare then 348 | self:_handlePageUpdate(self.ui.document.file, mapped_page) 349 | end 350 | end 351 | end 352 | 353 | function HardcoverApp:onPosUpdate(_, page) 354 | if self.state.process_page_turns then 355 | self:pageUpdateEvent(page) 356 | end 357 | end 358 | 359 | function HardcoverApp:onUpdatePos() 360 | self.page_mapper:cachePageMap() 361 | end 362 | 363 | function HardcoverApp:onReaderReady() 364 | --logger.warn("HARDCOVER on ready") 365 | 366 | self.page_mapper:cachePageMap() 367 | self:registerHighlight() 368 | self.state.page = self.ui:getCurrentPage() 369 | 370 | if self.ui.document and (self.settings:syncEnabled() or (not self.settings:bookLinked() and self.settings:autolinkEnabled())) then 371 | UIManager:scheduleIn(2, self.startReadCache, self) 372 | end 373 | end 374 | 375 | function HardcoverApp:cancelPendingUpdates() 376 | if self._cancelPageUpdate then 377 | self:_cancelPageUpdate() 378 | end 379 | 380 | if self._cancelPageUpdateEvent then 381 | self:_cancelPageUpdateEvent() 382 | end 383 | 384 | self.page_update_pending = false 385 | end 386 | 387 | function HardcoverApp:onDocumentClose() 388 | UIManager:unschedule(self.startCacheRead) 389 | 390 | self:cancelPendingUpdates() 391 | self.state.read_cache_started = false 392 | 393 | if not self.state.book_status.id and not self.settings:syncEnabled() then 394 | return 395 | end 396 | 397 | if self.page_update_pending then 398 | self:updatePageNow() 399 | end 400 | 401 | self.process_page_turns = false 402 | self.page_update_pending = false 403 | self.state.book_status = {} 404 | self.state.page_map = nil 405 | end 406 | 407 | function HardcoverApp:onSuspend() 408 | self:cancelPendingUpdates() 409 | 410 | Scheduler:clear() 411 | self.state.read_cache_started = false 412 | end 413 | 414 | function HardcoverApp:onResume() 415 | if self.settings:readSetting(SETTING.ENABLE_WIFI) and self.ui.document and self.settings:syncEnabled() then 416 | UIManager:scheduleIn(2, self.startReadCache, self) 417 | end 418 | end 419 | 420 | function HardcoverApp:updatePageNow(callback) 421 | local mapped_page = self.page_mapper:getMappedPage( 422 | self.state.page, 423 | self.ui.document:getPageCount(), 424 | self.settings:pages() 425 | ) 426 | self:_handlePageUpdate(self.ui.document.file, mapped_page, true, callback) 427 | end 428 | 429 | function HardcoverApp:onNetworkDisconnecting() 430 | --logger.warn("HARDCOVER on disconnecting") 431 | if self.settings:readSetting(SETTING.ENABLE_WIFI) then 432 | return 433 | end 434 | 435 | self:cancelPendingUpdates() 436 | 437 | Scheduler:clear() 438 | self.state.read_cache_started = false 439 | 440 | if self.page_update_pending and self.ui.document and self.state.book_status.id and self.settings:syncEnabled() and self.settings:trackByTime() then 441 | self:updatePageNow() 442 | end 443 | self.page_update_pending = false 444 | end 445 | 446 | function HardcoverApp:onNetworkConnected() 447 | if self.ui.document and self.settings:syncEnabled() and not self.state.read_cache_started then 448 | --logger.warn("HARDCOVER on connected", self.state.read_cache_started) 449 | 450 | self:startReadCache() 451 | end 452 | end 453 | 454 | function HardcoverApp:onEndOfBook() 455 | local file_path = self.ui.document.file 456 | 457 | if not self:syncFileUpdates(file_path) then 458 | return 459 | end 460 | 461 | local mark_read = false 462 | if G_reader_settings:isTrue("end_document_auto_mark") then 463 | mark_read = true 464 | end 465 | 466 | if not mark_read then 467 | local action = G_reader_settings:readSetting("end_document_action") or "pop-up" 468 | mark_read = action == "mark_read" 469 | 470 | if action == "pop-up" then 471 | mark_read = 'later' 472 | end 473 | end 474 | 475 | if not mark_read then 476 | return 477 | end 478 | 479 | local user_id = User:getId() 480 | 481 | local marker = function() 482 | local book_id = self.settings:readBookSetting(file_path, "book_id") 483 | local user_book = Api:findUserBook(book_id, user_id) or {} 484 | self.cache:updateBookStatus(file_path, HARDCOVER.STATUS.FINISHED, user_book.privacy_setting_id) 485 | end 486 | 487 | if mark_read == 'later' then 488 | UIManager:scheduleIn(30, function() 489 | local status = "reading" 490 | if DocSettings:hasSidecarFile(file_path) then 491 | local summary = DocSettings:open(file_path):readSetting("summary") 492 | if summary and summary.status and summary.status ~= "" then 493 | status = summary.status 494 | end 495 | end 496 | if status == "complete" then 497 | self.wifi:withWifi(function() 498 | marker() 499 | end) 500 | end 501 | end) 502 | else 503 | self.wifi:withWifi(function() 504 | marker() 505 | UIManager:show(InfoMessage:new { 506 | text = _("Hardcover status saved"), 507 | timeout = 2 508 | }) 509 | end) 510 | end 511 | end 512 | 513 | function HardcoverApp:syncFileUpdates(filename) 514 | return self.settings:readBookSetting(filename, "book_id") and self.settings:fileSyncEnabled(filename) 515 | end 516 | 517 | function HardcoverApp:onDocSettingsItemsChanged(file, doc_settings) 518 | if not self:syncFileUpdates(file) or not doc_settings then 519 | return 520 | end 521 | 522 | local status 523 | if doc_settings.summary.status == "complete" then 524 | status = HARDCOVER.STATUS.FINISHED 525 | elseif doc_settings.summary.status == "reading" then 526 | status = HARDCOVER.STATUS.READING 527 | end 528 | 529 | if status then 530 | local book_id = self.settings:readBookSetting(file, "book_id") 531 | local user_book = Api:findUserBook(book_id, User:getId()) or {} 532 | self.wifi:withWifi(function() 533 | self.cache:updateBookStatus(file, status, user_book.privacy_setting_id) 534 | 535 | UIManager:show(InfoMessage:new { 536 | text = _("Hardcover status saved"), 537 | timeout = 2 538 | }) 539 | end) 540 | end 541 | end 542 | 543 | function HardcoverApp:startReadCache() 544 | --logger.warn("HARDCOVER start read cache") 545 | if self.state.read_cache_started then 546 | --logger.warn("HARDCOVER Cache already started") 547 | return 548 | end 549 | 550 | if not self.ui.document then 551 | --logger.warn("HARDCOVER read cache fired outside of document") 552 | return 553 | end 554 | 555 | self.state.read_cache_started = true 556 | 557 | local cancel 558 | 559 | local restart = function(delay) 560 | --logger.warn("HARDCOVER restart cache fetch") 561 | delay = delay or 60 562 | cancel() 563 | self.state.read_cache_started = false 564 | UIManager:scheduleIn(delay, self.startReadCache, self) 565 | end 566 | 567 | cancel = Scheduler:withRetries(6, 3, function(success, fail) 568 | Trapper:wrap(function() 569 | if not self.ui.document then 570 | -- fail, but cancel retries 571 | return success() 572 | end 573 | local book_settings = self.settings:readBookSettings(self.ui.document.file) or {} 574 | --logger.warn("HARDCOVER", book_settings) 575 | if book_settings.book_id then 576 | if self.state.book_status.id then 577 | return success() 578 | else 579 | self.wifi:withWifi(function() 580 | if not NetworkManager:isConnected() then 581 | return restart() 582 | end 583 | 584 | local err = self.cache:cacheUserBook() 585 | --if err then 586 | --logger.warn("HARDCOVER cache error", err) 587 | --end 588 | if err and err.completed == false then 589 | return fail(err) 590 | end 591 | 592 | success() 593 | end) 594 | end 595 | else 596 | self.hardcover:tryAutolink() 597 | if self.settings:bookLinked() and self.settings:syncEnabled() then 598 | return restart(2) 599 | end 600 | end 601 | end) 602 | end, 603 | 604 | function() 605 | if self.settings:syncEnabled() then 606 | --logger.warn("HARDCOVER enabling page turns") 607 | 608 | self.state.process_page_turns = true 609 | end 610 | end, 611 | 612 | function() 613 | if NetworkManager:isConnected() then 614 | UIManager:show(Notification:new { 615 | text = _("Failed to fetch book information from Hardcover"), 616 | }) 617 | end 618 | end) 619 | end 620 | 621 | function HardcoverApp:registerHighlight() 622 | self.ui.highlight:removeFromHighlightDialog(HIGHLIGHT_MENU_NAME) 623 | 624 | if self.enabled and self.settings:bookLinked() then 625 | self.ui.highlight:addToHighlightDialog(HIGHLIGHT_MENU_NAME, function(this) 626 | return { 627 | text_func = function() 628 | return _("Hardcover quote") 629 | end, 630 | callback = function() 631 | local selected_text = this.selected_text 632 | local raw_page = selected_text.pos0.page 633 | if not raw_page then 634 | raw_page = self.view.document:getPageFromXPointer(selected_text.pos0) 635 | end 636 | -- open journal dialog 637 | self:onHardcoverNote({ 638 | text = selected_text.text, 639 | page_number = raw_page, 640 | note_type = "quote" 641 | }) 642 | 643 | this:onClose() 644 | end, 645 | } 646 | end) 647 | end 648 | end 649 | 650 | function HardcoverApp:addToMainMenu(menu_items) 651 | menu_items.hardcover = self.menu:mainMenu() 652 | end 653 | 654 | return HardcoverApp 655 | -------------------------------------------------------------------------------- /hardcover/lib/ui/hardcover_menu.lua: -------------------------------------------------------------------------------- 1 | local DataStorage = require("datastorage") 2 | local Device = require("device") 3 | local _ = require("gettext") 4 | local math = require("math") 5 | local os = require("os") 6 | local logger = require("logger") 7 | 8 | local T = require("ffi/util").template 9 | 10 | local Font = require("ui/font") 11 | local UIManager = require("ui/uimanager") 12 | 13 | local UpdateDoubleSpinWidget = require("hardcover/lib/ui/update_double_spin_widget") 14 | local InfoMessage = require("ui/widget/infomessage") 15 | local SpinWidget = require("ui/widget/spinwidget") 16 | 17 | local Api = require("hardcover/lib/hardcover_api") 18 | local Github = require("hardcover/lib/github") 19 | local User = require("hardcover/lib/user") 20 | local _t = require("hardcover/lib/table_util") 21 | 22 | local HARDCOVER = require("hardcover/lib/constants/hardcover") 23 | local ICON = require("hardcover/lib/constants/icons") 24 | local SETTING = require("hardcover/lib/constants/settings") 25 | local VERSION = require("hardcover_version") 26 | 27 | local HardcoverMenu = {} 28 | HardcoverMenu.__index = HardcoverMenu 29 | 30 | function HardcoverMenu:new(o) 31 | return setmetatable(o or { 32 | enabled = true 33 | }, self) 34 | end 35 | 36 | local privacy_labels = { 37 | [HARDCOVER.PRIVACY.PUBLIC] = "Public", 38 | [HARDCOVER.PRIVACY.FOLLOWS] = "Follows", 39 | [HARDCOVER.PRIVACY.PRIVATE] = "Private" 40 | } 41 | 42 | function HardcoverMenu:mainMenu() 43 | return { 44 | enabled_func = function() 45 | return self.enabled 46 | end, 47 | text_func = function() 48 | return self.settings:bookLinked() and _("Hardcover: " .. ICON.LINK) or _("Hardcover") 49 | end, 50 | sub_item_table_func = function() 51 | local has_book = self.ui.document and true or false 52 | return self:getSubMenuItems(has_book) 53 | end, 54 | } 55 | end 56 | 57 | function HardcoverMenu:getSubMenuItems(book_view) 58 | local menu_items = { 59 | book_view and { 60 | text_func = function() 61 | if self.settings:bookLinked() then 62 | -- need to show link information somehow. Maybe store title 63 | local title = self.settings:getLinkedTitle() 64 | if not title then 65 | title = self.settings:getLinkedBookId() 66 | end 67 | return _("Linked book: " .. title) 68 | else 69 | return _("Link book") 70 | end 71 | end, 72 | enabled_func = function() 73 | -- leave button enabled to allow clearing local link when api disabled 74 | return self.enabled or self.settings:bookLinked() 75 | end, 76 | hold_callback = function(menu_instance) 77 | if self.settings:bookLinked() then 78 | self.settings:updateBookSetting( 79 | self.ui.document.file, 80 | { 81 | _delete = { 'book_id', 'edition_id', 'edition_format', 'pages', 'title' } 82 | } 83 | ) 84 | 85 | menu_instance:updateItems() 86 | end 87 | end, 88 | keep_menu_open = true, 89 | callback = function(menu_instance) 90 | if not self.enabled then 91 | return 92 | end 93 | 94 | local force_search = self.settings:bookLinked() 95 | 96 | self.hardcover:showLinkBookDialog(force_search, function() 97 | menu_instance:updateItems() 98 | end) 99 | end, 100 | }, 101 | book_view and { 102 | text_func = function() 103 | local edition_format = self.settings:getLinkedEditionFormat() 104 | local title = "Change edition" 105 | 106 | if edition_format then 107 | title = title .. ": " .. edition_format 108 | elseif self.settings:getLinkedEditionId() then 109 | return title .. ": physical book" 110 | end 111 | 112 | return _(title) 113 | end, 114 | enabled_func = function() 115 | return self.enabled and self.settings:bookLinked() 116 | end, 117 | callback = function(menu_instance) 118 | local editions = Api:findEditions(self.settings:getLinkedBookId(), User:getId()) 119 | -- need to show "active" here, and prioritize current edition if available 120 | self.dialog_manager:buildSearchDialog( 121 | "Select edition", 122 | editions, 123 | { 124 | edition_id = self.settings:getLinkedEditionId() 125 | }, 126 | function(book) 127 | self.hardcover:linkBook(book) 128 | menu_instance:updateItems() 129 | end 130 | ) 131 | end, 132 | keep_menu_open = true, 133 | separator = true 134 | }, 135 | book_view and { 136 | text = _("Automatically track progress"), 137 | checked_func = function() 138 | return self.settings:syncEnabled() 139 | end, 140 | enabled_func = function() 141 | return self.settings:bookLinked() 142 | end, 143 | callback = function() 144 | local sync = not self.settings:syncEnabled() 145 | self.settings:setSync(sync) 146 | end, 147 | }, 148 | book_view and { 149 | text = _("Update status"), 150 | enabled_func = function() 151 | return self.settings:bookLinked() 152 | end, 153 | sub_item_table_func = function() 154 | self.cache:cacheUserBook() 155 | 156 | return self:getStatusSubMenuItems() 157 | end, 158 | separator = true 159 | }, 160 | { 161 | text = _("Settings"), 162 | sub_item_table_func = function() 163 | return self:getSettingsSubMenuItems() 164 | end, 165 | }, 166 | { 167 | text = _("About"), 168 | callback = function() 169 | local new_release = Github:newestRelease() 170 | local version = table.concat(VERSION, ".") 171 | local new_release_str = "" 172 | if new_release then 173 | new_release_str = " (latest v" .. new_release .. ")" 174 | end 175 | local settings_file = DataStorage:getSettingsDir() .. "/" .. "hardcoversync_settings.lua" 176 | 177 | UIManager:show(InfoMessage:new { 178 | text = [[ 179 | Hardcover plugin 180 | v]] .. version .. new_release_str .. [[ 181 | 182 | 183 | Updates book progress and status on Hardcover.app 184 | 185 | Project: 186 | github.com/billiam/hardcoverapp.koplugin 187 | 188 | Settings: 189 | ]] .. settings_file, 190 | face = Font:getFace("cfont", 18), 191 | show_icon = false, 192 | }) 193 | end, 194 | keep_menu_open = true 195 | } 196 | } 197 | return _t.filter(menu_items, function(v) return v end) 198 | end 199 | 200 | function HardcoverMenu:getVisibilitySubMenuItems() 201 | return { 202 | { 203 | text = _(privacy_labels[HARDCOVER.PRIVACY.PUBLIC]), 204 | checked_func = function() 205 | return self.state.book_status.privacy_setting_id == HARDCOVER.PRIVACY.PUBLIC 206 | end, 207 | callback = function() 208 | self.hardcover:changeBookVisibility(HARDCOVER.PRIVACY.PUBLIC) 209 | end, 210 | radio = true, 211 | }, 212 | { 213 | text = _(privacy_labels[HARDCOVER.PRIVACY.FOLLOWS]), 214 | checked_func = function() 215 | return self.state.book_status.privacy_setting_id == HARDCOVER.PRIVACY.FOLLOWS 216 | end, 217 | callback = function() 218 | self.hardcover:changeBookVisibility(HARDCOVER.PRIVACY.FOLLOWS) 219 | end, 220 | radio = true 221 | }, 222 | { 223 | text = _(privacy_labels[HARDCOVER.PRIVACY.PRIVATE]), 224 | checked_func = function() 225 | return self.state.book_status.privacy_setting_id == HARDCOVER.PRIVACY.PRIVATE 226 | end, 227 | callback = function() 228 | self.hardcover:changeBookVisibility(HARDCOVER.PRIVACY.PRIVATE) 229 | end, 230 | radio = true 231 | }, 232 | } 233 | end 234 | 235 | function HardcoverMenu:getStatusSubMenuItems() 236 | return { 237 | { 238 | text = _(ICON.BOOKMARK .. " Want To Read"), 239 | enabled_func = function() 240 | return self.enabled 241 | end, 242 | checked_func = function() 243 | return self.state.book_status.status_id == HARDCOVER.STATUS.TO_READ 244 | end, 245 | callback = function() 246 | self.cache:updateBookStatus(self.ui.document.file, HARDCOVER.STATUS.TO_READ) 247 | end, 248 | radio = true 249 | }, 250 | { 251 | text = _(ICON.OPEN_BOOK .. " Currently Reading"), 252 | enabled_func = function() 253 | return self.enabled 254 | end, 255 | checked_func = function() 256 | return self.state.book_status.status_id == HARDCOVER.STATUS.READING 257 | end, 258 | callback = function() 259 | self.cache:updateBookStatus(self.ui.document.file, HARDCOVER.STATUS.READING) 260 | end, 261 | radio = true 262 | }, 263 | { 264 | text = _(ICON.CHECKMARK .. " Read"), 265 | enabled_func = function() 266 | return self.enabled 267 | end, 268 | checked_func = function() 269 | return self.state.book_status.status_id == HARDCOVER.STATUS.FINISHED 270 | end, 271 | callback = function() 272 | self.cache:updateBookStatus(self.ui.document.file, HARDCOVER.STATUS.FINISHED) 273 | end, 274 | radio = true 275 | }, 276 | { 277 | text = _(ICON.STOP_CIRCLE .. " Did Not Finish"), 278 | enabled_func = function() 279 | return self.enabled 280 | end, 281 | checked_func = function() 282 | return self.state.book_status.status_id == HARDCOVER.STATUS.DNF 283 | end, 284 | callback = function() 285 | self.cache:updateBookStatus(self.ui.document.file, HARDCOVER.STATUS.DNF) 286 | end, 287 | radio = true, 288 | }, 289 | { 290 | text = _(ICON.TRASH .. " Remove"), 291 | enabled_func = function() 292 | return self.enabled and self.state.book_status.status_id ~= nil 293 | end, 294 | callback = function(menu_instance) 295 | local result = Api:removeRead(self.state.book_status.id) 296 | if result and result.id then 297 | self.state.book_status = {} 298 | menu_instance:updateItems() 299 | end 300 | end, 301 | keep_menu_open = true, 302 | separator = true 303 | }, 304 | { 305 | text_func = function() 306 | local reads = self.state.book_status.user_book_reads 307 | local current_page = reads and reads[#reads] and reads[#reads].progress_pages or 0 308 | local max_pages = self.settings:pages() 309 | 310 | if not max_pages then 311 | max_pages = "???" 312 | end 313 | 314 | return T(_("Update page: %1 of %2"), current_page, max_pages) 315 | end, 316 | enabled_func = function() 317 | return self.enabled and self.state.book_status.status_id == HARDCOVER.STATUS.READING and self.settings:pages() 318 | end, 319 | callback = function(menu_instance) 320 | local reads = self.state.book_status.user_book_reads 321 | local current_read = reads and reads[#reads] 322 | local last_hardcover_page = current_read and current_read.progress_pages or 0 323 | 324 | local document_page = self.ui:getCurrentPage() 325 | local document_pages = self.ui.document:getPageCount() 326 | 327 | local remote_pages = self.settings:pages() 328 | local mapped_page = self.page_mapper:getMappedPage(document_page, document_pages, remote_pages) 329 | 330 | local left_text = "Edition" 331 | if last_hardcover_page > 0 then 332 | left_text = left_text .. ": was " .. last_hardcover_page 333 | end 334 | 335 | local spinner = UpdateDoubleSpinWidget:new { 336 | ok_always_enabled = true, 337 | 338 | left_text = left_text, 339 | left_value = mapped_page, 340 | left_min = 0, 341 | left_max = remote_pages, 342 | left_step = 1, 343 | left_hold_step = 20, 344 | 345 | right_text = "Local page", 346 | right_value = document_page, 347 | right_min = 0, 348 | right_max = document_pages, 349 | right_step = 1, 350 | right_hold_step = 20, 351 | 352 | update_callback = function(new_edition_page, new_document_page, edition_page_changed) 353 | if edition_page_changed then 354 | local new_mapped_page = self.page_mapper:getUnmappedPage(new_edition_page, document_pages, remote_pages) 355 | return new_edition_page, new_mapped_page 356 | else 357 | local new_mapped_page = self.page_mapper:getMappedPage(new_document_page, document_pages, remote_pages) 358 | return new_mapped_page, new_document_page 359 | end 360 | end, 361 | ok_text = _("Set page"), 362 | title_text = _("Set current page"), 363 | 364 | callback = function(edition_page, _document_page) 365 | local result 366 | 367 | if current_read then 368 | result = Api:updatePage(current_read.id, current_read.edition_id, edition_page, 369 | current_read.started_at) 370 | else 371 | local start_date = os.date("%Y-%m-%d") 372 | result = Api:createRead(self.state.book_status.id, self.state.book_status.edition_id, edition_page, 373 | start_date) 374 | end 375 | 376 | if result then 377 | self.state.book_status = result 378 | menu_instance:updateItems() 379 | else 380 | 381 | end 382 | end 383 | } 384 | UIManager:show(spinner) 385 | end, 386 | keep_menu_open = true 387 | }, 388 | { 389 | text = _("Add a note"), 390 | enabled_func = function() 391 | return self.enabled and self.state.book_status.id ~= nil 392 | end, 393 | callback = function() 394 | local reads = self.state.book_status.user_book_reads 395 | local current_read = reads and reads[#reads] 396 | local current_page = current_read and current_read.progress_pages or 0 397 | 398 | -- allow premapped page 399 | self.dialog_manager:journalEntryForm( 400 | "", 401 | self.ui.document, 402 | current_page, 403 | self.settings:pages(), 404 | current_page, 405 | "note" 406 | ) 407 | end, 408 | keep_menu_open = true 409 | }, 410 | { 411 | text_func = function() 412 | local text 413 | if self.state.book_status.rating then 414 | text = "Update rating" 415 | local whole_star = math.floor(self.state.book_status.rating) 416 | local star_string = string.rep(ICON.STAR, whole_star) 417 | if self.state.book_status.rating - whole_star > 0 then 418 | star_string = star_string .. ICON.HALF_STAR 419 | end 420 | text = text .. ": " .. star_string 421 | else 422 | text = "Set rating" 423 | end 424 | 425 | return _(text) 426 | end, 427 | enabled_func = function() 428 | return self.enabled and self.state.book_status.id ~= nil 429 | end, 430 | callback = function(menu_instance) 431 | local rating = self.state.book_status.rating 432 | 433 | local spinner = SpinWidget:new { 434 | ok_always_enabled = rating == nil, 435 | value = rating or 2.5, 436 | value_min = 0, 437 | value_max = 5, 438 | value_step = 0.5, 439 | value_hold_step = 2, 440 | precision = "%.1f", 441 | ok_text = _("Save"), 442 | title_text = _("Set Rating"), 443 | callback = function(spin) 444 | local result = Api:updateRating(self.state.book_status.id, spin.value) 445 | if result then 446 | self.state.book_status = result 447 | menu_instance:updateItems() 448 | else 449 | self.dialog_magager:showError("Rating could not be saved") 450 | end 451 | end 452 | } 453 | UIManager:show(spinner) 454 | end, 455 | hold_callback = function(menu_instance) 456 | local result = Api:updateRating(self.state.book_status.id, 0) 457 | if result then 458 | self.state.book_status = result 459 | menu_instance:updateItems() 460 | end 461 | end, 462 | keep_menu_open = true, 463 | separator = true 464 | }, 465 | { 466 | text = _("Set status visibility"), 467 | enabled_func = function() 468 | return self.enabled and self.state.book_status.id ~= nil 469 | end, 470 | sub_item_table_func = function() 471 | return self:getVisibilitySubMenuItems() 472 | end, 473 | }, 474 | } 475 | end 476 | 477 | function HardcoverMenu:getTrackingSubMenuItems() 478 | return { 479 | { 480 | text = "Update periodically", 481 | radio = true, 482 | checked_func = function() 483 | return self.settings:trackByTime() 484 | end, 485 | callback = function() 486 | self.settings:setTrackMethod(SETTING.TRACK.FREQUENCY) 487 | end 488 | }, 489 | { 490 | text_func = function() 491 | return "Every " .. self.settings:trackFrequency() .. " minutes" 492 | end, 493 | enabled_func = function() 494 | return self.settings:trackByTime() 495 | end, 496 | callback = function(menu_instance) 497 | local spinner = SpinWidget:new { 498 | value = self.settings:trackFrequency(), 499 | value_min = 1, 500 | value_max = 120, 501 | value_step = 1, 502 | value_hold_step = 6, 503 | ok_text = _("Save"), 504 | title_text = _("Set track frequency"), 505 | callback = function(spin) 506 | self.settings:updateSetting(SETTING.TRACK_FREQUENCY, spin.value) 507 | menu_instance:updateItems() 508 | end 509 | } 510 | 511 | UIManager:show(spinner) 512 | end, 513 | keep_menu_open = true 514 | }, 515 | { 516 | text = "Update by progress", 517 | radio = true, 518 | checked_func = function() 519 | return self.settings:trackByProgress() 520 | end, 521 | callback = function() 522 | self.settings:setTrackMethod(SETTING.TRACK.PROGRESS) 523 | end 524 | }, 525 | { 526 | text_func = function() 527 | return "Every " .. self.settings:trackPercentageInterval() .. " percent completed" 528 | end, 529 | enabled_func = function() 530 | return self.settings:trackByProgress() 531 | end, 532 | callback = function(menu_instance) 533 | local spinner = SpinWidget:new { 534 | value = self.settings:trackPercentageInterval(), 535 | value_min = 1, 536 | value_max = 50, 537 | value_step = 1, 538 | value_hold_step = 10, 539 | ok_text = _("Save"), 540 | title_text = _("Set track progress"), 541 | callback = function(spin) 542 | self.settings:changeTrackPercentageInterval(spin.value) 543 | menu_instance:updateItems() 544 | end 545 | } 546 | 547 | UIManager:show(spinner) 548 | end, 549 | keep_menu_open = true 550 | }, 551 | } 552 | end 553 | 554 | function HardcoverMenu:getSettingsSubMenuItems() 555 | return { 556 | { 557 | text = "Automatically link by ISBN", 558 | checked_func = function() 559 | return self.settings:readSetting(SETTING.LINK_BY_ISBN) == true 560 | end, 561 | callback = function() 562 | local setting = self.settings:readSetting(SETTING.LINK_BY_ISBN) == true 563 | self.settings:updateSetting(SETTING.LINK_BY_ISBN, not setting) 564 | end 565 | }, 566 | { 567 | text = "Automatically link by Hardcover identifiers", 568 | checked_func = function() 569 | return self.settings:readSetting(SETTING.LINK_BY_HARDCOVER) == true 570 | end, 571 | callback = function() 572 | local setting = self.settings:readSetting(SETTING.LINK_BY_HARDCOVER) == true 573 | self.settings:updateSetting(SETTING.LINK_BY_HARDCOVER, not setting) 574 | end 575 | }, 576 | { 577 | text = "Automatically link by title and author", 578 | checked_func = function() 579 | return self.settings:readSetting(SETTING.LINK_BY_TITLE) == true 580 | end, 581 | callback = function() 582 | local setting = self.settings:readSetting(SETTING.LINK_BY_TITLE) == true 583 | self.settings:updateSetting(SETTING.LINK_BY_TITLE, not setting) 584 | end, 585 | separator = true 586 | }, 587 | { 588 | text_func = function() 589 | return "Track progress settings: " .. "" 590 | end, 591 | sub_item_table_func = function() 592 | return self:getTrackingSubMenuItems() 593 | end, 594 | }, 595 | { 596 | text = "Always track progress by default", 597 | checked_func = function() 598 | return self.settings:readSetting(SETTING.ALWAYS_SYNC) == true 599 | end, 600 | callback = function() 601 | local setting = self.settings:readSetting(SETTING.ALWAYS_SYNC) == true 602 | self.settings:updateSetting(SETTING.ALWAYS_SYNC, not setting) 603 | end, 604 | }, 605 | { 606 | text = "Enable wifi on demand", 607 | checked_func = function() 608 | return self.settings:readSetting(SETTING.ENABLE_WIFI) == true 609 | end, 610 | enabled_func = function() 611 | return Device:hasWifiRestore() 612 | end, 613 | callback = function() 614 | local setting = self.settings:readSetting(SETTING.ENABLE_WIFI) == true 615 | self.settings:updateSetting(SETTING.ENABLE_WIFI, not setting) 616 | end 617 | }, 618 | { 619 | text = "Compatibility mode", 620 | checked_func = function() 621 | return self.settings:compatibilityMode() 622 | end, 623 | callback = function() 624 | local setting = self.settings:compatibilityMode() 625 | self.settings:updateSetting(SETTING.COMPATIBILITY_MODE, not setting) 626 | end, 627 | hold_callback = function() 628 | UIManager:show(InfoMessage:new { 629 | text = [[Disable fancy menu for book and edition search results. 630 | 631 | May improve compatibility for some versions of KOReader]], 632 | }) 633 | end 634 | } 635 | } 636 | end 637 | 638 | return HardcoverMenu 639 | -------------------------------------------------------------------------------- /hardcover/vendor/covermenu.lua: -------------------------------------------------------------------------------- 1 | local ButtonDialog = require("ui/widget/buttondialog") 2 | local DocSettings = require("docsettings") 3 | local InfoMessage = require("ui/widget/infomessage") 4 | local Menu = require("ui/widget/menu") 5 | local UIManager = require("ui/uimanager") 6 | local logger = require("logger") 7 | local _ = require("gettext") 8 | local ImageLoader = require("hardcover/lib/ui/image_loader") 9 | local RenderImage = require("ui/renderimage") 10 | 11 | --local BookInfoManager = require("bookinfomanager") 12 | 13 | -- This is a kind of "base class" for both MosaicMenu and ListMenu. 14 | -- It implements the common code shared by these, mostly the non-UI 15 | -- work : the updating of items and the management of background jobs. 16 | -- 17 | -- Here the common overridden methods of Menu are defined: 18 | -- :updateItems(select_number) 19 | -- :onCloseWidget() 20 | -- 21 | -- MosaicMenu or ListMenu should implement specific UI methods: 22 | -- :_recalculateDimen() 23 | -- :_updateItemsBuildUI() 24 | -- This last method is called in the middle of :updateItems() , and 25 | -- should fill self.item_group with some specific UI layout. It may add 26 | -- not found item to self.items_to_update for us to update() them 27 | -- regularly. 28 | 29 | -- Store these as local, to be set by some object and re-used by 30 | -- another object (as we plug the methods below to different objects, 31 | -- we can't store them in 'self' if we want another one to use it) 32 | local current_path = nil 33 | local current_cover_specs = false 34 | 35 | -- Do some collectgarbage() every few drawings 36 | local NB_DRAWINGS_BETWEEN_COLLECTGARBAGE = 5 37 | local nb_drawings_since_last_collectgarbage = 0 38 | 39 | -- Simple holder of methods that will replace those 40 | -- in the real Menu class or instance 41 | local CoverMenu = {} 42 | 43 | function CoverMenu:updateCache(file, status, do_create, pages) 44 | if do_create then -- create new cache entry if absent 45 | if self.cover_info_cache[file] then return end 46 | local doc_settings = DocSettings:open(file) 47 | -- We can get nb of page in the new 'doc_pages' setting, or from the old 'stats.page' 48 | local doc_pages = doc_settings:readSetting("doc_pages") 49 | if doc_pages then 50 | pages = doc_pages 51 | else 52 | local stats = doc_settings:readSetting("stats") 53 | if stats and stats.pages and stats.pages ~= 0 then -- crengine with statistics disabled stores 0 54 | pages = stats.pages 55 | end 56 | end 57 | local percent_finished = doc_settings:readSetting("percent_finished") 58 | local summary = doc_settings:readSetting("summary") 59 | status = summary and summary.status 60 | local has_highlight 61 | local annotations = doc_settings:readSetting("annotations") 62 | if annotations then 63 | has_highlight = #annotations > 0 64 | else 65 | local highlight = doc_settings:readSetting("highlight") 66 | has_highlight = highlight and next(highlight) and true 67 | end 68 | self.cover_info_cache[file] = table.pack(pages, percent_finished, status, has_highlight) -- may be a sparse array 69 | else 70 | if self.cover_info_cache and self.cover_info_cache[file] then 71 | if status then 72 | self.cover_info_cache[file][3] = status 73 | else 74 | self.cover_info_cache[file] = nil 75 | end 76 | end 77 | end 78 | end 79 | 80 | function CoverMenu:updateItems(select_number, no_recalculate_dimen) 81 | -- As done in Menu:updateItems() 82 | local old_dimen = self.dimen and self.dimen:copy() 83 | -- self.layout must be updated for focusmanager 84 | self.layout = {} 85 | self.item_group:clear() 86 | -- NOTE: Our various _recalculateDimen overloads appear to have a stronger dependency 87 | -- on the rest of the widget elements being properly laid-out, 88 | -- so we have to run it *first*, unlike in Menu. 89 | -- Otherwise, various layout issues arise (e.g., MosaicMenu's page_info is misaligned). 90 | if not no_recalculate_dimen then 91 | self:_recalculateDimen() 92 | end 93 | self.page_info:resetLayout() 94 | self.return_button:resetLayout() 95 | self.content_group:resetLayout() 96 | 97 | -- Reset the list of items not found in db that will need to 98 | -- be updated by a scheduled action 99 | self.items_to_update = {} 100 | -- Cancel any previous (now obsolete) scheduled update 101 | if self.halt_image_loading then 102 | self.halt_image_loading() 103 | end 104 | 105 | -- Force garbage collecting before drawing a new page. 106 | -- It's not really needed from a memory usage point of view, we did 107 | -- all the free() where necessary, and koreader memory usage seems 108 | -- stable when file browsing only (15-25 MB). 109 | -- But I witnessed some freezes after browsing a lot when koreader's main 110 | -- process was using 100% cpu (and some slow downs while drawing soon before 111 | -- the freeze, like the full refresh happening before the final drawing of 112 | -- new text covers), while still having a small memory usage (20/30 Mb) 113 | -- that I suspect may be some garbage collecting happening at one point 114 | -- and getting stuck... 115 | -- With this, garbage collecting may be more deterministic, and it has 116 | -- no negative impact on user experience. 117 | -- But don't do it on every drawing, to not have all of them slow 118 | -- when memory usage is already high 119 | nb_drawings_since_last_collectgarbage = nb_drawings_since_last_collectgarbage + 1 120 | if nb_drawings_since_last_collectgarbage >= NB_DRAWINGS_BETWEEN_COLLECTGARBAGE then 121 | -- (delay it a bit so this pause is less noticeable) 122 | UIManager:scheduleIn(0.2, function() 123 | collectgarbage() 124 | collectgarbage() 125 | end) 126 | nb_drawings_since_last_collectgarbage = 0 127 | end 128 | 129 | -- Specific UI building implementation (defined in some other module) 130 | self._has_cover_images = false 131 | select_number = self:_updateItemsBuildUI() or select_number 132 | -- Set the local variables with the things we know 133 | -- These are used only by extractBooksInDirectory(), which should 134 | -- use the cover_specs set for FileBrowser, and not those from History. 135 | -- Hopefully, we get self.path=nil when called from History 136 | if self.path then 137 | current_path = self.path 138 | current_cover_specs = self.cover_specs 139 | end 140 | 141 | -- As done in Menu:updateItems() 142 | self:updatePageInfo(select_number) 143 | Menu.mergeTitleBarIntoLayout(self) 144 | 145 | self.show_parent.dithered = self._has_cover_images 146 | UIManager:setDirty(self.show_parent, function() 147 | local refresh_dimen = 148 | old_dimen and old_dimen:combine(self.dimen) 149 | or self.dimen 150 | return "ui", refresh_dimen, self.show_parent.dithered 151 | end) 152 | 153 | -- As additionally done in FileChooser:updateItems() 154 | if self.path_items then 155 | self.path_items[self.path] = (self.page - 1) * self.perpage + (select_number or 1) 156 | end 157 | 158 | local image_batch 159 | if #self.items_to_update > 0 then 160 | local images = {} 161 | local items_by_cover_url = {} 162 | for i = 1, #self.items_to_update do 163 | local item = self.items_to_update[i] 164 | if item.lazy_load_cover then 165 | table.insert(images, item.entry.cover_url) 166 | if not items_by_cover_url[item.entry.cover_url] then 167 | items_by_cover_url[item.entry.cover_url] = { item } 168 | else 169 | table.insert(items_by_cover_url[item.entry.cover_url], item) 170 | end 171 | end 172 | end 173 | self.items_to_update = {} 174 | 175 | if #images > 0 then 176 | UIManager:scheduleIn(1, function() 177 | image_batch, self.halt_image_loading = ImageLoader:loadImages(images, function(url, content) 178 | for _, item in ipairs(items_by_cover_url[url]) do 179 | item.entry.lazy_load_cover = false 180 | item.entry.has_cover = true 181 | 182 | item.entry.cover_bb = RenderImage:renderImageData(content, #content, false, item.cover_w, item.cover_h) 183 | item.entry.cover_bb:setAllocated(1) 184 | item:update() 185 | 186 | self.show_parent.dithered = item._has_cover_image 187 | 188 | local refreshfunc = function() 189 | if item.refresh_dimen then 190 | -- MosaicMenuItem may exceed its own dimen in its paintTo 191 | -- with its "description" hint 192 | return "ui", item.refresh_dimen, self.show_parent.dithered 193 | else 194 | return "ui", item[1].dimen, self.show_parent.dithered 195 | end 196 | end 197 | 198 | UIManager:setDirty(self.show_parent, refreshfunc) 199 | end 200 | end) 201 | end) 202 | end 203 | end 204 | 205 | -- (We may not need to do the following if we extend showFileDialog 206 | -- code in filemanager.lua to check for existence and call a 207 | -- method: self:getAdditionalButtons() to add our buttons 208 | -- to its own set.) 209 | 210 | -- We want to add some buttons to the showFileDialog popup. This function 211 | -- is dynamically created by FileManager:init(), and we don't want 212 | -- to override this... So, here, when we see the showFileDialog function, 213 | -- we replace it by ours. 214 | -- (FileManager may replace file_chooser.showFileDialog after we've been called once, so we need 215 | -- to replace it again if it is not ours) 216 | if self.path -- FileManager only 217 | and (not self.showFileDialog_ours -- never replaced 218 | or self.showFileDialog ~= self.showFileDialog_ours) then -- it is no more ours 219 | -- We need to do it at nextTick, once FileManager has instantiated 220 | -- its FileChooser completely 221 | UIManager:nextTick(function() 222 | -- Store original function, so we can call it 223 | self.showFileDialog_orig = self.showFileDialog 224 | 225 | -- Replace it with ours 226 | -- This causes luacheck warning: "shadowing upvalue argument 'self' on line 34". 227 | -- Ignoring it (as done in filemanager.lua for the same showFileDialog) 228 | self.showFileDialog = function(self, item) -- luacheck: ignore 229 | local file = item.path 230 | -- Call original function: it will create a ButtonDialog 231 | -- and store it as self.file_dialog, and UIManager:show() it. 232 | self.showFileDialog_orig(self, item) 233 | 234 | local bookinfo = self.book_props -- getBookInfo(file) called by FileManager 235 | if not bookinfo or bookinfo._is_directory then 236 | -- If no bookinfo (yet) about this file, or it's a directory, let the original dialog be 237 | return true 238 | end 239 | 240 | -- Remember some of this original ButtonDialog properties 241 | local orig_title = self.file_dialog.title 242 | local orig_title_align = self.file_dialog.title_align 243 | local orig_buttons = self.file_dialog.buttons 244 | -- Close original ButtonDialog (it has not yet been painted 245 | -- on screen, so we won't see it) 246 | UIManager:close(self.file_dialog) 247 | -- And clear the rendering stack to avoid inheriting its dirty/refresh queue 248 | UIManager:clearRenderStack() 249 | 250 | -- Add some new buttons to original buttons set 251 | table.insert(orig_buttons, { 252 | { -- Allow user to ignore some offending cover image 253 | text = bookinfo.ignore_cover and _("Unignore cover") or _("Ignore cover"), 254 | enabled = bookinfo.has_cover and true or false, 255 | callback = function() 256 | --BookInfoManager:setBookInfoProperties(file, { 257 | -- ["ignore_cover"] = not bookinfo.ignore_cover and 'Y' or false, 258 | --}) 259 | UIManager:close(self.file_dialog) 260 | self:updateItems(1, true) 261 | end, 262 | }, 263 | { -- Allow user to ignore some bad metadata (filename will be used instead) 264 | text = bookinfo.ignore_meta and _("Unignore metadata") or _("Ignore metadata"), 265 | enabled = bookinfo.has_meta and true or false, 266 | callback = function() 267 | --BookInfoManager:setBookInfoProperties(file, { 268 | -- ["ignore_meta"] = not bookinfo.ignore_meta and 'Y' or false, 269 | --}) 270 | UIManager:close(self.file_dialog) 271 | self:updateItems(1, true) 272 | end, 273 | }, 274 | }) 275 | table.insert(orig_buttons, { 276 | { -- Allow a new extraction (multiple interruptions, book replaced)... 277 | text = _("Refresh cached book information"), 278 | callback = function() 279 | -- Wipe the cache 280 | self:updateCache(file) 281 | --BookInfoManager:deleteBookInfo(file) 282 | UIManager:close(self.file_dialog) 283 | self:updateItems(1, true) 284 | end, 285 | }, 286 | }) 287 | 288 | -- Create the new ButtonDialog, and let UIManager show it 289 | self.file_dialog = ButtonDialog:new { 290 | title = orig_title, 291 | title_align = orig_title_align, 292 | buttons = orig_buttons, 293 | } 294 | UIManager:show(self.file_dialog) 295 | return true 296 | end 297 | 298 | -- Remember our function 299 | self.showFileDialog_ours = self.showFileDialog 300 | end) 301 | end 302 | end 303 | 304 | -- Similar to showFileDialog setup just above, but for History, 305 | -- which is plugged in main.lua _FileManagerHistory_updateItemTable() 306 | function CoverMenu:onHistoryMenuHold(item) 307 | -- Call original function: it will create a ButtonDialog 308 | -- and store it as self.histfile_dialog, and UIManager:show() it. 309 | self.onMenuHold_orig(self, item) 310 | local file = item.file 311 | 312 | local bookinfo = self.book_props -- getBookInfo(file) called by FileManagerHistory 313 | if not bookinfo then 314 | -- If no bookinfo (yet) about this file, let the original dialog be 315 | return true 316 | end 317 | 318 | -- Remember some of this original ButtonDialog properties 319 | local orig_title = self.histfile_dialog.title 320 | local orig_title_align = self.histfile_dialog.title_align 321 | local orig_buttons = self.histfile_dialog.buttons 322 | -- Close original ButtonDialog (it has not yet been painted 323 | -- on screen, so we won't see it) 324 | UIManager:close(self.histfile_dialog) 325 | UIManager:clearRenderStack() 326 | 327 | -- Add some new buttons to original buttons set 328 | table.insert(orig_buttons, { 329 | { -- Allow user to ignore some offending cover image 330 | text = bookinfo.ignore_cover and _("Unignore cover") or _("Ignore cover"), 331 | enabled = bookinfo.has_cover and true or false, 332 | callback = function() 333 | --BookInfoManager:setBookInfoProperties(file, { 334 | -- ["ignore_cover"] = not bookinfo.ignore_cover and 'Y' or false, 335 | --}) 336 | UIManager:close(self.histfile_dialog) 337 | self:updateItems(1, true) 338 | end, 339 | }, 340 | { -- Allow user to ignore some bad metadata (filename will be used instead) 341 | text = bookinfo.ignore_meta and _("Unignore metadata") or _("Ignore metadata"), 342 | enabled = bookinfo.has_meta and true or false, 343 | callback = function() 344 | --BookInfoManager:setBookInfoProperties(file, { 345 | -- ["ignore_meta"] = not bookinfo.ignore_meta and 'Y' or false, 346 | --}) 347 | UIManager:close(self.histfile_dialog) 348 | self:updateItems(1, true) 349 | end, 350 | }, 351 | }) 352 | table.insert(orig_buttons, { 353 | { -- Allow a new extraction (multiple interruptions, book replaced)... 354 | text = _("Refresh cached book information"), 355 | callback = function() 356 | -- Wipe the cache 357 | self:updateCache(file) 358 | --BookInfoManager:deleteBookInfo(file) 359 | UIManager:close(self.histfile_dialog) 360 | self:updateItems(1, true) 361 | end, 362 | }, 363 | }) 364 | 365 | -- Create the new ButtonDialog, and let UIManager show it 366 | self.histfile_dialog = ButtonDialog:new { 367 | title = orig_title, 368 | title_align = orig_title_align, 369 | buttons = orig_buttons, 370 | } 371 | UIManager:show(self.histfile_dialog) 372 | return true 373 | end 374 | 375 | -- Similar to showFileDialog setup just above, but for Collections, 376 | -- which is plugged in main.lua _FileManagerCollections_updateItemTable() 377 | function CoverMenu:onCollectionsMenuHold(item) 378 | -- Call original function: it will create a ButtonDialog 379 | -- and store it as self.collfile_dialog, and UIManager:show() it. 380 | self.onMenuHold_orig(self, item) 381 | local file = item.file 382 | 383 | local bookinfo = self.book_props -- getBookInfo(file) called by FileManagerCollection 384 | if not bookinfo then 385 | -- If no bookinfo (yet) about this file, let the original dialog be 386 | return true 387 | end 388 | 389 | -- Remember some of this original ButtonDialog properties 390 | local orig_title = self.collfile_dialog.title 391 | local orig_title_align = self.collfile_dialog.title_align 392 | local orig_buttons = self.collfile_dialog.buttons 393 | -- Close original ButtonDialog (it has not yet been painted 394 | -- on screen, so we won't see it) 395 | UIManager:close(self.collfile_dialog) 396 | UIManager:clearRenderStack() 397 | 398 | -- Add some new buttons to original buttons set 399 | table.insert(orig_buttons, { 400 | { -- Allow user to ignore some offending cover image 401 | text = bookinfo.ignore_cover and _("Unignore cover") or _("Ignore cover"), 402 | enabled = bookinfo.has_cover and true or false, 403 | callback = function() 404 | --BookInfoManager:setBookInfoProperties(file, { 405 | -- ["ignore_cover"] = not bookinfo.ignore_cover and 'Y' or false, 406 | --}) 407 | UIManager:close(self.collfile_dialog) 408 | self:updateItems(1, true) 409 | end, 410 | }, 411 | { -- Allow user to ignore some bad metadata (filename will be used instead) 412 | text = bookinfo.ignore_meta and _("Unignore metadata") or _("Ignore metadata"), 413 | enabled = bookinfo.has_meta and true or false, 414 | callback = function() 415 | --BookInfoManager:setBookInfoProperties(file, { 416 | -- ["ignore_meta"] = not bookinfo.ignore_meta and 'Y' or false, 417 | --}) 418 | UIManager:close(self.collfile_dialog) 419 | self:updateItems(1, true) 420 | end, 421 | }, 422 | }) 423 | table.insert(orig_buttons, { 424 | { -- Allow a new extraction (multiple interruptions, book replaced)... 425 | text = _("Refresh cached book information"), 426 | callback = function() 427 | -- Wipe the cache 428 | self:updateCache(file) 429 | --BookInfoManager:deleteBookInfo(file) 430 | UIManager:close(self.collfile_dialog) 431 | self:updateItems(1, true) 432 | end, 433 | }, 434 | }) 435 | 436 | -- Create the new ButtonDialog, and let UIManager show it 437 | self.collfile_dialog = ButtonDialog:new { 438 | title = orig_title, 439 | title_align = orig_title_align, 440 | buttons = orig_buttons, 441 | } 442 | UIManager:show(self.collfile_dialog) 443 | return true 444 | end 445 | 446 | function CoverMenu:onCloseWidget() 447 | -- Due to close callback in FileManagerHistory:onShowHist, we may be called 448 | -- multiple times (witnessed that with print(debug.traceback()) 449 | -- So, avoid doing what follows twice 450 | if self._covermenu_onclose_done then 451 | return 452 | end 453 | self._covermenu_onclose_done = true 454 | 455 | -- Stop background job if any (so that full cpu is available to reader) 456 | logger.dbg("CoverMenu:onCloseWidget: terminating jobs if needed") 457 | --BookInfoManager:terminateBackgroundJobs() 458 | --BookInfoManager:closeDbConnection() -- sqlite connection no more needed 459 | --BookInfoManager:cleanUp() -- clean temporary resources 460 | 461 | -- Cancel any still scheduled update 462 | if self.halt_image_loading then 463 | self.halt_image_loading() 464 | end 465 | 466 | for _, v in ipairs(self.item_table) do 467 | if v.cover_bb then 468 | v.cover_bb:free() 469 | end 470 | end 471 | 472 | -- Propagate a call to free() to all our sub-widgets, to release memory used by their _bb 473 | self.item_group:free() 474 | 475 | -- Clean any short term cache (used by ListMenu to cache some Doc Settings info) 476 | self.cover_info_cache = nil 477 | 478 | -- Force garbage collecting when leaving too 479 | -- (delay it a bit so this pause is less noticeable) 480 | UIManager:scheduleIn(0.2, function() 481 | collectgarbage() 482 | collectgarbage() 483 | end) 484 | nb_drawings_since_last_collectgarbage = 0 485 | 486 | -- Call the object's original onCloseWidget (i.e., Menu's, as none our our expected subclasses currently implement it) 487 | Menu.onCloseWidget(self) 488 | end 489 | 490 | function CoverMenu:tapPlus() 491 | -- Call original function: it will create a ButtonDialog 492 | -- and store it as self.file_dialog, and UIManager:show() it. 493 | CoverMenu._FileManager_tapPlus_orig(self) 494 | if self.file_dialog.select_mode then return end -- do not change select menu 495 | 496 | -- Remember some of this original ButtonDialog properties 497 | local orig_title = self.file_dialog.title 498 | local orig_title_align = self.file_dialog.title_align 499 | local orig_buttons = self.file_dialog.buttons 500 | -- Close original ButtonDialog (it has not yet been painted 501 | -- on screen, so we won't see it) 502 | UIManager:close(self.file_dialog) 503 | UIManager:clearRenderStack() 504 | 505 | -- Add a new button to original buttons set 506 | table.insert(orig_buttons, {}) -- separator 507 | table.insert(orig_buttons, { 508 | { 509 | text = _("Extract and cache book information"), 510 | callback = function() 511 | UIManager:close(self.file_dialog) 512 | local Trapper = require("ui/trapper") 513 | --Trapper:wrap(function() 514 | -- BookInfoManager:extractBooksInDirectory(current_path, current_cover_specs) 515 | --end) 516 | end, 517 | }, 518 | }) 519 | 520 | -- Create the new ButtonDialog, and let UIManager show it 521 | self.file_dialog = ButtonDialog:new { 522 | title = orig_title, 523 | title_align = orig_title_align, 524 | buttons = orig_buttons, 525 | } 526 | UIManager:show(self.file_dialog) 527 | return true 528 | end 529 | 530 | return CoverMenu 531 | --------------------------------------------------------------------------------