.png"
16 | def save(sub_dir : String, ext : String, io : IO)
17 | full_dir = File.join @dir, sub_dir
18 | filename = random_str + ext
19 | file_path = File.join full_dir, filename
20 |
21 | unless Dir.exists? full_dir
22 | Logger.debug "creating directory #{full_dir}"
23 | Dir.mkdir_p full_dir
24 | end
25 |
26 | File.open file_path, "w" do |f|
27 | IO.copy io, f
28 | end
29 |
30 | file_path
31 | end
32 |
33 | # Converts path to a file in the uploads directory to the URL path for
34 | # accessing the file.
35 | def path_to_url(path : String)
36 | dir_mathed = false
37 | ary = [] of String
38 | # We fill it with parts until it equals to @upload_dir
39 | dir_ary = [] of String
40 |
41 | Path.new(path).each_part do |part|
42 | if dir_mathed
43 | ary << part
44 | else
45 | dir_ary << part
46 | if File.same? @dir, File.join dir_ary
47 | dir_mathed = true
48 | end
49 | end
50 | end
51 |
52 | if ary.empty?
53 | Logger.warn "File #{path} is not in the upload directory #{@dir}"
54 | return
55 | end
56 |
57 | ary.unshift UPLOAD_URL_PREFIX
58 | File.join(ary).to_s
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/src/util/chapter_sort.cr:
--------------------------------------------------------------------------------
1 | # Helper method used to sort chapters in a folder
2 | # It respects the keywords like "Vol." and "Ch." in the filenames
3 | # This sorting method was initially implemented in JS and done in the frontend.
4 | # see https://github.com/hkalexling/Mango/blob/
5 | # 07100121ef15260b5a8e8da0e5948c993df574c5/public/js/sort-items.js#L15-L87
6 |
7 | require "big"
8 |
9 | private class Item
10 | getter numbers : Hash(String, BigDecimal)
11 |
12 | def initialize(@numbers)
13 | end
14 |
15 | # Compare with another Item using keys
16 | def <=>(other : Item, keys : Array(String))
17 | keys.each do |key|
18 | if !@numbers.has_key?(key) && !other.numbers.has_key?(key)
19 | next
20 | elsif !@numbers.has_key? key
21 | return 1
22 | elsif !other.numbers.has_key? key
23 | return -1
24 | elsif @numbers[key] == other.numbers[key]
25 | next
26 | else
27 | return @numbers[key] <=> other.numbers[key]
28 | end
29 | end
30 |
31 | 0
32 | end
33 | end
34 |
35 | private class KeyRange
36 | getter min : BigDecimal, max : BigDecimal, count : Int32
37 |
38 | def initialize(value : BigDecimal)
39 | @min = @max = value
40 | @count = 1
41 | end
42 |
43 | def update(value : BigDecimal)
44 | @min = value if value < @min
45 | @max = value if value > @max
46 | @count += 1
47 | end
48 |
49 | def range
50 | @max - @min
51 | end
52 | end
53 |
54 | class ChapterSorter
55 | @sorted_keys = [] of String
56 |
57 | def initialize(str_ary : Array(String))
58 | keys = {} of String => KeyRange
59 |
60 | str_ary.each do |str|
61 | scan str do |k, v|
62 | if keys.has_key? k
63 | keys[k].update v
64 | else
65 | keys[k] = KeyRange.new v
66 | end
67 | end
68 | end
69 |
70 | # Get the array of keys string and sort them
71 | @sorted_keys = keys.keys
72 | # Only use keys that are present in over half of the strings
73 | .select do |key|
74 | keys[key].count >= str_ary.size / 2
75 | end
76 | .sort! do |a_key, b_key|
77 | a = keys[a_key]
78 | b = keys[b_key]
79 | # Sort keys by the number of times they appear
80 | count_compare = b.count <=> a.count
81 | if count_compare == 0
82 | # Then sort by value range
83 | b.range <=> a.range
84 | else
85 | count_compare
86 | end
87 | end
88 | end
89 |
90 | def compare(a : String, b : String)
91 | item_a = str_to_item a
92 | item_b = str_to_item b
93 | item_a.<=>(item_b, @sorted_keys)
94 | end
95 |
96 | private def scan(str, &)
97 | str.scan /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/ do |match|
98 | key = match[1]
99 | num = match[2].to_big_d
100 |
101 | yield key, num
102 | end
103 | end
104 |
105 | private def str_to_item(str)
106 | numbers = {} of String => BigDecimal
107 | scan str do |k, v|
108 | numbers[k] = v
109 | end
110 | Item.new numbers
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/src/util/numeric_sort.cr:
--------------------------------------------------------------------------------
1 | # Properly sort alphanumeric strings
2 | # Used to sort the images files inside the archives
3 | # https://github.com/hkalexling/Mango/issues/12
4 |
5 | require "big"
6 |
7 | def is_numeric(str)
8 | /^\d+/.match(str) != nil
9 | end
10 |
11 | def split_by_alphanumeric(str)
12 | arr = [] of String
13 | str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
14 | arr += match.captures.select &.!= ""
15 | end
16 | arr
17 | end
18 |
19 | def compare_numerically(c, d)
20 | is_c_bigger = c.size <=> d.size
21 | if c.size > d.size
22 | d += [nil] * (c.size - d.size)
23 | elsif c.size < d.size
24 | c += [nil] * (d.size - c.size)
25 | end
26 | c.zip(d) do |a, b|
27 | return -1 if a.nil?
28 | return 1 if b.nil?
29 | if is_numeric(a) && is_numeric(b)
30 | compare = a.to_big_i <=> b.to_big_i
31 | return compare if compare != 0
32 | else
33 | compare = a <=> b
34 | return compare if compare != 0
35 | end
36 | end
37 | is_c_bigger
38 | end
39 |
40 | def compare_numerically(a : String, b : String)
41 | compare_numerically split_by_alphanumeric(a), split_by_alphanumeric(b)
42 | end
43 |
--------------------------------------------------------------------------------
/src/util/proxy.cr:
--------------------------------------------------------------------------------
1 | require "http_proxy"
2 |
3 | # Monkey-patch `HTTP::Client` to make it respect the `*_PROXY`
4 | # environment variables
5 | module HTTP
6 | class Client
7 | private def self.exec(uri : URI, tls : TLSContext = nil)
8 | Logger.debug "Setting proxy"
9 | previous_def uri, tls do |client, path|
10 | client.set_proxy get_proxy uri
11 | yield client, path
12 | end
13 | end
14 | end
15 | end
16 |
17 | private def get_proxy(uri : URI) : HTTP::Proxy::Client?
18 | no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]?
19 | return if no_proxy &&
20 | no_proxy.split(",").any? &.== uri.hostname
21 |
22 | case uri.scheme
23 | when "http"
24 | env_to_proxy "http_proxy"
25 | when "https"
26 | env_to_proxy "https_proxy"
27 | else
28 | nil
29 | end
30 | end
31 |
32 | private def env_to_proxy(key : String) : HTTP::Proxy::Client?
33 | val = ENV[key.downcase]? || ENV[key.upcase]?
34 | return if val.nil?
35 |
36 | begin
37 | uri = URI.parse val
38 | HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
39 | username: uri.user, password: uri.password
40 | rescue
41 | nil
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/src/util/signature.cr:
--------------------------------------------------------------------------------
1 | require "./util"
2 |
3 | class File
4 | abstract struct Info
5 | def inode : UInt64
6 | @stat.st_ino.to_u64
7 | end
8 | end
9 |
10 | # Returns the signature of the file at filename.
11 | # When it is not a supported file, returns 0. Otherwise, uses the inode
12 | # number as its signature. On most file systems, the inode number is
13 | # preserved even when the file is renamed, moved or edited.
14 | # Some cases that would cause the inode number to change:
15 | # - Reboot/remount on some file systems
16 | # - Replaced with a copied file
17 | # - Moved to a different device
18 | # Since we are also using the relative paths to match ids, we won't lose
19 | # information as long as the above changes do not happen together with
20 | # a file/folder rename, with no library scan in between.
21 | def self.signature(filename) : UInt64
22 | if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename)
23 | File.info(filename).inode
24 | else
25 | 0u64
26 | end
27 | end
28 | end
29 |
30 | class Dir
31 | # Returns the signature of the directory at dirname. See the comments for
32 | # `File.signature` for more information.
33 | def self.signature(dirname) : UInt64
34 | signatures = [File.info(dirname).inode]
35 | self.open dirname do |dir|
36 | dir.entries.each do |fn|
37 | next if fn.starts_with? "."
38 | path = File.join dirname, fn
39 | if File.directory? path
40 | signatures << Dir.signature path
41 | else
42 | _sig = File.signature path
43 | # Only add its signature value to `signatures` when it is a
44 | # supported file
45 | signatures << _sig if _sig > 0
46 | end
47 | end
48 | end
49 | Digest::CRC32.checksum(signatures.sort.join).to_u64
50 | end
51 |
52 | # Returns the contents signature of the directory at dirname for checking
53 | # to rescan.
54 | # Rescan conditions:
55 | # - When a file added, moved, removed, renamed (including which in nested
56 | # directories)
57 | def self.contents_signature(dirname, cache = {} of String => String) : String
58 | return cache[dirname] if cache[dirname]?
59 | Fiber.yield
60 | signatures = [] of String
61 | self.open dirname do |dir|
62 | dir.entries.sort.each do |fn|
63 | next if fn.starts_with? "."
64 | path = File.join dirname, fn
65 | if File.directory? path
66 | signatures << Dir.contents_signature path, cache
67 | else
68 | # Only add its signature value to `signatures` when it is a
69 | # supported file
70 | if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
71 | signatures << fn
72 | end
73 | end
74 | Fiber.yield
75 | end
76 | end
77 | hash = Digest::SHA1.hexdigest(signatures.join)
78 | cache[dirname] = hash
79 | hash
80 | end
81 |
82 | def self.directory_entry_signature(dirname, cache = {} of String => String)
83 | return cache[dirname + "?entry"] if cache[dirname + "?entry"]?
84 | Fiber.yield
85 | signatures = [] of String
86 | image_files = DirEntry.sorted_image_files dirname
87 | if image_files.size > 0
88 | image_files.each do |path|
89 | signatures << File.signature(path).to_s
90 | end
91 | end
92 | hash = Digest::SHA1.hexdigest(signatures.join)
93 | cache[dirname + "?entry"] = hash
94 | hash
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/src/util/util.cr:
--------------------------------------------------------------------------------
1 | IMGS_PER_PAGE = 5
2 | ENTRIES_IN_HOME_SECTIONS = 8
3 | UPLOAD_URL_PREFIX = "/uploads"
4 | STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt
5 | /manifest.json)
6 | SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
7 | SUPPORTED_IMG_TYPES = %w(
8 | image/jpeg
9 | image/png
10 | image/webp
11 | image/apng
12 | image/avif
13 | image/gif
14 | image/svg+xml
15 | image/jxl
16 | )
17 |
18 | def random_str
19 | UUID.random.to_s.gsub "-", ""
20 | end
21 |
22 | # Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
23 | # blob/master/src/crystal/system/unix/file_info.cr#L42-L48
24 | def ctime(file_path : String) : Time
25 | res = LibC.stat(file_path, out stat)
26 | raise "Unable to get ctime of file #{file_path}" if res != 0
27 |
28 | {% if flag?(:darwin) %}
29 | Time.new stat.st_ctimespec, Time::Location::UTC
30 | {% else %}
31 | Time.new stat.st_ctim, Time::Location::UTC
32 | {% end %}
33 | end
34 |
35 | def register_mime_types
36 | {
37 | # Comic Archives
38 | ".zip" => "application/zip",
39 | ".rar" => "application/x-rar-compressed",
40 | ".cbz" => "application/vnd.comicbook+zip",
41 | ".cbr" => "application/vnd.comicbook-rar",
42 |
43 | # Favicon
44 | ".ico" => "image/x-icon",
45 |
46 | # FontAwesome fonts
47 | ".woff" => "font/woff",
48 | ".woff2" => "font/woff2",
49 |
50 | # Supported image formats. JPG, PNG, GIF, WebP, and SVG are already
51 | # defiend by Crystal in `MIME.DEFAULT_TYPES`
52 | ".apng" => "image/apng",
53 | ".avif" => "image/avif",
54 | ".jxl" => "image/jxl",
55 | }.each do |k, v|
56 | MIME.register k, v
57 | end
58 | end
59 |
60 | def is_supported_file(path)
61 | SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
62 | end
63 |
64 | def is_supported_image_file(path)
65 | SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
66 | end
67 |
68 | struct Int
69 | def or(other : Int)
70 | if self == 0
71 | other
72 | else
73 | self
74 | end
75 | end
76 | end
77 |
78 | struct Nil
79 | def or(other : Int)
80 | other
81 | end
82 | end
83 |
84 | macro use_default
85 | def self.default : self
86 | unless @@default
87 | @@default = new
88 | end
89 | @@default.not_nil!
90 | end
91 | end
92 |
93 | class String
94 | def alphanumeric_underscore?
95 | self.chars.all? { |c| c.alphanumeric? || c == '_' }
96 | end
97 | end
98 |
99 | def env_is_true?(key : String, default : Bool = false) : Bool
100 | val = ENV[key.upcase]? || ENV[key.downcase]?
101 | return default unless val
102 | val.downcase.in? "1", "true"
103 | end
104 |
105 | def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
106 | cache_key = SortedTitlesCacheEntry.gen_key username, titles, opt
107 | cached_titles = LRUCache.get cache_key
108 | return cached_titles if cached_titles.is_a? Array(Title)
109 |
110 | case opt.method
111 | when .time_modified?
112 | ary = titles.sort { |a, b| (a.mtime <=> b.mtime).or \
113 | compare_numerically a.sort_title, b.sort_title }
114 | when .progress?
115 | ary = titles.sort do |a, b|
116 | (a.load_percentage(username) <=> b.load_percentage(username)).or \
117 | compare_numerically a.sort_title, b.sort_title
118 | end
119 | when .title?
120 | ary = titles.sort do |a, b|
121 | compare_numerically a.sort_title, b.sort_title
122 | end
123 | else
124 | unless opt.method.auto?
125 | Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
126 | "Auto instead"
127 | end
128 | ary = titles.sort { |a, b| compare_numerically a.sort_title, b.sort_title }
129 | end
130 |
131 | ary.reverse! unless opt.not_nil!.ascend
132 |
133 | LRUCache.set generate_cache_entry cache_key, ary
134 | ary
135 | end
136 |
137 | def remove_sorted_titles_cache(titles : Array(Title),
138 | sort_methods : Array(SortMethod),
139 | username : String)
140 | [false, true].each do |ascend|
141 | sort_methods.each do |sort_method|
142 | sorted_titles_cache_key = SortedTitlesCacheEntry.gen_key username,
143 | titles, SortOptions.new(sort_method, ascend)
144 | LRUCache.invalidate sorted_titles_cache_key
145 | end
146 | end
147 | end
148 |
149 | class String
150 | # Returns the similarity (in [0, 1]) of two paths.
151 | # For the two paths, separate them into arrays of components, count the
152 | # number of matching components backwards, and divide the count by the
153 | # number of components of the shorter path.
154 | def components_similarity(other : String) : Float64
155 | s, l = [self, other]
156 | .map { |str| Path.new(str).parts }
157 | .sort_by! &.size
158 |
159 | match = s.reverse.zip(l.reverse).count { |a, b| a == b }
160 | match / s.size
161 | end
162 | end
163 |
164 | # Does the followings:
165 | # - turns space-like characters into the normal whitespaces ( )
166 | # - strips and collapses spaces
167 | # - removes ASCII control characters
168 | # - replaces slashes (/) with underscores (_)
169 | # - removes leading dots (.)
170 | # - removes the following special characters: \:*?"<>|
171 | #
172 | # If the sanitized string is empty, returns a random string instead.
173 | def sanitize_filename(str : String) : String
174 | sanitized = str
175 | .gsub(/\s+/, " ")
176 | .strip
177 | .gsub(/\//, "_")
178 | .gsub(/^[\.\s]+/, "")
179 | .gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
180 | sanitized.size > 0 ? sanitized : random_str
181 | end
182 |
183 | def delete_cache_and_exit(path : String)
184 | File.delete path
185 | Logger.fatal "Invalid library cache deleted. Mango needs to " \
186 | "perform a full reset to recover from this. " \
187 | "Pleae restart Mango. This is NOT a bug."
188 | Logger.fatal "Exiting"
189 | exit 1
190 | end
191 |
--------------------------------------------------------------------------------
/src/util/validation.cr:
--------------------------------------------------------------------------------
1 | def validate_username(username)
2 | if username.size < 3
3 | raise "Username should contain at least 3 characters"
4 | end
5 | if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil?
6 | raise "Username can only contain alphanumeric characters, " \
7 | "underscores, and hyphens"
8 | end
9 | end
10 |
11 | def validate_password(password)
12 | if password.size < 6
13 | raise "Password should contain at least 6 characters"
14 | end
15 | if (password =~ /^[[:ascii:]]+$/).nil?
16 | raise "password should contain ASCII characters only"
17 | end
18 | end
19 |
20 | def validate_archive(path : String) : Exception?
21 | file = nil
22 | begin
23 | file = ArchiveFile.new path
24 | file.check
25 | file.close
26 | return
27 | rescue e
28 | file.close unless file.nil?
29 | e
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/src/util/web.cr:
--------------------------------------------------------------------------------
1 | # Web related helper functions/macros
2 |
3 | def is_admin?(env) : Bool
4 | is_admin = false
5 | if !Config.current.auth_proxy_header_name.empty? ||
6 | Config.current.disable_login
7 | is_admin = Storage.default.username_is_admin get_username env
8 | end
9 |
10 | # The token (if exists) takes precedence over other authentication methods.
11 | if token = env.session.string? "token"
12 | is_admin = Storage.default.verify_admin token
13 | end
14 |
15 | is_admin
16 | end
17 |
18 | macro layout(name)
19 | base_url = Config.current.base_url
20 | is_admin = is_admin? env
21 | begin
22 | page = {{name}}
23 | render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
24 | rescue e
25 | message = e.to_s
26 | Logger.error message
27 | page = "Error"
28 | render "src/views/message.html.ecr", "src/views/layout.html.ecr"
29 | end
30 | end
31 |
32 | macro send_error_page(msg)
33 | message = {{msg}}
34 | base_url = Config.current.base_url
35 | is_admin = is_admin? env
36 | page = "Error"
37 | html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
38 | send_file env, html.to_slice, "text/html"
39 | end
40 |
41 | macro send_img(env, img)
42 | cors
43 | send_file {{env}}, {{img}}.data, {{img}}.mime
44 | end
45 |
46 | def get_token_from_auth_header(env) : String?
47 | value = env.request.headers["Authorization"]
48 | if value && value.starts_with? "Bearer"
49 | session_id = value.split(" ")[1]
50 | return Kemal::Session.get(session_id).try &.string? "token"
51 | end
52 | end
53 |
54 | macro get_username(env)
55 | begin
56 | # Check if we can get the session id from the cookie
57 | token = env.session.string? "token"
58 | if token.nil?
59 | # If not, check if we can get the session id from the auth header
60 | token = get_token_from_auth_header env
61 | end
62 | # If we still don't have a token, we handle it in `resuce` with `not_nil!`
63 | (Storage.default.verify_token token.not_nil!).not_nil!
64 | rescue e
65 | if Config.current.disable_login
66 | Config.current.default_username
67 | elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
68 | env.request.headers[header]
69 | else
70 | raise e
71 | end
72 | end
73 | end
74 |
75 | macro cors
76 | env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
77 | "DELETE,OPTIONS"
78 | env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
79 | "X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
80 | "Authorization"
81 | env.response.headers["Access-Control-Allow-Origin"] = "*"
82 | end
83 |
84 | def send_json(env, json)
85 | cors
86 | env.response.content_type = "application/json"
87 | env.response.print json
88 | end
89 |
90 | def send_text(env, text)
91 | cors
92 | env.response.content_type = "text/plain"
93 | env.response.print text
94 | end
95 |
96 | def send_attachment(env, path)
97 | cors
98 | send_file env, path, filename: File.basename(path), disposition: "attachment"
99 | end
100 |
101 | def redirect(env, path)
102 | base = Config.current.base_url
103 | env.redirect File.join base, path
104 | end
105 |
106 | def hash_to_query(hash)
107 | hash.join "&" { |k, v| "#{k}=#{v}" }
108 | end
109 |
110 | def request_path_startswith(env, ary)
111 | ary.any? { |prefix| env.request.path.starts_with? prefix }
112 | end
113 |
114 | def requesting_static_file(env)
115 | request_path_startswith env, STATIC_DIRS
116 | end
117 |
118 | macro render_xml(path)
119 | base_url = Config.current.base_url
120 | send_file env, ECR.render({{path}}).to_slice, "application/xml"
121 | end
122 |
123 | macro render_component(filename)
124 | render "src/views/components/#{{{filename}}}.html.ecr"
125 | end
126 |
127 | macro get_sort_opt
128 | sort_method = env.params.query["sort"]?
129 |
130 | if sort_method
131 | is_ascending = true
132 |
133 | ascend = env.params.query["ascend"]?
134 | if ascend && ascend.to_i? == 0
135 | is_ascending = false
136 | end
137 |
138 | sort_opt = SortOptions.new sort_method, is_ascending
139 | end
140 | end
141 |
142 | macro get_and_save_sort_opt(dir)
143 | sort_method = env.params.query["sort"]?
144 |
145 | if sort_method
146 | is_ascending = true
147 |
148 | ascend = env.params.query["ascend"]?
149 | if ascend && ascend.to_i? == 0
150 | is_ascending = false
151 | end
152 |
153 | sort_opt = SortOptions.new sort_method, is_ascending
154 |
155 | TitleInfo.new {{dir}} do |info|
156 | info.sort_by[username] = sort_opt.to_tuple
157 | info.save
158 | end
159 | end
160 | end
161 |
162 | module HTTP
163 | class Client
164 | private def self.exec(uri : URI, tls : TLSContext = nil)
165 | previous_def uri, tls do |client, path|
166 | if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION"
167 | Logger.debug "Disabling SSL verification"
168 | client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE
169 | end
170 | Logger.debug "Setting read timeout"
171 | client.read_timeout = Config.current.download_timeout_seconds.seconds
172 | Logger.debug "Requesting #{uri}"
173 | yield client, path
174 | end
175 | end
176 | end
177 | end
178 |
--------------------------------------------------------------------------------
/src/views/admin.html.ecr:
--------------------------------------------------------------------------------
1 |
37 |
38 |
39 | 版本: v<%= MANGO_VERSION %> 汉化:昭君
40 | 登出
41 |
42 | <% content_for "script" do %>
43 |
44 |
45 | <% end %>
46 |
--------------------------------------------------------------------------------
/src/views/api.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Mango API 文档
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/views/components/card.html.ecr:
--------------------------------------------------------------------------------
1 | <% if item.is_a? NamedTuple(entry: Entry, percentage: Float64, grouped_count: Int32) %>
2 | <% grouped_count = item[:grouped_count] %>
3 | <% if grouped_count == 1 %>
4 | <% item = item[:entry] %>
5 | <% else %>
6 | <% item = item[:entry].book %>
7 | <% end %>
8 | <% else %>
9 | <% grouped_count = 1 %>
10 | <% end %>
11 |
12 |
14 | id="<%= item.id %>"
15 | <% end %>>
16 |
17 |
20 | <% end %>
21 | "
22 | <% if item.is_a? Entry %>
23 | <% if item.err_msg %>
24 | onclick="location='<%= base_url %>reader/<%= item.book.id %>/<%= item.id %>'"
25 | <% else %>
26 | data-encoded-path="<%= item.encoded_path %>"
27 | data-pages="<%= item.pages %>"
28 | data-progress="<%= (progress * 100).round(1) %>"
29 | data-encoded-book-title="<%= item.book.encoded_display_name %>"
30 | data-encoded-title="<%= item.encoded_display_name %>"
31 | data-book-id="<%= item.book.id %>"
32 | data-id="<%= item.id %>"
33 | <% end %>
34 | <% else %>
35 | onclick="location='<%= base_url %>book/<%= item.id %>'"
36 | <% end %>>
37 |
38 |
0"
39 | <% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
40 | x-init="disabled = false"
41 | <% end %>>
42 |
54 |
55 |
56 | <% unless progress < 0 || progress > 100 || progress.nan? %>
57 |
<%= (progress * 100).round(1) %>%
58 | <% end %>
59 |
60 |
62 | <%= "uk-margin-remove-bottom" %>
63 | <% end %>
64 | " data-title="<%= HTML.escape(item.display_name) %>"
65 | data-file-title="<%= HTML.escape(item.title || "") %>"
66 | data-sort-title="<%= HTML.escape(item.sort_title_db || "") %>"><%= HTML.escape(item.display_name) %>
67 |
68 | <% if page == "home" && item.is_a? Entry %>
69 |
<%= HTML.escape(item.book.display_name) %>
70 | <% end %>
71 | <% if item.is_a? Entry %>
72 | <% if item.err_msg %>
73 |
Error
74 |
<%= item.err_msg %>
75 | <% else %>
76 |
<%= item.pages %> 页
77 | <% end %>
78 | <% end %>
79 | <% if item.is_a? Title %>
80 | <% if grouped_count == 1 %>
81 |
<%= item.content_label %>
82 | <% else %>
83 |
<%= grouped_count %> 新条目
84 | <% end %>
85 | <% end %>
86 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/src/views/components/dots.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/views/components/entry-modal.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
19 |
20 |
阅读
21 |
22 | 从头开始
23 |
24 |
25 |
进度
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/views/components/head.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Mango - <%= page.split("-").map(&.capitalize).join(" ") %>
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/views/components/jquery-ui.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/views/components/moment.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/views/components/sort-form.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
--------------------------------------------------------------------------------
/src/views/components/uikit.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/views/download-manager.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | 章节 |
12 | 漫画 |
13 | 进度 |
14 | 时间 |
15 | 状态 |
16 | 插件 |
17 | 操作 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | |
26 |
27 |
28 |
29 | |
30 |
31 |
32 | |
33 | |
34 |
35 |
36 |
37 |
38 |
42 |
43 | |
44 |
45 | |
46 |
47 |
48 |
49 |
50 |
51 | |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | <% content_for "script" do %>
60 | <%= render_component "moment" %>
61 |
62 |
63 | <% end %>
64 |
--------------------------------------------------------------------------------
/src/views/home.html.ecr:
--------------------------------------------------------------------------------
1 | <%- if new_user && empty_library -%>
2 |
3 |
4 |
5 |
添加你的第一部漫画
6 |
我们还找不到任何文件。添加一些到您的库中,它们将出现在此处。
7 |
8 | - 当前资源库路径
9 | <%= Config.current.library_path %>
10 | - 想要更改您的资源库路径?
11 | - 配置
config.yml
其路径为: <%= Config.current.path %>
12 | - 还看不到您的文件?
13 | -
14 | 您必须等待 <%= Config.current.scan_interval_minutes %> 分钟才能完成库扫描
15 | <% if is_admin %>
16 | , 或者从 管理员手动扫描
17 | <% end %>.
18 |
19 |
20 |
21 |
22 | <%- elsif new_user && empty_library == false -%>
23 |
24 |
25 |
26 |
阅读你的第一部漫画
27 |
一旦你开始阅读,Mango 会记住你离开的地方
28 | 并在此处显示您的条目。
29 |
查看库
30 |
31 |
32 | <%- elsif new_user == false && empty_library == false -%>
33 |
34 | <%- if continue_reading.empty? && recently_added.empty? -%>
35 |
36 |

37 |
一个自托管的漫画服务器和阅读器
38 |
查看库
39 |
40 | <%- end -%>
41 |
42 | <%- unless continue_reading.empty? -%>
43 | 继续阅读
44 |
45 | <%- continue_reading.each do |cr| -%>
46 | <% item = cr[:entry] %>
47 | <% progress = cr[:percentage] %>
48 | <%= render_component "card" %>
49 | <%- end -%>
50 |
51 | <%- end -%>
52 |
53 | <%- unless start_reading.empty? -%>
54 | 开始阅读
55 |
56 | <%- start_reading.each do |t| -%>
57 | <% item = t %>
58 | <% progress = 0.0 %>
59 | <%= render_component "card" %>
60 | <%- end -%>
61 |
62 | <%- end -%>
63 |
64 | <%- unless recently_added.empty? -%>
65 | 最近添加
66 |
67 | <%- recently_added.each do |ra| -%>
68 | <% item = ra %>
69 | <% progress = ra[:percentage] %>
70 | <%= render_component "card" %>
71 | <%- end -%>
72 |
73 | <%- end -%>
74 |
75 | <%= render_component "entry-modal" %>
76 |
77 | <%- end -%>
78 |
79 | <% content_for "script" do %>
80 | <%= render_component "dots" %>
81 |
82 |
83 | <% end %>
84 |
--------------------------------------------------------------------------------
/src/views/layout.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= render_component "head" %>
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | - 主页
13 | - 资料库
14 | - 标签
15 | <% if is_admin %>
16 | - 管理员
17 | -
18 | 下载
19 |
24 |
25 | <% end %>
26 |
27 |
28 | - 登出
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
39 |
40 |

41 |
42 | - 主页
43 | - 资料库
44 | - 标签
45 | <% if is_admin %>
46 | - 管理员
47 | -
48 | 下载
49 |
58 |
59 | <% end %>
60 |
61 |
62 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | <%= content %>
76 |
79 |
80 |
81 |
85 | <%= render_component "uikit" %>
86 | <%= yield_content "script" %>
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/src/views/library.html.ecr:
--------------------------------------------------------------------------------
1 | 资料库
2 | <%= titles.size %> 文件找到
3 |
4 |
5 |
9 |
10 |
11 | <% hash = {
12 | "auto" => "自动",
13 | "title" => "名称",
14 | "time_modified" => "修改日期",
15 | "progress" => "进度"
16 | } %>
17 | <%= render_component "sort-form" %>
18 |
19 |
20 |
21 | <% titles.each_with_index do |item, i| %>
22 | <% progress = percentage[i] %>
23 | <%= render_component "card" %>
24 | <% end %>
25 |
26 |
27 | <% content_for "script" do %>
28 | <%= render_component "dots" %>
29 |
30 |
31 | <% end %>
32 |
--------------------------------------------------------------------------------
/src/views/login.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <% page = "登录" %>
5 | <%= render_component "head" %>
6 |
7 |
8 |
30 |
33 | <%= render_component "uikit" %>
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/views/message.html.ecr:
--------------------------------------------------------------------------------
1 | <%= message %>
2 |
--------------------------------------------------------------------------------
/src/views/missing-items.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
没有找到丢失的条目.
3 |
4 |
以下项目存在于您的资料库中,但现在我们找不到它们了。 如果您错误地删除了它们,请尝试恢复文件或文件夹,将它们放回原来的位置,然后重新扫描资料库。 除此之外,您可以使用下面的按钮安全地删除它们和相关的元数据以释放数据库空间。
5 |
6 |
7 |
8 |
9 | 类型 |
10 | 相对路径 |
11 | ID |
12 | 操作 |
13 |
14 |
15 |
16 |
17 |
18 | 标题 |
19 | |
20 | |
21 | |
22 |
23 |
24 |
25 |
26 | 路径 |
27 | |
28 | |
29 | |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | <% content_for "script" do %>
38 |
39 |
40 | <% end %>
41 |
--------------------------------------------------------------------------------
/src/views/opds/index.xml.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 | urn:mango:index
4 |
5 |
6 |
7 |
8 | 资料库
9 |
10 |
11 | Mango
12 | https://github.com/hkalexling/Mango
13 |
14 |
15 | <% titles.each do |t| %>
16 |
17 | <%= HTML.escape(t.display_name) %>
18 | urn:mango:<%= t.id %>
19 |
20 |
21 | <% end %>
22 |
23 |
--------------------------------------------------------------------------------
/src/views/opds/title.xml.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 | urn:mango:<%= title.id %>
4 |
5 |
6 |
7 |
8 | <%= HTML.escape(title.display_name) %>
9 |
10 |
11 | Mango
12 | https://github.com/hkalexling/Mango
13 |
14 |
15 | <% title.titles.each do |t| %>
16 |
17 | <%= HTML.escape(t.display_name) %>
18 | urn:mango:<%= t.id %>
19 |
20 |
21 | <% end %>
22 |
23 | <% title.entries.each do |e| %>
24 | <% next if e.err_msg %>
25 |
26 | <%= HTML.escape(e.display_name) %>
27 | urn:mango:<%= e.id %>
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | <% end %>
38 |
39 |
--------------------------------------------------------------------------------
/src/views/reader-error.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
13 | <% if next_entry = entry.next_entry username %>
14 | 下一个
15 | <% end %>
16 | 回到标题
17 |
18 |
19 |
20 |
21 |
22 | <% content_for "script" do %>
23 |
29 | <% end %>
30 |
--------------------------------------------------------------------------------
/src/views/subscription-manager.html.ecr:
--------------------------------------------------------------------------------
1 | 订阅管理器
2 |
3 |
4 |
5 |
未找到插件
6 |
我们下列目录中找不到任何插件 <%= Config.current.plugin_path %>
.
7 |
您可以从以下网址下载官方插件 Mango 插件库.
8 |
9 |
10 |
11 |
12 |
13 |
14 |
19 |
20 |
21 |
22 |
未找到订阅.
23 |
24 |
25 |
26 |
27 |
28 | 名称 |
29 | 插件 ID |
30 | 漫画标题 |
31 | 创建时间 |
32 | 上次检查 |
33 | 操作 |
34 |
35 |
36 |
37 |
38 |
39 | |
40 | |
41 | |
42 | |
43 | |
44 |
45 |
46 |
47 | |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
61 |
62 |
63 | - Name
64 |
65 | - 订阅 ID
66 |
67 | - 插件 ID
68 |
69 | - 漫画标题
70 |
71 | - 漫画 ID
72 |
73 | - 过滤器
74 |
75 |
76 |
77 |
78 | 关键词 |
79 | 种类 |
80 | 值 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | <% content_for "script" do %>
98 | <%= render_component "moment" %>
99 |
100 |
101 | <% end %>
102 |
--------------------------------------------------------------------------------
/src/views/tag.html.ecr:
--------------------------------------------------------------------------------
1 | 标签: <%= tag %>
2 | <%= titles.size %> <%= titles.size > 1 ? "titles" : "title" %> 标记
3 |
4 |
5 |
9 |
10 |
11 | <% hash = {
12 | "auto" => "自动",
13 | "time_modified" => "修改日期",
14 | "progress" => "进度"
15 | } %>
16 | <%= render_component "sort-form" %>
17 |
18 |
19 |
20 | <% titles.each_with_index do |item, i| %>
21 | <% progress = percentage[i] %>
22 | <%= render_component "card" %>
23 | <% end %>
24 |
25 |
26 | <% content_for "script" do %>
27 | <%= render_component "dots" %>
28 |
29 |
30 | <% end %>
31 |
--------------------------------------------------------------------------------
/src/views/tags.html.ecr:
--------------------------------------------------------------------------------
1 | 标签
2 | <%= tags.size %> <%= tags.size > 1 ? "标签" : "标签" %> 找到
3 |
4 | <% tags.each do |tag| %>
5 |
6 | <%= tag[:tag] %> (<%= tag[:count] %> <%= tag[:count] > 1 ? "titles" : "title" %>)
7 |
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/src/views/title.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
21 |
">
22 | <%= title.display_name %>
23 |
24 | <% if is_admin %>
25 |
26 | <% end %>
27 |
28 |
29 |
36 | <%= title.content_label %> 找到
37 |
38 |
39 |
41 |
42 |
43 |
44 |
45 |
49 |
50 |
51 | <% hash = {
52 | "auto" => "自动",
53 | "title" => "名称",
54 | "time_modified" => "修改日期",
55 | "time_added" => "添加日期",
56 | "progress" => "进度"
57 | } %>
58 | <%= render_component "sort-form" %>
59 |
60 |
61 |
62 |
63 | <% sorted_titles.each do |item| %>
64 | <% progress = title_percentage_map[item.id] %>
65 | <%= render_component "card" %>
66 | <% end %>
67 |
68 |
69 | <% entries.each_with_index do |item, i| %>
70 | <% progress = percentage[i] %>
71 | <%= render_component "card" %>
72 | <% end %>
73 |
74 |
75 | <%= render_component "entry-modal" %>
76 |
77 |
78 |
79 |
80 |
85 |
86 |
93 |
100 |
101 |
102 |
103 |
104 |
![]()
105 |
106 |
107 |
108 |
109 |
110 |
上传封面图片,将其拖放到此处或
111 |
112 | ">
113 | 选择一个
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | <% content_for "script" do %>
134 | <%= render_component "dots" %>
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 | <% end %>
143 |
--------------------------------------------------------------------------------
/src/views/user-edit.html.ecr:
--------------------------------------------------------------------------------
1 |
32 |
33 | <% content_for "script" do %>
34 |
44 |
45 |
46 | <% end %>
47 |
--------------------------------------------------------------------------------
/src/views/user.html.ecr:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 用户名 |
5 | 管理员权限 |
6 | 操作 |
7 |
8 |
9 |
10 | <%- users.each do |u| -%>
11 |
12 | <%= u[0] %> |
13 | <%= u[1] %> |
14 |
15 |
16 | <%- if u[0] != username %>
17 |
18 | <%- end %>
19 | |
20 |
21 | <%- end -%>
22 |
23 |
24 |
25 | 新用户
26 |
27 |
28 | <% content_for "script" do %>
29 |
30 |
31 | <% end %>
32 |
--------------------------------------------------------------------------------