Compare commits
6 commits
ff39289d6b
...
61587d6e0c
| Author | SHA1 | Date | |
|---|---|---|---|
| 61587d6e0c | |||
| af78545d43 | |||
| 0cf85cb6dc | |||
| 2e44bf7c10 | |||
| 15defdb0ba | |||
| 0d7213bd1f |
27 changed files with 533 additions and 178 deletions
14
.editorconfig
Normal file
14
.editorconfig
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# EditorConfig is awesome: http://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -10,3 +10,8 @@
|
|||
/Gemfile.lock
|
||||
/database.db
|
||||
/config.yml
|
||||
|
||||
# Test fiction
|
||||
/*.epub
|
||||
/*.html
|
||||
/*.md
|
||||
|
|
|
|||
2
Gemfile
2
Gemfile
|
|
@ -10,3 +10,5 @@ gem "rake", "~> 13.0"
|
|||
gem "minitest", "~> 5.16"
|
||||
|
||||
gem "rubocop", "~> 1.21"
|
||||
|
||||
gem 'irb'
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ require 'optparse'
|
|||
require 'ostruct'
|
||||
|
||||
options = OpenStruct.new
|
||||
OptParse.new do |opts|
|
||||
optparse = OptParse.new do |opts|
|
||||
opts.banner = 'Usage: fic_tracker [OPTIONS...]'
|
||||
|
||||
opts.on '-b', '--backend=BACKEND', 'The backend to use' do |backend|
|
||||
|
|
@ -29,8 +29,13 @@ OptParse.new do |opts|
|
|||
options.output = output
|
||||
end
|
||||
|
||||
opts.on '--only-changed', 'Only store the story if it has been changed since the last update' do
|
||||
options.only_changed = true
|
||||
end
|
||||
|
||||
opts.separator ''
|
||||
|
||||
|
||||
opts.on '-C', '--config=FILE', 'Specify a configuration file to read' do |config|
|
||||
options.config = config
|
||||
end
|
||||
|
|
@ -56,18 +61,32 @@ OptParse.new do |opts|
|
|||
puts FicTracker::VERSION
|
||||
exit
|
||||
end
|
||||
end.parse!
|
||||
end
|
||||
optparse.parse!
|
||||
|
||||
unless options.backend && options.format && options.story
|
||||
puts "Backend, format, and sotry must be provided\n"
|
||||
puts optparse
|
||||
exit 1
|
||||
end
|
||||
|
||||
FicTracker.logger.level = options.log_level || :warn
|
||||
FicTracker.configure!
|
||||
|
||||
backend = FicTracker::Backends.get(options.backend)
|
||||
story = FicTracker::Models::Story.find(backend_name: backend.name, slug: backend.parse_slug(options.story)) || FicTracker::Models::Story.new(backend: backend, slug: backend.parse_slug(options.story))
|
||||
|
||||
slug = options.story
|
||||
slug = backend.parse_slug(slug) if backend.respond_to? :parse_slug
|
||||
|
||||
story = FicTracker::Models::Story.find(backend_name: backend.name, slug:) || FicTracker::Models::Story.new(backend:, slug:)
|
||||
before = story.etag
|
||||
story.ensure_fully_loaded
|
||||
data = nil
|
||||
|
||||
options.output ||= "#{story.safe_name}.#{options.format}"
|
||||
FicTracker.logger.info "Saving to #{options.output}"
|
||||
File.open(options.output, 'w') { |f| FicTracker::Renderers.render(options.format, story, io: f) }
|
||||
if !options.only_changed || story.etag != before
|
||||
FicTracker.logger.info "Saving to #{options.output}"
|
||||
File.open(options.output, 'w') { |f| FicTracker::Renderers.render(options.format, story, io: f) }
|
||||
end
|
||||
|
||||
story.save_changes
|
||||
|
|
|
|||
10
config.ru
10
config.ru
|
|
@ -1,7 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'fic_tracker'
|
||||
require 'fic_tracker/server'
|
||||
|
||||
FicTracker.configure!
|
||||
|
||||
map '/' ->() { run FicTracker::Server }
|
||||
map '/health' do
|
||||
run -> { [200, { 'Content-Type' => 'text/plain' }, ['OK']] }
|
||||
end
|
||||
|
||||
server = FicTracker::Server.new
|
||||
map '/' do
|
||||
run server
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,5 +31,6 @@ Gem::Specification.new do |spec|
|
|||
spec.add_dependency 'rubyzip'
|
||||
spec.add_dependency 'sequel'
|
||||
spec.add_dependency 'sinatra'
|
||||
spec.add_dependency 'sinatra-contrib'
|
||||
spec.add_dependency 'sqlite3'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -51,50 +51,9 @@ module FicTracker
|
|||
@global_logger ||= false
|
||||
end
|
||||
|
||||
module Models
|
||||
def self.const_missing(const)
|
||||
raise 'No database connected' unless FicTracker.database
|
||||
|
||||
model = const.to_s.downcase
|
||||
require_relative "fic_tracker/models/#{model}"
|
||||
|
||||
mod = const_get(const) if const_defined? const
|
||||
return mod if mod
|
||||
|
||||
raise "Model not found: #{const}"
|
||||
end
|
||||
end
|
||||
|
||||
module Tasks
|
||||
autoload :Cleanup, 'fic_tracker/tasks/cleanup'
|
||||
end
|
||||
|
||||
module Renderers
|
||||
autoload :Epub, 'fic_tracker/renderers/epub'
|
||||
autoload :HTML, 'fic_tracker/renderers/html'
|
||||
autoload :Markdown, 'fic_tracker/renderers/markdown'
|
||||
|
||||
def self.render(type, story, **attrs)
|
||||
klass = case type
|
||||
when :Markdown, :markdown, :md
|
||||
Markdown
|
||||
when :HTML, :html
|
||||
HTML
|
||||
when :Epub, :epub
|
||||
Epub
|
||||
end
|
||||
|
||||
unless attrs[:io]
|
||||
require 'stringio'
|
||||
attrs[:io] = StringIO.new
|
||||
attrs[:_stringio] = true
|
||||
end
|
||||
|
||||
return klass.new(story, **attrs).render unless attrs[:stringio]
|
||||
|
||||
attrs[:io].string
|
||||
end
|
||||
end
|
||||
autoload :Models, 'fic_tracker/models'
|
||||
autoload :Renderers, 'fic_tracker/renderers'
|
||||
autoload :Tasks, 'fic_tracker/tasks'
|
||||
end
|
||||
|
||||
require_relative 'fic_tracker/backend'
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ module FicTracker
|
|||
return unless model
|
||||
|
||||
backend = const_get(model).const_get(:Backend)
|
||||
backend.new(**FicTracker.config.dig(:backends, backend.config_name, default: {}))
|
||||
backend.new(**FicTracker::Config.dig(:backends, backend.config_name, default: {}))
|
||||
rescue StandardError => e
|
||||
Logging.logger[backend].error "Failed to load, #{e.class}: #{e}"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'client'
|
||||
require_relative 'search_info'
|
||||
|
||||
module FicTracker::Backends::Ao3
|
||||
class Backend < FicTracker::Backend
|
||||
|
|
@ -25,20 +26,26 @@ module FicTracker::Backends::Ao3
|
|||
def load_author(author)
|
||||
author = FicTracker::Models::Author.new(slug: parse_slug(author), backend: self) unless author.is_a? FicTracker::Models::Author
|
||||
|
||||
logger.info "Loading author #{author.slug}"
|
||||
doc = client.request("/users/#{author.slug}")
|
||||
logger.info "Loading author #{author}"
|
||||
slug, pseud = author.slug.split('/')
|
||||
pseud ||= slug
|
||||
|
||||
doc = client.request("/users/#{slug}/pseuds/#{pseud}")
|
||||
|
||||
user = doc.at_css('#main .user')
|
||||
|
||||
name = user.at_css('h2.heading').text.sub(/\([^)]+\)$/, '').strip
|
||||
name = nil if name == pseud
|
||||
|
||||
url = user.at_css('.icon a')
|
||||
url = URI.join(Client::BASE_URL, url[:href]) if url
|
||||
image = user.at_css('.icon a img')
|
||||
image = URI.join(Client::BASE_URL, image[:src]) if image
|
||||
|
||||
author.set(
|
||||
name: user.at_css('h2.heading').text.strip,
|
||||
url: url.to_s,
|
||||
image: image.to_s,
|
||||
name: name,
|
||||
url: url&.to_s,
|
||||
image: image&.to_s,
|
||||
last_metadata_refresh: Time.now
|
||||
)
|
||||
end
|
||||
|
|
@ -47,7 +54,7 @@ module FicTracker::Backends::Ao3
|
|||
story = FicTracker::Models::Story.new(slug: parse_slug(story), backend: self) unless story.is_a? FicTracker::Models::Story
|
||||
|
||||
logger.info "Loading story #{story.slug}"
|
||||
doc = client.request("/works/#{story.slug}")
|
||||
doc = client.request("/works/#{story.slug}", query: { view_adult: true })
|
||||
|
||||
attrs = extract_story doc
|
||||
|
||||
|
|
@ -90,7 +97,7 @@ module FicTracker::Backends::Ao3
|
|||
story = FicTracker::Models::Story.new(slug: parse_slug(story), backend: self) unless story.is_a? FicTracker::Models::Story
|
||||
|
||||
logger.info "Loading all chapters for #{story.slug}"
|
||||
doc = client.request("/works/#{story.slug}", query: { view_full_work: true })
|
||||
doc = client.request("/works/#{story.slug}", query: { view_full_work: true, view_adult: true })
|
||||
|
||||
attrs = extract_story(doc)
|
||||
chapters = doc.css('#chapters > div.chapter').map { |chapter| extract_chapter(chapter) }
|
||||
|
|
@ -110,13 +117,23 @@ module FicTracker::Backends::Ao3
|
|||
chapter = FicTracker::Models::Chapter.new(slug: parse_slug(chapter), story: story) unless chapter.is_a? FicTracker::Models::Chapter
|
||||
|
||||
logger.info "Loading chapter #{chapter.slug} for #{story.slug}"
|
||||
doc = client.request("/works/#{story.slug}/chapters/#{chapter.slug}")
|
||||
doc = client.request("/works/#{story.slug}/chapters/#{chapter.slug}", query: { view_adult: true })
|
||||
|
||||
attrs = extract_chapter(doc.at_css('#chapters > div.chapter'))
|
||||
|
||||
chapter.set(**attrs)
|
||||
end
|
||||
|
||||
def get_search_info
|
||||
SearchInfo.new(self)
|
||||
end
|
||||
|
||||
def search(search_info)
|
||||
info = SearchInfo.from_search(search_info)
|
||||
|
||||
info
|
||||
end
|
||||
|
||||
def parse_slug(slug)
|
||||
return URI(slug).path.split('/').last if slug.is_a?(String) && slug.start_with?('http')
|
||||
|
||||
|
|
@ -131,9 +148,24 @@ module FicTracker::Backends::Ao3
|
|||
preface = doc.at_css('#workskin .preface')
|
||||
|
||||
name = preface.at_css('.title').text.strip
|
||||
synopsis = preface.at_css('.summary blockquote').children.to_xml
|
||||
synopsis = preface.at_css('.summary blockquote').children.to_xml.strip
|
||||
language = meta.at_css('dd.language')['lang']
|
||||
author = preface.at_css('a[rel="author"]')[:href].split('/')[2]
|
||||
authors = preface.css('a[rel="author"]').map do |aut|
|
||||
slug = aut[:href].split('/')[2]
|
||||
pseud = aut[:href].split('/').last
|
||||
|
||||
aut_name = nil
|
||||
if slug != pseud
|
||||
aut_name = pseud
|
||||
slug = "#{slug}/#{pseud}"
|
||||
end
|
||||
|
||||
{
|
||||
slug: slug,
|
||||
name: aut_name,
|
||||
url: aut[:href],
|
||||
}.compact
|
||||
end
|
||||
|
||||
tags = meta.css('dd.tags').map do |tagblock|
|
||||
category = tagblock[:class].split.first.to_sym
|
||||
|
|
@ -159,7 +191,7 @@ module FicTracker::Backends::Ao3
|
|||
|
||||
{
|
||||
name: name,
|
||||
author: author,
|
||||
authors: authors,
|
||||
synopsis: synopsis,
|
||||
url: url.to_s,
|
||||
language: language,
|
||||
|
|
@ -198,6 +230,7 @@ module FicTracker::Backends::Ao3
|
|||
|
||||
content: html,
|
||||
content_type: 'text/html',
|
||||
etag: Digest::SHA1.hexdigest(html),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ module FicTracker::Backends::Ao3
|
|||
debug_http(resp)
|
||||
case resp
|
||||
when Net::HTTPRedirection
|
||||
req.path.replace resp['location']
|
||||
uri = URI.join(url, resp['location'])
|
||||
uri.query = URI.encode_www_form(query) if query
|
||||
req.path.replace uri.request_uri
|
||||
when Net::HTTPTooManyRequests
|
||||
wait_time = 10
|
||||
if resp['retry-after']
|
||||
|
|
|
|||
34
lib/fic_tracker/backends/ao3/search_info.rb
Normal file
34
lib/fic_tracker/backends/ao3/search_info.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'fic_tracker/models/light/search_info'
|
||||
|
||||
module FicTracker::Backends::Ao3
|
||||
class SearchInfo < FicTracker::Models::Light::SearchInfo
|
||||
# Meta tags
|
||||
tag_category :category, list: [
|
||||
{name: 'Gen'},
|
||||
{}
|
||||
]
|
||||
tag_category :rating, list: [
|
||||
{},
|
||||
{}
|
||||
]
|
||||
|
||||
tag_category :character, freeform: true
|
||||
tag_category :fandom, search: ->(search) { autocomplete_fandom(search) }
|
||||
tag_category :relationship, freeform: true
|
||||
tag_category :freeform, freeform: true
|
||||
|
||||
def initialize(backend)
|
||||
@backend = backend
|
||||
@client = backend.client
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def autocomplete_fandom(name)
|
||||
@client.request("/autocomplete").map do |tag|
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,6 +4,7 @@ require 'psych'
|
|||
|
||||
require_relative 'util/cache'
|
||||
require_relative 'util/database'
|
||||
require_relative 'util/hash_extensions'
|
||||
|
||||
module FicTracker::Config
|
||||
class << self
|
||||
|
|
@ -24,6 +25,7 @@ module FicTracker::Config
|
|||
encoding: dig(:cache, :encoding, default: nil),
|
||||
options: dig(:cache, :options, default: {})
|
||||
)
|
||||
FicTracker.logger.level = dig(:log, :level, default: :info)
|
||||
|
||||
Sequel::Model.db = FicTracker.database
|
||||
end
|
||||
|
|
@ -49,8 +51,10 @@ module FicTracker::Config
|
|||
def load_internal
|
||||
@config_file = ENV['FT_CONFIG_FILE'] if ENV['FT_CONFIG_FILE']
|
||||
begin
|
||||
puts "Loading config #{@config_file}"
|
||||
@config = Psych.load(File.read(@config_file)).deep_transform_keys(&:to_sym)
|
||||
rescue
|
||||
rescue StandardError => e
|
||||
puts "Failed to load config #{@config_file}, #{e.class}: #{e}"
|
||||
@config = {}
|
||||
end
|
||||
@config[:database] ||= {}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Sequel.migration do
|
|||
String :backend_name, null: false
|
||||
index %i[slug backend_name], unique: true
|
||||
|
||||
String :name, null: false
|
||||
String :name, null: true, default: nil
|
||||
|
||||
String :url, null: true, default: nil
|
||||
String :image, null: true, default: nil
|
||||
|
|
@ -30,6 +30,14 @@ Sequel.migration do
|
|||
String :data, null: false, text: true, default: '{}'
|
||||
end
|
||||
|
||||
create_table(:authors_stories) do
|
||||
primary_key :id
|
||||
|
||||
foreign_key :author_id, :authors, on_delete: :cascade, on_update: :cascade
|
||||
foreign_key :story_id, :stories, on_delete: :cascade, on_update: :cascade
|
||||
index %i[author_id story_id], unique: true
|
||||
end
|
||||
|
||||
create_table(:chapters) do
|
||||
primary_key :id
|
||||
|
||||
|
|
@ -76,6 +84,8 @@ Sequel.migration do
|
|||
|
||||
foreign_key :collection_id, :collections, on_delete: :cascade, on_update: :cascade
|
||||
foreign_key :story_id, :stories, on_delete: :cascade, on_update: :cascade
|
||||
index %i[collection_id story_id], unique: true
|
||||
|
||||
Integer :index, null: true, default: nil
|
||||
end
|
||||
|
||||
|
|
@ -85,8 +95,6 @@ Sequel.migration do
|
|||
String :backend_name, null: false
|
||||
index %i[slug backend_name], unique: true
|
||||
|
||||
foreign_key :author_id, :authors, on_delete: :cascade, on_update: :cascade
|
||||
|
||||
String :name, null: false
|
||||
String :synopsis, null: false, text: true
|
||||
String :language, null: true, default: 'en'
|
||||
|
|
|
|||
15
lib/fic_tracker/models.rb
Normal file
15
lib/fic_tracker/models.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module FicTracker::Models
|
||||
def self.const_missing(const)
|
||||
raise 'No database connected' unless FicTracker.database
|
||||
|
||||
model = const.to_s.downcase
|
||||
require_relative "models/#{model}"
|
||||
|
||||
mod = const_get(const) if const_defined? const
|
||||
return mod if mod
|
||||
|
||||
raise "Model not found: #{const}"
|
||||
end
|
||||
end
|
||||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
module FicTracker::Models
|
||||
class Author < Sequel::Model
|
||||
# 1/week
|
||||
METADATA_REFRESH_INTERVAL = 7 * 24 * 60 * 60
|
||||
# 2/month
|
||||
METADATA_REFRESH_INTERVAL = 14 * 24 * 60 * 60
|
||||
|
||||
plugin :serialization, :json, :data
|
||||
|
||||
one_to_many :stories
|
||||
many_to_many :stories
|
||||
|
||||
def before_create
|
||||
backend.load_author(self) if last_metadata_refresh.nil?
|
||||
|
|
@ -15,6 +15,11 @@ module FicTracker::Models
|
|||
super
|
||||
end
|
||||
|
||||
def self.expire
|
||||
# TODO: Improve
|
||||
all.select { |a| a.stories.empty? }.each(&:destroy)
|
||||
end
|
||||
|
||||
def backend
|
||||
return unless backend_name
|
||||
|
||||
|
|
@ -40,9 +45,14 @@ module FicTracker::Models
|
|||
Time.now - (last_metadata_refresh || Time.at(0)) >= METADATA_REFRESH_INTERVAL
|
||||
end
|
||||
|
||||
def self.needing_metadata_refresh
|
||||
where { Sequel.|(last_metadata_refresh.nil?, last_metadata_refresh < date.function(Time.now - METADATA_REFRESH_INTERVAL, 'localtime')) }
|
||||
end
|
||||
|
||||
def to_s
|
||||
name || slug
|
||||
return name if name
|
||||
|
||||
slug
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,23 @@ module FicTracker::Models
|
|||
class Chapter < Sequel::Model
|
||||
# 3/day
|
||||
CONTENT_REFRESH_INTERVAL = 12 * 60 * 60
|
||||
CONTENT_CACHE_TIME = 7 * 24 * 60 * 60
|
||||
CONTENT_CACHE_TIME = 14 * 24 * 60 * 60
|
||||
|
||||
plugin :serialization, :json, :data
|
||||
|
||||
many_to_one :story
|
||||
|
||||
def after_create
|
||||
if @content
|
||||
key = cache_key + [:content]
|
||||
FicTracker.cache.set(key, @content, CONTENT_CACHE_TIME)
|
||||
end
|
||||
if @content_type
|
||||
key = cache_key + [:content_type]
|
||||
FicTracker.cache.set(key, @content_type, CONTENT_CACHE_TIME)
|
||||
end
|
||||
end
|
||||
|
||||
def to_s
|
||||
return "Chapter #{index}" unless name
|
||||
|
||||
|
|
@ -35,9 +46,10 @@ module FicTracker::Models
|
|||
return unless cache_key
|
||||
|
||||
key = cache_key + [:content]
|
||||
refresh_content! unless FicTracker.cache.has?(key)
|
||||
|
||||
@content ||= FicTracker.cache.get(key)
|
||||
refresh_content! unless @content
|
||||
|
||||
@content
|
||||
end
|
||||
|
||||
def content=(content)
|
||||
|
|
@ -46,7 +58,6 @@ module FicTracker::Models
|
|||
FicTracker.cache.set(key, content, CONTENT_CACHE_TIME)
|
||||
end
|
||||
@content = content
|
||||
etag = Digest::SHA1.hexdigest(content)
|
||||
end
|
||||
|
||||
def content_type?
|
||||
|
|
@ -62,9 +73,10 @@ module FicTracker::Models
|
|||
return unless cache_key
|
||||
|
||||
key = cache_key + [:content_type]
|
||||
refresh_content! unless FicTracker.cache.has?(key)
|
||||
|
||||
@content_type ||= FicTracker.cache.get(key)
|
||||
refresh_content! unless @content_type
|
||||
|
||||
@content_type
|
||||
end
|
||||
|
||||
def content_type=(type)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
|
||||
module FicTracker::Models::Light
|
||||
class SearchInfo
|
||||
def initialize(**data)
|
||||
data.each do |k, v|
|
||||
send(:"#{k}=", v) if respond_to?(:"#{k}=")
|
||||
end
|
||||
end
|
||||
|
||||
def with_language(lang)
|
||||
self
|
||||
end
|
||||
|
||||
def with_tag(tag)
|
||||
self
|
||||
end
|
||||
|
|
@ -15,10 +24,56 @@ module FicTracker::Models::Light
|
|||
self
|
||||
end
|
||||
|
||||
def with_cursor(cursor)
|
||||
self
|
||||
end
|
||||
|
||||
def find_tags(category, search = nil)
|
||||
raise ArgumentError, "No such category #{category.inspect}" unless @tags.key? category
|
||||
|
||||
tag_cat = @tags[category]
|
||||
return if tag_cat[:freeform]
|
||||
|
||||
list = begin
|
||||
if tag_cat[:list]
|
||||
return tag_cat[:list] if search.nil?
|
||||
return tag_cat[:list].select do |t|
|
||||
case search
|
||||
when Regex
|
||||
t[:name] =~ search
|
||||
when String
|
||||
t[:name].downcase.include? search.downcase
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
return tag_cat[:search].call(search) if tag_cat[:search]
|
||||
end
|
||||
list.each do |t|
|
||||
t[:id] ||= t[:name]
|
||||
t[:category] ||= category
|
||||
end
|
||||
FicTracker::Models::Light::Tag.load list
|
||||
end
|
||||
|
||||
def to_info_json
|
||||
self.class.to_info_json
|
||||
end
|
||||
|
||||
class << self
|
||||
def from_search(data)
|
||||
|
||||
end
|
||||
|
||||
def to_info_json
|
||||
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def tag_category(category, **params)
|
||||
(@tags ||= {})[category] = params
|
||||
end
|
||||
|
||||
def word_limits(*limits)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ module FicTracker::Models::Light
|
|||
end
|
||||
|
||||
def self.store(data)
|
||||
JSON.generate(data)
|
||||
data = JSON.generate(data)
|
||||
end
|
||||
|
||||
attr_accessor :id, :name, :category
|
||||
|
|
|
|||
|
|
@ -6,31 +6,22 @@ module FicTracker::Models
|
|||
class Story < Sequel::Model
|
||||
# 1/week
|
||||
METADATA_REFRESH_INTERVAL = 7 * 24 * 60 * 60
|
||||
# 3/day
|
||||
CONTENT_REFRESH_INTERVAL = 12 * 60 * 60
|
||||
# 6/day
|
||||
CONTENT_REFRESH_INTERVAL = 4 * 60 * 60
|
||||
# 2 months
|
||||
STORY_EXPIRY = 2 * 30 * 24 * 60 * 60
|
||||
|
||||
many_to_one :author
|
||||
many_to_many :authors
|
||||
one_to_many :chapters, order: :index
|
||||
many_to_many :collection, join_table: :collection_stories
|
||||
|
||||
plugin :serialization, [Light::Tag.method(:store), Light::Tag.method(:load)], :tags
|
||||
plugin :serialization, :json, :data
|
||||
|
||||
# Defer creation of author/chapters until story requiring them is to be saved
|
||||
def before_create
|
||||
if @author
|
||||
@author.save unless @author.id
|
||||
self.author_id = @author.id
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
# Defer creation of authors/chapters until the story requiring them is to be saved
|
||||
def after_create
|
||||
return if [@author, @chapters].all?(&:nil?)
|
||||
|
||||
self.author = @author if @author
|
||||
@author = nil
|
||||
@authors&.each { |author| add_author author }
|
||||
@authors = nil
|
||||
|
||||
if @chapters
|
||||
latest_chapter_at = self.updated_at || self.published_at || Time.at(0)
|
||||
|
|
@ -43,13 +34,21 @@ module FicTracker::Models
|
|||
@chapters = nil
|
||||
end
|
||||
|
||||
def self.expire
|
||||
where { last_accessed < date.function(Time.now - FicTracker::Models::Story::STORY_EXPIRY, 'localtime') }.destroy
|
||||
end
|
||||
|
||||
def completed?
|
||||
completed
|
||||
end
|
||||
|
||||
# Support attaching author to a not-yet-saved story
|
||||
def authors
|
||||
@authors || super
|
||||
end
|
||||
|
||||
def author
|
||||
@author || super
|
||||
authors.first
|
||||
end
|
||||
|
||||
def author=(author_name)
|
||||
|
|
@ -58,12 +57,44 @@ module FicTracker::Models
|
|||
author ||= backend.load_author(author_name) if id
|
||||
author ||= Author.new(backend: backend, slug: author_name)
|
||||
|
||||
if id
|
||||
@author = nil
|
||||
author.save unless author.id
|
||||
super(author)
|
||||
self.authors = [author]
|
||||
end
|
||||
|
||||
def authors=(authors)
|
||||
to_add = []
|
||||
to_remove = self.authors.map(&:id)
|
||||
|
||||
authors.each do |entry|
|
||||
aut = entry if entry.is_a?(FicTracker::Models::Author)
|
||||
|
||||
if aut
|
||||
to_add << aut
|
||||
else
|
||||
@author = author
|
||||
aut = self.authors.find { |c| c.slug == entry[:slug] }
|
||||
|
||||
if aut
|
||||
aut.set(**entry)
|
||||
else
|
||||
entry[:backend_name] = backend.name
|
||||
aut = FicTracker::Models::Author.new(**entry)
|
||||
to_add << aut
|
||||
end
|
||||
end
|
||||
to_remove.delete aut.id
|
||||
end
|
||||
|
||||
if id
|
||||
@authors = nil
|
||||
to_add.each do |entry|
|
||||
logger.debug "Adding author #{entry.inspect} to story #{self}"
|
||||
add_author entry
|
||||
end
|
||||
if to_remove.any?
|
||||
logger.debug "Removing author(s) #{to_remove.inspect} from story #{self}"
|
||||
author_dataset.where(id: to_remove).destroy
|
||||
end
|
||||
else
|
||||
@authors = (@authors || []) + to_add - to_remove
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -99,7 +130,7 @@ module FicTracker::Models
|
|||
end
|
||||
if to_remove.any?
|
||||
logger.debug "Removing chapter(s) #{to_remove.inspect} from story #{self}"
|
||||
chapter_dataset.where(id: to_remove).delete
|
||||
chapter_dataset.where(id: to_remove).destroy
|
||||
end
|
||||
|
||||
update(updated_at: latest_chapter_at) if latest_chapter_at > Time.at(0) && (updated_at.nil? || latest_chapter_at >= updated_at)
|
||||
|
|
@ -109,16 +140,15 @@ module FicTracker::Models
|
|||
end
|
||||
|
||||
def ensure_fully_loaded
|
||||
ensure_chapters
|
||||
refresh_content
|
||||
refresh_metadata
|
||||
|
||||
# FIXME: Should check for a reasonable set of parameters - full load unless XX% (75%?) of chapters have content
|
||||
backend.load_full_story(self) unless chapters && chapters.any? && chapters.all? { |c| c.content? && c.content_type? }
|
||||
end
|
||||
|
||||
def ensure_chapters
|
||||
# FIXME: Should check for a reasonable set of parameters - full load unless XX% (75%?) of chapters have content
|
||||
return if chapters && chapters.any? && chapters.all? { |c| c.content? && c.content_type? }
|
||||
|
||||
backend.load_full_story(self)
|
||||
ensure_fully_loaded
|
||||
end
|
||||
|
||||
def backend
|
||||
|
|
@ -133,7 +163,7 @@ module FicTracker::Models
|
|||
end
|
||||
|
||||
def etag
|
||||
chapters.select { |c| c.etag }.last
|
||||
chapters.select { |c| c.etag }.compact.last&.etag
|
||||
end
|
||||
|
||||
def cache_key
|
||||
|
|
@ -153,25 +183,50 @@ module FicTracker::Models
|
|||
end
|
||||
|
||||
def refresh_content
|
||||
backend.find_chapters(self) if backend && needs_content_refresh?
|
||||
chapters.each(&:refresh_content)
|
||||
refresh_content! if backend && needs_content_refresh?
|
||||
# chapters.each(&:refresh_content)
|
||||
end
|
||||
|
||||
def refresh_content!
|
||||
backend.find_chapters(self)
|
||||
chapters.each(&:refresh_content!)
|
||||
# chapters.each(&:refresh_content!)
|
||||
end
|
||||
|
||||
def needs_metadata_refresh?
|
||||
return true if id && authors.empty?
|
||||
|
||||
Time.now - (last_metadata_refresh || Time.at(0)) >= METADATA_REFRESH_INTERVAL
|
||||
end
|
||||
|
||||
def self.needing_metadata_refresh
|
||||
where { Sequel.|(last_metadata_refresh.nil?, last_metadata_refresh < date.function(Time.now - METADATA_REFRESH_INTERVAL, 'localtime')) }
|
||||
end
|
||||
|
||||
def needs_content_refresh?
|
||||
Time.now - (last_content_refresh || Time.at(0)) >= (completed? ? METADATA_REFRESH_INTERVAL : CONTENT_REFRESH_INTERVAL)
|
||||
end
|
||||
|
||||
def self.needing_content_refresh
|
||||
where { Sequel.|(last_content_refresh.nil?, last_content_refresh < date.function(Time.now - (completed? ? METADATA_REFRESH_INTERVAL : CONTENT_REFRESH_INTERVAL), 'localtime')) }
|
||||
end
|
||||
|
||||
def to_s
|
||||
"#{name}, by #{author.nil? ? '<Unknown>' : author.to_s}"
|
||||
author_names = self.authors.map(&:to_s)
|
||||
if author_names.empty?
|
||||
author_names = '<Unknown>'
|
||||
elsif author_names.size == 1
|
||||
author_names = author_names.first
|
||||
else
|
||||
author_names = author_names.reduce('') do |string, aut|
|
||||
string = string.dup
|
||||
string += 'and ' if aut != author_names.first && aut == author_names.last
|
||||
string += aut
|
||||
string += ', ' unless aut == author_names.last
|
||||
string
|
||||
end
|
||||
end
|
||||
|
||||
"#{name}, by #{author_names}"
|
||||
end
|
||||
|
||||
def uid
|
||||
|
|
|
|||
30
lib/fic_tracker/renderers.rb
Normal file
30
lib/fic_tracker/renderers.rb
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module FicTracker::Renderers
|
||||
autoload :Epub, 'fic_tracker/renderers/epub'
|
||||
autoload :HTML, 'fic_tracker/renderers/html'
|
||||
autoload :Markdown, 'fic_tracker/renderers/markdown'
|
||||
|
||||
def self.render(type, story, **attrs)
|
||||
klass = case type
|
||||
when :Markdown, :markdown, :md
|
||||
Markdown
|
||||
when :HTML, :html
|
||||
HTML
|
||||
when :Epub, :epub
|
||||
Epub
|
||||
end
|
||||
|
||||
stringio = nil
|
||||
unless attrs[:io]
|
||||
require 'stringio'
|
||||
attrs[:io] = StringIO.new
|
||||
stringio = true
|
||||
end
|
||||
|
||||
result = klass.new(story, **attrs).render
|
||||
return result unless stringio
|
||||
|
||||
attrs[:io].string
|
||||
end
|
||||
end
|
||||
|
|
@ -69,6 +69,8 @@ module FicTracker::Renderers
|
|||
end
|
||||
|
||||
def build_content_opf
|
||||
require_relative '../util/time_extensions'
|
||||
|
||||
Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
||||
xml.package version: '2.0', xmlns: 'http://www.idpf.org/2007/opf', 'unique-identifier': 'story-url' do
|
||||
xml.metadata 'xmlns:dc': 'http://purl.org/dc/elements/1.1/', 'xmlns:opf': 'http://www.idpf.org/2007/opf' do
|
||||
|
|
@ -79,12 +81,15 @@ module FicTracker::Renderers
|
|||
require_relative '../converters/from_html'
|
||||
xml['dc'].description FicTracker::Converters::FromHTML.to_plain(@story.synopsis)
|
||||
end
|
||||
xml['dc'].creator story.author&.to_s || '<Unknown>', 'opf:role': 'aut', 'opf:file-as': (story.author&.to_s || 'Unknown')
|
||||
story.authors.each do |aut|
|
||||
xml['dc'].creator aut.to_s, 'opf:role': 'aut', 'opf:file-as': aut.to_s
|
||||
end
|
||||
xml['dc'].creator '<Unknown>', 'opf:role': 'aut', 'opf:file-as': '<Unknown>' if story.authors.empty?
|
||||
xml['dc'].publisher story.backend.full_name
|
||||
xml['dc'].date (story.published_at || Time.now).to_datetime
|
||||
xml['dc'].date (story.published_at || story.updated_at || Time.now).to_datetime
|
||||
xml['dc'].relation story.backend.url
|
||||
xml['dc'].source story.url if story.url
|
||||
story.tags.each do |tag|
|
||||
story.tags&.each do |tag|
|
||||
xml['dc'].subject tag.to_s
|
||||
end
|
||||
xml.meta name: 'cover', content: 'coverImage' if cover
|
||||
|
|
@ -92,6 +97,7 @@ module FicTracker::Renderers
|
|||
xml.meta name: 'ft-backend', content: story.backend_name
|
||||
xml.meta name: 'ft-story', content: story.slug
|
||||
xml.meta name: 'ft-etag', content: story.etag
|
||||
xml.meta name: 'ft-modified', content: (story.updated_at || story.published_at || Time.now).to_header
|
||||
xml.meta name: 'ft-complete', content: story.completed?
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -44,11 +44,16 @@ module FicTracker::Renderers
|
|||
|
||||
html.address do
|
||||
html << "By "
|
||||
if story.author&.url
|
||||
html.a story.author.to_s, href: story.author.url
|
||||
story.authors.each do |author|
|
||||
html << 'and ' if author != story.authors.first && author == story.authors.last
|
||||
if author.url
|
||||
html.a author.to_s, href: author.url
|
||||
else
|
||||
html.i story.author&.to_s || '<Unknown>'
|
||||
html.i author.to_s
|
||||
end
|
||||
html << ', ' unless author == story.authors.last
|
||||
end
|
||||
html.i '<Unknown>' if story.authors.empty?
|
||||
end
|
||||
html.a "On #{story.backend.full_name}.", href: story.url if story.url
|
||||
|
||||
|
|
@ -64,7 +69,7 @@ module FicTracker::Renderers
|
|||
html.br
|
||||
|
||||
html.dl do
|
||||
story.tags.map { |td| FicTracker::Models::Light::Tag.load td }.sort_by(&:ordering).group_by(&:category).each do |category, tags|
|
||||
story.tags&.map { |td| FicTracker::Models::Light::Tag.load td }.sort_by(&:ordering).group_by(&:category).each do |category, tags|
|
||||
html.dt category.to_s.capitalize
|
||||
html.dd do
|
||||
tags.each do |tag|
|
||||
|
|
@ -117,17 +122,23 @@ module FicTracker::Renderers
|
|||
def build_html(html)
|
||||
return yield if body_only
|
||||
|
||||
require_relative '../util/time_extensions'
|
||||
|
||||
html.html(lang: story.language || 'en') do
|
||||
html.head do
|
||||
html.meta charset: 'utf-8'
|
||||
html.meta viewport: 'width=device-width, initial-scale=1'
|
||||
html.meta name: 'author', content: story.author.to_s
|
||||
html.meta name: 'viewport', content: 'width=device-width, initial-scale=1'
|
||||
story.authors.each do |aut|
|
||||
html.meta name: 'author', content: aut.to_s
|
||||
end
|
||||
html.meta name: 'generator', content: "FicTracker/#{FicTracker::VERSION}"
|
||||
html.meta name: 'keywords', content: story.tags.map(&:to_s).join(',')
|
||||
|
||||
html.meta name: 'ft-backend', content: story.backend_name
|
||||
html.meta name: 'ft-story', content: story.slug
|
||||
html.meta name: 'ft-etag', content: story.etag
|
||||
html.meta name: 'ft-modified', content: (story.updated_at || story.published_at || Time.now).to_header
|
||||
html.meta name: 'ft-complete', content: story.completed?
|
||||
|
||||
html.title story.to_s
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ require 'sinatra/base'
|
|||
module FicTracker
|
||||
# Web server for providing your fic tracking needs
|
||||
class Server < Sinatra::Base
|
||||
def initialize(*)
|
||||
@task_runner = Thread.new { background_tasks }
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
configure :development do
|
||||
require 'sinatra/reloader'
|
||||
register Sinatra::Reloader
|
||||
|
|
@ -45,7 +51,13 @@ module FicTracker
|
|||
end
|
||||
|
||||
get '/search/:backend', provides: :json do |_backend_name|
|
||||
backend.get_search_info.to_json
|
||||
search = backend.get_search_info
|
||||
if params['tag'] || params['name']
|
||||
raise "Missing params, must specify both tag and name" unless params['tag'] && params['name']
|
||||
return search.class.find_tags(params['tag'], params['name']).to_json
|
||||
end
|
||||
|
||||
search.to_info_json
|
||||
end
|
||||
|
||||
post '/search/:backend', provides: :html do |_backend_name|
|
||||
|
|
@ -86,6 +98,72 @@ module FicTracker
|
|||
collection&.save_changes
|
||||
end
|
||||
|
||||
head '/story/:backend/*.*' do |_backend_name, slug, format|
|
||||
mime = nil
|
||||
case format
|
||||
when 'epub', :epub
|
||||
format = :epub
|
||||
mime = 'application/epub+zip'
|
||||
when 'html', :html
|
||||
format = :html
|
||||
mime = 'text/html'
|
||||
when 'txt', :txt, 'md', :md
|
||||
format = :markdown
|
||||
mime = 'text/markdown'
|
||||
else
|
||||
halt 400, "Unknown format #{format}"
|
||||
end
|
||||
|
||||
content_type mime
|
||||
attachment "#{story.safe_name}.#{format}"
|
||||
|
||||
story = Models::Story.find(backend_name: backend.name, slug:)
|
||||
if story
|
||||
story.set(last_accessed: Time.now)
|
||||
|
||||
last_modified story.updated_at || story.published_at
|
||||
etag story.etag
|
||||
end
|
||||
ensure
|
||||
story&.save_changes
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
get '/story/:backend/*.*' do |_backend_name, slug, format|
|
||||
mime = nil
|
||||
case format
|
||||
when 'epub', :epub
|
||||
format = :epub
|
||||
mime = 'application/epub+zip'
|
||||
when 'html', :html
|
||||
format = :html
|
||||
mime = 'text/html'
|
||||
when 'txt', :txt, 'md', :md
|
||||
format = :markdown
|
||||
mime = 'text/markdown'
|
||||
else
|
||||
halt 400, "Unknown format #{format}"
|
||||
end
|
||||
|
||||
story = Models::Story.find(backend_name: backend.name, slug:)
|
||||
story ||= Models::Story.new(backend_name: backend.name, slug:)
|
||||
|
||||
story.ensure_fully_loaded unless story.chapters&.any?
|
||||
story.set(last_accessed: Time.now)
|
||||
|
||||
content_type mime
|
||||
attachment "#{story.safe_name}.#{format}"
|
||||
|
||||
last_modified story.updated_at || story.published_at
|
||||
etag story.etag
|
||||
|
||||
story.ensure_fully_loaded
|
||||
FicTracker::Renderers.render(format, story)
|
||||
ensure
|
||||
story&.save_changes
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
|
||||
get '/story/:backend/:slug', provides: :html do |_backend_name, slug|
|
||||
story = Models::Story.find(backend_name: backend.name, slug:)
|
||||
story ||= Models::Story.new(backend_name: backend.name, slug:)
|
||||
|
|
@ -105,8 +183,7 @@ module FicTracker
|
|||
story = Models::Story.find(backend_name: backend.name, slug:)
|
||||
story ||= Models::Story.new(backend_name: backend.name, slug:)
|
||||
|
||||
story.refresh_content
|
||||
story.refresh_metadata
|
||||
story.ensure_fully_loaded
|
||||
story.set(last_accessed: Time.now)
|
||||
|
||||
chapter = story.chapters[index.to_i]
|
||||
|
|
@ -119,44 +196,27 @@ module FicTracker
|
|||
story&.save_changes
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
get '/story/:backend/:slug.:format' do |_backend_name, slug, format|
|
||||
render_klass = nil
|
||||
mime = nil
|
||||
case format
|
||||
when 'epub', :epub
|
||||
render_klass = Renderers::Epub
|
||||
mime = 'application/epub+zip'
|
||||
when 'html', :html
|
||||
render_klass = Renderers::HTML
|
||||
mime = 'text/html'
|
||||
when 'txt', :txt, 'md', :md
|
||||
render_klass = Renderers::Markdown
|
||||
mime = 'text/markdown'
|
||||
else
|
||||
halt 400, "Unknown format #{format}"
|
||||
end
|
||||
private
|
||||
|
||||
story = Models::Story.find(backend_name: backend.name, slug:)
|
||||
story ||= Models::Story.new(backend_name: backend.name, slug:)
|
||||
def background_tasks
|
||||
$stderr.puts "Starting background task loop"
|
||||
loop do
|
||||
FicTracker::Models::Story.expire
|
||||
FicTracker::Models::Author.expire
|
||||
|
||||
story.refresh_content
|
||||
story.refresh_metadata
|
||||
story.set(last_accessed: Time.now)
|
||||
FicTracker::Models::Story.needing_content_refresh.each(&:refresh_content)
|
||||
FicTracker::Models::Story.needing_metadata_refresh.each(&:refresh_metadata)
|
||||
FicTracker::Models::Author.needing_metadata_refresh.each(&:refresh_metadata)
|
||||
|
||||
attachment "#{story.safe_name}.#{format}"
|
||||
content_type mime
|
||||
|
||||
last_modified story.updated_at || story.published_at
|
||||
etag story.etag
|
||||
|
||||
story.ensure_fully_loaded
|
||||
stream do |out|
|
||||
render_klass.new(story, io: out).render
|
||||
end
|
||||
FicTracker.cache.expire
|
||||
rescue StandardError => e
|
||||
FicTracker.logger.error "Failed when running background tasks, #{e.class}: #{e}\n#{e.backtrace[-5,5].join("\n ")}"
|
||||
ensure
|
||||
story&.save_changes
|
||||
iter += 1
|
||||
sleep 30 * 60
|
||||
end
|
||||
rescue StandardError => e
|
||||
$stderr.puts "Fatal background error: #{e.class}: #{e}"
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
end
|
||||
end
|
||||
|
|
|
|||
5
lib/fic_tracker/tasks.rb
Normal file
5
lib/fic_tracker/tasks.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module FicTracker::Tasks
|
||||
autoload :Cleanup, 'fic_tracker/tasks/cleanup'
|
||||
end
|
||||
|
|
@ -176,8 +176,8 @@ module FicTracker::Util
|
|||
if (flags & COMPRESSED_FLAG) == COMPRESSED_FLAG
|
||||
require 'zlib'
|
||||
data = Zlib::Inflate.inflate(data)
|
||||
data.force_encoding('UTF-8')
|
||||
end
|
||||
data.force_encoding('UTF-8')
|
||||
|
||||
case (flags & ENCODING_FLAGS)
|
||||
when ENCODING_MARSHAL
|
||||
|
|
|
|||
4
lib/fic_tracker/util/cache/database.rb
vendored
4
lib/fic_tracker/util/cache/database.rb
vendored
|
|
@ -7,8 +7,8 @@ module FicTracker::Util::CacheImpl
|
|||
|
||||
def initialize(table: 'cache', **redis)
|
||||
@dataset = FicTracker.database[table.to_s.to_sym]
|
||||
@dataset_expired = @dataset.exclude{ Sequel.|({ expire_at: nil }, Sequel::CURRENT_DATE < expire_at) }
|
||||
@dataset_live = @dataset.where{ Sequel.|({ expire_at: nil }, Sequel::CURRENT_DATE < expire_at) }
|
||||
@dataset_expired = @dataset_live.invert
|
||||
end
|
||||
|
||||
def expire
|
||||
|
|
@ -16,7 +16,7 @@ module FicTracker::Util::CacheImpl
|
|||
end
|
||||
|
||||
def has?(key)
|
||||
dataset_live.filter(key: key).any?
|
||||
!dataset_live.filter(key: key).empty?
|
||||
end
|
||||
|
||||
def get(key)
|
||||
|
|
|
|||
7
lib/fic_tracker/util/time_extensions.rb
Normal file
7
lib/fic_tracker/util/time_extensions.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Time
|
||||
def to_header
|
||||
getgm.strftime("%a, %d %b %Y %T GMT")
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue