Merge branch 'feature/tagging' into dev

This commit is contained in:
Alex Ling 2020-12-30 11:15:44 +00:00
commit 911848ad11
15 changed files with 368 additions and 31 deletions

View File

@ -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}`);
});
}
};
};

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -12,7 +12,7 @@
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
<script src="<%= base_url %>js/common.js"></script>
</head>

View File

@ -0,0 +1,12 @@
<div class="uk-margin" x-data="tagsComponent()" x-cloak x-init="load(<%= is_admin %>)">
<p class="uk-text-meta" @selectstart.prevent>
<span style="position:relative; bottom:3px; margin-right:5px;">Tags: </span>
<template x-for="tag in tags" :key="tag">
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
<a class="uk-link-reset" x-show="isAdmin" @click="rm($event)" :id="`${tag}-rm`"><span uk-icon="close" style="margin-right: 5px; position: relative; bottom: 1.5px;"></span></a><a class="uk-link-reset" x-text="tag" :href="`<%= base_url %>tags/${encodeURIComponent(tag)}`"></a>
</span>
</template>
<a class="uk-link-reset" style="position:relative; bottom:3px;" :uk-icon="inputShown ? 'close' : 'plus'" @click="toggleInput($nextTick)" x-show="isAdmin"></a>
</p>
<input id="tag-input" class="uk-input" type="text" placeholder="Type in a new tag and hit enter" x-model="newTag" @keydown="keydown($event)" x-show="inputShown">
</div>

View File

@ -11,6 +11,7 @@
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li class="uk-parent">
@ -40,6 +41,7 @@
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li>

30
src/views/tag.html.ecr Normal file
View File

@ -0,0 +1,30 @@
<h2 class=uk-title>Tag: <%= tag %></h2>
<p class="uk-text-meta"><%= titles.size %> <%= titles.size > 1 ? "titles" : "title" %> tagged</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

8
src/views/tags.html.ecr Normal file
View File

@ -0,0 +1,8 @@
<h2 class=uk-title>Tags</h2>
<p class="uk-text-meta"><%= tags.size %> <%= tags.size > 1 ? "tags" : "tag" %> found</p>
<% tags.zip(encoded_tags, counts).each do |tag, encoded, count| %>
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
<a class="uk-link-reset" href="<%= base_url %>tags/<%= encoded %>"><%= tag %> (<%= count %> <%= count > 1 ? "titles" : "title" %>)</a>
</span>
<% end %>

View File

@ -33,6 +33,9 @@
<li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul>
<p class="uk-text-meta"><%= title.content_label %> found</p>
<%= render_component "tags" %>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">