diff --git a/migration/sort_title.12.cr b/migration/sort_title.12.cr new file mode 100644 index 0000000..9853182 --- /dev/null +++ b/migration/sort_title.12.cr @@ -0,0 +1,94 @@ +class SortTitle < MG::Base + def up : String + <<-SQL + -- add sort_title column to ids and titles + ALTER TABLE ids ADD COLUMN sort_title TEXT; + ALTER TABLE titles ADD COLUMN sort_title TEXT; + SQL + end + + def down : String + <<-SQL + -- remove sort_title column from ids + ALTER TABLE ids RENAME TO tmp; + + CREATE TABLE ids ( + path TEXT NOT NULL, + id TEXT NOT NULL, + signature TEXT, + unavailable INTEGER NOT NULL DEFAULT 0 + ); + + INSERT INTO ids + SELECT path, id, signature, unavailable + FROM tmp; + + DROP TABLE tmp; + + -- recreate the indices + CREATE UNIQUE INDEX path_idx ON ids (path); + CREATE UNIQUE INDEX id_idx ON ids (id); + + -- recreate the foreign key constraint on thumbnails + ALTER TABLE thumbnails RENAME TO tmp; + + CREATE TABLE thumbnails ( + id TEXT NOT NULL, + data BLOB NOT NULL, + filename TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + FOREIGN KEY (id) REFERENCES ids (id) + ON UPDATE CASCADE + ON DELETE CASCADE + ); + + INSERT INTO thumbnails + SELECT * FROM tmp; + + DROP TABLE tmp; + + CREATE UNIQUE INDEX tn_index ON thumbnails (id); + + -- remove sort_title column from titles + ALTER TABLE titles RENAME TO tmp; + + CREATE TABLE titles ( + id TEXT NOT NULL, + path TEXT NOT NULL, + signature TEXT, + unavailable INTEGER NOT NULL DEFAULT 0 + ); + + INSERT INTO titles + SELECT id, path, signature, unavailable + FROM tmp; + + DROP TABLE tmp; + + -- recreate the indices + CREATE UNIQUE INDEX titles_id_idx on titles (id); + CREATE UNIQUE INDEX titles_path_idx on titles (path); + + -- recreate the foreign key constraint on tags + ALTER TABLE tags RENAME TO tmp; + + CREATE TABLE tags ( + id TEXT NOT NULL, + tag TEXT NOT NULL, + UNIQUE (id, tag), + FOREIGN KEY (id) REFERENCES titles (id) + ON UPDATE CASCADE + ON DELETE CASCADE + ); + + INSERT INTO tags + SELECT * FROM tmp; + + DROP TABLE tmp; + + CREATE INDEX tags_id_idx ON tags (id); + CREATE INDEX tags_tag_idx ON tags (tag); + SQL + end +end diff --git a/public/js/title.js b/public/js/title.js index 1aca6d6..5d0e49f 100644 --- a/public/js/title.js +++ b/public/js/title.js @@ -60,6 +60,11 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi UIkit.modal($('#modal')).show(); } +UIkit.util.on(document, 'hidden', '#modal', () => { + $('#read-btn').off('click'); + $('#unread-btn').off('click'); +}); + const updateProgress = (tid, eid, page) => { let url = `${base_url}api/progress/${tid}/${page}` const query = $.param({ @@ -90,8 +95,6 @@ const renameSubmit = (name, eid) => { const upload = $('.upload-field'); const titleId = upload.attr('data-title-id'); - console.log(name); - if (name.length === 0) { alert('danger', 'The display name should not be empty'); return; @@ -122,15 +125,47 @@ const renameSubmit = (name, eid) => { }); }; +const renameSortNameSubmit = (name, eid) => { + const upload = $('.upload-field'); + const titleId = upload.attr('data-title-id'); + + const params = {}; + if (eid) params.eid = eid; + if (name) params.name = name; + const query = $.param(params); + let url = `${base_url}api/admin/sort_title/${titleId}?${query}`; + + $.ajax({ + type: 'PUT', + url, + contentType: 'application/json', + dataType: 'json' + }) + .done(data => { + if (data.error) { + alert('danger', `Failed to update sort title. Error: ${data.error}`); + return; + } + location.reload(); + }) + .fail((jqXHR, status) => { + alert('danger', `Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }); +}; + const edit = (eid) => { const cover = $('#edit-modal #cover'); let url = cover.attr('data-title-cover'); let displayName = $('h2.uk-title > span').text(); + let fileTitle = $('h2.uk-title').attr('data-file-title'); + let sortTitle = $('h2.uk-title').attr('data-sort-title'); if (eid) { const item = $(`#${eid}`); url = item.find('img').attr('data-src'); displayName = item.find('.uk-card-title').attr('data-title'); + fileTitle = item.find('.uk-card-title').attr('data-file-title'); + sortTitle = item.find('.uk-card-title').attr('data-sort-title'); $('#title-progress-control').attr('hidden', ''); } else { $('#title-progress-control').removeAttr('hidden'); @@ -140,14 +175,26 @@ const edit = (eid) => { const displayNameField = $('#display-name-field'); displayNameField.attr('value', displayName); - console.log(displayNameField); + displayNameField.attr('placeholder', fileTitle); displayNameField.keyup(event => { if (event.keyCode === 13) { - renameSubmit(displayNameField.val(), eid); + renameSubmit(displayNameField.val() || fileTitle, eid); } }); displayNameField.siblings('a.uk-form-icon').click(() => { - renameSubmit(displayNameField.val(), eid); + renameSubmit(displayNameField.val() || fileTitle, eid); + }); + + const sortTitleField = $('#sort-title-field'); + sortTitleField.val(sortTitle); + sortTitleField.attr('placeholder', fileTitle); + sortTitleField.keyup(event => { + if (event.keyCode === 13) { + renameSortNameSubmit(sortTitleField.val(), eid); + } + }); + sortTitleField.siblings('a.uk-form-icon').click(() => { + renameSortNameSubmit(sortTitleField.val(), eid); }); setupUpload(eid); @@ -155,6 +202,16 @@ const edit = (eid) => { UIkit.modal($('#edit-modal')).show(); }; +UIkit.util.on(document, 'hidden', '#edit-modal', () => { + const displayNameField = $('#display-name-field'); + displayNameField.off('keyup'); + displayNameField.off('click'); + + const sortTitleField = $('#sort-title-field'); + sortTitleField.off('keyup'); + sortTitleField.off('click'); +}); + const setupUpload = (eid) => { const upload = $('.upload-field'); const bar = $('#upload-progress').get(0); @@ -166,7 +223,6 @@ const setupUpload = (eid) => { queryObj['eid'] = eid; const query = $.param(queryObj); const url = `${base_url}api/admin/upload/cover?${query}`; - console.log(url); UIkit.upload('.upload-field', { url: url, name: 'file', diff --git a/src/library/cache.cr b/src/library/cache.cr index d0d3f01..10e4f60 100644 --- a/src/library/cache.cr +++ b/src/library/cache.cr @@ -1,6 +1,7 @@ require "digest" require "./entry" +require "./title" require "./types" # Base class for an entry in the LRU cache. @@ -81,6 +82,31 @@ class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry)) end end +class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title)) + def self.to_save_t(value : Array(Title)) + value.map &.id + end + + def self.to_return_t(value : Array(String)) + value.map { |title_id| Library.default.title_hash[title_id].not_nil! } + end + + def instance_size + instance_sizeof(SortedTitlesCacheEntry) + # sizeof itself + instance_sizeof(String) + @key.bytesize + # allocated memory for @key + @value.size * (instance_sizeof(String) + sizeof(String)) + + @value.sum(&.bytesize) # elements in Array(String) + end + + def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?) + titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s + user_context = opt && opt.method == SortMethod::Progress ? username : "" + sig = Digest::SHA1.hexdigest (titles_sig + user_context + + (opt ? opt.to_tuple.to_s : "nil")) + "#{sig}:sorted_titles" + end +end + class String def instance_size instance_sizeof(String) + bytesize @@ -101,14 +127,18 @@ struct Tuple(*T) end end -alias CacheableType = Array(Entry) | String | Tuple(String, Int32) +alias CacheableType = Array(Entry) | Array(Title) | String | + Tuple(String, Int32) alias CacheEntryType = SortedEntriesCacheEntry | + SortedTitlesCacheEntry | CacheEntry(String, String) | CacheEntry(Tuple(String, Int32), Tuple(String, Int32)) def generate_cache_entry(key : String, value : CacheableType) if value.is_a? Array(Entry) SortedEntriesCacheEntry.new key, value + elsif value.is_a? Array(Title) + SortedTitlesCacheEntry.new key, value else CacheEntry(typeof(value), typeof(value)).new key, value end diff --git a/src/library/entry.cr b/src/library/entry.cr index 43fbb23..ceaa531 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -8,6 +8,9 @@ class Entry size : String, pages : Int32, id : String, encoded_path : String, encoded_title : String, mtime : Time, err_msg : String? + @[YAML::Field(ignore: true)] + @sort_title : String? + def initialize(@zip_path, @book) storage = Storage.default @encoded_path = URI.encode @zip_path @@ -56,6 +59,7 @@ class Entry json.field {{str}}, @{{str.id}} {% end %} json.field "title_id", @book.id + json.field "sort_title", sort_title json.field "pages" { json.number @pages } unless slim json.field "display_name", @book.display_name @title @@ -66,6 +70,35 @@ class Entry end end + def sort_title + sort_title_cached = @sort_title + return sort_title_cached if sort_title_cached + sort_title = @book.entry_sort_title_db id + if sort_title + @sort_title = sort_title + return sort_title + end + @sort_title = @title + @title + end + + def set_sort_title(sort_title : String | Nil, username : String) + Storage.default.set_entry_sort_title id, sort_title + if sort_title == "" || sort_title.nil? + @sort_title = nil + else + @sort_title = sort_title + end + + @book.entry_sort_title_cache = nil + @book.remove_sorted_entries_cache [SortMethod::Auto, SortMethod::Title], + username + end + + def sort_title_db + @book.entry_sort_title_db @id + end + def display_name @book.display_name @title end @@ -177,11 +210,7 @@ class Entry @book.parents.each do |parent| LRUCache.invalidate "#{parent.id}:#{username}:progress_sum" end - [false, true].each do |ascend| - sorted_entries_cache_key = SortedEntriesCacheEntry.gen_key @book.id, - username, @book.entries, SortOptions.new(SortMethod::Progress, ascend) - LRUCache.invalidate sorted_entries_cache_key - end + @book.remove_sorted_caches [SortMethod::Progress], username TitleInfo.new @book.dir do |info| if info.progress[username]?.nil? diff --git a/src/library/library.cr b/src/library/library.cr index 2a5de7d..a2dc28d 100644 --- a/src/library/library.cr +++ b/src/library/library.cr @@ -161,7 +161,7 @@ class Library .select { |path| File.directory? path } .map { |path| Title.new path, "", cache } .select { |title| !(title.entries.empty? && title.titles.empty?) } - .sort! { |a, b| a.title <=> b.title } + .sort! { |a, b| a.sort_title <=> b.sort_title } .each do |title| @title_hash[title.id] = title @title_ids << title.id diff --git a/src/library/title.cr b/src/library/title.cr index 4886f3d..539f114 100644 --- a/src/library/title.cr +++ b/src/library/title.cr @@ -8,8 +8,13 @@ class Title entries : Array(Entry), title : String, id : String, encoded_title : String, mtime : Time, signature : UInt64, entry_cover_url_cache : Hash(String, String)? - setter entry_cover_url_cache : Hash(String, String)? + setter entry_cover_url_cache : Hash(String, String)?, + entry_sort_title_cache : Hash(String, String | Nil)? + @[YAML::Field(ignore: true)] + @sort_title : String? + @[YAML::Field(ignore: true)] + @entry_sort_title_cache : Hash(String, String | Nil)? @[YAML::Field(ignore: true)] @entry_display_name_cache : Hash(String, String)? @[YAML::Field(ignore: true)] @@ -66,7 +71,7 @@ class Title end sorter = ChapterSorter.new @entries.map &.title @entries.sort! do |a, b| - sorter.compare a.title, b.title + sorter.compare a.sort_title, b.sort_title end end @@ -180,9 +185,9 @@ class Title end end if is_entries_added || previous_entries_size != @entries.size - sorter = ChapterSorter.new @entries.map &.title + sorter = ChapterSorter.new @entries.map &.sort_title @entries.sort! do |a, b| - sorter.compare a.title, b.title + sorter.compare a.sort_title, b.sort_title end end @@ -204,6 +209,7 @@ class Title json.field {{str}}, @{{str.id}} {% end %} json.field "signature" { json.number @signature } + json.field "sort_title", sort_title unless slim json.field "display_name", display_name json.field "cover_url", cover_url @@ -250,6 +256,15 @@ class Title @title_ids.map { |tid| Library.default.get_title! tid } end + def sorted_titles(username, opt : SortOptions? = nil) + if opt.nil? + opt = SortOptions.from_info_json @dir, username + end + + # Helper function from src/util/util.cr + sort_titles titles, opt.not_nil!, username + end + # Get all entries, including entries in nested titles def deep_entries return @entries if title_ids.empty? @@ -286,6 +301,48 @@ class Title ary.join " and " end + def sort_title + sort_title_cached = @sort_title + return sort_title_cached if sort_title_cached + sort_title = Storage.default.get_title_sort_title id + if sort_title + @sort_title = sort_title + return sort_title + end + @sort_title = @title + @title + end + + def set_sort_title(sort_title : String | Nil, username : String) + Storage.default.set_title_sort_title id, sort_title + if sort_title == "" || sort_title.nil? + @sort_title = nil + else + @sort_title = sort_title + end + + if parents.size > 0 + target = parents[-1].titles + else + target = Library.default.titles + end + remove_sorted_titles_cache target, + [SortMethod::Auto, SortMethod::Title], username + end + + def sort_title_db + Storage.default.get_title_sort_title id + end + + def entry_sort_title_db(entry_id) + unless @entry_sort_title_cache + @entry_sort_title_cache = + Storage.default.get_entries_sort_title @entries.map &.id + end + + @entry_sort_title_cache.not_nil![entry_id]? + end + def tags Storage.default.get_title_tags @id end @@ -472,28 +529,30 @@ class Title case opt.not_nil!.method when .title? - ary = @entries.sort { |a, b| compare_numerically a.title, b.title } + ary = @entries.sort do |a, b| + compare_numerically a.sort_title, b.sort_title + end when .time_modified? ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \ - compare_numerically a.title, b.title } + compare_numerically a.sort_title, b.sort_title } when .time_added? ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \ - compare_numerically a.title, b.title } + compare_numerically a.sort_title, b.sort_title } 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]).or \ - compare_numerically a_tp[0].title, b_tp[0].title } + compare_numerically a_tp[0].sort_title, b_tp[0].sort_title } .map &.[0] else unless opt.method.auto? Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \ "Auto instead" end - sorter = ChapterSorter.new @entries.map &.title + sorter = ChapterSorter.new @entries.map &.sort_title ary = @entries.sort do |a, b| - sorter.compare(a.title, b.title).or \ - compare_numerically a.title, b.title + sorter.compare(a.sort_title, b.sort_title).or \ + compare_numerically a.sort_title, b.sort_title end end @@ -560,17 +619,32 @@ class Title zip + titles.flat_map &.deep_entries_with_date_added end + def remove_sorted_entries_cache(sort_methods : Array(SortMethod), + username : String) + [false, true].each do |ascend| + sort_methods.each do |sort_method| + sorted_entries_cache_key = + SortedEntriesCacheEntry.gen_key @id, username, @entries, + SortOptions.new(sort_method, ascend) + LRUCache.invalidate sorted_entries_cache_key + end + end + end + + def remove_sorted_caches(sort_methods : Array(SortMethod), username : String) + remove_sorted_entries_cache sort_methods, username + parents.each do |parent| + remove_sorted_titles_cache parent.titles, sort_methods, username + end + remove_sorted_titles_cache Library.default.titles, sort_methods, username + end + def bulk_progress(action, ids : Array(String), username) LRUCache.invalidate "#{@id}:#{username}:progress_sum" parents.each do |parent| LRUCache.invalidate "#{parent.id}:#{username}:progress_sum" end - [false, true].each do |ascend| - sorted_entries_cache_key = - SortedEntriesCacheEntry.gen_key @id, username, @entries, - SortOptions.new(SortMethod::Progress, ascend) - LRUCache.invalidate sorted_entries_cache_key - end + remove_sorted_caches [SortMethod::Progress], username selected_entries = ids .map { |id| diff --git a/src/routes/api.cr b/src/routes/api.cr index 1fea598..28ec314 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -371,6 +371,38 @@ struct APIRouter end end + Koa.describe "Sets the sort title of a title or an entry", <<-MD + When `eid` is provided, apply the sort title to the entry. Otherwise, apply the sort title to the title identified by `tid`. + MD + Koa.tags ["admin", "library"] + Koa.path "tid", desc: "Title ID" + Koa.query "eid", desc: "Entry ID", required: false + Koa.query "name", desc: "The new sort title" + Koa.response 200, schema: "result" + put "/api/admin/sort_title/:tid" do |env| + username = get_username env + begin + title = (Library.default.get_title env.params.url["tid"]) + .not_nil! + name = env.params.query["name"]? + entry = env.params.query["eid"]? + if entry.nil? + title.set_sort_title name, username + else + eobj = title.get_entry entry + eobj.set_sort_title name, username unless eobj.nil? + end + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + else + send_json env, {"success" => true}.to_json + end + end + ws "/api/admin/mangadex/queue" do |socket, env| interval_raw = env.params.query["interval"]? interval = (interval_raw.to_i? if interval_raw) || 5 diff --git a/src/routes/main.cr b/src/routes/main.cr index 4aa7da6..ea2f0d8 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -61,9 +61,15 @@ struct MainRouter sort_opt = SortOptions.from_info_json title.dir, username get_and_save_sort_opt title.dir + sorted_titles = title.sorted_titles username, 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 + title_percentage_map = {} of String => Float64 + title_percentage.each_with_index do |tp, i| + t = title.titles[i] + title_percentage_map[t.id] = tp + end layout "title" rescue e diff --git a/src/storage.cr b/src/storage.cr index 32f446a..4c8cfe7 100644 --- a/src/storage.cr +++ b/src/storage.cr @@ -342,6 +342,67 @@ class Storage end end + def get_title_sort_title(title_id : String) + sort_title = nil + MainFiber.run do + get_db do |db| + sort_title = + db.query_one? "Select sort_title from titles where id = (?)", + title_id, as: String | Nil + end + end + sort_title + end + + def set_title_sort_title(title_id : String, sort_title : String | Nil) + sort_title = nil if sort_title == "" + MainFiber.run do + get_db do |db| + db.exec "update titles set sort_title = (?) where id = (?)", + sort_title, title_id + end + end + end + + def get_entry_sort_title(entry_id : String) + sort_title = nil + MainFiber.run do + get_db do |db| + sort_title = + db.query_one? "Select sort_title from ids where id = (?)", + entry_id, as: String | Nil + end + end + sort_title + end + + def get_entries_sort_title(ids : Array(String)) + results = Hash(String, String | Nil).new + MainFiber.run do + get_db do |db| + db.query "select id, sort_title from ids where id in " \ + "(#{ids.join "," { |id| "'#{id}'" }})" do |rs| + rs.each do + id = rs.read String + sort_title = rs.read String | Nil + results[id] = sort_title + end + end + end + end + results + end + + def set_entry_sort_title(entry_id : String, sort_title : String | Nil) + sort_title = nil if sort_title == "" + MainFiber.run do + get_db do |db| + db.exec "update ids set sort_title = (?) where id = (?)", + sort_title, entry_id + end + end + end + def save_thumbnail(id : String, img : Image) MainFiber.run do get_db do |db| diff --git a/src/util/util.cr b/src/util/util.cr index 9f5ffee..11d1a13 100644 --- a/src/util/util.cr +++ b/src/util/util.cr @@ -87,30 +87,49 @@ def env_is_true?(key : String) : Bool end def sort_titles(titles : Array(Title), opt : SortOptions, username : String) - ary = titles + cache_key = SortedTitlesCacheEntry.gen_key username, titles, opt + cached_titles = LRUCache.get cache_key + return cached_titles if cached_titles.is_a? Array(Title) case opt.method when .time_modified? - ary.sort! { |a, b| (a.mtime <=> b.mtime).or \ - compare_numerically a.title, b.title } + ary = titles.sort { |a, b| (a.mtime <=> b.mtime).or \ + compare_numerically a.sort_title, b.sort_title } when .progress? - ary.sort! do |a, b| + ary = titles.sort do |a, b| (a.load_percentage(username) <=> b.load_percentage(username)).or \ - compare_numerically a.title, b.title + compare_numerically a.sort_title, b.sort_title + end + when .title? + ary = titles.sort do |a, b| + compare_numerically a.sort_title, b.sort_title end else unless opt.method.auto? Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \ "Auto instead" end - ary.sort! { |a, b| compare_numerically a.title, b.title } + ary = titles.sort { |a, b| compare_numerically a.sort_title, b.sort_title } end ary.reverse! unless opt.not_nil!.ascend + LRUCache.set generate_cache_entry cache_key, ary ary end +def remove_sorted_titles_cache(titles : Array(Title), + sort_methods : Array(SortMethod), + username : String) + [false, true].each do |ascend| + sort_methods.each do |sort_method| + sorted_titles_cache_key = SortedTitlesCacheEntry.gen_key username, + titles, SortOptions.new(sort_method, ascend) + LRUCache.invalidate sorted_titles_cache_key + end + end +end + class String # Returns the similarity (in [0, 1]) of two paths. # For the two paths, separate them into arrays of components, count the diff --git a/src/views/components/card.html.ecr b/src/views/components/card.html.ecr index b85d39e..5549499 100644 --- a/src/views/components/card.html.ecr +++ b/src/views/components/card.html.ecr @@ -61,7 +61,9 @@ <% if page == "home" && item.is_a? Entry %> <%= "uk-margin-remove-bottom" %> <% end %> - " data-title="<%= HTML.escape(item.display_name) %>"><%= HTML.escape(item.display_name) %> + " data-title="<%= HTML.escape(item.display_name) %>" + data-file-title="<%= HTML.escape(item.title || "") %>" + data-sort-title="<%= HTML.escape(item.sort_title_db || "") %>"><%= HTML.escape(item.display_name) %> <% if page == "home" && item.is_a? Entry %> <%= HTML.escape(item.book.display_name) %> diff --git a/src/views/library.html.ecr b/src/views/library.html.ecr index 39e9856..a5e8b59 100644 --- a/src/views/library.html.ecr +++ b/src/views/library.html.ecr @@ -10,6 +10,7 @@