Handle library/title sorting on backend (#86)

This commit is contained in:
Alex Ling 2020-07-15 10:34:53 +00:00
parent 360913ee78
commit 94a1e63963
10 changed files with 263 additions and 205 deletions

View File

@ -1,131 +1,15 @@
$(() => {
const titleID = $('.data').attr('data-title-id') || 'library';
const sortItems = () => {
$('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id');
localStorage.setItem(`sort-${titleID}`, sort);
const ary = sort.split('-');
const by = ary[0];
const dir = ary[1];
let items = $('.item');
items.remove();
const ctxAry = [];
const keyRange = {};
if (by === 'auto') {
// intelligent sorting
items.each((i, item) => {
const name = $(item).find('.uk-card-title').text();
const regex = /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/g;
const numbers = {};
let match = regex.exec(name);
while (match) {
const key = match[1];
const num = parseFloat(match[2]);
numbers[key] = num;
if (!keyRange[key]) {
keyRange[key] = [num, num, 1];
} else {
keyRange[key][2] += 1;
if (num < keyRange[key][0]) {
keyRange[key][0] = num;
} else if (num > keyRange[key][1]) {
keyRange[key][1] = num;
}
}
match = regex.exec(name);
}
ctxAry.push({
index: i,
numbers: numbers
});
});
console.log(keyRange);
const sortedKeys = Object.keys(keyRange).filter(k => {
return keyRange[k][2] >= items.length / 2;
});
sortedKeys.sort((a, b) => {
// sort by frequency of the key first
if (keyRange[a][2] !== keyRange[b][2]) {
return (keyRange[a][2] < keyRange[b][2]) ? 1 : -1;
}
// then sort by range of the key
return ((keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0])) ? 1 : -1;
});
console.log(sortedKeys);
ctxAry.sort((a, b) => {
for (let i = 0; i < sortedKeys.length; i++) {
const key = sortedKeys[i];
if (a.numbers[key] === undefined && b.numbers[key] === undefined)
continue;
if (a.numbers[key] === undefined)
return 1;
if (b.numbers[key] === undefined)
return -1;
if (a.numbers[key] === b.numbers[key])
continue;
return (a.numbers[key] > b.numbers[key]) ? 1 : -1;
}
return 0;
});
const sortedItems = [];
ctxAry.forEach(ctx => {
sortedItems.push(items[ctx.index]);
});
items = sortedItems;
if (dir === 'down') {
items.reverse();
}
} else {
items.sort((a, b) => {
var res;
if (by === 'name')
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
else if (by === 'date')
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
else if (by === 'progress') {
const ap = parseFloat($(a).attr('data-progress'));
const bp = parseFloat($(b).attr('data-progress'));
if (ap === bp)
// if progress is the same, we compare by name
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
else
res = ap > bp;
}
if (dir === 'up')
return res ? 1 : -1;
else
return !res ? 1 : -1;
});
}
$('#item-container').append(items);
setupAcard();
};
$('#sort-select').change(() => {
sortItems();
const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({
sort: by,
ascend: dir === 'up' ? 1 : 0
})}`;
window.location.href = newURL;
});
const sortID = localStorage.getItem(`sort-${titleID}`);
if (sortID)
$(`option#${sortID}`).attr('selected', '');
else if ($('option#auto-up').length > 0)
$('option#auto-up').attr('selected', '');
else
$('option#name-up').attr('selected', '');
sortItems();
});

View File

@ -38,6 +38,9 @@ end
describe "chapter_sort" do
it "sorts correctly" do
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
chapter_sort(ary.reverse).should eq ary
sorter = ChapterSorter.new ary
ary.reverse.sort do |a, b|
sorter.compare a, b
end.should eq ary
end
end

View File

@ -6,6 +6,45 @@ require "./archive"
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
enum SortMethod
Auto
Title
Progress
TimeModified
TimeAdded
end
class SortOptions
property method : SortMethod, ascend : Bool
def initialize(in_method : String? = nil, @ascend = true)
@method = SortMethod::Auto
SortMethod.each do |m, _|
if in_method && m.to_s.underscore == in_method
@method = m
return
end
end
end
def initialize(in_method : SortMethod? = nil, @ascend = true)
if in_method
@method = in_method
else
@method = SortMethod::Auto
end
end
def self.from_tuple(tp : Tuple(String, Bool))
method, ascend = tp
self.new method, ascend
end
def to_tuple
{@method.to_s.underscore, ascend}
end
end
struct Image
property data : Bytes
property mime : String
@ -99,10 +138,11 @@ class Entry
img
end
def next_entry
idx = @book.entries.index self
return nil if idx.nil? || idx == @book.entries.size - 1
@book.entries[idx + 1]
def next_entry(username)
entries = @book.sorted_entries username
idx = entries.index self
return nil if idx.nil? || idx == entries.size - 1
entries[idx + 1]
end
def previous_entry
@ -239,8 +279,9 @@ class Title
compare_numerically @library.title_hash[a].title,
@library.title_hash[b].title
end
sorter = ChapterSorter.new @entries.map { |e| e.title }
@entries.sort! do |a, b|
compare_numerically a.title, b.title
sorter.compare a.title, b.title
end
end
@ -405,28 +446,20 @@ class Title
deep_read_page_count(username) / deep_total_page_count
end
def get_continue_reading_entry(username)
in_progress_entries = @entries.select do |e|
load_progress(username, e.title) > 0
end
return nil if in_progress_entries.empty?
latest_read_entry = in_progress_entries[-1]
if load_progress(username, latest_read_entry.title) ==
latest_read_entry.pages
next_entry latest_read_entry
else
latest_read_entry
end
end
def load_progress_for_all_entries(username)
def load_progress_for_all_entries(username, opt : SortOptions? = nil,
unsorted = false)
progress = {} of String => Int32
TitleInfo.new @dir do |info|
progress = info.progress[username]?
end
@entries.map do |e|
if unsorted
ary = @entries
else
ary = sorted_entries username, opt
end
ary.map do |e|
info_progress = 0
if progress && progress.has_key? e.title
info_progress = [progress[e.title], e.pages].min
@ -435,13 +468,71 @@ class Title
end
end
def load_percentage_for_all_entries(username)
progress = load_progress_for_all_entries username
@entries.map_with_index do |e, i|
def load_percentage_for_all_entries(username, opt : SortOptions? = nil,
unsorted = false)
if unsorted
ary = @entries
else
ary = sorted_entries username, opt
end
progress = load_progress_for_all_entries username, opt, unsorted
ary.map_with_index do |e, i|
progress[i] / e.pages
end
end
# Returns the sorted entries array
#
# When `opt` is nil, it uses the preferred sorting options in info.json, or
# use the default (auto, ascending)
# When `opt` is not nil, it saves the options to info.json
def sorted_entries(username, opt : SortOptions? = nil)
if opt.nil?
opt = load_sort_options username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
case opt.not_nil!.method
when .title?
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
when .time_modified?
ary = @entries.sort { |a, b| a.mtime <=> b.mtime }
when .time_added?
ary = @entries.sort { |a, b| a.date_added <=> b.date_added }
when .progress?
percentage_ary = load_percentage_for_all_entries username, opt, true
ary = @entries.zip(percentage_ary)
.sort { |a_tp, b_tp| a_tp[1] <=> b_tp[1] }
.map { |tp| tp[0] }
when .auto?
sorter = ChapterSorter.new @entries.map { |e| e.title }
ary = @entries.sort do |a, b|
sorter.compare a.title, b.title
end
else
raise "Unknown sorting method #{opt.not_nil!.method}"
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
def load_sort_options(username)
opt = SortOptions.new
TitleInfo.new @dir do |info|
if info.sort_by.has_key? username
opt = SortOptions.from_tuple info.sort_by[username]
end
end
opt
end
# === helper methods ===
# Gets the last read entry in the title. If the entry has been completed,
@ -464,7 +555,7 @@ class Title
end
if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry
last_read_entry = last_read_entry.next_entry username
end
last_read_entry
@ -511,6 +602,7 @@ class TitleInfo
property entry_cover_url = {} of String => String
property last_read = {} of String => Hash(String, Time)
property date_added = {} of String => Time
property sort_by = {} of String => Tuple(String, Bool)
@[JSON::Field(ignore: true)]
property dir : String = ""
@ -693,4 +785,45 @@ class Library
recently_added[0..11]
end
def sorted_titles(username, opt : SortOptions? = nil)
if opt.nil?
opt = load_sort_options username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
# This is a hack to bypass a compiler bug
ary = titles
case opt.not_nil!.method
when .auto?
ary.sort! { |a, b| compare_numerically a.title, b.title }
when .time_modified?
ary.sort! { |a, b| a.mtime <=> b.mtime }
when .progress?
ary.sort! do |a, b|
a.load_percentage(username) <=> b.load_percentage(username)
end
else
raise "Unknown sorting method #{opt.not_nil!.method}"
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
def load_sort_options(username)
opt = SortOptions.new
TitleInfo.new @dir do |info|
if info.sort_by.has_key? username
opt = SortOptions.from_tuple info.sort_by[username]
end
end
opt
end
end

View File

@ -39,9 +39,14 @@ class MainRouter < Router
get "/library" do |env|
begin
titles = @context.library.titles
username = get_username env
sort_opt = @context.library.load_sort_options username
get_sort_opt
titles = @context.library.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username
layout "library"
rescue e
@context.error e
@ -53,12 +58,18 @@ class MainRouter < Router
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
username = get_username env
percentage = title.load_percentage_for_all_entries username
sort_opt = title.load_sort_options username
get_sort_opt
entries = title.sorted_entries username, sort_opt
percentage = title.load_percentage_for_all_entries username, sort_opt
title_percentage = title.titles.map &.load_percentage username
layout "title"
rescue e
@context.error e
env.response.status_code = 404
env.response.status_code = 500
end
end

View File

@ -48,7 +48,7 @@ class ReaderRouter < Router
next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil
exit_url = "#{base_url}book/#{title.id}"
next_entry = entry.next_entry
next_entry = entry.next_entry username
unless next_page > entry.pages
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
end

View File

@ -7,9 +7,9 @@
require "big"
private class Item
getter index : Int32, numbers : Hash(String, BigDecimal)
getter numbers : Hash(String, BigDecimal)
def initialize(@index, @numbers)
def initialize(@numbers)
end
# Compare with another Item using keys
@ -51,57 +51,62 @@ private class KeyRange
end
end
def chapter_sort(in_ary : Array(String)) : Array(String)
ary = in_ary.sort do |a, b|
compare_numerically a, b
class ChapterSorter
@sorted_keys = [] of String
def initialize(str_ary : Array(String))
keys = {} of String => KeyRange
str_ary.each do |str|
scan str do |k, v|
if keys.has_key? k
keys[k].update v
else
keys[k] = KeyRange.new v
end
end
end
# Get the array of keys string and sort them
@sorted_keys = keys.keys
# Only use keys that are present in over half of the strings
.select do |key|
keys[key].count >= str_ary.size / 2
end
.sort do |a_key, b_key|
a = keys[a_key]
b = keys[b_key]
# Sort keys by the number of times they appear
count_compare = b.count <=> a.count
if count_compare == 0
# Then sort by value range
b.range <=> a.range
else
count_compare
end
end
end
items = [] of Item
keys = {} of String => KeyRange
ary.each_with_index do |str, i|
numbers = {} of String => BigDecimal
def compare(a : String, b : String)
item_a = str_to_item a
item_b = str_to_item b
item_a.<=>(item_b, @sorted_keys)
end
private def scan(str, &)
str.scan /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/ do |match|
key = match[1]
num = match[2].to_big_d
numbers[key] = num
if keys.has_key? key
keys[key].update num
else
keys[key] = KeyRange.new num
end
yield key, num
end
items << Item.new(i, numbers)
end
# Get the array of keys string and sort them
sorted_keys = keys.keys
# Only use keys that are present in over half of the strings
.select do |key|
keys[key].count >= ary.size / 2
end
.sort do |a_key, b_key|
a = keys[a_key]
b = keys[b_key]
# Sort keys by the number of times they appear
count_compare = b.count <=> a.count
if count_compare == 0
# Then sort by value range
b.range <=> a.range
else
count_compare
end
end
items
.sort do |a, b|
a.<=>(b, sorted_keys)
end
.map do |item|
ary[item.index]
private def str_to_item(str)
numbers = {} of String => BigDecimal
scan str do |k, v|
numbers[k] = v
end
Item.new numbers
end
end

View File

@ -66,3 +66,18 @@ end
macro render_component(filename)
render "src/views/components/#{{{filename}}}.html.ecr"
end
macro get_sort_opt
sort_method = env.params.query["sort"]?
if sort_method
is_ascending = true
ascend = env.params.query["ascend"]?
if ascend && ascend.to_i? == 0
is_ascending = false
end
sort_opt = SortOptions.new sort_method, is_ascending
end
end

View File

@ -1,8 +1,14 @@
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<% hash.each do |k, v| %>
<option id="<%= k %>-up">▲ <%= v %></option>
<option id="<%= k %>-down">▼ <%= v %></option>
<option id="<%= k %>-up"
<% if sort_opt && k == sort_opt.method.to_s.underscore && sort_opt.ascend %>
<%= "selected" %>
<% end %>>▲ <%= v %></option>
<option id="<%= k %>-down"
<% if sort_opt && k == sort_opt.method.to_s.underscore && !sort_opt.ascend %>
<%= "selected" %>
<% end %>>▼ <%= v %></option>
<% end %>
</select>
</div>

View File

@ -9,8 +9,8 @@
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"name" => "Name",
"date" => "Date Modified",
"auto" => "Auto",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>

View File

@ -25,8 +25,9 @@
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"name" => "Name",
"date" => "Date Modified",
"title" => "Name",
"time_modified" => "Date Modified",
"time_added" => "Date Added",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
@ -37,7 +38,7 @@
<% progress = title_percentage[i] %>
<%= render_component "card" %>
<% end %>
<% title.entries.each_with_index do |item, i| %>
<% entries.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>