From 94a1e63963901cea138b0268e39d7b5dfd2fbcb3 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Wed, 15 Jul 2020 10:34:53 +0000 Subject: [PATCH] Handle library/title sorting on backend (#86) --- public/js/sort-items.js | 130 +---------------- spec/util_spec.cr | 5 +- src/library.cr | 185 ++++++++++++++++++++---- src/routes/main.cr | 17 ++- src/routes/reader.cr | 2 +- src/util/chapter_sort.cr | 93 ++++++------ src/util/web.cr | 15 ++ src/views/components/sort-form.html.ecr | 10 +- src/views/library.html.ecr | 4 +- src/views/title.html.ecr | 7 +- 10 files changed, 263 insertions(+), 205 deletions(-) diff --git a/public/js/sort-items.js b/public/js/sort-items.js index 6b7e70c..c1eb365 100644 --- a/public/js/sort-items.js +++ b/public/js/sort-items.js @@ -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(); }); diff --git a/spec/util_spec.cr b/spec/util_spec.cr index d0d6630..94c326c 100644 --- a/spec/util_spec.cr +++ b/spec/util_spec.cr @@ -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 diff --git a/src/library.cr b/src/library.cr index 8c0d7fe..b33f844 100644 --- a/src/library.cr +++ b/src/library.cr @@ -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 diff --git a/src/routes/main.cr b/src/routes/main.cr index e798d78..00c5582 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -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 diff --git a/src/routes/reader.cr b/src/routes/reader.cr index 4e3bc06..69a4cc3 100644 --- a/src/routes/reader.cr +++ b/src/routes/reader.cr @@ -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 diff --git a/src/util/chapter_sort.cr b/src/util/chapter_sort.cr index d6b9bc0..44dfb4e 100644 --- a/src/util/chapter_sort.cr +++ b/src/util/chapter_sort.cr @@ -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 diff --git a/src/util/web.cr b/src/util/web.cr index 93efebd..041e25e 100644 --- a/src/util/web.cr +++ b/src/util/web.cr @@ -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 diff --git a/src/views/components/sort-form.html.ecr b/src/views/components/sort-form.html.ecr index 93148d9..fa9a5e7 100644 --- a/src/views/components/sort-form.html.ecr +++ b/src/views/components/sort-form.html.ecr @@ -1,8 +1,14 @@
diff --git a/src/views/library.html.ecr b/src/views/library.html.ecr index 2550f1e..26c54f5 100644 --- a/src/views/library.html.ecr +++ b/src/views/library.html.ecr @@ -9,8 +9,8 @@
<% hash = { - "name" => "Name", - "date" => "Date Modified", + "auto" => "Auto", + "time_modified" => "Date Modified", "progress" => "Progress" } %> <%= render_component "sort-form" %> diff --git a/src/views/title.html.ecr b/src/views/title.html.ecr index 45cee1f..9f6a8e5 100644 --- a/src/views/title.html.ecr +++ b/src/views/title.html.ecr @@ -25,8 +25,9 @@
<% 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 %>