diff --git a/public/js/title.js b/public/js/title.js index d46c173..7ddd093 100644 --- a/public/js/title.js +++ b/public/js/title.js @@ -252,3 +252,68 @@ const bulkProgress = (action, el) => { deselectAll(); }); }; + +const tagsComponent = () => { + return { + loading: true, + isAdmin: false, + tags: [], + newTag: '', + inputShown: false, + tid: $('.upload-field').attr('data-title-id'), + load(admin) { + this.isAdmin = admin; + const url = `${base_url}api/tags/${this.tid}`; + this.request(url, 'GET', (data) => { + this.tags = data.tags; + this.loading = false; + }); + }, + add() { + const tag = this.newTag.trim(); + const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`; + this.request(url, 'PUT', () => { + this.tags.push(tag); + this.newTag = ''; + }); + }, + keydown(event) { + if (event.key === 'Enter') + this.add() + }, + rm(event) { + const tag = event.currentTarget.id.split('-')[0]; + const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`; + this.request(url, 'DELETE', () => { + const idx = this.tags.indexOf(tag); + if (idx < 0) return; + this.tags.splice(idx, 1); + }); + }, + toggleInput(nextTick) { + this.inputShown = !this.inputShown; + if (this.inputShown) { + nextTick(() => { + $('#tag-input').get(0).focus(); + }); + } + }, + request(url, method, cb) { + $.ajax({ + url: url, + method: method, + dataType: 'json' + }) + .done(data => { + if (data.success) + cb(data); + else { + alert('danger', data.error); + } + }) + .fail((jqXHR, status) => { + alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }); + } + }; +}; diff --git a/src/handlers/auth_handler.cr b/src/handlers/auth_handler.cr index 1db4376..3f094eb 100644 --- a/src/handlers/auth_handler.cr +++ b/src/handlers/auth_handler.cr @@ -74,10 +74,17 @@ class AuthHandler < Kemal::Handler end if request_path_startswith env, ["/admin", "/api/admin", "/download"] - unless validate_token_admin(env) || - Storage.default.username_is_admin Config.current.default_username - env.response.status_code = 403 + # The token (if exists) takes precedence over the default user option. + # this is why we check the default username first before checking the + # token. + should_reject = true + if Storage.default.username_is_admin Config.current.default_username + should_reject = false end + if env.session.string? "token" + should_reject = !validate_token_admin(env) + end + env.response.status_code = 403 if should_reject end call_next env diff --git a/src/library/library.cr b/src/library/library.cr index 35d9e89..a7e1c49 100644 --- a/src/library/library.cr +++ b/src/library/library.cr @@ -68,29 +68,8 @@ class Library end end - # This is a hack to bypass a compiler bug - ary = titles - - case opt.not_nil!.method - when .time_modified? - ary.sort! { |a, b| (a.mtime <=> b.mtime).or \ - compare_numerically a.title, b.title } - when .progress? - ary.sort! do |a, b| - (a.load_percentage(username) <=> b.load_percentage(username)).or \ - compare_numerically a.title, b.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 } - end - - ary.reverse! unless opt.not_nil!.ascend - - ary + # Helper function from src/util/util.cr + sort_titles titles, opt.not_nil!, username end def deep_titles diff --git a/src/library/title.cr b/src/library/title.cr index d72bb48..f9af6f7 100644 --- a/src/library/title.cr +++ b/src/library/title.cr @@ -122,6 +122,18 @@ class Title ary.join " and " end + def tags + Storage.default.get_title_tags @id + end + + def add_tag(tag) + Storage.default.add_tag @id, tag + end + + def delete_tag(tag) + Storage.default.delete_tag @id, tag + end + def get_entry(eid) @entries.find { |e| e.id == eid } end diff --git a/src/routes/api.cr b/src/routes/api.cr index ad4f497..4ec7407 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -160,6 +160,12 @@ class APIRouter < Router "ids" => "$strAry", } + Koa.object "tagsResult", { + "success" => "boolean", + "tags" => "$strAry?", + "error" => "string?", + } + Koa.describe "Returns a page in a manga entry" Koa.path "tid", desc: "Title ID" Koa.path "eid", desc: "Entry ID" @@ -685,6 +691,73 @@ class APIRouter < Router end end + Koa.describe "Gets the tags of a title" + Koa.path "tid", desc: "A title ID" + Koa.response 200, ref: "$tagsResult" + get "/api/tags/:tid" do |env| + begin + title = (@context.library.get_title env.params.url["tid"]).not_nil! + tags = title.tags + + send_json env, { + "success" => true, + "tags" => tags, + }.to_json + rescue e + @context.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Adds a new tag to a title" + Koa.path "tid", desc: "A title ID" + Koa.response 200, ref: "$result" + Koa.tag "admin" + put "/api/admin/tags/:tid/:tag" do |env| + begin + title = (@context.library.get_title env.params.url["tid"]).not_nil! + tag = env.params.url["tag"] + + title.add_tag tag + send_json env, { + "success" => true, + "error" => nil, + }.to_json + rescue e + @context.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Deletes a tag from a title" + Koa.path "tid", desc: "A title ID" + Koa.response 200, ref: "$result" + Koa.tag "admin" + delete "/api/admin/tags/:tid/:tag" do |env| + begin + title = (@context.library.get_title env.params.url["tid"]).not_nil! + tag = env.params.url["tag"] + + title.delete_tag tag + send_json env, { + "success" => true, + "error" => nil, + }.to_json + rescue e + @context.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/main.cr b/src/routes/main.cr index cb919cf..67bbd31 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -114,6 +114,43 @@ class MainRouter < Router end end + get "/tags/:tag" do |env| + begin + username = get_username env + tag = env.params.url["tag"] + + sort_opt = SortOptions.new + get_sort_opt + + title_ids = Storage.default.get_tag_titles tag + + raise "Tag #{tag} not found" if title_ids.empty? + + titles = title_ids.map { |id| @context.library.get_title id } + .select Title + + titles = sort_titles titles, sort_opt, username + percentage = titles.map &.load_percentage username + + layout "tag" + rescue e + @context.error e + env.response.status_code = 404 + end + end + + get "/tags" do |env| + tags = Storage.default.list_tags + encoded_tags = tags.map do |t| + URI.encode_www_form t, space_to_plus: false + end + counts = tags.map do |t| + Storage.default.get_tag_titles(t).size + end + + layout "tags" + end + get "/api" do |env| render "src/views/api.html.ecr" end diff --git a/src/storage.cr b/src/storage.cr index ffcd753..d145181 100644 --- a/src/storage.cr +++ b/src/storage.cr @@ -35,16 +35,24 @@ class Storage MainFiber.run do DB.open "sqlite3://#{@path}" do |db| begin + # v0.18.0 + db.exec "create table tags (id text, tag text, unique (id, tag))" + db.exec "create index tags_id_idx on tags (id)" + db.exec "create index tags_tag_idx on tags (tag)" + + # v0.15.0 db.exec "create table thumbnails " \ "(id text, data blob, filename text, " \ "mime text, size integer)" db.exec "create unique index tn_index on thumbnails (id)" + # v0.1.1 db.exec "create table ids" \ "(path text, id text, is_title integer)" db.exec "create unique index path_idx on ids (path)" db.exec "create unique index id_idx on ids (id)" + # v0.1.0 db.exec "create table users" \ "(username text, password text, token text, admin integer)" rescue e @@ -296,6 +304,70 @@ class Storage img end + def get_title_tags(id : String) : Array(String) + tags = [] of String + MainFiber.run do + get_db do |db| + db.query "select tag from tags where id = (?)", id do |rs| + rs.each do + tags << rs.read String + end + end + end + end + tags + end + + def get_tag_titles(tag : String) : Array(String) + tids = [] of String + MainFiber.run do + get_db do |db| + db.query "select id from tags where tag = (?)", tag do |rs| + rs.each do + tids << rs.read String + end + end + end + end + tids + end + + def list_tags : Array(String) + tags = [] of String + MainFiber.run do + get_db do |db| + db.query "select distinct tag from tags" do |rs| + rs.each do + tags << rs.read String + end + end + end + end + tags + end + + def add_tag(id : String, tag : String) + err = nil + MainFiber.run do + begin + get_db do |db| + db.exec "insert into tags values (?, ?)", id, tag + end + rescue e + err = e + end + end + raise err.not_nil! if err + end + + def delete_tag(id : String, tag : String) + MainFiber.run do + get_db do |db| + db.exec "delete from tags where id = (?) and tag = (?)", id, tag + end + end + end + def optimize MainFiber.run do Logger.info "Starting DB optimization" @@ -322,6 +394,15 @@ class Storage db.exec "delete from thumbnails where id not in (select id from ids)" Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted" end + + # Delete dangling tags + trash_tags_count = db.query_one "select count(*) from tags " \ + "where id not in " \ + "(select id from ids)", as: Int32 + if trash_tags_count > 0 + db.exec "delete from tags where id not in (select id from ids)" + Logger.info "#{trash_tags_count} dangling tags deleted" + end end Logger.info "DB optimization finished" end diff --git a/src/util/util.cr b/src/util/util.cr index 3fc1b2d..d7c0412 100644 --- a/src/util/util.cr +++ b/src/util/util.cr @@ -67,3 +67,28 @@ def env_is_true?(key : String) : Bool return false unless val val.downcase.in? "1", "true" end + +def sort_titles(titles : Array(Title), opt : SortOptions, username : String) + ary = titles + + case opt.method + when .time_modified? + ary.sort! { |a, b| (a.mtime <=> b.mtime).or \ + compare_numerically a.title, b.title } + when .progress? + ary.sort! do |a, b| + (a.load_percentage(username) <=> b.load_percentage(username)).or \ + compare_numerically a.title, b.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 } + end + + ary.reverse! unless opt.not_nil!.ascend + + ary +end diff --git a/src/util/web.cr b/src/util/web.cr index bf3ed41..03af6a2 100644 --- a/src/util/web.cr +++ b/src/util/web.cr @@ -4,13 +4,16 @@ macro layout(name) base_url = Config.current.base_url begin is_admin = false - if token = env.session.string? "token" - is_admin = @context.storage.verify_admin token - end + # The token (if exists) takes precedence over the default user option. + # this is why we check the default username first before checking the + # token. if Config.current.disable_login is_admin = @context.storage. username_is_admin Config.current.default_username end + if token = env.session.string? "token" + is_admin = @context.storage.verify_admin token + end page = {{name}} render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr" rescue e diff --git a/src/views/components/head.html.ecr b/src/views/components/head.html.ecr index c65ddea..2fe57ef 100644 --- a/src/views/components/head.html.ecr +++ b/src/views/components/head.html.ecr @@ -12,7 +12,7 @@ - - + + diff --git a/src/views/components/tags.html.ecr b/src/views/components/tags.html.ecr new file mode 100644 index 0000000..67588a5 --- /dev/null +++ b/src/views/components/tags.html.ecr @@ -0,0 +1,12 @@ +
+

+ Tags: + + +

+ +
diff --git a/src/views/layout.html.ecr b/src/views/layout.html.ecr index 0646534..2c240de 100644 --- a/src/views/layout.html.ecr +++ b/src/views/layout.html.ecr @@ -11,6 +11,7 @@