diff --git a/.editorconfig b/.editorconfig index 2395ff823..10681860d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,6 +22,6 @@ indent_size = 8 indent_style = tab indent_size = 8 -[*.{js,json,css,scss,sass,html,handlebars,tpl}] +[*.{js,css,scss,sass,html,handlebars,tpl}] indent_style = space indent_size = 2 diff --git a/frontend/apps/reader/modules/readerpaging.lua b/frontend/apps/reader/modules/readerpaging.lua index 39d672896..c230d087e 100644 --- a/frontend/apps/reader/modules/readerpaging.lua +++ b/frontend/apps/reader/modules/readerpaging.lua @@ -131,10 +131,20 @@ end function ReaderPaging:onSaveSettings() self.ui.doc_settings:saveSetting("page_positions", self.page_positions) self.ui.doc_settings:saveSetting("last_page", self:getTopPage()) - self.ui.doc_settings:saveSetting("percent_finished", self.current_page/self.number_of_pages) + self.ui.doc_settings:saveSetting("percent_finished", self:getLastPercent()) self.ui.doc_settings:saveSetting("show_overlap_enable", self.show_overlap_enable) end +function ReaderPaging:getLastProgress() + return self:getTopPage() +end + +function ReaderPaging:getLastPercent() + if self.current_page > 0 and self.number_of_pages > 0 then + return self.current_page/self.number_of_pages + end +end + function ReaderPaging:addToMainMenu(tab_item_table) -- FIXME: repeated code with page overlap menu for readerrolling -- needs to keep only one copy of the logic as for the DRY principle. diff --git a/frontend/apps/reader/modules/readerrolling.lua b/frontend/apps/reader/modules/readerrolling.lua index 83b4effad..2619f8214 100644 --- a/frontend/apps/reader/modules/readerrolling.lua +++ b/frontend/apps/reader/modules/readerrolling.lua @@ -208,6 +208,10 @@ function ReaderRolling:onSaveSettings() self.ui.doc_settings:saveSetting("show_overlap_enable", self.show_overlap_enable) end +function ReaderRolling:getLastProgress() + return self.xpointer +end + function ReaderRolling:addToMainMenu(tab_item_table) -- FIXME: repeated code with page overlap menu for readerpaging -- needs to keep only one copy of the logic as for the DRY principle. diff --git a/frontend/apps/reader/readerui.lua b/frontend/apps/reader/readerui.lua index c23d5d709..4418e854a 100644 --- a/frontend/apps/reader/readerui.lua +++ b/frontend/apps/reader/readerui.lua @@ -368,7 +368,8 @@ function ReaderUI:closeDocument() self.document = nil end -function ReaderUI:onCloseDocument() +function ReaderUI:notifyCloseDocument() + self:handleEvent(Event:new("CloseDocument")) if self.document:isEdited() then UIManager:show(ConfirmBox:new{ text = _("Do you want to save this document?"), @@ -392,7 +393,7 @@ function ReaderUI:onClose() self:saveSettings() if self.document ~= nil then DEBUG("closing document") - self:onCloseDocument() + self:notifyCloseDocument() end UIManager:close(self.dialog, "full") -- serialize last used items for later launch diff --git a/frontend/httpclient.lua b/frontend/httpclient.lua index 9961edcaa..fb2668435 100644 --- a/frontend/httpclient.lua +++ b/frontend/httpclient.lua @@ -2,7 +2,6 @@ local UIManager = require("ui/uimanager") local DEBUG = require("dbg") local HTTPClient = { - headers = {}, input_timeouts = 0, INPUT_TIMEOUT = 100*1000, } @@ -14,20 +13,7 @@ function HTTPClient:new() return o end -function HTTPClient:addHeader(header, value) - self.headers[header] = value -end - -function HTTPClient:removeHeader(header) - self.headers[header] = nil -end - function HTTPClient:request(request, response_callback) - request.on_headers = function(headers) - for header, value in pairs(self.headers) do - headers[header] = value - end - end request.connect_timeout = 10 request.request_timeout = 20 UIManager:initLooper() diff --git a/plugins/kosync.koplugin/KOSyncClient.lua b/plugins/kosync.koplugin/KOSyncClient.lua new file mode 100644 index 000000000..55986dc8f --- /dev/null +++ b/plugins/kosync.koplugin/KOSyncClient.lua @@ -0,0 +1,150 @@ +local UIManager = require("ui/uimanager") +local DEBUG = require("dbg") + +local KOSyncClient = { + service_spec = nil, +} + +function KOSyncClient:new(o) + local o = o or {} + setmetatable(o, self) + self.__index = self + if o.init then o:init() end + return o +end + +function KOSyncClient:init() + local Spore = require("Spore") + self.client = Spore.new_from_spec(self.service_spec) + package.loaded['Spore.Middleware.GinClient'] = {} + require('Spore.Middleware.GinClient').call = function(self, req) + req.headers['accept'] = "application/vnd.koreader.v1+json" + end + package.loaded['Spore.Middleware.KOSyncAuth'] = {} + require('Spore.Middleware.KOSyncAuth').call = function(args, req) + req.headers['x-auth-user'] = args.username + req.headers['x-auth-key'] = args.userkey + end + local HTTPClient = require("httpclient") + local async_http_client = HTTPClient:new() + package.loaded['Spore.Middleware.AsyncHTTP'] = {} + require('Spore.Middleware.AsyncHTTP').call = function(args, req) + req:finalize() + local result + async_http_client:request({ + url = req.url, + method = req.method, + body = req.env.spore.payload, + on_headers = function(headers) + for header, value in pairs(req.headers) do + if type(header) == 'string' then + headers:add(header, value) + end + end + end, + }, function(res) + result = res + -- Turbo HTTP client uses code instead of status + -- change to status so that Spore can understand + result.status = res.code + coroutine.resume(args.thread) + end) + return coroutine.create(function() coroutine.yield(result) end) + end +end + +function KOSyncClient:register(username, password) + self.client:reset_middlewares() + self.client:enable('Format.JSON') + self.client:enable("GinClient") + local ok, res = pcall(function() + return self.client:register({ + username = username, + password = password, + }) + end) + if ok then + return res.status == 201, res.body + else + DEBUG(ok, res) + return false, res.body + end +end + +function KOSyncClient:authorize(username, password) + self.client:reset_middlewares() + self.client:enable('Format.JSON') + self.client:enable("GinClient") + self.client:enable("KOSyncAuth", { + username = username, + userkey = password, + }) + local ok, res = pcall(function() + return self.client:authorize() + end) + if ok then + return res.status == 200, res.body + else + DEBUG("err:", res) + return false, res + end +end + +function KOSyncClient:update_progress(username, password, + document, progress, percentage, device, callback) + self.client:reset_middlewares() + self.client:enable('Format.JSON') + self.client:enable("GinClient") + self.client:enable("KOSyncAuth", { + username = username, + userkey = password, + }) + local co = coroutine.create(function() + local ok, res = pcall(function() + return self.client:update_progress({ + document = document, + progress = progress, + percentage = percentage, + device = device, + }) + end) + if ok then + callback(res.status == 200, res.body) + else + DEBUG("err:", res) + callback(false, res) + end + end) + self.client:enable("AsyncHTTP", {thread = co}) + coroutine.resume(co) + UIManager.INPUT_TIMEOUT = 100 +end + +function KOSyncClient:get_progress(username, password, + document, callback) + self.client:reset_middlewares() + self.client:enable('Format.JSON') + self.client:enable("GinClient") + self.client:enable("KOSyncAuth", { + username = username, + userkey = password, + }) + local co = coroutine.create(function() + local ok, res = pcall(function() + return self.client:get_progress({ + document = document, + }) + end) + if ok then + callback(res.status == 200, res.body) + else + DEBUG("err:", res) + callback(false, res) + end + end) + self.client:enable("AsyncHTTP", {thread = co}) + coroutine.resume(co) + UIManager.INPUT_TIMEOUT = 100 +end + +return KOSyncClient diff --git a/plugins/kosync.koplugin/api.json b/plugins/kosync.koplugin/api.json new file mode 100644 index 000000000..3db2673cb --- /dev/null +++ b/plugins/kosync.koplugin/api.json @@ -0,0 +1,49 @@ +{ + "base_url" : "https://vislab.bjmu.edu.cn:7200/", + "name" : "koreader-sync-api", + "methods" : { + "register" : { + "path" : "/users/create", + "method" : "POST", + "required_params" : [ + "username", + "password", + ], + "payload" : [ + "username", + "password", + ], + "expected_status" : [201, 402] + }, + "authorize" : { + "path" : "/users/auth", + "method" : "GET", + "expected_status" : [200, 401] + }, + "update_progress" : { + "path" : "/syncs/progress", + "method" : "PUT", + "required_params" : [ + "document", + "progress", + "percentage", + "device", + ], + "payload" : [ + "document", + "progress", + "percentage", + "device", + ], + "expected_status" : [200, 202, 401] + }, + "get_progress" : { + "path" : "/syncs/progress/:document", + "method" : "GET", + "required_params" : [ + "document", + ], + "expected_status" : [200, 401] + }, + } +} diff --git a/plugins/kosync.koplugin/main.lua b/plugins/kosync.koplugin/main.lua new file mode 100644 index 000000000..bd3275d19 --- /dev/null +++ b/plugins/kosync.koplugin/main.lua @@ -0,0 +1,272 @@ +local InputContainer = require("ui/widget/container/inputcontainer") +local LoginDialog = require("ui/widget/logindialog") +local InfoMessage = require("ui/widget/infomessage") +local ConfirmBox = require("ui/widget/confirmbox") +local DocSettings = require("docsettings") +local NetworkMgr = require("ui/networkmgr") +local UIManager = require("ui/uimanager") +local Screen = require("device").screen +local Device = require("device") +local Event = require("ui/event") +local DEBUG = require("dbg") +local T = require("ffi/util").template +local _ = require("gettext") +local md5 = require("MD5") + +local KOSync = InputContainer:new{ + name = "kosync", + register_title = _("Register an account in Koreader server"), + login_title = _("Login to Koreader server"), +} + +function KOSync:init() + local settings = G_reader_settings:readSetting("kosync") or {} + self.kosync_username = settings.username or "" + self.kosync_userkey = settings.userkey + self.ui:registerPostInitCallback(function() + UIManager:scheduleIn(1, function() self:getProgress() end) + end) + self.ui.menu:registerToMainMenu(self) +end + +function KOSync:addToMainMenu(tab_item_table) + table.insert(tab_item_table.plugins, { + text = _("Progress Sync"), + sub_item_table = { + { + text_func = function() + return self.kosync_userkey and (_("Logout")) + or _("Register") .. " / " .. _("Login") + end, + callback_func = function() + return self.kosync_userkey and + function() self:logout() end or + function() self:login() end + end, + }, + } + }) +end + +function KOSync:login() + if NetworkMgr:getWifiStatus() == false then + NetworkMgr:promptWifiOn() + end + self.login_dialog = LoginDialog:new{ + title = self.kosync_username and self.login_title or self.register_title, + username = self.kosync_username or "", + buttons = { + { + { + text = _("Cancel"), + enabled = true, + callback = function() + self:closeDialog() + end, + }, + { + text = _("Login"), + enabled = true, + callback = function() + local username, password = self:getCredential() + self:closeDialog() + UIManager:scheduleIn(0.5, function() + self:doLogin(username, password) + end) + + UIManager:show(InfoMessage:new{ + text = _("Logging in. Please wait..."), + timeout = 1, + }) + end, + }, + { + text = _("Register"), + enabled = not self.kosync and true or false, + callback = function() + local username, password = self:getCredential() + self:closeDialog() + UIManager:scheduleIn(0.5, function() + self:doRegister(username, password) + end) + + UIManager:show(InfoMessage:new{ + text = _("Registering. Please wait..."), + timeout = 1, + }) + end, + }, + }, + }, + width = Screen:getWidth() * 0.8, + height = Screen:getHeight() * 0.4, + } + + self.login_dialog:onShowKeyboard() + UIManager:show(self.login_dialog) +end + +function KOSync:closeDialog() + self.login_dialog:onClose() + UIManager:close(self.login_dialog) +end + +function KOSync:getCredential() + return self.login_dialog:getCredential() +end + +function KOSync:doRegister(username, password) + local KOSyncClient = require("KOSyncClient") + local client = KOSyncClient:new{ + service_spec = self.path .. "/api.json" + } + local userkey = md5:sum(password) + local ok, status, body = pcall(client.register, client, username, userkey) + if not ok and status then + UIManager:show(InfoMessage:new{ + text = _("An error occurred while registering:") .. + "\n" .. status, + }) + elseif ok then + if status then + self.kosync_username = username + self.kosync_userkey = userkey + UIManager:show(InfoMessage:new{ + text = _("Registered to Koreader server successfully."), + }) + else + UIManager:show(InfoMessage:new{ + text = _(body.message or "Unknown server error"), + }) + end + end + + self:onSaveSettings() +end + +function KOSync:doLogin(username, password) + local KOSyncClient = require("KOSyncClient") + local client = KOSyncClient:new{ + service_spec = self.path .. "/api.json" + } + local userkey = md5:sum(password) + local ok, status, body = pcall(client.authorize, client, username, userkey) + if not ok and status then + UIManager:show(InfoMessage:new{ + text = _("An error occurred while logging in:") .. + "\n" .. status, + }) + elseif ok then + if status then + self.kosync_username = username + self.kosync_userkey = userkey + UIManager:show(InfoMessage:new{ + text = _("Logged in to Koreader server successfully."), + }) + else + UIManager:show(InfoMessage:new{ + text = _(body.message or "Unknown server error"), + }) + end + end + + self:onSaveSettings() +end + +function KOSync:logout() + self.kosync_username = nil + self.kosync_userkey = nil + self:onSaveSettings() +end + +function KOSync:getLastPercent() + if self.ui.document.info.has_pages then + return self.ui.paging:getLastPercent() + else + return self.ui.rolling:getLastPercent() + end +end + +function KOSync:getLastProgress() + if self.ui.document.info.has_pages then + return self.ui.paging:getLastProgress() + else + return self.ui.rolling:getLastProgress() + end +end + +function KOSync:syncToProgress(progress) + DEBUG("sync to", progress) + if self.ui.document.info.has_pages then + self.ui:handleEvent(Event:new("GotoPage", tonumber(progress))) + else + self.ui:handleEvent(Event:new("GotoXPointer", progress)) + end +end + +function KOSync:updateProgress() + if self.kosync_username and self.kosync_userkey then + local KOSyncClient = require("KOSyncClient") + local client = KOSyncClient:new{ + service_spec = self.path .. "/api.json" + } + local doc_digest = self.view.document:fastDigest() + local progress = self:getLastProgress() + local percentage = self:getLastPercent() + local ok, err = pcall(client.update_progress, client, + self.kosync_username, self.kosync_userkey, + doc_digest, progress, percentage, Device.model, + function(ok, body) + DEBUG("update progress for", self.view.document.file, ok) + end) + if not ok and err then + DEBUG("err:", err) + end + end +end + +function KOSync:getProgress() + if self.kosync_username and self.kosync_userkey then + local KOSyncClient = require("KOSyncClient") + local client = KOSyncClient:new{ + service_spec = self.path .. "/api.json" + } + local doc_digest = self.view.document:fastDigest() + local ok, err = pcall(client.get_progress, client, + self.kosync_username, self.kosync_userkey, + doc_digest, function(ok, body) + DEBUG("get progress for", self.view.document.file, ok, body) + if body and body.percentage then + local percentage = self:getLastPercent() + DEBUG("current progress", percentage) + if (body.percentage - percentage) > 0.0001 then + UIManager:show(ConfirmBox:new{ + text = T(_("Sync to furthest location from '%1'?"), + body.device), + ok_callback = function() + self:syncToProgress(body.progress) + end, + }) + end + end + end) + if not ok and err then + DEBUG("err:", err) + end + end +end + +function KOSync:onSaveSettings() + local settings = { + username = self.kosync_username, + userkey = self.kosync_userkey, + } + G_reader_settings:saveSetting("kosync", settings) +end + +function KOSync:onCloseDocument() + DEBUG("on close document") + self:updateProgress() +end + +return KOSync diff --git a/reader.lua b/reader.lua index fa3441d89..81484714d 100755 --- a/reader.lua +++ b/reader.lua @@ -5,8 +5,8 @@ require "defaults" pcall(dofile, "defaults.persistent.lua") -- set search path for 'require()' -package.path = "common/?.lua;frontend/?.lua;" .. package.path -package.cpath = "common/?.so;common/?.dll;/usr/lib/lua/?.so;" .. package.cpath +package.path = "common/?.lua;frontend/?.lua;rocks/share/lua/5.1/?.lua;" .. package.path +package.cpath = "common/?.so;common/?.dll;/usr/lib/lua/?.so;rocks/lib/lua/5.1/?.so;" .. package.cpath -- set search path for 'ffi.load()' local ffi = require("ffi") diff --git a/spec/unit/kosync_spec.lua b/spec/unit/kosync_spec.lua new file mode 100644 index 000000000..9a77352b0 --- /dev/null +++ b/spec/unit/kosync_spec.lua @@ -0,0 +1,178 @@ +package.path = "rocks/share/lua/5.1/?.lua;" .. package.path +package.cpath = "rocks/lib/lua/5.1/?.so;" .. package.cpath +require("commonrequire") +local UIManager = require("ui/uimanager") +local HTTPClient = require("httpclient") +local DEBUG = require("dbg") +local md5 = require("MD5") +DEBUG:turnOn() + +local service = [[ +{ + "base_url" : "https://192.168.1.101:7200", + "name" : "api", + "methods" : { + "register" : { + "path" : "/users/create", + "method" : "POST", + "required_params" : [ + "username", + "password", + ], + "payload" : [ + "username", + "password", + ], + "expected_status" : [201, 402] + }, + "authorize" : { + "path" : "/users/auth", + "method" : "GET", + "expected_status" : [200, 401] + }, + "update_progress" : { + "path" : "/syncs/progress", + "method" : "PUT", + "required_params" : [ + "document", + "progress", + "percentage", + "device", + ], + "payload" : [ + "document", + "progress", + "percentage", + "device", + ], + "expected_status" : [200, 202, 401] + }, + "get_progress" : { + "path" : "/syncs/progress/:document", + "method" : "GET", + "required_params" : [ + "document", + ], + "expected_status" : [200, 401] + }, + } +} +]] + +describe("KOSync modules #notest #nocov", function() + local Spore = require("Spore") + local client = Spore.new_from_string(service) + package.loaded['Spore.Middleware.GinClient'] = {} + require('Spore.Middleware.GinClient').call = function(self, req) + req.headers['accept'] = "application/vnd.koreader.v1+json" + end + package.loaded['Spore.Middleware.KOSyncAuth'] = {} + require('Spore.Middleware.KOSyncAuth').call = function(args, req) + req.headers['x-auth-user'] = args.username + req.headers['x-auth-key'] = args.userkey + end + -- password should be hashed before submitting to server + local username, password = "koreader", md5:sum("koreader") + -- fake progress data + local doc, percentage, progress, device = + "41cce710f34e5ec21315e19c99821415", -- fast digest of the document + 0.356, -- percentage of the progress + "69", -- page number or xpointer + "my kpw" -- device name + it("should create new user", function() + client:reset_middlewares() + client:enable('Format.JSON') + client:enable("GinClient") + local ok, res = pcall(function() + return client:register({ + username = username, + password = password, + }) + end) + if ok then + if res.status == 200 then + DEBUG("register successful to ", res.body.username) + elseif res.status == 402 then + DEBUG("register unsuccessful: ", res.body.message) + end + else + DEBUG("Please retry later", res) + end + end) + it("should authorize user", function() + client:reset_middlewares() + client:enable('Format.JSON') + client:enable("GinClient") + client:enable("KOSyncAuth", { + username = username, + userkey = password, + }) + local ok, res = pcall(function() + return client:authorize() + end) + if ok then + if res.status == 200 then + assert.are.same("OK", res.body.authorized) + else + DEBUG(res.body) + end + else + DEBUG("Please retry later", res) + end + end) + it("should update progress", function() + client:reset_middlewares() + client:enable('Format.JSON') + client:enable("GinClient") + client:enable("KOSyncAuth", { + username = username, + userkey = password, + }) + local ok, res = pcall(function() + return client:update_progress({ + document = doc, + progress = progress, + percentage = percentage, + device = device, + }) + end) + if ok then + if res.status == 200 then + local result = res.body + assert.are.same(progress, result.progress) + assert.are.same(percentage, result.percentage) + assert.are.same(device, result.device) + else + DEBUG(res.body.message) + end + else + DEBUG("Please retry later", res) + end + end) + it("should get progress", function() + client:reset_middlewares() + client:enable('Format.JSON') + client:enable("GinClient") + client:enable("KOSyncAuth", { + username = username, + userkey = password, + }) + local ok, res = pcall(function() + return client:get_progress({ + document = doc, + }) + end) + if ok then + if res.status == 200 then + local result = res.body + assert.are.same(progress, result.progress) + assert.are.same(percentage, result.percentage) + assert.are.same(device, result.device) + else + DEBUG(res.body.message) + end + else + DEBUG("Please retry later", res) + end + end) +end) diff --git a/spec/unit/spore_spec.lua b/spec/unit/spore_spec.lua index 326f006b5..612bc50db 100644 --- a/spec/unit/spore_spec.lua +++ b/spec/unit/spore_spec.lua @@ -55,12 +55,35 @@ describe("Lua Spore modules #nocov", function() end) end) -describe("Lua Spore modules with async request #nocov", function() +describe("Lua Spore modules with async http request #nocov", function() local Spore = require("Spore") local client = Spore.new_from_string(service) - client:enable("Format.JSON") - package.loaded['Spore.Middleware.Async'] = {} local async_http_client = HTTPClient:new() + package.loaded['Spore.Middleware.AsyncHTTP'] = {} + require('Spore.Middleware.AsyncHTTP').call = function(args, req) + req:finalize() + local result + async_http_client:request({ + url = req.url, + method = req.method, + body = req.env.spore.payload, + on_headers = function(headers) + for header, value in pairs(req.headers) do + if type(header) == 'string' then + headers:add(header, value) + end + end + end, + }, function(res) + result = res + -- Turbo HTTP client uses code instead of status + -- change to status so that Spore can understand + result.status = res.code + coroutine.resume(args.thread) + UIManager.INPUT_TIMEOUT = 100 -- no need in production + end) + return coroutine.create(function() coroutine.yield(result) end) + end it("should complete GET request", function() UIManager:quit() local co = coroutine.create(function() @@ -69,22 +92,10 @@ describe("Lua Spore modules with async request #nocov", function() UIManager:quit() assert.are.same(res.body.args, info) end) - require('Spore.Middleware.Async').call = function(self, req) - req:finalize() - local result - async_http_client:request({ - url = req.url, - method = req.method, - }, function(res) - result = res - coroutine.resume(co) - UIManager.INPUT_TIMEOUT = 100 -- no need in production - end) - return coroutine.create(function() coroutine.yield(result) end) - end - client:enable("Async") + client:reset_middlewares() + client:enable("Format.JSON") + client:enable("AsyncHTTP", {thread = co}) coroutine.resume(co) - UIManager.INPUT_TIMEOUT = 100 UIManager:runForever() end) it("should complete POST request", function() @@ -95,22 +106,10 @@ describe("Lua Spore modules with async request #nocov", function() UIManager:quit() assert.are.same(res.body.json, info) end) - require('Spore.Middleware.Async').call = function(self, req) - req:finalize() - local result - async_http_client:request({ - url = req.url, - method = req.method, - }, function(res) - result = res - coroutine.resume(co) - UIManager.INPUT_TIMEOUT = 100 -- no need in production - end) - return coroutine.create(function() coroutine.yield(result) end) - end - client:enable("Async") + client:reset_middlewares() + client:enable("Format.JSON") + client:enable("AsyncHTTP", {thread = co}) coroutine.resume(co) - UIManager.INPUT_TIMEOUT = 100 UIManager:runForever() end) end)