diff --git a/README.md b/README.md index 6e04104..e1545d6 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r ### CLI ``` - Mango - Manga Server and Web Reader. Version 0.20.2 + Mango - Manga Server and Web Reader. Version 0.21.0 Usage: @@ -93,7 +93,7 @@ default_username: "" auth_proxy_header_name: "" mangadex: base_url: https://mangadex.org - api_url: https://mangadex.org/api/v2 + api_url: https://api.mangadex.org/v2 download_wait_seconds: 5 download_retries: 4 download_queue_db_path: ~/mango/queue.db diff --git a/migration/md_account.11.cr b/migration/md_account.11.cr new file mode 100644 index 0000000..3aece3e --- /dev/null +++ b/migration/md_account.11.cr @@ -0,0 +1,20 @@ +class CreateMangaDexAccount < MG::Base + def up : String + <<-SQL + CREATE TABLE md_account ( + username TEXT NOT NULL PRIMARY KEY, + token TEXT NOT NULL, + expire INTEGER NOT NULL, + FOREIGN KEY (username) REFERENCES users (username) + ON UPDATE CASCADE + ON DELETE CASCADE + ); + SQL + end + + def down : String + <<-SQL + DROP TABLE md_account; + SQL + end +end diff --git a/public/css/mango.less b/public/css/mango.less index 3309be4..cd69876 100644 --- a/public/css/mango.less +++ b/public/css/mango.less @@ -34,9 +34,11 @@ .uk-card-body { padding: 20px; .uk-card-title { - max-height: 3em; font-size: 1rem; } + .uk-card-title:not(.free-height) { + max-height: 3em; + } } } diff --git a/public/css/uikit.less b/public/css/uikit.less index 9621f3b..76aa4ac 100644 --- a/public/css/uikit.less +++ b/public/css/uikit.less @@ -43,3 +43,22 @@ @internal-list-bullet-image: "../img/list-bullet.svg"; @internal-accordion-open-image: "../img/accordion-open.svg"; @internal-accordion-close-image: "../img/accordion-close.svg"; + +.hook-card-default() { + .uk-light & { + background: @card-secondary-background; + color: @card-secondary-color; + } +} + +.hook-card-default-title() { + .uk-light & { + color: @card-secondary-title-color; + } +} + +.hook-card-default-hover() { + .uk-light & { + background-color: @card-secondary-hover-background; + } +} diff --git a/public/js/common.js b/public/js/common.js index 22fc7dc..d1fd829 100644 --- a/public/js/common.js +++ b/public/js/common.js @@ -117,14 +117,10 @@ const setTheme = (theme) => { if (theme === 'dark') { $('html').css('background', 'rgb(20, 20, 20)'); $('body').addClass('uk-light'); - $('.uk-card').addClass('uk-card-secondary'); - $('.uk-card').removeClass('uk-card-default'); $('.ui-widget-content').addClass('dark'); } else { $('html').css('background', ''); $('body').removeClass('uk-light'); - $('.uk-card').removeClass('uk-card-secondary'); - $('.uk-card').addClass('uk-card-default'); $('.ui-widget-content').removeClass('dark'); } }; diff --git a/public/js/download.js b/public/js/download.js index 4041d6a..74fab76 100644 --- a/public/js/download.js +++ b/public/js/download.js @@ -3,9 +3,12 @@ const downloadComponent = () => { chaptersLimit: 1000, loading: false, addingToDownload: false, + searchAvailable: false, searchInput: '', data: {}, chapters: [], + mangaAry: undefined, // undefined: not searching; []: searched but no result + candidateManga: {}, langChoice: 'All', groupChoice: 'All', chapterRange: '', @@ -48,7 +51,21 @@ const downloadComponent = () => { childList: true, subtree: true }); + + $.getJSON(`${base_url}api/admin/mangadex/expires`) + .done((data) => { + if (data.error) { + alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error); + return; + } + if (data.expires && data.expires > Math.floor(Date.now() / 1000)) + this.searchAvailable = true; + }) + .fail((jqXHR, status) => { + alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }) }, + filtersUpdated() { if (!this.data.chapters) this.chapters = []; @@ -90,10 +107,11 @@ const downloadComponent = () => { console.log('filtered chapters:', _chapters); this.chapters = _chapters; }, + search() { if (this.loading || this.searchInput === '') return; - this.loading = true; this.data = {}; + this.mangaAry = undefined; var int_id = -1; try { @@ -103,29 +121,54 @@ const downloadComponent = () => { } catch (e) { int_id = parseInt(this.searchInput); } - if (int_id <= 0 || isNaN(int_id)) { - alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.'); - this.loading = false; - return; + + if (!isNaN(int_id) && int_id > 0) { + // The input is a positive integer. We treat it as an ID. + this.loading = true; + $.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`) + .done((data) => { + if (data.error) { + alert('danger', 'Failed to get manga info. Error: ' + data.error); + return; + } + + this.data = data; + this.chapters = data.chapters; + this.mangaAry = undefined; + }) + .fail((jqXHR, status) => { + alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }) + .always(() => { + this.loading = false; + }); + } else { + if (!this.searchAvailable) { + alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex. If you are trying to search MangaDex with a search term, please log in to MangaDex first by going to "Admin -> Connect to MangaDex".'); + return; + } + + // Search as a search term + this.loading = true; + $.getJSON(`${base_url}api/admin/mangadex/search?${$.param({ + query: this.searchInput + })}`) + .done((data) => { + if (data.error) { + alert('danger', `Failed to search MangaDex. Error: ${data.error}`); + return; + } + + this.mangaAry = data.manga; + this.data = {}; + }) + .fail((jqXHR, status) => { + alert('danger', `Failed to search MangaDex. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }) + .always(() => { + this.loading = false; + }); } - - $.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`) - .done((data) => { - if (data.error) { - alert('danger', 'Failed to get manga info. Error: ' + data.error); - return; - } - - this.data = data; - this.chapters = data.chapters; - }) - .fail((jqXHR, status) => { - alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`); - }) - .always(() => { - this.loading = false; - }); - }, parseRange(str) { @@ -228,6 +271,17 @@ const downloadComponent = () => { this.addingToDownload = false; }); }); + }, + + chooseManga(manga) { + this.candidateManga = manga; + UIkit.modal($('#modal').get(0)).show(); + }, + + confirmManga(id) { + UIkit.modal($('#modal').get(0)).hide(); + this.searchInput = id; + this.search(); } }; }; diff --git a/public/js/mangadex.js b/public/js/mangadex.js new file mode 100644 index 0000000..3271c4b --- /dev/null +++ b/public/js/mangadex.js @@ -0,0 +1,61 @@ +const component = () => { + return { + username: '', + password: '', + expires: undefined, + loading: true, + loggingIn: false, + + init() { + this.loading = true; + $.ajax({ + type: 'GET', + url: `${base_url}api/admin/mangadex/expires`, + contentType: "application/json", + }) + .done(data => { + console.log(data); + if (data.error) { + alert('danger', `Failed to retrieve MangaDex token status. Error: ${data.error}`); + return; + } + this.expires = data.expires; + this.loading = false; + }) + .fail((jqXHR, status) => { + alert('danger', `Failed to retrieve MangaDex token status. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }); + }, + login() { + if (!(this.username && this.password)) return; + this.loggingIn = true; + $.ajax({ + type: 'POST', + url: `${base_url}api/admin/mangadex/login`, + contentType: "application/json", + dataType: 'json', + data: JSON.stringify({ + username: this.username, + password: this.password + }) + }) + .done(data => { + console.log(data); + if (data.error) { + alert('danger', `Failed to log in. Error: ${data.error}`); + return; + } + this.expires = data.expires; + }) + .fail((jqXHR, status) => { + alert('danger', `Failed to log in. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }) + .always(() => { + this.loggingIn = false; + }); + }, + get expired() { + return this.expires && moment().diff(moment.unix(this.expires)) > 0; + } + }; +}; diff --git a/public/js/reader.js b/public/js/reader.js index c6a00b5..1aa1bd1 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -9,6 +9,7 @@ const readerComponent = () => { flipAnimation: null, longPages: false, lastSavedPage: page, + selectedIndex: 0, // 0: not selected; 1: the first page /** * Initialize the component by fetching the page dimensions @@ -221,10 +222,7 @@ const readerComponent = () => { */ showControl(event) { const idx = event.currentTarget.id; - const pageCount = this.items.length; - const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; - $('#progress-label').text(progressText); - $('#page-select').val(idx); + this.selectedIndex = idx; UIkit.modal($('#modal-sections')).show(); }, /** @@ -263,19 +261,22 @@ const readerComponent = () => { }); }, /** - * Exits the reader, and optionally sets the reading progress tp 100% + * Exits the reader, and sets the reading progress tp 100% * * @param {string} exitUrl - The Exit URL - * @param {boolean} [markCompleted] - Whether we should mark the - * reading progress to 100% */ - exitReader(exitUrl, markCompleted = false) { - if (!markCompleted) { - return this.redirect(exitUrl); - } + exitReader(exitUrl) { this.saveProgress(this.items.length, () => { this.redirect(exitUrl); }); + }, + + /** + * Handles the `change` event for the entry selector + */ + entryChanged() { + const id = $('#entry-select').val(); + this.redirect(`${base_url}reader/${tid}/${id}`); } }; } diff --git a/shard.lock b/shard.lock index 024ee1a..254f3a5 100644 --- a/shard.lock +++ b/shard.lock @@ -50,11 +50,11 @@ shards: koa: git: https://github.com/hkalexling/koa.git - version: 0.5.0 + version: 0.7.0 mangadex: git: https://github.com/hkalexling/mangadex.git - version: 0.5.0+git.commit.323110c56c2d5134ce4162b27a9b24ec34137fcb + version: 0.8.0+git.commit.24e6fb51afd043721139355854e305b43bf98c43 mg: git: https://github.com/hkalexling/mg.git diff --git a/shard.yml b/shard.yml index 40fbe72..390ddc6 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: mango -version: 0.20.2 +version: 0.21.0 authors: - Alex Ling diff --git a/src/config.cr b/src/config.cr index abacbb3..a05c3a7 100644 --- a/src/config.cr +++ b/src/config.cr @@ -29,7 +29,7 @@ class Config @[YAML::Field(ignore: true)] @mangadex_defaults = { "base_url" => "https://mangadex.org", - "api_url" => "https://mangadex.org/api/v2", + "api_url" => "https://api.mangadex.org/v2", "download_wait_seconds" => 5, "download_retries" => 4, "download_queue_db_path" => File.expand_path("~/mango/queue.db", @@ -93,15 +93,23 @@ class Config raise "Login is disabled, but default username is not set. " \ "Please set a default username" end + + # `Logger.default` is not available yet + Log.setup :debug unless mangadex["api_url"] =~ /\/v2/ - # `Logger.default` is not available yet - Log.setup :debug Log.warn { "It looks like you are using the deprecated MangaDex API " \ - "v1 in your config file. Please update it to either " \ - "https://mangadex.org/api/v2 or " \ + "v1 in your config file. Please update it to " \ "https://api.mangadex.org/v2 to suppress this warning." } - mangadex["api_url"] = "https://mangadex.org/api/v2" + mangadex["api_url"] = "https://api.mangadex.org/v2" end + if mangadex["api_url"] =~ /\/api\/v2/ + Log.warn { "It looks like you are using the outdated MangaDex API " \ + "url (mangadex.org/api/v2) in your config file. Please " \ + "update it to https://api.mangadex.org/v2 to suppress this " \ + "warning." } + mangadex["api_url"] = "https://api.mangadex.org/v2" + end + mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/" mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/" end diff --git a/src/library/entry.cr b/src/library/entry.cr index 7a4e753..cb45895 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -134,10 +134,11 @@ class Entry entries[idx + 1] end - def previous_entry - idx = @book.entries.index self + def previous_entry(username) + entries = @book.sorted_entries username + idx = entries.index self return nil if idx.nil? || idx == 0 - @book.entries[idx - 1] + entries[idx - 1] end def date_added diff --git a/src/library/library.cr b/src/library/library.cr index 83c0b0a..7e97e85 100644 --- a/src/library/library.cr +++ b/src/library/library.cr @@ -121,7 +121,7 @@ class Library # Get the last read time of the entry. If it hasn't been started, get # the last read time of the previous entry last_read = e.load_last_read username - pe = e.previous_entry + pe = e.previous_entry username if last_read.nil? && pe last_read = pe.load_last_read username end diff --git a/src/mango.cr b/src/mango.cr index 0591045..b5fd168 100644 --- a/src/mango.cr +++ b/src/mango.cr @@ -8,7 +8,7 @@ require "option_parser" require "clim" require "tallboy" -MANGO_VERSION = "0.20.2" +MANGO_VERSION = "0.21.0" # From http://www.network-science.de/ascii/ BANNER = %{ diff --git a/src/routes/admin.cr b/src/routes/admin.cr index fd63ec8..616d452 100644 --- a/src/routes/admin.cr +++ b/src/routes/admin.cr @@ -73,5 +73,9 @@ struct AdminRouter get "/admin/missing" do |env| layout "missing-items" end + + get "/admin/mangadex" do |env| + layout "mangadex" + end end end diff --git a/src/routes/api.cr b/src/routes/api.cr index ff6a087..7d2af12 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -10,7 +10,7 @@ struct APIRouter macro s(fields) { {% for field in fields %} - {{field}} => "string", + {{field}} => String, {% end %} } end @@ -33,160 +33,49 @@ struct APIRouter MD Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}" - Koa.global_tag "admin", desc: <<-MD + Koa.define_tag "admin", desc: <<-MD These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints. MD - Koa.binary "binary", desc: "A binary file" - Koa.array "entryAry", "$entry", desc: "An array of entries" - Koa.array "titleAry", "$title", desc: "An array of titles" - Koa.array "strAry", "string", desc: "An array of strings" + Koa.schema "entry", { + "pages" => Int32, + "mtime" => Int64, + }.merge(s %w(zip_path title size id title_id display_name cover_url)), + desc: "An entry in a book" - entry_schema = { - "pages" => "integer", - "mtime" => "integer", - }.merge s %w(zip_path title size id title_id display_name cover_url) - Koa.object "entry", entry_schema, desc: "An entry in a book" - - title_schema = { - "mtime" => "integer", - "entries" => "$entryAry", - "titles" => "$titleAry", - "parents" => "$strAry", - }.merge s %w(dir title id display_name cover_url) - Koa.object "title", title_schema, + Koa.schema "title", { + "mtime" => Int64, + "entries" => ["entry"], + "titles" => ["title"], + "parents" => [String], + }.merge(s %w(dir title id display_name cover_url)), desc: "A manga title (a collection of entries and sub-titles)" - Koa.object "library", { - "dir" => "string", - "titles" => "$titleAry", - }, desc: "A library containing a list of top-level titles" - - Koa.object "scanResult", { - "milliseconds" => "integer", - "titles" => "integer", + Koa.schema "result", { + "success" => Bool, + "error" => String?, } - Koa.object "progressResult", { - "progress" => "number", - } + Koa.schema("mdChapter", { + "id" => Int64, + "group" => {} of String => String, + }.merge(s %w(title volume chapter language full_title time + manga_title manga_id)), + desc: "A MangaDex chapter") - Koa.object "result", { - "success" => "boolean", - "error" => "string?", - } - - mc_schema = { - "groups" => "object", - }.merge s %w(id title volume chapter language full_title time manga_title manga_id) - Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter" - - Koa.array "chapterAry", "$mangadexChapter" - - mm_schema = { - "chapers" => "$chapterAry", - }.merge s %w(id title description author artist cover_url) - Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga" - - Koa.object "chaptersObj", { - "chapters" => "$chapterAry", - } - - Koa.object "successFailCount", { - "success" => "integer", - "fail" => "integer", - } - - job_schema = { - "pages" => "integer", - "success_count" => "integer", - "fail_count" => "integer", - "time" => "integer", - }.merge s %w(id manga_id title manga_title status_message status) - Koa.object "job", job_schema, desc: "A download job in the queue" - - Koa.array "jobAry", "$job" - - Koa.object "jobs", { - "success" => "boolean", - "paused" => "boolean", - "jobs" => "$jobAry", - } - - Koa.object "binaryUpload", { - "file" => "$binary", - } - - Koa.object "pluginListBody", { - "plugin" => "string", - "query" => "string", - } - - Koa.object "pluginChapter", { - "id" => "string", - "title" => "string", - } - - Koa.array "pluginChapterAry", "$pluginChapter" - - Koa.object "pluginList", { - "success" => "boolean", - "chapters" => "$pluginChapterAry?", - "title" => "string?", - "error" => "string?", - } - - Koa.object "pluginDownload", { - "plugin" => "string", - "title" => "string", - "chapters" => "$pluginChapterAry", - } - - Koa.object "dimension", { - "width" => "integer", - "height" => "integer", - } - - Koa.array "dimensionAry", "$dimension" - - Koa.object "dimensionResult", { - "success" => "boolean", - "dimensions" => "$dimensionAry?", - "margin" => "number", - "error" => "string?", - } - - Koa.object "ids", { - "ids" => "$strAry", - } - - Koa.object "tagsResult", { - "success" => "boolean", - "tags" => "$strAry?", - "error" => "string?", - } - - Koa.object "missing", { - "path" => "string", - "id" => "string", - "signature" => "string", - } - - Koa.array "missingAry", "$missing" - - Koa.object "missingResult", { - "success" => "boolean", - "error" => "string?", - "entries" => "$missingAry?", - "titles" => "$missingAry?", - } + Koa.schema "mdManga", { + "id" => Int64, + "chapters" => ["mdChapter"], + }.merge(s %w(title description author artist cover_url)), + desc: "A MangaDex manga" Koa.describe "Returns a page in a manga entry" Koa.path "tid", desc: "Title ID" Koa.path "eid", desc: "Entry ID" - Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)" - Koa.response 200, ref: "$binary", media_type: "image/*" + Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)" + Koa.response 200, schema: Bytes, media_type: "image/*" Koa.response 500, "Page not found or not readable" + Koa.tag "reader" get "/api/page/:tid/:eid/:page" do |env| begin tid = env.params.url["tid"] @@ -212,8 +101,9 @@ struct APIRouter Koa.describe "Returns the cover image of a manga entry" Koa.path "tid", desc: "Title ID" Koa.path "eid", desc: "Entry ID" - Koa.response 200, ref: "$binary", media_type: "image/*" + Koa.response 200, schema: Bytes, media_type: "image/*" Koa.response 500, "Page not found or not readable" + Koa.tag "library" get "/api/cover/:tid/:eid" do |env| begin tid = env.params.url["tid"] @@ -238,8 +128,9 @@ struct APIRouter Koa.describe "Returns the book with title `tid`" Koa.path "tid", desc: "Title ID" - Koa.response 200, ref: "$title" + Koa.response 200, schema: "title" Koa.response 404, "Title not found" + Koa.tag "library" get "/api/book/:tid" do |env| begin tid = env.params.url["tid"] @@ -255,14 +146,21 @@ struct APIRouter end Koa.describe "Returns the entire library with all titles and entries" - Koa.response 200, ref: "$library" + Koa.response 200, schema: { + "dir" => String, + "titles" => ["title"], + } + Koa.tag "library" get "/api/library" do |env| send_json env, Library.default.to_json end Koa.describe "Triggers a library scan" - Koa.tag "admin" - Koa.response 200, ref: "$scanResult" + Koa.tags ["admin", "library"] + Koa.response 200, schema: { + "milliseconds" => Float64, + "titles" => Int32, + } post "/api/admin/scan" do |env| start = Time.utc Library.default.scan @@ -274,8 +172,10 @@ struct APIRouter end Koa.describe "Returns the thumbnail generation progress between 0 and 1" - Koa.tag "admin" - Koa.response 200, ref: "$progressResult" + Koa.tags ["admin", "library"] + Koa.response 200, schema: { + "progress" => Float64, + } get "/api/admin/thumbnail_progress" do |env| send_json env, { "progress" => Library.default.thumbnail_generation_progress, @@ -283,7 +183,7 @@ struct APIRouter end Koa.describe "Triggers a thumbnail generation" - Koa.tag "admin" + Koa.tags ["admin", "library"] post "/api/admin/generate_thumbnails" do |env| spawn do Library.default.generate_thumbnails @@ -291,8 +191,8 @@ struct APIRouter end Koa.describe "Deletes a user with `username`" - Koa.tag "admin" - Koa.response 200, ref: "$result" + Koa.tags ["admin", "users"] + Koa.response 200, schema: "result" delete "/api/admin/user/delete/:username" do |env| begin username = env.params.url["username"] @@ -319,7 +219,8 @@ struct APIRouter Koa.path "tid", desc: "Title ID" Koa.query "eid", desc: "Entry ID", required: false Koa.path "page", desc: "The new page number indicating the progress" - Koa.response 200, ref: "$result" + Koa.response 200, schema: "result" + Koa.tag "progress" put "/api/progress/:tid/:page" do |env| begin username = get_username env @@ -350,8 +251,11 @@ struct APIRouter Koa.describe "Updates the reading progress of multiple entries in a title" Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`" Koa.path "tid", desc: "Title ID" - Koa.body ref: "$ids", desc: "An array of entry IDs" - Koa.response 200, ref: "$result" + Koa.body schema: { + "ids" => [String], + }, desc: "An array of entry IDs" + Koa.response 200, schema: "result" + Koa.tag "progress" put "/api/bulk_progress/:action/:tid" do |env| begin username = get_username env @@ -377,11 +281,11 @@ struct APIRouter Koa.describe "Sets the display name of a title or an entry", <<-MD When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`. MD - Koa.tag "admin" + Koa.tags ["admin", "library"] Koa.path "tid", desc: "Title ID" Koa.query "eid", desc: "Entry ID", required: false Koa.path "name", desc: "The new display name" - Koa.response 200, ref: "$result" + Koa.response 200, schema: "result" put "/api/admin/display_name/:tid/:name" do |env| begin title = (Library.default.get_title env.params.url["tid"]) @@ -408,9 +312,9 @@ struct APIRouter Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD On error, returns a JSON that contains the error message in the `error` field. MD - Koa.tag "admin" + Koa.tags ["admin", "mangadex"] Koa.path "id", desc: "A MangaDex manga ID" - Koa.response 200, ref: "$mangadexManga" + Koa.response 200, schema: "mdManga" get "/api/admin/mangadex/manga/:id" do |env| begin id = env.params.url["id"] @@ -425,9 +329,14 @@ struct APIRouter Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD On error, returns a JSON that contains the error message in the `error` field. MD - Koa.tag "admin" - Koa.body ref: "$chaptersObj" - Koa.response 200, ref: "$successFailCount" + Koa.tags ["admin", "mangadex", "downloader"] + Koa.body schema: { + "chapters" => ["mdChapter"], + } + Koa.response 200, schema: { + "success" => Int32, + "fail" => Int32, + } post "/api/admin/mangadex/download" do |env| begin chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h } @@ -467,8 +376,18 @@ struct APIRouter Koa.describe "Returns the current download queue", <<-MD On error, returns a JSON that contains the error message in the `error` field. MD - Koa.tag "admin" - Koa.response 200, ref: "$jobs" + Koa.tags ["admin", "downloader"] + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "paused" => Bool?, + "jobs?" => [{ + "pages" => Int32, + "success_count" => Int32, + "fail_count" => Int32, + "time" => Int64, + }.merge(s %w(id manga_id title manga_title status_message status))], + } get "/api/admin/mangadex/queue" do |env| begin jobs = Queue.default.get_all @@ -494,10 +413,10 @@ struct APIRouter When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue. MD - Koa.tag "admin" + Koa.tags ["admin", "downloader"] Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`." Koa.query "id", required: false, desc: "A job ID" - Koa.response 200, ref: "$result" + Koa.response 200, schema: "result" post "/api/admin/mangadex/queue/:action" do |env| begin action = env.params.url["action"] @@ -546,8 +465,10 @@ struct APIRouter When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry. MD Koa.tag "admin" - Koa.body type: "multipart/form-data", ref: "$binaryUpload" - Koa.response 200, ref: "$result" + Koa.body media_type: "multipart/form-data", schema: { + "file" => Bytes, + } + Koa.response 200, schema: "result" post "/api/admin/upload/:target" do |env| begin target = env.params.url["target"] @@ -603,9 +524,18 @@ struct APIRouter end Koa.describe "Lists the chapters in a title from a plugin" - Koa.tag "admin" - Koa.body ref: "$pluginListBody" - Koa.response 200, ref: "$pluginList" + Koa.tags ["admin", "downloader"] + Koa.query "plugin", schema: String + Koa.query "query", schema: String + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "chapters?" => [{ + "id" => String, + "title" => String, + }], + "title" => String?, + } get "/api/admin/plugin/list" do |env| begin query = env.params.query["query"].as String @@ -629,9 +559,19 @@ struct APIRouter end Koa.describe "Adds a list of chapters from a plugin to the download queue" - Koa.tag "admin" - Koa.body ref: "$pluginDownload" - Koa.response 200, ref: "$successFailCount" + Koa.tags ["admin", "downloader"] + Koa.body schema: { + "plugin" => String, + "title" => String, + "chapters" => [{ + "id" => String, + "title" => String, + }], + } + Koa.response 200, schema: { + "success" => Int32, + "fail" => Int32, + } post "/api/admin/plugin/download" do |env| begin plugin = Plugin.new env.params.json["plugin"].as String @@ -664,7 +604,16 @@ struct APIRouter Koa.describe "Returns the image dimensions of all pages in an entry" Koa.path "tid", desc: "A title ID" Koa.path "eid", desc: "An entry ID" - Koa.response 200, ref: "$dimensionResult" + Koa.tag "reader" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "dimensions?" => [{ + "width" => Int32, + "height" => Int32, + }], + "margin" => Int32?, + } get "/api/dimensions/:tid/:eid" do |env| begin tid = env.params.url["tid"] @@ -692,8 +641,9 @@ struct APIRouter Koa.describe "Downloads an entry" Koa.path "tid", desc: "A title ID" Koa.path "eid", desc: "An entry ID" - Koa.response 200, ref: "$binary" + Koa.response 200, schema: Bytes Koa.response 404, "Entry not found" + Koa.tags ["library", "reader"] get "/api/download/:tid/:eid" do |env| begin title = (Library.default.get_title env.params.url["tid"]).not_nil! @@ -708,7 +658,12 @@ struct APIRouter Koa.describe "Gets the tags of a title" Koa.path "tid", desc: "A title ID" - Koa.response 200, ref: "$tagsResult" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "tags" => [String?], + } + Koa.tags ["library", "tags"] get "/api/tags/:tid" do |env| begin title = (Library.default.get_title env.params.url["tid"]).not_nil! @@ -728,7 +683,12 @@ struct APIRouter end Koa.describe "Returns all tags" - Koa.response 200, ref: "$tagsResult" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "tags" => [String?], + } + Koa.tags ["library", "tags"] get "/api/tags" do |env| begin tags = Storage.default.list_tags @@ -747,8 +707,8 @@ struct APIRouter Koa.describe "Adds a new tag to a title" Koa.path "tid", desc: "A title ID" - Koa.response 200, ref: "$result" - Koa.tag "admin" + Koa.response 200, schema: "result" + Koa.tags ["admin", "library", "tags"] put "/api/admin/tags/:tid/:tag" do |env| begin title = (Library.default.get_title env.params.url["tid"]).not_nil! @@ -770,8 +730,8 @@ struct APIRouter Koa.describe "Deletes a tag from a title" Koa.path "tid", desc: "A title ID" - Koa.response 200, ref: "$result" - Koa.tag "admin" + Koa.response 200, schema: "result" + Koa.tags ["admin", "library", "tags"] delete "/api/admin/tags/:tid/:tag" do |env| begin title = (Library.default.get_title env.params.url["tid"]).not_nil! @@ -792,8 +752,16 @@ struct APIRouter end Koa.describe "Lists all missing titles" - Koa.response 200, ref: "$missingResult" - Koa.tag "admin" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "titles?" => [{ + "path" => String, + "id" => String, + "signature" => String, + }], + } + Koa.tags ["admin", "library"] get "/api/admin/titles/missing" do |env| begin send_json env, { @@ -810,8 +778,16 @@ struct APIRouter end Koa.describe "Lists all missing entries" - Koa.response 200, ref: "$missingResult" - Koa.tag "admin" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "entries?" => [{ + "path" => String, + "id" => String, + "signature" => String, + }], + } + Koa.tags ["admin", "library"] get "/api/admin/entries/missing" do |env| begin send_json env, { @@ -828,8 +804,8 @@ struct APIRouter end Koa.describe "Deletes all missing titles" - Koa.response 200, ref: "$result" - Koa.tag "admin" + Koa.response 200, schema: "result" + Koa.tags ["admin", "library"] delete "/api/admin/titles/missing" do |env| begin Storage.default.delete_missing_title @@ -846,8 +822,8 @@ struct APIRouter end Koa.describe "Deletes all missing entries" - Koa.response 200, ref: "$result" - Koa.tag "admin" + Koa.response 200, schema: "result" + Koa.tags ["admin", "library"] delete "/api/admin/entries/missing" do |env| begin Storage.default.delete_missing_entry @@ -866,8 +842,8 @@ struct APIRouter Koa.describe "Deletes a missing title identified by `tid`", <<-MD Does nothing if the given `tid` is not found or if the title is not missing. MD - Koa.response 200, ref: "$result" - Koa.tag "admin" + Koa.response 200, schema: "result" + Koa.tags ["admin", "library"] delete "/api/admin/titles/missing/:tid" do |env| begin tid = env.params.url["tid"] @@ -887,8 +863,8 @@ struct APIRouter Koa.describe "Deletes a missing entry identified by `eid`", <<-MD Does nothing if the given `eid` is not found or if the entry is not missing. MD - Koa.response 200, ref: "$result" - Koa.tag "admin" + Koa.response 200, schema: "result" + Koa.tags ["admin", "library"] delete "/api/admin/entries/missing/:eid" do |env| begin eid = env.params.url["eid"] @@ -905,6 +881,115 @@ struct APIRouter end end + Koa.describe "Logs the current user into their MangaDex account", <<-MD + If successful, returns the expiration date (as a unix timestamp) of the newly created token. + MD + Koa.body schema: { + "username" => String, + "password" => String, + } + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "expires" => Int64?, + } + Koa.tags ["admin", "mangadex", "users"] + post "/api/admin/mangadex/login" do |env| + begin + username = env.params.json["username"].as String + password = env.params.json["password"].as String + mango_username = get_username env + + client = MangaDex::Client.from_config + client.auth username, password + + Storage.default.save_md_token mango_username, client.token.not_nil!, + client.token_expires + + send_json env, { + "success" => true, + "error" => nil, + "expires" => client.token_expires.to_unix, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Returns the expiration date (as a unix timestamp) of the mangadex token if it exists" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "expires" => Int64?, + } + Koa.tags ["admin", "mangadex", "users"] + get "/api/admin/mangadex/expires" do |env| + begin + username = get_username env + _, expires = Storage.default.get_md_token username + + send_json env, { + "success" => true, + "error" => nil, + "expires" => expires.try &.to_unix, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Searches MangaDex for manga matching `query`", <<-MD + Returns an empty list if the current user hasn't logged in to MangaDex. + MD + Koa.query "query" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "manga?" => [{ + "id" => Int64, + "title" => String, + "description" => String, + "mainCover" => String, + }], + } + Koa.tags ["admin", "mangadex"] + get "/api/admin/mangadex/search" do |env| + begin + username = get_username env + token, expires = Storage.default.get_md_token username + + unless expires && token + raise "No token found for user #{username}" + end + + client = MangaDex::Client.from_config + client.token = token + client.token_expires = expires + + query = env.params.query["query"] + + send_json env, { + "success" => true, + "error" => nil, + "manga" => client.partial_search query, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + doc = Koa.generate @@api_json = doc.to_json if doc diff --git a/src/routes/reader.cr b/src/routes/reader.cr index e61b9a0..40b86aa 100644 --- a/src/routes/reader.cr +++ b/src/routes/reader.cr @@ -30,6 +30,11 @@ struct ReaderRouter title = (Library.default.get_title env.params.url["title"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil! + + sort_opt = SortOptions.from_info_json title.dir, username + get_sort_opt + entries = title.sorted_entries username, sort_opt + page_idx = env.params.url["page"].to_i if page_idx > entry.pages || page_idx <= 0 raise "Page #{page_idx} not found." @@ -37,10 +42,12 @@ struct ReaderRouter exit_url = "#{base_url}book/#{title.id}" - next_entry_url = nil - next_entry = entry.next_entry username - unless next_entry.nil? - next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}" + next_entry_url = entry.next_entry(username).try do |e| + "#{base_url}reader/#{title.id}/#{e.id}" + end + + previous_entry_url = entry.previous_entry(username).try do |e| + "#{base_url}reader/#{title.id}/#{e.id}" end render "src/views/reader.html.ecr" diff --git a/src/storage.cr b/src/storage.cr index 937200f..971bba3 100644 --- a/src/storage.cr +++ b/src/storage.cr @@ -34,7 +34,7 @@ class Storage dir = File.dirname @path unless Dir.exists? dir Logger.info "The DB directory #{dir} does not exist. " \ - "Attepmting to create it" + "Attempting to create it" Dir.mkdir_p dir end MainFiber.run do @@ -514,6 +514,37 @@ class Storage delete_missing "titles", id end + def save_md_token(username : String, token : String, expire : Time) + MainFiber.run do + get_db do |db| + count = db.query_one "select count(*) from md_account where " \ + "username = (?)", username, as: Int64 + if count == 0 + db.exec "insert into md_account values (?, ?, ?)", username, token, + expire.to_unix + else + db.exec "update md_account set token = (?), expire = (?) " \ + "where username = (?)", token, expire.to_unix, username + end + end + end + end + + def get_md_token(username) : Tuple(String?, Time?) + token = nil + expires = nil + MainFiber.run do + get_db do |db| + db.query_one? "select token, expire from md_account where " \ + "username = (?)", username do |res| + token = res.read String + expires = Time.unix res.read Int64 + end + end + end + {token, expires} + end + def close MainFiber.run do unless @db.nil? diff --git a/src/views/admin.html.ecr b/src/views/admin.html.ecr index fb64d3e..a6e9b31 100644 --- a/src/views/admin.html.ecr +++ b/src/views/admin.html.ecr @@ -33,6 +33,7 @@ +
  • Connect to MangaDex

  • diff --git a/src/views/components/dots-scripts.html.ecr b/src/views/components/dots.html.ecr similarity index 67% rename from src/views/components/dots-scripts.html.ecr rename to src/views/components/dots.html.ecr index 41bb7b0..6e98eb7 100644 --- a/src/views/components/dots-scripts.html.ecr +++ b/src/views/components/dots.html.ecr @@ -1,3 +1,3 @@ - - + + diff --git a/src/views/components/jquery-ui.html.ecr b/src/views/components/jquery-ui.html.ecr new file mode 100644 index 0000000..2141e25 --- /dev/null +++ b/src/views/components/jquery-ui.html.ecr @@ -0,0 +1 @@ + diff --git a/src/views/components/moment.html.ecr b/src/views/components/moment.html.ecr new file mode 100644 index 0000000..fdec5cf --- /dev/null +++ b/src/views/components/moment.html.ecr @@ -0,0 +1 @@ + diff --git a/src/views/download-manager.html.ecr b/src/views/download-manager.html.ecr index 552405a..2b6e434 100644 --- a/src/views/download-manager.html.ecr +++ b/src/views/download-manager.html.ecr @@ -63,7 +63,7 @@ <% content_for "script" do %> - + <%= render_component "moment" %> <% end %> diff --git a/src/views/download.html.ecr b/src/views/download.html.ecr index 7a96858..0ea8527 100644 --- a/src/views/download.html.ecr +++ b/src/views/download.html.ecr @@ -1,17 +1,39 @@

    Download from MangaDex

    -
    -
    - +
    +
    +
    -
    +
    + +
    -
    +
    @@ -107,11 +129,34 @@
    + +
    <% content_for "script" do %> - - + <%= render_component "moment" %> + <%= render_component "jquery-ui" %> <% end %> diff --git a/src/views/home.html.ecr b/src/views/home.html.ecr index 598709d..ceb9b69 100644 --- a/src/views/home.html.ecr +++ b/src/views/home.html.ecr @@ -77,7 +77,7 @@ <%- end -%> <% content_for "script" do %> - <%= render_component "dots-scripts" %> + <%= render_component "dots" %> <% end %> diff --git a/src/views/library.html.ecr b/src/views/library.html.ecr index 21ec280..39e9856 100644 --- a/src/views/library.html.ecr +++ b/src/views/library.html.ecr @@ -24,7 +24,7 @@
    <% content_for "script" do %> - <%= render_component "dots-scripts" %> + <%= render_component "dots" %> <% end %> diff --git a/src/views/mangadex.html.ecr b/src/views/mangadex.html.ecr new file mode 100644 index 0000000..764c4f4 --- /dev/null +++ b/src/views/mangadex.html.ecr @@ -0,0 +1,39 @@ +
    +

    Connect to MangaDex

    +
    +
    +

    This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:

    +
      +
    • Search MangaDex by search terms in addition to manga IDs
    • +
    • Automatically download new chapters when they are available (coming soon)
    • +
    +
    + +
    +

    + You have logged in to MangaDex! + You have logged in to MangaDex but the token has expired. + The expiration date of your token is . + If the integration is not working, you + You + can log in again and the token will be updated. +

    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +<% content_for "script" do %> + <%= render_component "moment" %> + + +<% end %> diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index 68eb156..692e22f 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -68,7 +68,7 @@ var pid = "<%= plugin.not_nil!.info.id %>"; <% end %> - + <%= render_component "jquery-ui" %> diff --git a/src/views/reader.html.ecr b/src/views/reader.html.ecr index 90cc78a..0b4ee4d 100644 --- a/src/views/reader.html.ecr +++ b/src/views/reader.html.ecr @@ -36,7 +36,7 @@ <%- if next_entry_url -%> <%- else -%> - + <%- end -%>
    @@ -68,12 +68,12 @@
    -

    +

    - +
    - <%- (1..entry.pages).each do |p| -%> <%- end -%> @@ -89,9 +89,33 @@
    + +
    + +
    + +
    + +
    +
    diff --git a/src/views/tag.html.ecr b/src/views/tag.html.ecr index c6f2332..8d6d257 100644 --- a/src/views/tag.html.ecr +++ b/src/views/tag.html.ecr @@ -24,7 +24,7 @@ <% content_for "script" do %> - <%= render_component "dots-scripts" %> + <%= render_component "dots" %> <% end %> diff --git a/src/views/title.html.ecr b/src/views/title.html.ecr index 11b4393..78edf98 100644 --- a/src/views/title.html.ecr +++ b/src/views/title.html.ecr @@ -123,7 +123,7 @@ <% content_for "script" do %> - <%= render_component "dots-scripts" %> + <%= render_component "dots" %>