--[[-- The book detail page's view model. Everything the page draws comes from here: the server's detail payload plus what only this device knows (the file on it, KOReader's reading statistics, the sidecar's highlights, the list the page was opened from). The rules are pure functions over plain tables so the specs can check them without a screen; DetailData.build gathers their inputs from the catalog. ]] local T = require("ffi/util").template local _ = require("gettext") local CatalogUtil = require("bookorbit_catalog_util") local DetailData = {} local DAY = 86400 -- Formats that are audio, not text: listed, never offered for KOReader. local AUDIO_FORMATS = { m4b = true, mp3 = true, m4a = true, aac = true, flac = true, ogg = true, opus = true } -- ------------------------------------------------------------ text helpers local function trim(value) if type(value) ~= "string" then return nil end local text = value:gsub("^%s+", ""):gsub("%s+$", "") return text ~= "" and text or nil end -- The plugin's HTML cleanup, then the Goodreads "alternative cover" line -- some imports open with. function DetailData.cleanDescription(text) local cleaned = CatalogUtil.cleanDescriptionText(text) if not cleaned then return nil end cleaned = cleaned:gsub("^[Aa]n alternative cover for this ASIN can be found here%.?%s*", "") return trim(cleaned) end -- The main page runs paragraphs together: a clamped excerpt should not spend -- a line on a paragraph break. function DetailData.excerpt(description) if not description then return nil end return (description:gsub("%s*\n+%s*", " ")) end -- A trailing ".0" is noise on a series index: 3, not 3.0. function DetailData.seriesIndexText(index) local value = tonumber(index) if not value then return nil end if value == math.floor(value) then return tostring(math.floor(value)) end local text = string.format("%.2f", value):gsub("0+$", ""):gsub("%.$", "") return text end -- Genres then tags, without repeats (any case) and without an author's own -- name, which some sources file as a genre. function DetailData.chips(detail) local seen, out = {}, {} local authors = {} for _i, author in ipairs(detail.authors or {}) do if type(author) == "string" then authors[author:lower()] = true end end for _i, list in ipairs({ detail.genres or {}, detail.tags or {} }) do for _j, value in ipairs(list) do local text = trim(value) local key = text and text:lower() if key and not seen[key] and not authors[key] then seen[key] = true table.insert(out, text) end end end return out end -- The words every title in a list shares ("Harry Potter and the ...") are -- dropped when the pager names a neighbour. function DetailData.pagerTitle(title, titles) if type(title) ~= "string" then return nil end local words = {} for _i, t in ipairs(titles or {}) do if type(t) == "string" then local w = {} for word in t:gmatch("%S+") do table.insert(w, word) end table.insert(words, w) end end if #words < 2 then return title end local k = 0 while true do local word = words[1][k + 1] if not word or k + 1 >= #words[1] then break end local all = true for _i, w in ipairs(words) do if w[k + 1] ~= word or k + 1 >= #w then all = false; break end end if not all then break end k = k + 1 end if k < 2 then return title end local out = {} local index = 0 for word in title:gmatch("%S+") do index = index + 1 if index > k then table.insert(out, word) end end return #out > 0 and table.concat(out, " ") or title end -- ------------------------------------------------------------------ files -- What each file is and whether KOReader can use it. Read-along is named only -- when the source EPUB really carries narration; the audioless delivery of a -- plain EPUB is just the EPUB. -- opts: opens(format) -> bool, local_size(file) -> bytes on this device or nil, -- on_device(file) -> bool. function DetailData.files(detail, opts) opts = opts or {} local list = {} for _i, file in ipairs(detail.files or {}) do table.insert(list, file) end table.sort(list, function(a, b) local pa, pb = a.role == "primary" and 0 or 1, b.role == "primary" and 0 or 1 if pa ~= pb then return pa < pb end return false end) local by_format, opens_at = {}, {} -- The main file is the first one this reader can open: the primary when it -- can, so a book whose primary is an audiobook leads with its ebook. local main for i, file in ipairs(list) do local fmt = string.lower(file.format or "") by_format[fmt] = (by_format[fmt] or 0) + 1 opens_at[i] = AUDIO_FORMATS[fmt] ~= true and (opts.opens == nil or opts.opens(fmt) == true) if main == nil and opens_at[i] then main = file end end local usable, other = {}, {} for i, file in ipairs(list) do local fmt = string.lower(file.format or "") local audio = AUDIO_FORMATS[fmt] == true local opens = opens_at[i] local read_along = fmt == "epub" and file.sourceMediaOverlayAvailable == true local text_only = read_along and file.downloadVariant == "audioless_epub" local on_device = opts.on_device ~= nil and opts.on_device(file) == true local bytes = file.sizeBytes -- The audioless delivery of a plain EPUB is the same book; its source -- size is a fair size to show (never for a read-along, whose source -- carries the narration). if bytes == nil and not read_along then bytes = file.sourceSizeBytes end if on_device and opts.local_size then bytes = opts.local_size(file) or bytes end local title if audio then title = _("Audiobook") elseif read_along then title = _("Read-along edition") elseif not opens then title = T(_("%1 file"), fmt:upper()) elseif by_format[fmt] > 1 then title = file == main and _("Main edition") or _("Another edition") elseif file == main then title = _("Main file") else title = _("Also available") end local entry = { file = file, format = fmt:upper(), title = title, audio = audio, opens = opens, read_along = read_along, text_only = text_only, on_device = on_device, size = bytes and CatalogUtil.formatBytes(bytes) or nil, duration = CatalogUtil.formatDuration(file.durationSeconds), } if opens then table.insert(usable, entry) else table.insert(other, entry) end end return usable, other end -- The sub-line under a file row, longest first; the view keeps the first -- that fits. function DetailData.fileSubs(entry) local function join(...) local parts = {} for i = 1, select("#", ...) do local v = select(i, ...) if v and v ~= "" then table.insert(parts, v) end end return table.concat(parts, " · ") end if entry.read_along and entry.text_only then return { join(entry.size, _("Text only here; narration plays in BookOrbit")), join(entry.size, _("Text only; narration in BookOrbit")), join(entry.size, _("Text only")), } end if entry.read_along then return { join(entry.size, _("With narration")), join(entry.size) } end return { join(entry.size) } end -- The one line naming files KOReader cannot use, longest first. function DetailData.otherFilesSubs(other) local long, short = {}, {} for _i, entry in ipairs(other) do if entry.audio then table.insert(long, entry.duration and T(_("audiobook, %1"), entry.duration) or _("audiobook")) table.insert(short, _("audiobook")) else table.insert(long, T(_("%1 format"), entry.format)) table.insert(short, entry.format) end end return { T(_("Not for KOReader: %1. In BookOrbit."), table.concat(long, "; ")), T(_("Not for KOReader: %1"), table.concat(long, "; ")), T(_("Not for KOReader: %1"), table.concat(short, ", ")), _("Not for KOReader"), } end -- --------------------------------------------------------------- shelves -- Shelves never repeat a book: the author shelf drops series books, the -- similar shelf drops both, and none shows the book itself. function DetailData.shelves(detail) local by_id = {} for _i, section in ipairs(detail.relatedSections or {}) do if type(section) == "table" and section.id and type(section.books) == "table" then by_id[section.id] = section.books end end local seen = { [detail.id or -1] = true } local out = {} for _i, id in ipairs({ "series", "author", "similar" }) do local list = {} for _j, book in ipairs(by_id[id] or {}) do if book.id and not seen[book.id] then seen[book.id] = true table.insert(list, book) end end out[id] = list end return out end -- Similar books are scored mostly on genres and tags; a book with neither -- gets an arbitrary list, so it gets no shelf. function DetailData.similarFor(detail, shelves) local tagged = #(detail.genres or {}) + #(detail.tags or {}) > 0 if not tagged or #(shelves.similar or {}) == 0 then return nil end return shelves.similar end -- The main page's shelves, as tabs: Similar books for a tagged book, then -- the author's other books (series books dropped). short is the author tab's -- label when the name does not fit. function DetailData.shelfTabs(detail, shelves, author) local tabs = {} local similar = DetailData.similarFor(detail, shelves) if similar then table.insert(tabs, { kind = "similar", label = _("Similar books"), books = similar }) end if #(shelves.author or {}) > 0 and author then table.insert(tabs, { kind = "author", label = T(_("More by %1"), author), short = _("More by this author"), books = shelves.author }) end return tabs end -- The shelf the main page shows: the chosen tab, else the first. Returns the -- shelf and every tab. function DetailData.mainShelf(detail, shelves, author, chosen) local tabs = DetailData.shelfTabs(detail, shelves, author) for _i, tab in ipairs(tabs) do if tab.kind == chosen then return tab, tabs end end return tabs[1], tabs end -- The series path: every book of the series with this one in place, by -- index; the next one is the first after this one not yet read. function DetailData.series(detail) if not trim(detail.seriesName) then return nil end local books = {} -- The full list when the server sends it; the capped related shelf -- otherwise (older servers). local members = type(detail.seriesMembers) == "table" and detail.seriesMembers or nil if not members then members = {} for _i, section in ipairs(detail.relatedSections or {}) do if section.id == "series" then for _j, book in ipairs(section.books or {}) do table.insert(members, book) end end end end for _i, book in ipairs(members) do if book.id ~= detail.id then table.insert(books, { id = book.id, title = book.title, index = DetailData.seriesIndexText(book.seriesIndex), sort = tonumber(book.seriesIndex), status = book.readStatus, book = book }) end end table.insert(books, { id = detail.id, title = detail.title, index = DetailData.seriesIndexText(detail.seriesIndex), sort = tonumber(detail.seriesIndex), status = detail.readStatus, current = true, pct = tonumber(detail.progressPercentage) }) table.sort(books, function(a, b) local sa, sb = a.sort or math.huge, b.sort or math.huge if sa ~= sb then return sa < sb end return (a.id or 0) < (b.id or 0) end) local current, read = nil, 0 for index, book in ipairs(books) do if book.current then current = index end if book.status == "read" then read = read + 1 end end local next_book for index = (current or 0) + 1, #books do if books[index].status ~= "read" then next_book = books[index]; break end end -- Only a series this library holds more than this one book of gets a path. if #books < 2 then return nil end -- "Book 3 of 7" only when the library holds the whole run 1..N, each once. local complete = true for index, book in ipairs(books) do if book.sort ~= index then complete = false; break end end return { name = trim(detail.seriesName), books = books, current = current, total = #books, read = read, next = next_book, complete = complete } end -- "Book 3 of 7 · Harry Potter", "Book 18 · Absolute Batman", or the series -- name alone when the book has no index. function DetailData.kicker(detail, series) local name = trim(detail.seriesName) if not name then return nil end local index = DetailData.seriesIndexText(detail.seriesIndex) if not index then return name end if series and series.complete then return T(_("Book %1 of %2 · %3"), index, series.total, name) end return T(_("Book %1 · %2"), index, name) end -- The author line, longest first: every author, the first two and a count, -- the first and a count. function DetailData.authorLines(authors) local list = {} for _i, a in ipairs(authors or {}) do local name = trim(a) if name then table.insert(list, name) end end if #list == 0 then return {} end local out = { table.concat(list, ", ") } if #list > 2 then table.insert(out, T(_("%1, %2 and %3 more"), list[1], list[2], #list - 2)) end if #list > 1 then table.insert(out, T(_("%1 and %2 more"), list[1], #list - 1)) end return out end -- ---------------------------------------------------------------- facts local LANGUAGES = { en = "English", eng = "English", english = "English", ja = "Japanese", ["ja-jp"] = "Japanese", fr = "French", de = "German", es = "Spanish", it = "Italian", pt = "Portuguese", nl = "Dutch" } function DetailData.languageName(code) local text = trim(code) if not text then return nil end return LANGUAGES[text:lower()] or text end local MONTHS = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" } -- "18 Jun 2013" from an ISO date or timestamp. function DetailData.dateLabel(value, with_year) if type(value) ~= "string" then return nil end local y, m, d = value:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)") if not y then return nil end local month = _(MONTHS[tonumber(m)] or m) if with_year == false then return T(_("%1 %2"), tonumber(d), month) end return T(_("%1 %2 %3"), tonumber(d), month, y) end -- The More details ledger: every field the payload has. function DetailData.facts(detail) local rows = {} local function add(key, value) if value ~= nil and value ~= "" then table.insert(rows, { key, tostring(value) }) end end add(_("Published"), DetailData.dateLabel(detail.publishedDate) or detail.publishedYear) add(_("Publisher"), trim(detail.publisher)) add(_("Pages"), detail.pageCount) add(_("Language"), DetailData.languageName(detail.language)) add(_("ISBN"), trim(detail.isbn13) or trim(detail.isbn10)) add(_("Library"), trim(detail.libraryName)) add(_("Added"), DetailData.dateLabel(detail.addedAt)) local names = {} for _i, collection in ipairs(detail.collections or {}) do local name = trim(collection.name) if name then table.insert(names, name) end end if #names > 0 then add(#names > 1 and _("Collections") or _("Collection"), table.concat(names, ", ")) end return rows end -- The key facts strip on the main page: year, pages, language and the file. function DetailData.spec(detail, usable, other) local items = {} if detail.publishedYear then table.insert(items, { tostring(detail.publishedYear), _("Published") }) end if detail.pageCount then table.insert(items, { tostring(detail.pageCount), _("Pages") }) end local lang = DetailData.languageName(detail.language) if lang then table.insert(items, { lang, _("Language") }) end local file for _i, entry in ipairs(usable) do if entry.on_device then file = entry; break end end file = file or usable[1] if file then local value = file.size and (file.format .. " · " .. file.size) or file.format table.insert(items, { value, file.on_device and _("On this device") or _("Not downloaded") }) elseif #other > 0 then local audio for _i, entry in ipairs(other) do if entry.audio then audio = entry; break end end if audio then table.insert(items, { audio.size and (audio.format .. " · " .. audio.size) or audio.format, _("Audiobook") }) else table.insert(items, { _("None"), _("Readable file") }) end end return items end -- ---------------------------------------------------------------- action -- The page's one action, with shorter labels for narrow buttons. -- state: on_device, status, pct, offline, downloading = { pct, done, total }. function DetailData.primary(state, usable, other) if state.downloading then return { kind = "downloading", icon = "download", pct = state.downloading.pct, sub = state.downloading.sub } end if state.on_device then if state.status == "read" then return { kind = "again", icon = "rotate-ccw", labels = { _("Read again") } } elseif (state.pct or 0) > 0 then return { kind = "continue", icon = "play", labels = { _("Continue reading"), _("Continue") } } end return { kind = "start", icon = "book-open", labels = { _("Start reading"), _("Read") } } end local file = usable[1] if not file then for _i, entry in ipairs(other or {}) do if entry.audio then return { kind = "none", icon = "headphones", labels = { _("Audiobook · listen in BookOrbit"), _("Audiobook only") } } end end return { kind = "none", icon = "x", labels = { _("No file KOReader can open"), _("Unavailable") } } end if state.offline then return { kind = "offline", icon = "cloud-off", labels = { _("Download when online"), _("Offline") } } end -- The format rides the label only when there is a choice to make. local choice = #usable > 1 local labels = {} if choice and file.size then table.insert(labels, T(_("Download %1 · %2"), file.format, file.size)) end if file.size then table.insert(labels, T(_("Download · %1"), file.size)) end if choice then table.insert(labels, T(_("Download %1"), file.format)) end table.insert(labels, _("Download")) return { kind = "download", icon = "download", labels = labels, entry = file } end -- ---------------------------------------------------------------- progress local function fmtDuration(min) min = math.floor((min or 0) + 0.5) if min < 60 then return T(_("%1 min"), min) end local h, m = math.floor(min / 60), min % 60 if m == 0 then return T(_("%1 h"), h) end return T(_("%1 h %2 min"), h, m) end DetailData.formatDuration = fmtDuration local function fmtShort(min) min = math.floor((min or 0) + 0.5) if min < 60 then return T(_("%1m"), min) end local h, m = math.floor(min / 60), min % 60 if m == 0 then return T(_("%1h"), h) end return T(_("%1h %2m"), h, m) end DetailData.formatShortDuration = fmtShort function DetailData.pctLabel(pct) local p = tonumber(pct) if not p then return nil end if p > 0 and p < 0.5 then return "<1%" end if p < 100 and p >= 99.5 then return "99%" end return tostring(math.floor(p + 0.5)) .. "%" end -- Where you stand. forecast: { minutes_left, per_day, day, date } from the -- device's statistics, or nil. function DetailData.progress(detail, forecast) local p = tonumber(detail.progressPercentage) if not p or p <= 0 then return nil end local out = { pct = p, label = DetailData.pctLabel(p) } local pages = tonumber(detail.pageCount) if pages and pages > 0 then local at = math.floor(pages * p / 100 + 0.5) out.page = T(_("p. %1 of %2"), at, pages) out.page_short = T(_("%1 / %2 pages"), at, pages) end if forecast and forecast.minutes_left and detail.readStatus ~= "read" then out.left = T(_("%1 left"), fmtDuration(forecast.minutes_left)) out.left_short = T(_("%1 left"), fmtShort(forecast.minutes_left)) out.finish = { day = forecast.day, short = forecast.short, date = forecast.date, per_day = math.floor((forecast.per_day or 0) + 0.5) } end return out end -- The reading path: furthest percentage reached by each day from the first -- read to today, carried over days without reading. At most `max_days` -- values (the oldest days are folded together). function DetailData.readingPath(journal, now, max_days) if not journal or not journal.first_read or #(journal.path or {}) == 0 then return nil end local by_day = {} for _i, entry in ipairs(journal.path) do local total = entry.total or 0 if total > 0 then by_day[entry.day] = math.min(100, entry.page / total * 100) end end local start = journal.first_read local days = math.max(1, math.floor((now - start) / DAY) + 1) local values, best = {}, 0 for i = 0, days - 1 do local key = os.date("%Y-%m-%d", start + i * DAY) if by_day[key] and by_day[key] > best then best = by_day[key] end table.insert(values, best) end max_days = max_days or 120 if #values > max_days then local folded = {} local step = #values / max_days for i = 1, max_days do folded[i] = values[math.min(#values, math.floor(i * step))] end values = folded end return values end -- ---------------------------------------------------------------- pager -- The list the page was opened from: the books around this one and where it -- sits. list: { label, titles, index, total, prev_title, next_title }. function DetailData.pager(list) if not list or not list.total or list.total < 2 or not list.index then return nil end return { label = list.label, index = list.index, total = list.total, prev = list.has_prev and (DetailData.pagerTitle(list.prev_title, list.titles) or _("Previous")) or nil, next = list.has_next and (DetailData.pagerTitle(list.next_title, list.titles) or _("Next")) or nil, } end -- ---------------------------------------------------------------- build local STATUS_LABELS = CatalogUtil.READ_STATUS_LABELS function DetailData.statusLabel(status) return STATUS_LABELS[status or "unread"] or STATUS_LABELS.unread or _("Unread") end local function relativeDay(t, now) local BoardData = require("bookorbit_board_data") return BoardData.relativeDay(os.date("!%Y-%m-%dT%H:%M:%SZ", t), now) end -- Highlights in the book's sidecar: how many, and the newest. local function sidecarHighlights(path) if not path then return nil end local ok, result = pcall(function() local DocSettings = require("docsettings") if not DocSettings:hasSidecarFile(path) then return nil end local Sidecar = require("bookorbit_sidecar") local list = Sidecar.normalizeAnnotations(DocSettings:open(path):readSetting("annotations")) local items = {} for _i, a in ipairs(list) do if trim(a.text) then table.insert(items, a) end end if #items == 0 then return nil end table.sort(items, function(a, b) return (a.datetimeUpdated or a.datetime) > (b.datetimeUpdated or b.datetime) end) return { count = #items, items = items } end) return ok and result or nil end DetailData.sidecarHighlights = sidecarHighlights local function highlightView(entry) if not entry then return nil end local when = entry.datetime and DetailData.dateLabel(entry.datetime:gsub(" ", "T"), false) return { text = trim(entry.text), chapter = trim(entry.chapter), note = trim(entry.note), when = when } end DetailData.highlightView = highlightView -- Builds the page's model. catalog: the catalog controller; context: the -- detail context; now: os.time(). function DetailData.build(catalog, context, now) now = now or os.time() local NetworkMgr = require("ui/network/manager") local datetime = require("datetime") local BoardData = require("bookorbit_board_data") local detail = context.detail or {} local d = { detail = detail, now = now } d.title = trim(detail.title) or _("Untitled") d.subtitle = trim(detail.subtitle) local authors = {} for _i, a in ipairs(detail.authors or {}) do if trim(a) then table.insert(authors, trim(a)) end end d.authors = #authors > 0 and table.concat(authors, ", ") or nil d.author_first = authors[1] d.status = detail.readStatus or "unread" d.status_label = DetailData.statusLabel(d.status) d.rating = tonumber(detail.rating) or 0 d.pct = tonumber(detail.progressPercentage) d.pages = tonumber(detail.pageCount) d.description = DetailData.cleanDescription(detail.description) d.excerpt = DetailData.excerpt(d.description) d.chips = DetailData.chips(detail) d.facts = DetailData.facts(detail) d.online = NetworkMgr:isConnected() d.offline = context.stale == true or not d.online d.clock = datetime.secondsToHour(now, G_reader_settings:isTrue("twelve_hour_clock")) local path, state = catalog:thumbnailDisplay(detail) d.cover_path, d.cover_state = path, state d.read_path = catalog:detailReadPath(detail) d.on_device = d.read_path ~= nil local lfs = require("libs/libkoreader-lfs") d.usable, d.other = DetailData.files(detail, { opens = function(fmt) return CatalogUtil.isSupportedFormat(fmt) and true or false end, on_device = function(file) return catalog:onDeviceFilePath(file) ~= nil end, local_size = function(file) local local_path = catalog:onDeviceFilePath(file) return local_path and lfs.attributes(local_path, "size") or nil end, }) d.spec = DetailData.spec(detail, d.usable, d.other) local shelves = DetailData.shelves(detail) d.similar = DetailData.similarFor(detail, shelves) for _i, list in ipairs({ shelves.series or {}, shelves.author or {}, d.similar or {} }) do for _j, book in ipairs(list) do local cover, cover_state = catalog:thumbnailDisplay(book) book.cover_path, book.cover_state = cover, cover_state end end d.series = DetailData.series(detail) d.kicker = DetailData.kicker(detail, d.series) d.author_lines = DetailData.authorLines(detail.authors) d.shelf, d.shelf_tabs = DetailData.mainShelf(detail, shelves, d.author_first, context.detail_shelf) -- What this device's statistics say about the book. local md5 if d.read_path then catalog._board_md5 = catalog._board_md5 or {} md5 = catalog._board_md5[d.read_path] if md5 == nil then local ok, util = pcall(require, "util") md5 = ok and util.partialMD5 and util.partialMD5(d.read_path) or false catalog._board_md5[d.read_path] = md5 end md5 = md5 or nil end local forecast = md5 and BoardData.forecast({ progressPercentage = d.pct }, md5, now) or nil d.progress = DetailData.progress(detail, forecast) local Stats = require("bookorbit_stats_reader") local journal = md5 and Stats.getBookJournal(md5) or nil if journal and journal.seconds > 0 then local pace_info = md5 and Stats.getBookPace(md5, 14) or nil local per_hour = pace_info and pace_info.seconds_per_page and pace_info.seconds_per_page > 0 and math.floor(3600 / pace_info.seconds_per_page + 0.5) or nil d.journal = { started = DetailData.dateLabel(os.date("%Y-%m-%d", journal.first_read), false), time = fmtDuration(journal.seconds / 60), time_short = fmtShort(journal.seconds / 60), days = journal.days, last_read = relativeDay(journal.last_read, now), pace = per_hour and T(_("%1 pages an hour"), per_hour) or nil, pace_short = per_hour and T(_("%1 p/h"), per_hour) or nil, path = DetailData.readingPath(journal, now, 120), } end if d.status == "read" then local finished = detail.lastReadAt and DetailData.dateLabel(detail.lastReadAt, false) d.finished = { date = finished, journal = d.journal } if d.journal then d.journal.finished_on = finished end elseif not d.progress and d.pages then local records = BoardData.records(now) local per_hour = records and records.pages_per_hour and records.pages_per_hour > 0 and records.pages_per_hour or nil d.not_started = { pages = d.pages, estimate = per_hour and fmtDuration(math.floor(d.pages / per_hour * 60 / 10 + 0.5) * 10) or nil } end local hl = d.read_path and sidecarHighlights(d.read_path) or nil d.highlights = hl and { count = hl.count, latest = highlightView(hl.items[1]), items = hl.items } or nil d.primary = DetailData.primary({ on_device = d.on_device, status = d.status, pct = d.pct, offline = not d.online, downloading = context.downloading, }, d.usable, d.other) d.pager = DetailData.pager(catalog:detailListPosition()) d.back = context.back_label return d end return DetailData