diff --git a/frontend/apps/filemanager/filemanagermenu.lua b/frontend/apps/filemanager/filemanagermenu.lua index cd1edb6ec..c899d40b2 100644 --- a/frontend/apps/filemanager/filemanagermenu.lua +++ b/frontend/apps/filemanager/filemanagermenu.lua @@ -31,6 +31,14 @@ function FileManagerMenu:init() tools = { icon = "resources/icons/appbar.tools.png", }, + opdscatalog = { + icon = "resources/icons/appbar.magnify.browse.png", + callback = function() + self:onCloseFileManagerMenu() + local OPDSCatalog = require("apps/opdscatalog/opdscatalog") + OPDSCatalog:showCatalog() + end, + }, home = { icon = "resources/icons/appbar.home.png", callback = function() @@ -182,6 +190,7 @@ function FileManagerMenu:onShowMenu() self.tab_item_table.setting, self.tab_item_table.info, self.tab_item_table.tools, + self.tab_item_table.opdscatalog, self.tab_item_table.home, }, show_parent = menu_container, @@ -212,6 +221,11 @@ function FileManagerMenu:onShowMenu() return true end +function FileManagerMenu:onCloseFileManagerMenu() + UIManager:close(self.menu_container) + return true +end + function FileManagerMenu:onTapShowMenu() self:onShowMenu() return true diff --git a/frontend/apps/opdscatalog/opdscatalog.lua b/frontend/apps/opdscatalog/opdscatalog.lua new file mode 100644 index 000000000..dd83823f4 --- /dev/null +++ b/frontend/apps/opdscatalog/opdscatalog.lua @@ -0,0 +1,86 @@ +local InputContainer = require("ui/widget/container/inputcontainer") +local FrameContainer = require("ui/widget/container/framecontainer") +local FileManagerMenu = require("apps/filemanager/filemanagermenu") +local DocumentRegistry = require("document/documentregistry") +local VerticalGroup = require("ui/widget/verticalgroup") +local ButtonDialog = require("ui/widget/buttondialog") +local VerticalSpan = require("ui/widget/verticalspan") +local OPDSBrowser = require("ui/widget/opdsbrowser") +local TextWidget = require("ui/widget/textwidget") +local lfs = require("libs/libkoreader-lfs") +local UIManager = require("ui/uimanager") +local Font = require("ui/font") +local Screen = require("ui/screen") +local Geom = require("ui/geometry") +local Event = require("ui/event") +local DEBUG = require("dbg") +local _ = require("gettext") +local util = require("ffi/util") + +local OPDSCatalog = InputContainer:extend{ + title = _("OPDS Catalog"), + opds_servers = { + { + title = "Project Gutenberg", + subtitle = "Free ebooks since 1971.", + url = "http://m.gutenberg.org/ebooks.opds/?format=opds", + }, + { + title = "Feedbooks", + subtitle = "", + url = "http://www.feedbooks.com/publicdomain/catalog.atom", + }, + { + title = "ManyBooks", + subtitle = "Online Catalog for Manybooks.net", + url = "http://manybooks.net/opds/index.php", + }, + { + title = "Internet Archive", + subtitle = "Internet Archive Catalog", + baseurl = "http://bookserver.archive.org/catalog/", + }, + }, + onExit = function() end, +} + +function OPDSCatalog:init() + local opds_browser = OPDSBrowser:new{ + opds_servers = self.opds_servers, + title = self.title, + show_parent = self, + is_popout = false, + is_borderless = true, + has_close_button = true, + close_callback = function() return self:onClose() end, + } + + self[1] = FrameContainer:new{ + padding = 0, + bordersize = 0, + background = 0, + opds_browser, + } + +end + +function OPDSCatalog:showCatalog() + DEBUG("show OPDS catalog") + UIManager:show(OPDSCatalog:new{ + dimen = Screen:getSize(), + onExit = function() + --UIManager:quit() + end + }) +end + +function OPDSCatalog:onClose() + DEBUG("close OPDS catalog") + UIManager:close(self) + if self.onExit then + self:onExit() + end + return true +end + +return OPDSCatalog diff --git a/frontend/apps/reader/modules/readermenu.lua b/frontend/apps/reader/modules/readermenu.lua index 68f38ec0b..4a947f969 100644 --- a/frontend/apps/reader/modules/readermenu.lua +++ b/frontend/apps/reader/modules/readermenu.lua @@ -190,6 +190,11 @@ function ReaderMenu:onShowReaderMenu() return true end +function ReaderMenu:onCloseReaderMenu() + UIManager:close(self.menu_container) + return true +end + function ReaderMenu:onTapShowMenu() self.ui:handleEvent(Event:new("ShowConfigMenu")) self.ui:handleEvent(Event:new("ShowReaderMenu")) diff --git a/frontend/luxl.lua b/frontend/luxl.lua new file mode 100644 index 000000000..096c37115 --- /dev/null +++ b/frontend/luxl.lua @@ -0,0 +1,436 @@ +--[[ + This code was derived from the pico_xml project + which can be found here: + + http://kd7yhr.org/bushbo/pico_xml.md + + The original C code, written by: Brian O. Bush + + Pure Lua Version written by: William A Adams + Dramatic Speed Improvements by: Robert G Jakabosky + + References + + http://www.faqs.org/rfcs/rfc3076.html + http://www.w3.org/TR/REC-xml/ + +]] + +local ffi = require "ffi" +local bit = require "bit" +local band = bit.band + +--[[ + Types of characters; 0 is not valid, 1 is letters, 2 are digits + (including '.') and 3 whitespace. +--]] +local char_type = ffi.new("const int[256]", { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 3, 0, 0, 3, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, +}); + +-- Internal states that the parser can be in at any given time. +local ST_START = 0; -- starting base state; default state +local ST_TEXT =1; -- text state +local ST_START_TAG = 2; -- start tag state +local ST_START_TAGNAME =3; -- start tagname state +local ST_START_TAGNAME_END =4; -- start tagname ending state +local ST_END_TAG =5; -- end tag state +local ST_END_TAGNAME=6; -- end tag tagname state +local ST_END_TAGNAME_END=7; -- end tag tagname ending +local ST_EMPTY_TAG=8; -- empty tag state +local ST_SPACE=9; -- linear whitespace state +local ST_ATTR_NAME=10; -- attribute name state +local ST_ATTR_NAME_END=11; -- attribute name ending state +local ST_ATTR_VAL=12; -- attribute value starting state +local ST_ATTR_VAL2=13; -- attribute value state +local ST_ERROR=14; -- error state + +-- character classes that we will match against; This could be expanded if +-- need be, however, we are aiming for simple. +local CCLASS_NONE = 0; -- matches nothing; a base state +local CCLASS_LEFT_ANGLE=1; -- matches start tag '<' +local CCLASS_SLASH=2; -- matches forward slash +local CCLASS_RIGHT_ANGLE=3; -- matches end tag '>' +local CCLASS_EQUALS=4; -- matches equals sign +local CCLASS_QUOTE=5; -- matches double-quotes +local CCLASS_LETTERS=6; -- matches a-zA-Z letters and digits 0-9 +local CCLASS_SPACE=7; -- matches whitespace +local CCLASS_ANY=8; -- matches any ASCII character; will match all above classes + +-- Types of events: start element, end element, text, attr name, attr +-- val and start/end document. Other events can be ignored! +local EVENT_START = 0; -- Start tag +local EVENT_END = 1; -- End tag +local EVENT_TEXT = 2; -- Text +local EVENT_ATTR_NAME = 3; -- Attribute name +local EVENT_ATTR_VAL = 4; -- Attribute value +local EVENT_END_DOC = 5; -- End of document +local EVENT_MARK = 6; -- Internal only; notes position in buffer +local EVENT_NONE = 7; -- Internal only; should never see this event + +local entity_refs = { + ["<"] = '<', + [">"] = '>', + ["&"] = '&', + ["'"] = '\'', + ["""] = '"', +} + +-- Map constant values to constant names. +local STATE_NAMES = { + [ST_START] = "ST_START", + [ST_TEXT] = "ST_TEXT", + [ST_START_TAG] = "ST_START_TAG", + [ST_START_TAGNAME] = "ST_START_TAGNAME", + [ST_START_TAGNAME_END] = "ST_START_TAGNAME_END", + [ST_END_TAG] = "ST_END_TAG", + [ST_END_TAGNAME] = "ST_END_TAGNAME", + [ST_END_TAGNAME_END] = "ST_END_TAGNAME_END", + [ST_EMPTY_TAG] = "ST_EMPTY_TAG", + [ST_SPACE] = "ST_SPACE", + [ST_ATTR_NAME] = "ST_ATTR_NAME", + [ST_ATTR_NAME_END] = "ST_ATTR_NAME_END", + [ST_ATTR_VAL] = "ST_ATTR_VAL", + [ST_ATTR_VAL2] = "ST_ATTR_VAL2", + [ST_ERROR] = "ST_ERROR", +} + +--[[ + State transition table element; contains: + (1) current state, + (2) clazz that must match, + (3) next state if we match, and + (4) event that is emitted upon match. +--]] + +-- Note: States must be grouped in match order AND grouped together! +local LEXER_STATES = { + -- [0-2] starting state, which also serves as the default state in case of error + { state = ST_START, cclass = CCLASS_SPACE, next_state = ST_SPACE, event = EVENT_NONE }, + { state = ST_START, cclass = CCLASS_LEFT_ANGLE, next_state = ST_START_TAG, event = EVENT_NONE }, + { state = ST_START, cclass = CCLASS_ANY, next_state = ST_TEXT, event = EVENT_MARK }, + + -- [3-5] space state handles linear white space + { state = ST_SPACE, cclass = CCLASS_SPACE, next_state = ST_SPACE, event = EVENT_NONE }, + { state = ST_SPACE, cclass = CCLASS_LEFT_ANGLE, next_state = ST_START_TAG, event = EVENT_TEXT }, + { state = ST_SPACE, cclass = CCLASS_ANY, next_state = ST_TEXT, event = EVENT_MARK }, + + -- [6-8] handle start tag + { state = ST_START_TAG, cclass = CCLASS_LETTERS, next_state = ST_START_TAGNAME, event = EVENT_MARK }, + { state = ST_START_TAG, cclass = CCLASS_SLASH, next_state = ST_END_TAG, event = EVENT_MARK }, + { state = ST_START_TAG, cclass = CCLASS_SPACE, next_state = ST_START_TAG, event = EVENT_NONE }, -- < tag > + + -- [9-12] handle start tag name + { state = ST_START_TAGNAME, cclass = CCLASS_LETTERS, next_state = ST_START_TAGNAME, event = EVENT_NONE }, + { state = ST_START_TAGNAME, cclass = CCLASS_SPACE, next_state = ST_START_TAGNAME_END, event = EVENT_START }, + { state = ST_START_TAGNAME, cclass = CCLASS_SLASH, next_state = ST_EMPTY_TAG, event = EVENT_END }, + { state = ST_START_TAGNAME, cclass = CCLASS_RIGHT_ANGLE, next_state = ST_START, event = EVENT_START }, + + -- [13-16] handle start tag name end + { state = ST_START_TAGNAME_END, cclass = CCLASS_LETTERS, next_state = ST_ATTR_NAME, event = EVENT_MARK }, + { state = ST_START_TAGNAME_END, cclass = CCLASS_SPACE, next_state = ST_START_TAGNAME_END, event = EVENT_NONE }, + { state = ST_START_TAGNAME_END, cclass = CCLASS_RIGHT_ANGLE, next_state = ST_START, event = EVENT_START }, + { state = ST_START_TAGNAME_END, cclass = CCLASS_SLASH, next_state = ST_EMPTY_TAG, event = EVENT_MARK }, -- Empty tag
+ + -- [17] handle empty tags, e.g.,
+ { state = ST_EMPTY_TAG, cclass = CCLASS_RIGHT_ANGLE, next_state = ST_START, event = EVENT_END }, -- Empty tag
+ + -- [18] handle end tag, e.g., + { state = ST_END_TAG, cclass = CCLASS_LETTERS, next_state = ST_END_TAGNAME, event = EVENT_NONE }, + + -- [19-21] handle end tag name + { state = ST_END_TAGNAME, cclass = CCLASS_LETTERS, next_state = ST_END_TAGNAME, event = EVENT_NONE }, + { state = ST_END_TAGNAME, cclass = CCLASS_RIGHT_ANGLE, next_state = ST_START, event = EVENT_END }, + { state = ST_END_TAGNAME, cclass = CCLASS_SPACE, next_state = ST_END_TAGNAME_END, event = EVENT_END }, -- space after end tag name
+ + -- [22-23] handle ending of end tag name + { state = ST_END_TAGNAME_END, cclass = CCLASS_SPACE, next_state = ST_END_TAGNAME_END, event = EVENT_NONE }, + { state = ST_END_TAGNAME_END, cclass = CCLASS_RIGHT_ANGLE,next_state = ST_START, event = EVENT_NONE }, + + -- [24-26] handle text + { state = ST_TEXT, cclass = CCLASS_SPACE, next_state = ST_SPACE, event = EVENT_NONE }, + { state = ST_TEXT, cclass = CCLASS_LEFT_ANGLE, next_state = ST_START_TAG, event = EVENT_TEXT }, + { state = ST_TEXT, cclass = CCLASS_ANY, next_state = ST_TEXT, event = EVENT_NONE }, + + -- [27-29] handle attribute names + { state = ST_ATTR_NAME, cclass = CCLASS_LETTERS, next_state = ST_ATTR_NAME, event = EVENT_MARK }, + { state = ST_ATTR_NAME, cclass = CCLASS_SPACE, next_state = ST_ATTR_NAME_END, event = EVENT_ATTR_NAME }, -- space before '=' sign + { state = ST_ATTR_NAME, cclass = CCLASS_EQUALS, next_state = ST_ATTR_VAL, event = EVENT_ATTR_NAME }, -- + + -- [30-32] attribute name end + { state = ST_ATTR_NAME_END, cclass = CCLASS_SPACE, next_state = ST_ATTR_NAME_END, event = EVENT_NONE }, + { state = ST_ATTR_NAME_END, cclass = CCLASS_LETTERS, next_state = ST_ATTR_NAME, event = EVENT_MARK }, + { state = ST_ATTR_NAME_END, cclass = CCLASS_EQUALS, next_state = ST_ATTR_VAL, event = EVENT_NONE }, + + -- [33-34] handle attribute values, initial quote and spaces + { state = ST_ATTR_VAL, cclass = CCLASS_QUOTE, next_state = ST_ATTR_VAL2, event = EVENT_NONE }, + { state = ST_ATTR_VAL, cclass = CCLASS_SPACE, next_state = ST_ATTR_VAL, event = EVENT_NONE }, -- initial spaces before quoted attribute value + + -- [35-37] handle actual attribute values + { state = ST_ATTR_VAL2, cclass = CCLASS_QUOTE, next_state = ST_START_TAGNAME_END, event = EVENT_ATTR_VAL }, + { state = ST_ATTR_VAL2, cclass = CCLASS_LETTERS, next_state = ST_ATTR_VAL2, event = EVENT_MARK }, + { state = ST_ATTR_VAL2, cclass = CCLASS_SLASH, next_state = ST_ATTR_VAL2, event = EVENT_NONE }, + + -- [38] End of table marker + { state = ST_ERROR, cclass = CCLASS_NONE, next_state = ST_ERROR, event = EVENT_NONE } +}; + +ffi.cdef[[ +struct parse_state { + const char* buf; /* reference to buffer */ + int bufsz; /* size of buf */ + int mark; + int i; + int ix; /* index into buffer */ +}; +]] + +local cclass_match = { + [CCLASS_LETTERS] = "(ctype == 1 or ctype == 2)", + [CCLASS_LEFT_ANGLE] = "(c == T_LT)", + [CCLASS_SLASH] = "(c == T_SLASH)", + [CCLASS_RIGHT_ANGLE] = "(c == T_GT)", + [CCLASS_EQUALS] = "(c == T_EQ)", + [CCLASS_QUOTE] = "(c == T_QUOTE)", + [CCLASS_SPACE] = "(ctype == 3)", + [CCLASS_ANY] = "true", +} + +local STATES = {} +local STATE_FUNCS = {} + +local function next_char(ps, state, verbose) + local i = ps.ix + if i >= ps.bufsz then + return EVENT_END_DOC, 0, i, state + end + ps.i = i + local c = band(ps.buf[i], 0xff); + ps.ix = i + 1 + if verbose then + verbose(i, STATE_FUNCS[state], c) + end + + -- run state function to find next state. + return state(ps, c, verbose) +end + + +local fsm_code = '' +local function code(...) + for i=1,select("#", ...) do + fsm_code = fsm_code .. tostring(select(i, ...)) + end +end +code[[ +local STATES, next_char, char_type = ... + +local T_LT = string.byte('<') +local T_SLASH = string.byte('/') +local T_GT = string.byte('>') +local T_EQ = string.byte('=') +local T_QUOTE = string.byte('"') + +]] +-- pre-define locals for state functions. +for i=0,#STATE_NAMES do + local name = STATE_NAMES[i] + code('local ', name, '_f\n') +end +-- group LEXER states. +for i=1,#LEXER_STATES do + local p_state = LEXER_STATES[i] + local state = STATES[p_state.state] + local cclasses + if not state then + cclasses = {} + state = { state = p_state.state, cclasses = cclasses } + STATES[p_state.state] = state + else + cclasses = state.cclasses + end + cclasses[#cclasses + 1] = p_state +end +local function gen_cclass_code(prefix, cclass) + local next_state = STATE_NAMES[cclass.next_state] + if cclass.event == EVENT_MARK then + code(prefix, "if(ps.mark == 0) then ps.mark = ps.i end -- mark the position\n") + elseif cclass.event ~= EVENT_NONE then + code(prefix, "if(ps.mark > 0) then\n") + code(prefix,' return ', cclass.event,', ',next_state,'_f\n') + code(prefix, "end\n") + end + code(prefix,'return next_char(ps, ', next_state,'_f, verbose)\n') +end +-- generate state functions. +for i=0,#STATE_NAMES do + local name = STATE_NAMES[i] + local state = STATES[i] + local cclasses = state.cclasses + code('function ', name, '_f(ps, c, verbose)\n') + code(' local ctype = char_type[c]\n') + local has_any + for i=1,#cclasses do + local cclass = cclasses[i] + local id = cclass.cclass + local condition = cclass_match[id] + if id == CCLASS_ANY then + has_any = cclass + elseif i == 1 then + code(' if ', condition, ' then\n') + gen_cclass_code(' ', cclass) + else + code(' elseif ', condition, ' then\n') + gen_cclass_code(' ', cclass) + end + end + code(' end\n') + -- catch-all for cclass_any or goto error state. + if has_any then + gen_cclass_code(' ', has_any) + else + -- exit from FSM on error. + code(' return nil, ',name,'_f, c\n') + end + code('end\n') + -- map state id to state function + code('STATES[', i, '] = ', name, '_f\n') + -- reverse map state function to state id + code('STATES[', name, '_f] = ', i, '\n') +end +-- Compile FSM code +local state_funcs = assert(loadstring(fsm_code, "luxl FSM code")) +state_funcs(STATE_FUNCS, next_char, char_type) +fsm_code = nil + + +local luxl = { + EVENT_START = EVENT_START; -- Start tag + EVENT_END = EVENT_END; -- End tag + EVENT_TEXT = EVENT_TEXT; -- Text + EVENT_ATTR_NAME = EVENT_ATTR_NAME; -- Attribute name + EVENT_ATTR_VAL = EVENT_ATTR_VAL; -- Attribute value + EVENT_END_DOC = EVENT_END_DOC; -- End of document + EVENT_MARK = EVENT_MARK; -- Internal only; notes position in buffer + EVENT_NONE = EVENT_NONE; -- Internal only; should never see this event +} +local luxl_mt = { __index = luxl } + +function luxl.new(buffer, bufflen) + local newone = { + buf = ffi.cast("const uint8_t *", buffer); -- pointer to "uint8_t *" buffer (0 based) + bufsz = bufflen; -- size of input buffer + state = ST_START; -- current state + event = EVENT_NONE; -- current event + err = 0; -- number of errors thus far + markix = 0; -- offset of current item of interest + marksz = 0; -- size of current item of interest + MsgHandler = nil; -- Routine to handle messages + ErrHandler = nil; -- Routine to call when there's an error + EventHandler = nil; + ps = ffi.new('struct parse_state', { + buf = buffer, + bufsz = bufflen, + mark = 0, + i = 0, + ix = 0, + }), + } + setmetatable(newone, luxl_mt); + + return newone; +end + +function luxl:Reset(buffer, bufflen) + self.buf = buffer -- pointer to "uint8_t *" buffer (0 based) + self.bufsz = bufflen -- size of input buffer + self.state = ST_START -- current state + self.event = EVENT_NONE -- current event + self.err = 0 -- number of errors thus far + self.markix = 0 -- offset of current item of interest + self.marksz = 0 -- size of current item of interest + local ps = self.ps + ps.buf = buffer + ps.bufsz = bufflen + ps.mark = 0 + ps.i = 0 + ps.ix = 0 +end + +function luxl:SetMessageHandler(handler) + self.MsgHandler = handler; +end + + +--[[ + GetNext is responsible for moving through the stream + of characters. At the moment, it's fairly naive in + terms of character encodings. + + In a more robust implementation, luxl will read from a + stream, which knows about the specific encoding, and + will hand out code points based on that particular encoding. + + So, only straight ASCII for the moment. + + Returns event type, starting offset, size +--]] + +function luxl:GetNext() + local event, state_f, c + local ps = self.ps + ps.mark = 0 + state_f = STATE_FUNCS[self.state] + repeat + event, state_f, c = next_char(ps, state_f, self.MsgHandler) + -- update state id. + self.state = STATE_FUNCS[state_f] + if not event then + -- handle error + -- default to start state + self.err = self.err + 1; + if self.ErrHandler then + self.ErrHandler(ps.i, self.state, c); + end + end + until event + -- basically we are guaranteed never to have an event of + -- type EVENT_MARK or EVENT_NONE here. + self.event = event + local markix = ps.mark + local marksz = ps.i-ps.mark + self.markix = markix + self.marksz = marksz + if(self.EventHandler) then + self.EventHandler(event, markix, marksz) + end + return event, markix, marksz +end + +function luxl:Lexemes() + return function() + local event, offset, size = self:GetNext(); + if(event == EVENT_END_DOC) then + return nil; + else + return event, offset, size; + end + end +end + +return luxl diff --git a/frontend/ui/opdsparser.lua b/frontend/ui/opdsparser.lua new file mode 100644 index 000000000..139d11638 --- /dev/null +++ b/frontend/ui/opdsparser.lua @@ -0,0 +1,93 @@ +--[[ + This code is derived from the LAPHLibs which can be found here: + + https://github.com/Wiladams/LAPHLibs +--]] +local util = require("ffi/util") +local luxl = require("luxl") +local DEBUG = require("dbg") +local ffi = require("ffi") + +local OPDSParser = {} + +local unescape_map = { + ["lt"] = "<", + ["gt"] = ">", + ["amp"] = "&", + ["quot"] = '"', + ["apos"] = "'" +} + +local gsub, char = string.gsub, string.char +local function unescape(str) + return gsub(str, '(&(#?)([%d%a]+);)', function(orig, n, s) + return unescape_map[s] or n=="#" and util.unichar(tonumber(s)) or orig + end) +end + +function OPDSParser:createFlatXTable(xlex, currentelement) + currentelement = currentelement or {} + + local currentattributename = nil; + local attribute_count = 0; + + -- start reading the thing + local txt = nil; + for event, offset, size in xlex:Lexemes() do + txt = ffi.string(xlex.buf + offset, size) + + if event == luxl.EVENT_START and txt ~= "xml" then + -- does current element already have something + -- with this name? + + -- if it does, if it's a table, add to it + -- if it doesn't, then add a table + local tab = self:createFlatXTable(xlex) + if txt == "entry" or txt == "link" then + if currentelement[txt] == nil then + currentelement[txt] = {} + end + table.insert(currentelement[txt], tab) + elseif type(currentelement) == "table" then + currentelement[txt] = tab + end + end + + if event == luxl.EVENT_ATTR_NAME then + currentattributename = txt + end + + if event == luxl.EVENT_ATTR_VAL then + currentelement[currentattributename] = txt + attribute_count = attribute_count + 1; + currentattributename = nil + end + + if event == luxl.EVENT_TEXT then + --if attribute_count < 1 then + -- return txt + --end + + currentelement = unescape(txt) + end + + if event == luxl.EVENT_END then + return currentelement + end + end + + return currentelement +end + +function OPDSParser:parse(text) + -- luxl cannot properly handle xml comments and we need first remove them + text = text:gsub("", "") + -- luxl prefers
, other two forms are valid in HTML, + -- but will kick the ass of luxl + text = text:gsub("
", "
") + text = text:gsub("
", "
") + local xlex = luxl.new(text, #text) + return self:createFlatXTable(xlex) +end + +return OPDSParser diff --git a/frontend/ui/widget/opdsbrowser.lua b/frontend/ui/widget/opdsbrowser.lua new file mode 100644 index 000000000..af90caccd --- /dev/null +++ b/frontend/ui/widget/opdsbrowser.lua @@ -0,0 +1,412 @@ +local MultiInputDialog = require("ui/widget/multiinputdialog") +local ButtonDialog = require("ui/widget/buttondialog") +local InfoMessage = require("ui/widget/infomessage") +local lfs = require("libs/libkoreader-lfs") +local OPDSParser = require("ui/opdsparser") +local NetworkMgr = require("ui/networkmgr") +local UIManager = require("ui/uimanager") +local CacheItem = require("cacheitem") +local Menu = require("ui/widget/menu") +local Screen = require("ui/screen") +local Device = require("ui/device") +local url = require('socket.url') +local util = require("ffi/util") +local Cache = require("cache") +local DEBUG = require("dbg") +local _ = require("gettext") +local ffi = require("ffi") + +local socket = require('socket') +local http = require('socket.http') +local https = require('ssl.https') +local ltn12 = require('ltn12') + +local CatalogCacheItem = CacheItem:new{ + size = 1024, -- fixed size for catalog item +} + +-- cache catalog parsed from feed xml +local CatalogCache = Cache:new{ + max_memsize = 20*1024, -- keep only 20 cache items + current_memsize = 0, + cache = {}, + cache_order = {}, +} + +local OPDSBrowser = Menu:extend{ + opds_servers = {}, + catalog_type = "application/atom%+xml", + search_type = "application/opensearchdescription%+xml", + acquisition_rel = "http://opds-spec.org/acquisition", + thumbnail_rel = "http://opds-spec.org/image/thumbnail", + + formats = { + ["application/epub+zip"] = "EPUB", + ["application/pdf"] = "PDF", + ["text/plain"] = "TXT", + ["application/x-mobipocket-ebook"] = "MOBI", + ["application/x-mobi8-ebook"] = "AZW3", + }, + + width = Screen:getWidth(), + height = Screen:getHeight(), + no_title = false, + parent = nil, +} + +function OPDSBrowser:init() + self.item_table = self:genItemTableFromRoot(self.opds_servers) + Menu.init(self) -- call parent's init() +end + +function OPDSBrowser:addServerFromInput(fields) + DEBUG("input catalog", fields) + local servers = G_reader_settings:readSetting("opds_servers") or {} + table.insert(servers, { + title = fields[1], + url = fields[2], + }) + G_reader_settings:saveSetting("opds_servers", servers) + self:init() +end + +function OPDSBrowser:addNewCatalog() + self.add_server_dialog = MultiInputDialog:new{ + title = _("Add OPDS catalog"), + fields = { + { + text = "", + hint = _("Catalog Name"), + }, + { + text = "", + hint = _("Catalog URL"), + }, + }, + buttons = { + { + { + text = _("Cancel"), + callback = function() + self.add_server_dialog:onClose() + UIManager:close(self.add_server_dialog) + end + }, + { + text = _("Add"), + callback = function() + self.add_server_dialog:onClose() + UIManager:close(self.add_server_dialog) + self:addServerFromInput(MultiInputDialog:getFields()) + end + }, + }, + }, + width = Screen:getWidth() * 0.95, + height = Screen:getHeight() * 0.2, + } + self.add_server_dialog:onShowKeyboard() + UIManager:show(self.add_server_dialog) +end + +function OPDSBrowser:genItemTableFromRoot() + local item_table = {} + for i, server in ipairs(self.opds_servers) do + table.insert(item_table, { + text = server.title, + content = server.subtitle, + url = server.url, + baseurl = server.baseurl, + }) + end + local added_servers = G_reader_settings:readSetting("opds_servers") or {} + for i, server in ipairs(added_servers) do + table.insert(item_table, { + text = server.title, + content = server.subtitle, + url = server.url, + baseurl = server.baseurl, + }) + end + table.insert(item_table, { + text = _("Add new OPDS catalog"), + callback = function() + self:addNewCatalog() + end, + }) + return item_table +end + +function OPDSBrowser:fetchFeed(feed_url) + local headers, request, sink = {}, {}, {} + local parsed = url.parse(feed_url) + request['url'] = feed_url + request['method'] = 'GET' + request['sink'] = ltn12.sink.table(sink) + DEBUG("request", request) + http.TIMEOUT, https.TIMEOUT = 10, 10 + local httpRequest = parsed.scheme == 'http' and http.request or https.request + local code, headers, status = socket.skip(1, httpRequest(request)) + + -- raise error message when network is unavailable + if headers == nil then + error("Network is unreachable") + end + + local xml = table.concat(sink) + if xml ~= "" then + --DEBUG("xml", xml) + return xml + end +end + +function OPDSBrowser:parseFeed(feed_url) + local feed = nil + local hash = "opds|catalog|" .. feed_url + local cache = CatalogCache:check(hash) + if cache then + feed = cache.feed + else + DEBUG("cache", hash) + feed = self:fetchFeed(feed_url) + if feed then + local cache = CatalogCacheItem:new{ + feed = feed + } + CatalogCache:insert(hash, cache) + end + end + if feed then + return OPDSParser:parse(feed) + end +end + +function OPDSBrowser:getCatalog(feed_url) + local ok, catalog = pcall(self.parseFeed, self, feed_url) + -- prompt users to turn on Wifi if network is unreachable + if not ok and catalog and catalog:find("Network is unreachable") then + NetworkMgr:promptWifiOn() + return + elseif not ok and catalog then + DEBUG(catalog) + return + end + + if ok and catalog then + --DEBUG("catalog", catalog) + return catalog + end +end + +function OPDSBrowser:genItemTableFromURL(item_url, base_url) + local item_table = {} + local catalog = self:getCatalog(item_url or base_url) + if catalog then + local feed = catalog.feed or catalog + local function build_href(href) + if href:match("^http") then + return href + elseif base_url then + return base_url .. "/" .. href + elseif item_url then + local parsed = url.parse(item_url) + -- update item url with href parts(mostly path and query) + for k, v in pairs(url.parse(href) or {}) do + if k == "path" then + v = "/" .. v + end + parsed[k] = v + end + return url.build(parsed) + end + end + local hrefs = {} + if feed.link then + for i, link in ipairs(feed.link) do + if link.type:find(self.catalog_type) or + link.type:find(self.search_type) then + if link.rel and link.href then + hrefs[link.rel] = build_href(link.href) + end + end + end + end + item_table.hrefs = hrefs + if feed.entry then + for i, entry in ipairs(feed.entry) do + local item = {} + item.baseurl = base_url + item.acquisitions = {} + if entry.link then + for i, link in ipairs(entry.link) do + if link.type:find(self.catalog_type) then + item.url = build_href(link.href) + end + if link.rel == self.acquisition_rel then + table.insert(item.acquisitions, { + type = link.type, + href = build_href(link.href), + }) + end + if link.rel == self.thumbnail_rel then + item.thumbnail = build_href(link.href) + end + if link.rel == self.image_rel then + item.image = build_href(link.href) + end + end + end + local title = "Unknown" + local title_type = type(entry.title) + if type(entry.title) == "string" then + title = entry.title + elseif type(entry.title) == "table" then + if entry.title.type == "text/xhtml" then + title = entry.title.div or title + end + end + if title == "Unknown" then + DEBUG("Cannot handle title", entry.title) + end + item.text = title + item.title = title + item.id = entry.id + item.content = entry.content + item.updated = entry.updated + table.insert(item_table, item) + end + end + end + return item_table +end + +function OPDSBrowser:updateCatalog(url, baseurl) + local menu_table = self:genItemTableFromURL(url, baseurl) + if #menu_table > 0 then + --DEBUG("menu table", menu_table) + self:swithItemTable(nil, menu_table) + return true + end +end + +function OPDSBrowser:appendCatalog(url, baseurl) + local new_table = self:genItemTableFromURL(url, baseurl) + for i, item in ipairs(new_table) do + table.insert(self.item_table, item) + end + self.item_table.hrefs = new_table.hrefs + self:swithItemTable(nil, self.item_table) + return true +end + +function OPDSBrowser:downloadFile(title, format, remote_url) + -- download to last opened dir + -- TODO: let the user select where to store the downloaded file? + local lastdir = G_reader_settings:readSetting("lastdir") + local local_path = lastdir .. "/" .. title .. "." .. string.lower(format) + DEBUG("downloading file", local_path, "from", remote_url) + + local parsed = url.parse(remote_url) + http.TIMEOUT, https.TIMEOUT = 10, 10 + local httpRequest = parsed.scheme == 'http' and http.request or https.request + local r, c, h = httpRequest{ + url = remote_url, + sink = ltn12.sink.file(io.open(local_path, "w")), + } + + if c == 200 then + DEBUG("file downloaded successfully to", local_path) + UIManager:show(InfoMessage:new{ + text = _("File is successfully saved to:\n") .. local_path, + timeout = 3, + }) + else + DEBUG("response", {r, c, h}) + end +end + +function OPDSBrowser:showDownloads(item) + local acquisitions = item.acquisitions + local downloadsperline = 2 + local lines = math.ceil(#acquisitions/downloadsperline) + local buttons = {} + for i = 1, lines do + local line = {} + for j = 1, downloadsperline do + local button = {} + local index = (i-1)*downloadsperline + j + local acquisition = acquisitions[index] + if acquisition then + local format = self.formats[acquisition.type] + if format then + button.text = format + button.callback = function() + UIManager:scheduleIn(1, function() + self:downloadFile(item.title, format, acquisition.href) + end) + UIManager:close(self.download_dialog) + UIManager:show(InfoMessage:new{ + text = _("Downloading may take several minutes..."), + timeout = 3, + }) + end + table.insert(line, button) + end + end + end + table.insert(buttons, line) + end + + self.download_dialog = ButtonDialog:new{ + buttons = buttons + } + UIManager:show(self.download_dialog) +end + +function OPDSBrowser:onMenuSelect(item) + -- add catalog + if item.callback then + item.callback() + -- acquisition + elseif item.acquisitions and #item.acquisitions > 0 then + DEBUG("downloads available", item) + self:showDownloads(item) + -- navigation + else + table.insert(self.paths, { + url = item.url, + baseurl = item.baseurl, + }) + if not self:updateCatalog(item.url, item.baseurl) then + table.remove(self.paths) + end + end + return true +end + +function OPDSBrowser:onReturn() + DEBUG("return to last page catalog") + if #self.paths > 0 then + table.remove(self.paths) + local path = self.paths[#self.paths] + if path then + -- return to last path + self:updateCatalog(path.url, path.baseurl) + else + -- return to root path, we simply reinit opdsbrowser + self:init() + end + end + return true +end + +function OPDSBrowser:onNext() + DEBUG("fetch next page catalog") + local hrefs = self.item_table.hrefs + if hrefs and hrefs.next then + self:appendCatalog(hrefs.next) + end + return true +end + +return OPDSBrowser diff --git a/resources/icons/appbar.arrow.left.up.png b/resources/icons/appbar.arrow.left.up.png new file mode 100644 index 000000000..b655a4701 Binary files /dev/null and b/resources/icons/appbar.arrow.left.up.png differ diff --git a/resources/icons/appbar.cabinet.files.png b/resources/icons/appbar.cabinet.files.png index abca8bd15..45ef9f31d 100644 Binary files a/resources/icons/appbar.cabinet.files.png and b/resources/icons/appbar.cabinet.files.png differ diff --git a/resources/icons/appbar.home.png b/resources/icons/appbar.home.png index e1bbc52de..7720557da 100644 Binary files a/resources/icons/appbar.home.png and b/resources/icons/appbar.home.png differ diff --git a/resources/icons/appbar.magnify.browse.png b/resources/icons/appbar.magnify.browse.png new file mode 100644 index 000000000..1b92e3e81 Binary files /dev/null and b/resources/icons/appbar.magnify.browse.png differ diff --git a/resources/icons/src/appbar.arrow.left.up.svg b/resources/icons/src/appbar.arrow.left.up.svg new file mode 100644 index 000000000..e26cb5c6c --- /dev/null +++ b/resources/icons/src/appbar.arrow.left.up.svg @@ -0,0 +1,52 @@ + +image/svg+xml \ No newline at end of file diff --git a/resources/icons/src/appbar.cabinet.files.svg b/resources/icons/src/appbar.cabinet.files.svg new file mode 100644 index 000000000..e0a245a74 --- /dev/null +++ b/resources/icons/src/appbar.cabinet.files.svg @@ -0,0 +1,45 @@ + +image/svg+xml \ No newline at end of file diff --git a/resources/icons/src/appbar.home.svg b/resources/icons/src/appbar.home.svg index 5c42286d3..2356736be 100644 --- a/resources/icons/src/appbar.home.svg +++ b/resources/icons/src/appbar.home.svg @@ -1,5 +1,48 @@ - - - - - + +image/svg+xml \ No newline at end of file diff --git a/resources/icons/src/appbar.magnify.browse.svg b/resources/icons/src/appbar.magnify.browse.svg new file mode 100644 index 000000000..496419405 --- /dev/null +++ b/resources/icons/src/appbar.magnify.browse.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/spec/unit/opdsparser_spec.lua b/spec/unit/opdsparser_spec.lua new file mode 100644 index 000000000..6e1a633b3 --- /dev/null +++ b/spec/unit/opdsparser_spec.lua @@ -0,0 +1,188 @@ + +local navigation_sample = [[ + + +http://m.gutenberg.org/ebooks.opds/ +2014-05-17T12:04:49Z +Project Gutenberg +Free ebooks since 1971. + +Marcello Perathoner +http://www.gutenberg.org +webmaster@gutenberg.org + +http://m.gutenberg.org/pics/favicon.png + + + + +25 +1 + +2014-05-17T12:04:49Z +http://m.gutenberg.org/ebooks/search.opds/?sort_order=downloads +Popular +Our most popular books. + + + + +2014-05-17T12:04:49Z +http://m.gutenberg.org/ebooks/search.opds/?sort_order=release_date +Latest +Our latest releases. + + + + +2014-05-17T12:04:49Z +http://m.gutenberg.org/ebooks/search.opds/?sort_order=random +Random +Random books. + + + + +]] + +local acquisition_sample = [[ + + +http://m.gutenberg.org:80/ebooks/42474.opds +2014-09-03T12:16:15Z +1000 Mythological Characters Briefly Described by Edward Sylvester Ellis +Free ebooks since 1971. + +Marcello Perathoner +http://www.gutenberg.org +webmaster@gutenberg.org + +http://m.gutenberg.org:80/pics/favicon.png + + + + +25 +1 + +2014-09-03T12:16:15Z +1000 Mythological Characters Briefly Described + +
+

This edition had all images removed.

+

+Title: +1000 Mythological Characters Briefly Described
Adapted to Private Schools, High Schools and Academies +

+

Author: Ellis, Edward Sylvester, 1840-1916

+

Ebook No.: 42474

+

Published: Apr 7, 2013

+

Downloads: 1209

+

Language: English

+

Category: Text

+

Rights: Public domain in the USA.

+
+
+urn:gutenberg:42474:2 +2013-04-07T00:00:00+00:00 +Public domain in the USA. + +Ellis, Edward Sylvester + + +en +1 + + + + + + +
+ +2014-09-03T12:16:15Z +1000 Mythological Characters Briefly Described + +
+

This edition has images.

+

+Title: +1000 Mythological Characters Briefly Described
Adapted to Private Schools, High Schools and Academies +

+

Author: Ellis, Edward Sylvester, 1840-1916

+

Ebook No.: 42474

+

Published: Apr 7, 2013

+

Downloads: 1209

+

Language: English

+

Category: Text

+

Rights: Public domain in the USA.

+
+
+urn:gutenberg:42474:3 +2013-04-07T00:00:00+00:00 +Public domain in the USA. + +Ellis, Edward Sylvester + + +en +1 + + + + + + +
+
+]] + +package.path = "?.lua;common/?.lua;frontend/?.lua" + +local OPDSParser = require("ui/opdsparser") +local DEBUG = require("dbg") +DEBUG:turnOn() + +describe("OPDS parser module", function() + it("should parse OPDS navigation catalog", function() + local catalog = OPDSParser:parse(navigation_sample) + local feed = catalog.feed + assert.truthy(feed) + assert.are.same(feed.title, "Project Gutenberg") + local entries = feed.entry + assert.truthy(entries) + assert.are.same(#entries, 3) + local entry = entries[3] + assert.are.same(entry.title, "Random") + assert.are.same(entry.id, "http://m.gutenberg.org/ebooks/search.opds/?sort_order=random") + end) + it("should parse OPDS acquisition catalog", function() + local catalog = OPDSParser:parse(acquisition_sample) + local feed = catalog.feed + --DEBUG(feed) + assert.truthy(feed) + local entries = feed.entry + assert.truthy(entries) + assert.are.same(#entries, 2) + local entry = entries[2] + assert.are.same(entry.title, "1000 Mythological Characters Briefly Described") + end) +end)