mirror of
https://github.com/koreader/koreader
synced 2024-11-08 07:10:27 +00:00
d33fee3a40
Makes percentage completed easily accessible through the statistics plugin. It has always been accessible through the status bar, but not through the statistics menu.
643 lines
23 KiB
Lua
Executable File
643 lines
23 KiB
Lua
Executable File
local Widget = require("ui/widget/widget")
|
|
local MultiInputDialog = require("ui/widget/multiinputdialog")
|
|
local KeyValuePage = require("ui/widget/keyvaluepage")
|
|
local UIManager = require("ui/uimanager")
|
|
local Screen = require("device").screen
|
|
local TimeVal = require("ui/timeval")
|
|
local DataStorage = require("datastorage")
|
|
local lfs = require("libs/libkoreader-lfs")
|
|
local DEBUG = require("dbg")
|
|
local T = require("ffi/util").template
|
|
local joinPath = require("ffi/util").joinPath
|
|
local _ = require("gettext")
|
|
local util = require("util")
|
|
local tableutil = require("tableutil")
|
|
local ReadHistory = require("readhistory")
|
|
local DocSettings = require("docsettings")
|
|
local ReaderProgress = require("readerprogress")
|
|
local statistics_dir = DataStorage:getDataDir() .. "/statistics/"
|
|
|
|
-- a copy of page_max_read_sec
|
|
local page_max_time
|
|
|
|
local ReaderStatistics = Widget:extend{
|
|
last_time = nil,
|
|
page_min_read_sec = 5,
|
|
page_max_read_sec = 90,
|
|
current_period = 0,
|
|
pages_current_period = 0,
|
|
is_enabled = nil,
|
|
data = {
|
|
title = "",
|
|
authors = "",
|
|
language = "",
|
|
series = "",
|
|
performance_in_pages = {},
|
|
total_time_in_sec = 0,
|
|
highlights = 0,
|
|
notes = 0,
|
|
pages = 0,
|
|
},
|
|
}
|
|
|
|
function ReaderStatistics:isDocless()
|
|
return self.ui == nil or self.ui.document == nil
|
|
end
|
|
|
|
function ReaderStatistics:init()
|
|
if not self:isDocless() and self.ui.document.is_pic then
|
|
return
|
|
end
|
|
|
|
self.ui.menu:registerToMainMenu(self)
|
|
self.current_period = 0
|
|
self.pages_current_period = 0
|
|
|
|
local settings = G_reader_settings:readSetting("statistics") or {}
|
|
self.page_min_read_sec = tonumber(settings.min_sec)
|
|
self.page_max_read_sec = tonumber(settings.max_sec)
|
|
-- use later in getDatesFromBook
|
|
page_max_time = self.page_max_read_sec
|
|
self.is_enabled = not (settings.is_enabled == false)
|
|
self.last_time = TimeVal:now()
|
|
end
|
|
|
|
function ReaderStatistics:getBookProperties()
|
|
local props = self.view.document:getProps()
|
|
if props.title == "No document" or props.title == "" then
|
|
-- FIXME: sometimes crengine returns "No document", try one more time
|
|
props = self.view.document:getProps()
|
|
end
|
|
return props
|
|
end
|
|
|
|
function ReaderStatistics:initData(config)
|
|
if self:isDocless() or not self.is_enabled then
|
|
return
|
|
end
|
|
-- first execution
|
|
if not self.data then
|
|
self.data = { performance_in_pages= {} }
|
|
self:inplaceMigration(); -- first time merge data
|
|
end
|
|
|
|
local book_properties = self:getBookProperties()
|
|
self.data.title = book_properties.title
|
|
self.data.authors = book_properties.authors
|
|
self.data.language = book_properties.language
|
|
self.data.series = book_properties.series
|
|
|
|
self.data.pages = self.view.document:getPageCount()
|
|
return
|
|
end
|
|
|
|
local function generateReadBooksTable(title, dates)
|
|
local result = {}
|
|
for k, v in tableutil.spairs(dates, function(t, a, b) return t[b].date < t[a].date end) do
|
|
table.insert(result, {
|
|
k,
|
|
T(_("Pages (%1) Time: %2"), v.count, util.secondsToClock(v.read, false))
|
|
})
|
|
end
|
|
return result
|
|
end
|
|
|
|
function ReaderStatistics:getStatisticEnabledMenuItem()
|
|
return {
|
|
text = _("Enabled"),
|
|
checked_func = function() return self.is_enabled end,
|
|
callback = function()
|
|
-- if was enabled, have to save data to file
|
|
if self.last_time and self.is_enabled and not self:isDocless() then
|
|
self.ui.doc_settings:saveSetting("stats", self.data)
|
|
end
|
|
|
|
self.is_enabled = not self.is_enabled
|
|
-- if was disabled have to get data from file
|
|
if self.is_enabled and not self:isDocless() then
|
|
self:initData(self.ui.doc_settings)
|
|
end
|
|
self:saveSettings()
|
|
end,
|
|
}
|
|
end
|
|
|
|
function ReaderStatistics:updateSettings()
|
|
self.settings_dialog = MultiInputDialog:new {
|
|
title = _("Statistics settings"),
|
|
fields = {
|
|
{
|
|
text = "",
|
|
input_type = "number",
|
|
hint = T(_("Min seconds, default is 5. Current value: %1"),
|
|
self.page_min_read_sec),
|
|
},
|
|
{
|
|
text = "",
|
|
input_type = "number",
|
|
hint = T(_("Max seconds, default is 90. Current value: %1"),
|
|
self.page_max_read_sec),
|
|
},
|
|
},
|
|
buttons = {
|
|
{
|
|
{
|
|
text = _("Cancel"),
|
|
callback = function()
|
|
self.settings_dialog:onClose()
|
|
UIManager:close(self.settings_dialog)
|
|
end
|
|
},
|
|
{
|
|
text = _("Apply"),
|
|
callback = function()
|
|
self:saveSettings(MultiInputDialog:getFields())
|
|
self.settings_dialog:onClose()
|
|
UIManager:close(self.settings_dialog)
|
|
end
|
|
},
|
|
},
|
|
},
|
|
width = Screen:getWidth() * 0.95,
|
|
height = Screen:getHeight() * 0.2,
|
|
input_type = "number",
|
|
}
|
|
self.settings_dialog:onShowKeyboard()
|
|
UIManager:show(self.settings_dialog)
|
|
end
|
|
|
|
function ReaderStatistics:addToMainMenu(tab_item_table)
|
|
table.insert(tab_item_table.plugins, {
|
|
text = _("Statistics"),
|
|
sub_item_table = {
|
|
self:getStatisticEnabledMenuItem(),
|
|
{
|
|
text = _("Settings"),
|
|
callback = function() self:updateSettings() end,
|
|
},
|
|
{
|
|
text = _("Current book"),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = _("Statistics"),
|
|
kv_pairs = self:getCurrentStat(),
|
|
})
|
|
end,
|
|
enabled = not self:isDocless()
|
|
},
|
|
{
|
|
text = _("All books"),
|
|
callback = function()
|
|
local total_msg, kv_pairs = self:getTotalStats()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = total_msg,
|
|
kv_pairs = kv_pairs,
|
|
})
|
|
end
|
|
},
|
|
{
|
|
text = _("Reading progress"),
|
|
callback = function()
|
|
UIManager:show(ReaderProgress:new{
|
|
dates = self:getDatesFromAll(7, "daily_weekday"),
|
|
current_period = self.current_period,
|
|
current_pages = self.pages_current_period,
|
|
})
|
|
end
|
|
},
|
|
{
|
|
text = _("Time range"),
|
|
sub_item_table = {
|
|
{
|
|
text = _("Last week"),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = _("Last week"),
|
|
kv_pairs = generateReadBooksTable("", self:getDatesFromAll(7, "daily_weekday")),
|
|
})
|
|
end,
|
|
},
|
|
{
|
|
text = _("Last month by day"),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = _("Last month by day"),
|
|
kv_pairs = generateReadBooksTable("", self:getDatesFromAll(30, "daily_weekday")),
|
|
})
|
|
end,
|
|
},
|
|
{
|
|
text = _("Last year by day"),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = _("Last year by day"),
|
|
kv_pairs = generateReadBooksTable("", self:getDatesFromAll(365, "daily")),
|
|
})
|
|
end,
|
|
},
|
|
{
|
|
text = _("Last year by week"),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = _("Last year by week"),
|
|
kv_pairs = generateReadBooksTable("", self:getDatesFromAll(365, "weekly")),
|
|
})
|
|
end,
|
|
},
|
|
{
|
|
text = _("Last 10 years by month"),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = _("Last 10 years by month"),
|
|
kv_pairs = generateReadBooksTable("", self:getDatesFromAll(3650, "monthly")),
|
|
})
|
|
end,
|
|
},
|
|
}
|
|
},
|
|
},
|
|
})
|
|
end
|
|
|
|
function ReaderStatistics:getCurrentStat()
|
|
local dates = {}
|
|
local now_stamp = os.time()
|
|
local now_t = os.date("*t")
|
|
local from_begin_day = now_t.hour * 3600 + now_t.min * 60 + now_t.sec
|
|
local start_today_time = now_stamp - from_begin_day
|
|
local pages_today_period = 0
|
|
local time_today_period = 0
|
|
local last_period = 0
|
|
local sorted_today_performance = {}
|
|
local diff
|
|
for k, v in pairs(self.data.performance_in_pages) do
|
|
dates[os.date("%Y-%m-%d", k)] = true
|
|
if k >= start_today_time then
|
|
pages_today_period = pages_today_period + 1
|
|
table.insert(sorted_today_performance, k)
|
|
end
|
|
end
|
|
local total_days = util.tableSize(dates)
|
|
local read_pages = util.tableSize(self.data.performance_in_pages)
|
|
local current_page = self.view.state.page -- get current page from the view
|
|
local avg_time_per_page = self.data.total_time_in_sec / read_pages
|
|
|
|
table.sort(sorted_today_performance)
|
|
for _, n in pairs(sorted_today_performance) do
|
|
if last_period == 0 then
|
|
last_period = n
|
|
time_today_period = avg_time_per_page
|
|
else
|
|
diff = n - last_period
|
|
if (diff <= page_max_time and diff > 0) then
|
|
time_today_period = time_today_period + diff
|
|
else
|
|
time_today_period = time_today_period + avg_time_per_page
|
|
end
|
|
last_period = n
|
|
end
|
|
|
|
end
|
|
return {
|
|
{ _("Current period"), util.secondsToClock(self.current_period, false) },
|
|
{ _("Current pages"), self.pages_current_period },
|
|
{ _("Today period"), util.secondsToClock(time_today_period, false) },
|
|
{ _("Today pages"), pages_today_period },
|
|
{ _("Time to read"), util.secondsToClock((self.data.pages - current_page) * avg_time_per_page, false) },
|
|
{ _("Total time"), util.secondsToClock(self.data.total_time_in_sec, false) },
|
|
{ _("Total highlights"), self.data.highlights },
|
|
{ _("Total notes"), self.data.notes },
|
|
{ _("Total days"), total_days },
|
|
{ _("Average time per page"), util.secondsToClock(avg_time_per_page, false) },
|
|
{ _("Read pages/Total pages"), read_pages .. "/" .. self.data.pages },
|
|
{ _("Percentage completed"), math.floor(read_pages / self.data.pages * 100 + 0.5) .. "%" }, -- adding 0.5 rounds to nearest integer with math.floor
|
|
}
|
|
end
|
|
|
|
-- For backward compatibility
|
|
local function getDatesForBookOldFormat(book)
|
|
local dates = {}
|
|
|
|
for k, v in pairs(book.details) do
|
|
local date_text = os.date("%Y-%m-%d", v.time)
|
|
if not dates[date_text] then
|
|
dates[date_text] = {
|
|
date = v.time,
|
|
read = v.read,
|
|
count = 1
|
|
}
|
|
else
|
|
dates[date_text] = {
|
|
read = dates[date_text].read + v.read,
|
|
count = dates[date_text].count + 1,
|
|
date = dates[date_text].date
|
|
}
|
|
end
|
|
end
|
|
|
|
return generateReadBooksTable(book.title, dates)
|
|
end
|
|
|
|
-- sdays -> number of days to show
|
|
-- ptype -> daily - show daily without weekday name
|
|
-- daily_weekday - show daily with weekday name
|
|
-- weekly - show weekly
|
|
-- monthly - show monthly
|
|
function ReaderStatistics:getDatesFromAll(sdays, ptype)
|
|
local dates = {}
|
|
local sorted_performance_in_pages
|
|
local diff
|
|
local now_t = os.date("*t")
|
|
local from_begin_day = now_t.hour *3600 + now_t.min*60 + now_t.sec
|
|
local now_stamp = os.time()
|
|
local one_day = 24 * 3600 -- one day in seconds
|
|
local avg_time_per_page
|
|
local period = now_stamp - ((sdays -1) * one_day) - from_begin_day
|
|
for _, v in pairs(ReadHistory.hist) do
|
|
local book_stats = DocSettings:open(v.file):readSetting('stats')
|
|
if book_stats ~= nil then
|
|
-- if current reading book
|
|
if book_stats.title == self.data.title then
|
|
book_stats = self.data
|
|
local read_pages = util.tableSize(self.data.performance_in_pages)
|
|
avg_time_per_page = self.data.total_time_in_sec / read_pages
|
|
else
|
|
avg_time_per_page = book_stats.total_time_in_sec / book_stats.pages
|
|
end
|
|
--zeros table sorted_performance_in_pages
|
|
sorted_performance_in_pages = {}
|
|
for k1, v1 in pairs(book_stats.performance_in_pages) do
|
|
if k1 >= period then
|
|
table.insert(sorted_performance_in_pages, k1)
|
|
end --if period
|
|
end -- for book_performance
|
|
-- sort table by time (unix timestamp)
|
|
local date_text
|
|
table.sort(sorted_performance_in_pages)
|
|
for i, n in pairs(sorted_performance_in_pages) do
|
|
if ptype == "daily_weekday" then
|
|
date_text = os.date("%Y-%m-%d (%a)", n)
|
|
elseif ptype == "daily" then
|
|
date_text = os.date("%Y-%m-%d" , n)
|
|
elseif ptype == "weekly" then
|
|
date_text = os.date("%Y Week %W" , n)
|
|
elseif ptype == "monthly" then
|
|
date_text = os.date("%B %Y" , n)
|
|
else
|
|
date_text = os.date("%Y-%m-%d" , n)
|
|
end --if ptype
|
|
if not dates[date_text] then
|
|
dates[date_text] = {
|
|
-- first pages of day is set to average of all pages
|
|
read = avg_time_per_page,
|
|
date = n,
|
|
count = 1
|
|
}
|
|
else
|
|
local entry = dates[date_text]
|
|
diff = n - entry.date
|
|
-- page_max_time
|
|
if (diff <= page_max_time and diff > 0) then
|
|
entry.read = entry.read + n - entry.date
|
|
else
|
|
--add average time if time > page_max_time
|
|
entry.read = avg_time_per_page + entry.read
|
|
end --if diff
|
|
if diff < 0 then
|
|
entry.read = avg_time_per_page + entry.read
|
|
end
|
|
entry.date = n
|
|
entry.count = entry.count + 1
|
|
end --if not dates[]
|
|
end -- for sorted_performance_in_pages
|
|
end -- if book_status
|
|
end --for pairs(ReadHistory.hist)
|
|
return dates
|
|
end
|
|
|
|
local function getDatesForBook(book)
|
|
local dates = {}
|
|
local sorted_performance_in_pages = {}
|
|
local diff
|
|
local read_pages = util.tableSize(book.performance_in_pages)
|
|
for k, v in pairs(book.performance_in_pages) do
|
|
table.insert(sorted_performance_in_pages, k)
|
|
end
|
|
-- sort table by time (unix timestamp)
|
|
table.sort(sorted_performance_in_pages)
|
|
for i, n in pairs(sorted_performance_in_pages) do
|
|
local date_text = os.date("%Y-%m-%d", n)
|
|
if not dates[date_text] then
|
|
dates[date_text] = {
|
|
-- first pages of day is set to average of all pages
|
|
read = book.total_time_in_sec / read_pages,
|
|
date = n,
|
|
count = 1
|
|
}
|
|
else
|
|
local entry = dates[date_text]
|
|
diff = n - entry.date
|
|
if diff <= page_max_time then
|
|
entry.read = entry.read + n - entry.date
|
|
else
|
|
--add average time if time > page_max_time e.g longer break while reading
|
|
entry.read = book.total_time_in_sec / read_pages + entry.read
|
|
end
|
|
entry.date = n
|
|
entry.count = entry.count + 1
|
|
end
|
|
end
|
|
return generateReadBooksTable(book.title, dates)
|
|
end
|
|
|
|
function ReaderStatistics:getTotalStats()
|
|
local total_stats = {}
|
|
if not self:isDocless() then
|
|
total_stats = {
|
|
{
|
|
self.data.title,
|
|
util.secondsToClock(self.data.total_time_in_sec, false),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = self.data.title,
|
|
kv_pairs = getDatesForBook(self.data),
|
|
})
|
|
end,
|
|
}
|
|
}
|
|
end
|
|
-- find stats for all other books in history
|
|
local proceded_titles, total_books_time = self:getStatisticsFromHistory(total_stats)
|
|
total_books_time = total_books_time + self:getOldStatisticsFromDirectory(proceded_titles, total_stats)
|
|
total_books_time = total_books_time + tonumber(self.data.total_time_in_sec)
|
|
|
|
return T(_("Total hours read %1"),
|
|
util.secondsToClock(total_books_time, false)),
|
|
total_stats
|
|
end
|
|
|
|
function ReaderStatistics:getStatisticsFromHistory(total_stats)
|
|
local titles = {}
|
|
local total_books_time = 0
|
|
for _, v in pairs(ReadHistory.hist) do
|
|
local book_stats = DocSettings:open(v.file):readSetting('stats')
|
|
if book_stats and book_stats.total_time_in_sec > 0
|
|
and book_stats.title ~= self.data.title then
|
|
titles[book_stats.title] = true
|
|
table.insert(total_stats, {
|
|
book_stats.title,
|
|
util.secondsToClock(book_stats.total_time_in_sec, false),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = book_stats.title,
|
|
kv_pairs = getDatesForBook(book_stats),
|
|
})
|
|
end,
|
|
})
|
|
total_books_time = total_books_time + tonumber(book_stats.total_time_in_sec)
|
|
end --if book_stats
|
|
end --for pairs(ReadHistory.hist)
|
|
return titles, total_books_time
|
|
end
|
|
|
|
-- For backward compatibility
|
|
function ReaderStatistics:getOldStatisticsFromDirectory(exlude_titles, total_stats)
|
|
if lfs.attributes(statistics_dir, "mode") ~= "directory" then
|
|
return 0
|
|
end
|
|
local total_books_time = 0
|
|
for curr_file in lfs.dir(statistics_dir) do
|
|
local path = statistics_dir .. curr_file
|
|
if lfs.attributes(path, "mode") == "file" then
|
|
local book_result = self:importFromFile(statistics_dir, curr_file)
|
|
if book_result and book_result.total_time > 0
|
|
and book_result.title ~= self.data.title
|
|
and not exlude_titles[book_result.title] then
|
|
table.insert(total_stats, {
|
|
book_result.title,
|
|
util.secondsToClock(book_result.total_time, false),
|
|
callback = function()
|
|
UIManager:show(KeyValuePage:new{
|
|
title = book_result.title,
|
|
kv_pairs = getDatesForBookOldFormat(book_result),
|
|
})
|
|
end,
|
|
})
|
|
total_books_time = total_books_time + tonumber(book_result.total_time)
|
|
end
|
|
end
|
|
end
|
|
return total_books_time
|
|
end
|
|
|
|
function ReaderStatistics:onPageUpdate(pageno)
|
|
if self:isDocless() or not self.is_enabled then
|
|
return
|
|
end
|
|
local curr_time = TimeVal:now()
|
|
local diff_time = curr_time.sec - self.last_time.sec
|
|
|
|
-- if last update was more then 10 minutes then current period set to 0
|
|
if (diff_time > 600) then
|
|
self.current_period = 0
|
|
self.pages_current_period = 0
|
|
end
|
|
|
|
if diff_time >= self.page_min_read_sec and diff_time <= self.page_max_read_sec then
|
|
self.current_period = self.current_period + diff_time
|
|
self.pages_current_period = self.pages_current_period + 1
|
|
self.data.total_time_in_sec = self.data.total_time_in_sec + diff_time
|
|
self.data.performance_in_pages[curr_time.sec] = pageno
|
|
-- we cannot save stats each time this is a page update event,
|
|
-- because the self.data may not even be initialized when such a event
|
|
-- comes, which will render a blank stats written into doc settings
|
|
-- and all previous stats are totally wiped out.
|
|
self.ui.doc_settings:saveSetting("stats", self.data)
|
|
end
|
|
|
|
self.last_time = curr_time
|
|
end
|
|
|
|
-- For backward compatibility
|
|
function ReaderStatistics:inplaceMigration()
|
|
local oldData = self:importFromFile(statistics_dir, self.data.title .. ".stat")
|
|
if oldData then
|
|
for k, v in pairs(oldData.details) do
|
|
self.data.performance_in_pages[v.time] = v.page
|
|
end
|
|
end
|
|
end
|
|
|
|
-- For backward compatibility
|
|
function ReaderStatistics:importFromFile(base_path, item)
|
|
item = string.gsub(item, "^%s*(.-)%s*$", "%1") -- trim
|
|
if item ~= ".stat" then
|
|
local statistic_file = joinPath(base_path, item)
|
|
if lfs.attributes(statistic_file, "mode") == "directory" then
|
|
return
|
|
end
|
|
local ok, stored = pcall(dofile, statistic_file)
|
|
if ok then
|
|
return stored
|
|
else
|
|
DEBUG(stored)
|
|
end
|
|
end
|
|
end
|
|
|
|
function ReaderStatistics:onCloseDocument()
|
|
if not self:isDocless() and self.last_time and self.is_enabled then
|
|
self.ui.doc_settings:saveSetting("stats", self.data)
|
|
end
|
|
end
|
|
|
|
function ReaderStatistics:onAddHighlight()
|
|
self.data.highlights = self.data.highlights + 1
|
|
end
|
|
|
|
function ReaderStatistics:onAddNote()
|
|
self.data.notes = self.data.notes + 1
|
|
end
|
|
|
|
-- in case when screensaver starts
|
|
function ReaderStatistics:onSaveSettings()
|
|
self:saveSettings()
|
|
if not self:isDocless() then
|
|
self.ui.doc_settings:saveSetting("stats", self.data)
|
|
self.current_period = 0
|
|
self.pages_current_period = 0
|
|
end
|
|
end
|
|
|
|
-- screensaver off
|
|
function ReaderStatistics:onResume()
|
|
self.current_period = 0
|
|
self.pages_current_period = 0
|
|
|
|
end
|
|
|
|
function ReaderStatistics:saveSettings(fields)
|
|
if fields then
|
|
self.page_min_read_sec = tonumber(fields[1])
|
|
self.page_max_read_sec = tonumber(fields[2])
|
|
end
|
|
|
|
local settings = {
|
|
min_sec = self.page_min_read_sec,
|
|
max_sec = self.page_max_read_sec,
|
|
is_enabled = self.is_enabled,
|
|
}
|
|
G_reader_settings:saveSetting("statistics", settings)
|
|
end
|
|
|
|
function ReaderStatistics:onReadSettings(config)
|
|
self.data = config.data.stats
|
|
end
|
|
|
|
function ReaderStatistics:onReaderReady()
|
|
-- we have correct page count now, do the actual initialization work
|
|
self:initData()
|
|
end
|
|
|
|
return ReaderStatistics
|