diff --git a/public/js/title.js b/public/js/title.js index 6bb5330..3c57012 100644 --- a/public/js/title.js +++ b/public/js/title.js @@ -15,10 +15,14 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi if (percentage === 100) { $('#read-btn').attr('hidden', ''); } - $('#modal-title').find('span').text(entry); - $('#modal-title').next().attr('data-id', titleID); - $('#modal-title').next().attr('data-entry-id', entryID); - $('#modal-title').next().find('.title-rename-field').val(entry); + + $('#modal-title-link').text(title); + $('#modal-title-link').attr('href', '/book/' + titleID); + + $('#modal-entry-title').find('span').text(entry); + $('#modal-entry-title').next().attr('data-id', titleID); + $('#modal-entry-title').next().attr('data-entry-id', entryID); + $('#modal-entry-title').next().find('.title-rename-field').val(entry); $('#path-text').text(zipPath); $('#pages-text').text(pages + ' pages'); diff --git a/src/config.cr b/src/config.cr index 72faab7..c96bf6f 100644 --- a/src/config.cr +++ b/src/config.cr @@ -3,6 +3,8 @@ require "yaml" class Config include YAML::Serializable + @[YAML::Field(ignore: true)] + property path : String = "" property port : Int32 = 9000 property base_url : String = "/" property session_secret : String = "mango-session-secret" @@ -44,6 +46,7 @@ class Config if File.exists? cfg_path config = self.from_yaml File.read cfg_path config.preprocess + config.path = path config.fill_defaults return config end @@ -54,6 +57,7 @@ class Config abort "Aborting..." end default = self.allocate + default.path = path default.fill_defaults cfg_dir = File.dirname cfg_path unless Dir.exists? cfg_dir diff --git a/src/library.cr b/src/library.cr index a2a7a5a..67a2f75 100644 --- a/src/library.cr +++ b/src/library.cr @@ -17,7 +17,8 @@ end class Entry property zip_path : String, book : Title, title : String, size : String, pages : Int32, id : String, title_id : String, - encoded_path : String, encoded_title : String, mtime : Time + encoded_path : String, encoded_title : String, mtime : Time, + date_added : Time def initialize(path, @book, @title_id, storage) @zip_path = path @@ -33,6 +34,7 @@ class Entry file.close @id = storage.get_id @zip_path, false @mtime = File.info(@zip_path).modification_time + @date_added = load_date_added end def to_json(json : JSON::Builder) @@ -87,6 +89,20 @@ class Entry end img end + + private def load_date_added + date_added = nil + TitleInfo.new @book.dir do |info| + info_da = info.date_added[@title]? + if info_da.nil? + date_added = info.date_added[@title] = ctime @zip_path + info.save + else + date_added = info_da + end + end + date_added.not_nil! # is it ok to set not_nil! here? + end end class Title @@ -289,6 +305,12 @@ class Title else info.progress[username][entry] = page end + # save last_read timestamp + if info.last_read[username]?.nil? + info.last_read[username] = {entry => Time.utc} + else + info.last_read[username][entry] = Time.utc + end info.save end end @@ -304,14 +326,14 @@ class Title progress end - def load_percetage(username, entry) + def load_percentage(username, entry) page = load_progress username, entry entry_obj = @entries.find { |e| e.title == entry } return 0.0 if entry_obj.nil? page / entry_obj.pages end - def load_percetage(username) + def load_percentage(username) return 0.0 if @entries.empty? read_pages = total_pages = 0 @entries.each do |e| @@ -321,11 +343,53 @@ class Title read_pages / total_pages end + def load_last_read(username, entry) + last_read = nil + TitleInfo.new @dir do |info| + unless info.last_read[username]?.nil? || + info.last_read[username][entry]?.nil? + last_read = info.last_read[username][entry] + end + end + last_read + end + def next_entry(current_entry_obj) idx = @entries.index current_entry_obj return nil if idx.nil? || idx == @entries.size - 1 @entries[idx + 1] end + + def previous_entry(current_entry_obj) + idx = @entries.index current_entry_obj + return nil if idx.nil? || idx == 0 + @entries[idx - 1] + 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 + + # TODO: More concise title? + def get_last_read_for_continue_reading(username, entry_obj) + last_read = load_last_read username, entry_obj.title + if last_read.nil? # grab from previous entry if current entry hasn't been started yet + previous_entry = previous_entry(entry_obj) + return load_last_read username, previous_entry.title if previous_entry + end + last_read + end end class TitleInfo @@ -337,6 +401,8 @@ class TitleInfo property entry_display_name = {} of String => String property cover_url = "" property entry_cover_url = {} of String => String + property last_read = {} of String => Hash(String, Time) + property date_added = {} of String => Time @[JSON::Field(ignore: true)] property dir : String = "" @@ -440,4 +506,85 @@ class Library end Logger.debug "Scan completed" end + + def get_continue_reading_entries(username) + # map: get the continue-reading entry or nil for each Title + # select: select only entries (and ignore Nil's) from the array + # produced by map + continue_reading_entries = titles.map { |t| + get_continue_reading_entry username, t + }.select Entry + + continue_reading = continue_reading_entries.map { |e| + { + entry: e, + percentage: e.book.load_percentage(username, e.title), + last_read: get_relevant_last_read(username, e) + } + } + + # Sort by by last_read, most recent first (nils at the end) + continue_reading.sort! { |a, b| + next 0 if a[:last_read].nil? && b[:last_read].nil? + next 1 if a[:last_read].nil? + next -1 if b[:last_read].nil? + b[:last_read].not_nil! <=> a[:last_read].not_nil! + }[0..11] + end + + alias RA = NamedTuple(entry: Entry, percentage: Float64, grouped_count: Int32) + + def get_recently_added_entries(username) + entries = [] of Entry + titles.each do |t| + t.entries.each { |e| entries << e } + end + entries.sort! { |a, b| b.date_added <=> a.date_added } + entries.select! { |e| e.date_added > 3.months.ago } + + # Group Entries if neighbour is same Title + recently_added = [] of RA + entries.each do |e| + last = recently_added.last? + if last && e.title_id == last[:entry].title_id + # A NamedTuple is immutable, so we have to cast it to a Hash first + last_hash = last.to_h + count = last_hash[:grouped_count].as(Int32) + last_hash[:grouped_count] = count + 1 + recently_added[recently_added.size - 1] = RA.from last_hash + else + recently_added << { + entry: e, + percentage: e.book.load_percentage(username, e.title), + grouped_count: 1, + } + end + end + + recently_added[0..11] + end + + private def get_continue_reading_entry(username, title) + in_progress_entries = title.entries.select do |e| + title.load_progress(username, e.title) > 0 + end + return nil if in_progress_entries.empty? + + latest_read_entry = in_progress_entries[-1] + if title.load_progress(username, latest_read_entry.title) == + latest_read_entry.pages + title.next_entry latest_read_entry + else + latest_read_entry + end + end + + private def get_relevant_last_read(username, entry_obj) + last_read = entry_obj.book.load_last_read username, entry_obj.title + if last_read.nil? # grab from previous entry if current entry hasn't been started yet + previous_entry = entry_obj.book.previous_entry(entry_obj) + return entry_obj.book.load_last_read username, previous_entry.title if previous_entry + end + last_read + end end diff --git a/src/routes/main.cr b/src/routes/main.cr index 33ed6ba..f08b785 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -37,12 +37,12 @@ class MainRouter < Router end end - get "/" do |env| + get "/library" do |env| begin titles = @context.library.titles username = get_username env - percentage = titles.map &.load_percetage username - layout "index" + percentage = titles.map &.load_percentage username + layout "library" rescue e @context.error e env.response.status_code = 500 @@ -54,7 +54,7 @@ class MainRouter < Router title = (@context.library.get_title env.params.url["title"]).not_nil! username = get_username env percentage = title.entries.map { |e| - title.load_percetage username, e.title + title.load_percentage username, e.title } layout "title" rescue e @@ -67,5 +67,20 @@ class MainRouter < Router mangadex_base_url = Config.current.mangadex["base_url"] layout "download" end + + get "/" do |env| + begin + username = get_username env + continue_reading = @context.library.get_continue_reading_entries username + recently_added = @context.library.get_recently_added_entries username + titles = @context.library.titles + new_user = ! titles.any? { |t| t.load_percentage(username) > 0 } + empty_library = titles.size == 0 + layout "home" + rescue e + @context.error e + env.response.status_code = 500 + end + end end end diff --git a/src/util.cr b/src/util.cr index aa22251..901036a 100644 --- a/src/util.cr +++ b/src/util.cr @@ -135,3 +135,16 @@ end macro render_component(filename) render "src/views/components/#{{{filename}}}.ecr" end + +# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/ +# blob/master/src/crystal/system/unix/file_info.cr#L42-L48 +def ctime(file_path : String) : Time + res = LibC.stat(file_path, out stat) + raise "Unable to get ctime of file #{file_path}" if res != 0 + + {% if flag?(:darwin) %} + Time.new stat.st_ctimespec, Time::Location::UTC + {% else %} + Time.new stat.st_ctim, Time::Location::UTC + {% end %} +end diff --git a/src/views/home.ecr b/src/views/home.ecr new file mode 100644 index 0000000..619231b --- /dev/null +++ b/src/views/home.ecr @@ -0,0 +1,132 @@ +<%- if new_user && empty_library -%> + +
+ +

Add your first manga

+

We can't find any files yet. Add some to your library and they'll appear here.

+
+
Current library path
+
<%= Config.current.library_path %>
+
Want to change your library path?
+
Update config.yml located at: <%= Config.current.path %>
+
Can't see your files yet?
+
You must wait <%= Config.current.scan_interval %> minutes for the library scan to complete + <% if is_admin %>, or manually re-scan from Admin<% end %>.
+
+
+ +<%- elsif new_user && empty_library == false -%> + +
+ +

Read your first manga

+

Once you start reading, Mango will remember where you left off + and show your entries here.

+ View library +
+ +<%- elsif new_user == false && empty_library == false -%> + +<%- if continue_reading.empty? && recently_added.empty? -%> +
+ +

A self-hosted manga server and reader

+ View library +
+<%- end -%> + +<%- unless continue_reading.empty? -%> +

Continue Reading

+
+ <%- continue_reading.each do |cr| -%> +
+ +
+
+ +
+
+
<%= (cr[:percentage] * 100).round(1) %>%
+

"><%= cr[:entry].display_name %>

+

<%= cr[:entry].pages %> pages

+
+
+
+
+ <%- end -%> +
+<%- end -%> + +<%- unless recently_added.empty? -%> +

Recently Added

+
+ <%- recently_added.each do |ra| -%> + <%- if ra[:grouped_count] == 1 -%> +
+ +
+
+ +
+
+
<%= (ra[:percentage] * 100).round(1) %>%
+

"><%= ra[:entry].display_name %>

+

<%= ra[:entry].pages %> pages

+
+
+
+
+ <%- else -%> +
+ +
+
+ +
+
+

"><%= ra[:entry].book.display_name %>

+

<%= ra[:grouped_count] %> new entries

+
+
+
+
+ <%- end -%> + <%- end -%> +
+<%- end -%> + + + + +<%- end -%> + +<% content_for "script" do %> + + + + +<% end %> diff --git a/src/views/layout.ecr b/src/views/layout.ecr index b52ede9..166b053 100644 --- a/src/views/layout.ecr +++ b/src/views/layout.ecr @@ -23,6 +23,7 @@