Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_signin_page.lua

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
--[[--
Sign in to BookOrbit: the server and the KOReader login on one page.

Each field opens a BookOrbit text panel over the page, with KOReader's
keyboard; Enter moves on to the next empty field, and from the password
straight to signing in. What went wrong shows on the page. Test checks the
address before any password is typed: a BookOrbit server answers the login
endpoint with "not signed in", which tells it apart from a wrong address. The
QR code opens Settings, KOReader on that server, so a login can be created on
a phone.
]]

local Device = require("device")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local md5 = require("ffi/sha2").md5
local util = require("util")
local _ = require("gettext")
local T = require("ffi/util").template

local BookOrbitApi = require("bookorbit_api")
local BoardMetrics = require("bookorbit_board_metrics")
local BoardUI = require("bookorbit_board_ui")
local DialogUI = require("bookorbit_dialog_ui")
local MenuPage = require("bookorbit_menu_page")
local MenuUI = require("bookorbit_menu_ui")

local INK = BoardMetrics.INK
local px = BoardMetrics.px
local text, vstack, hstack, flex, space, icon = BoardUI.text, BoardUI.vstack, BoardUI.hstack, BoardUI.flex, BoardUI.space, BoardUI.icon

local SignIn = {}

local Toast = setmetatable({}, { __index = function(_t, k) return require("bookorbit_toast")[k] end })
local TextEntry = setmetatable({}, { __index = function(_t, k) return require("bookorbit_text_entry")[k] end })

-- The web address of Settings, KOReader on the server the API lives on.
function SignIn.settingsUrl(server_url)
    if type(server_url) ~= "string" or server_url == "" then return nil end
    local origin = server_url:gsub("/api/v1/?$", ""):gsub("/+$", "")
    return origin .. "/settings/koreader"
end

local function field(w, value, o)
    local h = px(48)
    local b = math.max(1, px(o.focus and 2.5 or 1.5))
    local items = { icon(o.icon, px(18), { sw = 2 }) }
    local trail = o.trail
    local tw = w - 2 * b - 2 * px(14) - px(18) - px(10) - (trail and (trail:getSize().w + px(10)) or 0)
    table.insert(items, text(value ~= "" and value or o.placeholder, { s = 16, col = value ~= "" and INK.black or INK.g6, w = tw, data = true }))
    if trail then table.insert(items, trail) end
    local box = BoardUI.box({ w = w, h = h, radius = px(10), border = b, border_col = INK.black, bg = INK.white },
        hstack({ w = w - 2 * b, h = h - 2 * b }, { space(px(14), 1), hstack({ gap = px(10) }, items), flex(0) }))
    return o.action and BoardUI.hit(box, o.action) or box
end

local function qr(text_, size)
    local ok, QRWidget = pcall(require, "ui/widget/qrwidget")
    if not ok or not text_ then return nil end
    local ok2, widget = pcall(function() return QRWidget:new{ text = text_, width = size, height = size } end)
    return ok2 and widget or nil
end

-- A one-line outcome under a field: found, or what went wrong.
local function outcome(w, glyph, str)
    return hstack({ w = w, gap = px(6), valign = "top" }, { icon(glyph, px(16), { sw = 2.25 }), text(str, { s = 13, b = true, w = w - px(16) - px(6), lines = 3, data = true }) })
end

local function build(page, sw, sh)
    local s = page.form
    local mx = MenuUI.mx()
    local top, bottom = px(6), px(12)
    local w = sw - 2 * mx
    local pad = px(18)
    local url = SignIn.settingsUrl(BookOrbitApi.normalizeServerUrl(s.server))

    -- The card's parts at an inner width iw; gap is the room above and
    -- below each part, tighter on a short screen.
    local function serverPart(iw, gap)
        local status
        if s.test == "found" then
            status = outcome(iw, "circle-check", _("BookOrbit found"))
        elseif s.test == "failed" then
            status = outcome(iw, "triangle-alert", s.test_message or _("No BookOrbit server answered there"))
        end
        return vstack({ w = iw, pad = { gap, 0, gap, 0 } }, {
            text(_("Server"), { s = 13, b = true }),
            space(nil, px(8)),
            field(iw, MenuUI.serverHost(s.server) or "", { icon = "globe", placeholder = "books.example.com", action = { kind = "edit", field = "server" },
                trail = MenuUI.pill(_("Test"), { kind = "outline", h = 32, s = 13, pad_x = 12, action = { kind = "test" } }) }),
            status and space(nil, px(8)) or false,
            status or false,
        })
    end
    local function helpPart(iw)
        local qr_size = px(88)
        local code = url and qr(url, qr_size)
        local help_w = iw - (code and (qr_size + px(14)) or 0)
        local help = vstack({ w = help_w }, {
            text(_("No KOReader login yet?"), { s = 14, b = true, w = help_w }),
            space(nil, px(2)),
            text(code and _("Scan to create one in BookOrbit on your phone, or open Settings, KOReader on the web.")
                or _("Create one in BookOrbit on the web: Settings, KOReader."), { s = 13, col = INK.g4, lines = 3, w = help_w }),
        })
        return code and hstack({ w = iw, gap = px(14) }, { help, code }) or help
    end
    local function loginPart(iw, gap, with_help)
        return vstack({ w = iw, pad = { gap, 0, gap, 0 } }, {
            text(_("KOReader login"), { s = 13, b = true }),
            space(nil, px(8)),
            field(iw, s.username or "", { icon = "circle-user-round", placeholder = _("Username"), action = { kind = "edit", field = "username" }, focus = s.focus == "username" }),
            space(nil, px(8)),
            field(iw, s.password and string.rep("•", #s.password) or "", { icon = "key-round", placeholder = _("Password"), action = { kind = "edit", field = "password" }, focus = s.focus == "password" }),
            s.error and space(nil, px(8)) or false,
            s.error and outcome(iw, "triangle-alert", s.error) or false,
            with_help and space(nil, px(12)) or false,
            with_help and helpPart(iw) or false,
        })
    end
    local function actions(card_w)
        return {
            MenuUI.pill(_("Sign in"), { h = 50, s = 16, w = card_w, action = { kind = "sign-in" } }),
            space(nil, px(8)),
            hstack({ w = card_w }, { flex(0), MenuUI.pill(_("Not now"), { kind = "ghost", h = 40, s = 14, action = { kind = "close" } }), flex(0) }),
        }
    end
    -- One card: the server over the login, Sign in under it.
    local function oneColumn(with_help)
        local card_w = math.min(w, px(560))
        local iw = card_w - 2 * pad
        local card = MenuUI.card(card_w, { serverPart(iw, px(16)), BoardUI.rect(iw, MenuUI.hair(), INK.gb), loginPart(iw, px(16), with_help) }, { pad = 18, strong = true })
        local items = { card, space(nil, px(14)) }
        for _i, item in ipairs(actions(card_w)) do table.insert(items, item) end
        return vstack({ w = card_w }, items)
    end
    -- A wide, short screen: the server (and the help) beside the login and
    -- Sign in.
    local function twoColumns(with_help, gap)
        local col_gap = px(24)
        local col_w = math.min(math.floor((w - col_gap) / 2), px(460))
        local iw = col_w - 2 * pad
        local left_items = { serverPart(iw, gap) }
        if with_help then
            table.insert(left_items, BoardUI.rect(iw, MenuUI.hair(), INK.gb))
            table.insert(left_items, vstack({ w = iw, pad = { gap, 0, gap, 0 } }, { helpPart(iw) }))
        end
        local left = MenuUI.card(col_w, left_items, { pad = 18, strong = true })
        local right_items = { MenuUI.card(col_w, { loginPart(iw, gap, false) }, { pad = 18, strong = true }), space(nil, px(14)) }
        for _i, item in ipairs(actions(col_w)) do table.insert(right_items, item) end
        return hstack({ gap = col_gap, valign = "top" }, { left, vstack({ w = col_w }, right_items) })
    end

    local header = MenuUI.pageHeader(w, _("Sign in to BookOrbit"), {
        back = false, sub = _("Sync progress and highlights, and browse your library"),
        right = MenuUI.iconButton("x", { kind = "close" }), right_inset = MenuUI.tapInset(24),
    })
    local footer = text(_("The BookOrbit icon stays in KOReader's menu bar, so you can sign in later."), { s = 13, col = INK.g4, w = w, lines = 2, align = "center" })
    -- On a short screen the note under the page goes first, then the help
    -- beside the QR code; a wide one then sets the login beside the server,
    -- tightening its gaps last. The fields and Sign in always stay.
    local room = sh - top - bottom - header:getSize().h - 2 * px(8)
    local variants = {
        function() return oneColumn(true) end,
        function() return oneColumn(false) end,
    }
    if MenuUI.wide() then
        table.insert(variants, function() return twoColumns(true, px(16)) end)
        table.insert(variants, function() return twoColumns(false, px(16)) end)
        table.insert(variants, function() return twoColumns(false, px(8)) end)
    end
    local pick, pick_h = #variants, nil
    for index, make in ipairs(variants) do
        local clean, probe = BoardUI.fits(function() return BoardUI.collectHits(make) end)
        local h = probe and probe.getSize and probe:getSize().h or math.huge
        if probe and probe.free then probe:free() end
        if index == 1 then pick_h = h end
        if clean and h <= room then pick, pick_h = index, h; break end
    end
    local with_footer = pick == 1 and pick_h + footer:getSize().h <= room
    local body = variants[pick]()
    return vstack({ w = sw, h = sh, pad = { top, mx, bottom, mx } }, {
        header,
        flex(px(8)),
        hstack({ w = w }, { flex(0), body, flex(0) }),
        flex(px(8)),
        with_footer and footer or false,
    })
end

-- The server as it is typed: without the API path the plugin adds.
local function serverInput(server)
    if not server or server == "" then return "https://" end
    return (server:gsub("/api/v1/?$", ""))
end

local function ready(s)
    return BookOrbitApi.normalizeServerUrl(s.server) and (s.username or "") ~= "" and (s.password or "") ~= ""
end

local function firstMissing(s)
    if not BookOrbitApi.normalizeServerUrl(s.server) then return "server" end
    if (s.username or "") == "" then return "username" end
    if (s.password or "") == "" then return "password" end
end

local signIn

local function edit(page, name)
    local s = page.form
    local fields = {
        server = { title = _("Server address"), icon = "globe", hint = "https://books.example.com",
            note = _("The address you open BookOrbit at in a browser.") },
        username = { title = _("Username"), icon = "circle-user-round",
            note = _("Your KOReader login, made in BookOrbit on the web: Settings, KOReader.") },
        password = { title = _("Password"), icon = "key-round", password = true },
    }
    local f = fields[name]
    local last = name == "password"
    TextEntry.open{
        title = f.title,
        icon = f.icon,
        hint = f.hint,
        password = f.password,
        value = name == "server" and serverInput(s.server) or (s[name] or ""),
        validate = name == "server" and function(value)
            if not BookOrbitApi.normalizeServerUrl(value) then return _("Enter a valid http:// or https:// server address.") end
        end or nil,
        below = function(_page, w)
            local done = last and BookOrbitApi.normalizeServerUrl(s.server) and (s.username or "") ~= ""
            return {
                f.note and DialogUI.note(w, "info", f.note, { col = INK.g3 }) or false,
                DialogUI.pillRow(w, {
                    { label = _("Cancel"), kind = "outline", action = { kind = "close" } },
                    done and { label = _("Sign in"), action = { kind = "submit" } }
                        or { label = last and _("Done") or _("Next"), action = { kind = "submit" } },
                }),
            }
        end,
        on_submit = function(value)
            if page.closed then return end
            s[name] = value
            s.error = nil
            if name == "server" then s.test = nil end
            if ready(s) and last then
                s.focus = nil
                page:refresh()
                signIn(page)
                return
            end
            local next_field = firstMissing(s)
            s.focus = next_field ~= "server" and next_field or nil
            page:refresh()
            -- Straight on to the next empty field, as a login form does.
            if next_field and next_field ~= name then
                UIManager:tickAfterNext(function() if not page.closed then edit(page, next_field) end end)
            end
        end,
    }
end

-- Shows a loading pill while fn runs; the request cannot be stopped.
local function busy(label, fn)
    local pill = Toast.loading(label, { dismissable = false })
    local a, b = fn()
    pill:close()
    return a, b
end

-- A BookOrbit server answers the login endpoint even without a login; a wrong
-- address does not.
local function test(page, then_fn)
    local s = page.form
    local normalized = BookOrbitApi.normalizeServerUrl(s.server)
    if not normalized then
        s.test = "failed"
        s.test_message = (not s.server or util.trim(s.server) == "") and _("Type the server's address first")
            or _("Enter a valid http:// or https:// server address.")
        page:refresh()
        return
    end
    NetworkMgr:runWhenConnected(function()
        local opts = page.plugin:apiOpts(false)
        opts.server_url, opts.username, opts.userkey = normalized, nil, nil
        local body, err = busy(_("Looking for BookOrbit…"), function() return BookOrbitApi.new(opts):auth() end)
        if body or err == 401 or err == 403 then
            s.test, s.test_message = "found", nil
        else
            s.test = "failed"
            s.test_message = err == 404 and _("That address is not a BookOrbit server") or T(_("Could not reach it: %1"), page.plugin:errorLabel(err))
        end
        page:refresh()
        if then_fn and s.test == "found" then then_fn() end
    end)
end

signIn = function(page)
    local s = page.form
    local plugin = page.plugin
    local missing = firstMissing(s)
    if missing then
        edit(page, missing)
        return
    end
    local normalized = BookOrbitApi.normalizeServerUrl(s.server)
    NetworkMgr:runWhenConnected(function()
        local opts = plugin:apiOpts(false)
        opts.server_url, opts.username, opts.userkey = normalized, s.username, md5(s.password)
        Device:setIgnoreInput(true)
        local body, err = busy(_("Signing in…"), function() return BookOrbitApi.new(opts):auth() end)
        Device:setIgnoreInput(false)
        if body then
            plugin:completeSignIn(normalized, s.username, opts.userkey)
            page:close()
            if page.on_signed_in then page.on_signed_in() end
            return
        elseif err == 401 or err == 403 then
            s.error = _("That username and password were not accepted. Check your KOReader login in BookOrbit on the web: Settings, KOReader.")
        else
            s.error = T(_("Could not reach the BookOrbit server: %1"), plugin:errorLabel(err))
        end
        page:refresh()
    end)
end

local function onAction(page, action)
    local kind = action.kind
    if kind == "edit" then
        edit(page, action.field)
    elseif kind == "test" then
        test(page)
    elseif kind == "sign-in" then
        signIn(page)
    end
    return true
end

-- on_signed_in runs after a successful sign-in, once the page has closed.
function SignIn.open(plugin, on_signed_in)
    local settings = plugin.settings
    local page = MenuPage:new{
        name = "bookorbit_sign_in",
        build = build,
        on_action = onAction,
        plugin = plugin,
        on_signed_in = on_signed_in,
        form = { server = settings.server_url, username = settings.username, focus = settings.server_url and "username" or nil },
    }
    UIManager:show(page, "ui")
    return page
end

return SignIn