| @@ -1,7 +1,6 @@ | |||||
| class PostsController < ApplicationController | class PostsController < ApplicationController | ||||
| Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) | Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) | ||||
| # GET /posts | |||||
| def index | def index | ||||
| page = (params[:page].presence || 1).to_i | page = (params[:page].presence || 1).to_i | ||||
| limit = (params[:limit].presence || 20).to_i | limit = (params[:limit].presence || 20).to_i | ||||
| @@ -18,7 +17,7 @@ class PostsController < ApplicationController | |||||
| 'posts.created_at)' | 'posts.created_at)' | ||||
| q = | q = | ||||
| filtered_posts | filtered_posts | ||||
| .preload(:tags) | |||||
| .preload(tags: :tag_name) | |||||
| .with_attached_thumbnail | .with_attached_thumbnail | ||||
| .select("posts.*, #{ sort_sql } AS sort_ts") | .select("posts.*, #{ sort_sql } AS sort_ts") | ||||
| .order(Arel.sql("#{ sort_sql } DESC")) | .order(Arel.sql("#{ sort_sql } DESC")) | ||||
| @@ -36,7 +35,8 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| render json: { posts: posts.map { |post| | render json: { posts: posts.map { |post| | ||||
| post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap do |json| | |||||
| post.as_json(include: { tags: { only: [:id, :category, :post_count], | |||||
| methods: [:name] } }).tap do |json| | |||||
| json['thumbnail'] = | json['thumbnail'] = | ||||
| if post.thumbnail.attached? | if post.thumbnail.attached? | ||||
| rails_storage_proxy_url(post.thumbnail, only_path: false) | rails_storage_proxy_url(post.thumbnail, only_path: false) | ||||
| @@ -48,19 +48,19 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def random | def random | ||||
| post = filtered_posts.order('RAND()').first | |||||
| post = filtered_posts.preload(tags: :tag_name).order('RAND()').first | |||||
| return head :not_found unless post | return head :not_found unless post | ||||
| viewed = current_user&.viewed?(post) || false | viewed = current_user&.viewed?(post) || false | ||||
| render json: (post | render json: (post | ||||
| .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | |||||
| .as_json(include: { tags: { only: [:id, :category, :post_count], | |||||
| methods: [:name] } }) | |||||
| .merge(viewed:)) | .merge(viewed:)) | ||||
| end | end | ||||
| # GET /posts/1 | |||||
| def show | def show | ||||
| post = Post.includes(:tags).find(params[:id]) | |||||
| post = Post.includes(tags: :tag_name).find(params[:id]) | |||||
| return head :not_found unless post | return head :not_found unless post | ||||
| viewed = current_user&.viewed?(post) || false | viewed = current_user&.viewed?(post) || false | ||||
| @@ -73,7 +73,6 @@ class PostsController < ApplicationController | |||||
| render json: | render json: | ||||
| end | end | ||||
| # POST /posts | |||||
| def create | def create | ||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.member? | return head :forbidden unless current_user.member? | ||||
| @@ -96,7 +95,8 @@ class PostsController < ApplicationController | |||||
| tags = Tag.normalise_tags(tag_names) | tags = Tag.normalise_tags(tag_names) | ||||
| tags = Tag.expand_parent_tags(tags) | tags = Tag.expand_parent_tags(tags) | ||||
| sync_post_tags!(post, tags) | sync_post_tags!(post, tags) | ||||
| render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | |||||
| render json: post.as_json(include: { tags: { only: [:id, :category, :post_count], | |||||
| methods: [:name] } }), | |||||
| status: :created | status: :created | ||||
| else | else | ||||
| render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | ||||
| @@ -117,7 +117,6 @@ class PostsController < ApplicationController | |||||
| head :no_content | head :no_content | ||||
| end | end | ||||
| # PATCH/PUT /posts/1 | |||||
| def update | def update | ||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.member? | return head :forbidden unless current_user.member? | ||||
| @@ -153,7 +152,7 @@ class PostsController < ApplicationController | |||||
| pts = PostTag.with_discarded | pts = PostTag.with_discarded | ||||
| pts = pts.where(post_id: id) if id.present? | pts = pts.where(post_id: id) if id.present? | ||||
| pts = pts.includes(:post, :tag, :created_user, :deleted_user) | |||||
| pts = pts.includes(:post, { tag: :tag_name }, :created_user, :deleted_user) | |||||
| events = [] | events = [] | ||||
| pts.each do |pt| | pts.each do |pt| | ||||
| @@ -192,15 +191,15 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def filter_posts_by_tags tag_names, match_type | def filter_posts_by_tags tag_names, match_type | ||||
| posts = Post.joins(:tags) | |||||
| posts = Post.joins(tags: :tag_name) | |||||
| if match_type == 'any' | if match_type == 'any' | ||||
| posts = posts.where(tags: { name: tag_names }).distinct | |||||
| posts.where(tag_names: { name: tag_names }).distinct | |||||
| else | else | ||||
| tag_names.each do |tag| | |||||
| posts = posts.where(id: Post.joins(:tags).where(tags: { name: tag })) | |||||
| end | |||||
| posts.where(tag_names: { name: tag_names }) | |||||
| .group('posts.id') | |||||
| .having('COUNT(DISTINCT tag_names.id) = ?', tag_names.uniq.size) | |||||
| end | end | ||||
| posts.distinct | |||||
| end | end | ||||
| def sync_post_tags! post, desired_tags | def sync_post_tags! post, desired_tags | ||||
| @@ -251,7 +250,8 @@ class PostsController < ApplicationController | |||||
| return nil unless tag | return nil unless tag | ||||
| if path.include?(tag_id) | if path.include?(tag_id) | ||||
| return tag.as_json(only: [:id, :name, :category, :post_count]).merge(children: []) | |||||
| return tag.as_json(only: [:id, :category, :post_count], | |||||
| methods: [:name]).merge(children: []) | |||||
| end | end | ||||
| if memo.key?(tag_id) | if memo.key?(tag_id) | ||||
| @@ -263,7 +263,8 @@ class PostsController < ApplicationController | |||||
| children = child_ids.filter_map { |cid| build_node.(cid, new_path) } | children = child_ids.filter_map { |cid| build_node.(cid, new_path) } | ||||
| memo[tag_id] = tag.as_json(only: [:id, :name, :category, :post_count]).merge(children:) | |||||
| memo[tag_id] = tag.as_json(only: [:id, :category, :post_count], | |||||
| methods: [:name]).merge(children:) | |||||
| end | end | ||||
| root_ids.filter_map { |id| build_node.call(id, []) } | root_ids.filter_map { |id| build_node.call(id, []) } | ||||
| @@ -1,33 +1,39 @@ | |||||
| class TagsController < ApplicationController | class TagsController < ApplicationController | ||||
| def index | def index | ||||
| post_id = params[:post] | post_id = params[:post] | ||||
| tags = if post_id.present? | |||||
| Tag.joins(:posts).where(posts: { id: post_id }) | |||||
| else | |||||
| Tag.all | |||||
| end | |||||
| render json: tags | |||||
| tags = | |||||
| if post_id.present? | |||||
| Tag.joins(:posts).where(posts: { id: post_id }) | |||||
| else | |||||
| Tag.all | |||||
| end | |||||
| render json: tags.includes(:tag_name) | |||||
| end | end | ||||
| def autocomplete | def autocomplete | ||||
| q = params[:q].to_s.strip | q = params[:q].to_s.strip | ||||
| return render json: [] if q.blank? | return render json: [] if q.blank? | ||||
| tags = (Tag | |||||
| .where('(category = ? AND name LIKE ?) OR name LIKE ?', | |||||
| tags = (Tag.joins(:tag_name).includes(:tag_name) | |||||
| .where('(tags.category = ? AND tag_names.name LIKE ?) OR tag_names.name LIKE ?', | |||||
| 'nico', "nico:#{ q }%", "#{ q }%") | 'nico', "nico:#{ q }%", "#{ q }%") | ||||
| .order('post_count DESC, name ASC') | |||||
| .order(Arel.sql('post_count DESC, tag_names.name ASC')) | |||||
| .limit(20)) | .limit(20)) | ||||
| render json: tags | render json: tags | ||||
| end | end | ||||
| def show | def show | ||||
| tag = Tag.find(params[:id]) | |||||
| tag = Tag.find_by(id: params[:id]) | |||||
| render json: tag | render json: tag | ||||
| end | end | ||||
| def show_by_name | def show_by_name | ||||
| tag = Tag.find_by(name: params[:name]) | |||||
| name = params[:name].to_s.strip | |||||
| return head :bad_request if name.blank? | |||||
| tag = Tag.joins(:tag_name).includes(:tag_name).find_by(tag_names: { name: }) | |||||
| if tag | if tag | ||||
| render json: tag | render json: tag | ||||
| else | else | ||||
| @@ -6,15 +6,19 @@ class WikiPagesController < ApplicationController | |||||
| end | end | ||||
| def show | def show | ||||
| render_wiki_page_or_404 WikiPage.find(params[:id]) | |||||
| render_wiki_page_or_404 WikiPage.includes(:tag_name).find_by(id: params[:id]) | |||||
| end | end | ||||
| def show_by_title | def show_by_title | ||||
| render_wiki_page_or_404 WikiPage.find_by(title: params[:title]) | |||||
| title = params[:title].to_s.strip | |||||
| page = WikiPage.joins(:tag_name) | |||||
| .includes(:tag_name) | |||||
| .find_by(tag_names: { name: title }) | |||||
| render_wiki_page_or_404 page | |||||
| end | end | ||||
| def exists | def exists | ||||
| if WikiPage.exists?(params[:id]) | |||||
| if WikiPage.exists?(id: params[:id]) | |||||
| head :no_content | head :no_content | ||||
| else | else | ||||
| head :not_found | head :not_found | ||||
| @@ -22,7 +26,8 @@ class WikiPagesController < ApplicationController | |||||
| end | end | ||||
| def exists_by_title | def exists_by_title | ||||
| if WikiPage.exists?(title: params[:title]) | |||||
| title = params[:title].to_s.strip | |||||
| if WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) | |||||
| head :no_content | head :no_content | ||||
| else | else | ||||
| head :not_found | head :not_found | ||||
| @@ -115,11 +120,11 @@ class WikiPagesController < ApplicationController | |||||
| end | end | ||||
| def search | def search | ||||
| title = params[:title]&.strip | |||||
| title = params[:title].to_s.strip | |||||
| q = WikiPage.all | |||||
| q = WikiPage.joins(:tag_name).includes(:tag_name) | |||||
| if title.present? | if title.present? | ||||
| q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") | |||||
| q = q.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") | |||||
| end | end | ||||
| render json: q.limit(20) | render json: q.limit(20) | ||||
| @@ -1,6 +1,10 @@ | |||||
| class PostTag < ApplicationRecord | class PostTag < ApplicationRecord | ||||
| include Discard::Model | include Discard::Model | ||||
| before_destroy do | |||||
| raise ActiveRecord::ReadOnlyRecord, '消さないでください.' | |||||
| end | |||||
| belongs_to :post | belongs_to :post | ||||
| belongs_to :tag, counter_cache: :post_count | belongs_to :tag, counter_cache: :post_count | ||||
| belongs_to :created_user, class_name: 'User', optional: true | belongs_to :created_user, class_name: 'User', optional: true | ||||
| @@ -21,6 +21,10 @@ class Tag < ApplicationRecord | |||||
| dependent: :destroy | dependent: :destroy | ||||
| has_many :parents, through: :reversed_tag_implications, source: :parent_tag | has_many :parents, through: :reversed_tag_implications, source: :parent_tag | ||||
| belongs_to :tag_name | |||||
| delegate :name, to: :tag_name | |||||
| validates :tag_name, presence: true | |||||
| enum :category, { deerjikist: 'deerjikist', | enum :category, { deerjikist: 'deerjikist', | ||||
| meme: 'meme', | meme: 'meme', | ||||
| character: 'character', | character: 'character', | ||||
| @@ -29,7 +33,6 @@ class Tag < ApplicationRecord | |||||
| nico: 'nico', | nico: 'nico', | ||||
| meta: 'meta' } | meta: 'meta' } | ||||
| validates :name, presence: true, length: { maximum: 255 } | |||||
| validates :category, presence: true, inclusion: { in: Tag.categories.keys } | validates :category, presence: true, inclusion: { in: Tag.categories.keys } | ||||
| validate :nico_tag_name_must_start_with_nico | validate :nico_tag_name_must_start_with_nico | ||||
| @@ -44,31 +47,31 @@ class Tag < ApplicationRecord | |||||
| 'mtr:' => 'material', | 'mtr:' => 'material', | ||||
| 'meta:' => 'meta' }.freeze | 'meta:' => 'meta' }.freeze | ||||
| def name= val | |||||
| (self.tag_name ||= build_tag_name).name = val | |||||
| end | |||||
| def self.tagme | def self.tagme | ||||
| @tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag| | |||||
| tag.category = 'meta' | |||||
| end | |||||
| @tagme ||= find_or_create_by_tag_name!('タグ希望', category: 'meta') | |||||
| end | end | ||||
| def self.bot | def self.bot | ||||
| @bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag| | |||||
| tag.category = 'meta' | |||||
| end | |||||
| @bot ||= find_or_create_by_tag_name!('bot操作', category: 'meta') | |||||
| end | end | ||||
| def self.normalise_tags tag_names, with_tagme: true | def self.normalise_tags tag_names, with_tagme: true | ||||
| tags = tag_names.map do |name| | tags = tag_names.map do |name| | ||||
| pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] | pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] | ||||
| name.delete_prefix!(pf) | name.delete_prefix!(pf) | ||||
| Tag.find_or_initialize_by(name:).tap do |tag| | |||||
| find_or_create_by_tag_name!(name, category: (cat || 'general')).tap do |tag| | |||||
| if cat && tag.category != cat | if cat && tag.category != cat | ||||
| tag.category = cat | |||||
| tag.save! | |||||
| tag.update!(category: cat) | |||||
| end | end | ||||
| end | end | ||||
| end | end | ||||
| tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) | tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) | ||||
| tags.uniq | |||||
| tags.uniq(&:id) | |||||
| end | end | ||||
| def self.expand_parent_tags tags | def self.expand_parent_tags tags | ||||
| @@ -94,6 +97,13 @@ class Tag < ApplicationRecord | |||||
| (result + tags).uniq { |t| t.id } | (result + tags).uniq { |t| t.id } | ||||
| end | end | ||||
| def self.find_or_create_by_tag_name!(name, category:) | |||||
| tn = TagName.find_or_create_by!(name: name.to_s.strip) | |||||
| Tag.find_or_create_by!(tag_name_id: tn.id) do |t| | |||||
| t.category = category | |||||
| end | |||||
| end | |||||
| private | private | ||||
| def nico_tag_name_must_start_with_nico | def nico_tag_name_must_start_with_nico | ||||
| @@ -0,0 +1,9 @@ | |||||
| class TagName < ApplicationRecord | |||||
| has_one :tag | |||||
| has_one :wiki_page | |||||
| belongs_to :canonical, class_name: 'TagName', optional: true | |||||
| has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id | |||||
| validates :name, presence: true, length: { maximum: 255 }, uniqueness: true | |||||
| end | |||||
| @@ -11,7 +11,16 @@ class WikiPage < ApplicationRecord | |||||
| foreign_key: :redirect_page_id, | foreign_key: :redirect_page_id, | ||||
| dependent: :nullify | dependent: :nullify | ||||
| validates :title, presence: true, length: { maximum: 255 }, uniqueness: true | |||||
| belongs_to :tag_name | |||||
| validates :tag_name, presence: true | |||||
| def title | |||||
| tag_name.name | |||||
| end | |||||
| def title= val | |||||
| (self.tag_name ||= build_tag_name).name = val | |||||
| end | |||||
| def current_revision | def current_revision | ||||
| wiki_revisions.order(id: :desc).first | wiki_revisions.order(id: :desc).first | ||||
| @@ -4,6 +4,7 @@ namespace :nico do | |||||
| require 'open3' | require 'open3' | ||||
| require 'open-uri' | require 'open-uri' | ||||
| require 'nokogiri' | require 'nokogiri' | ||||
| require 'set' | |||||
| fetch_thumbnail = -> url do | fetch_thumbnail = -> url do | ||||
| html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read | html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read | ||||
| @@ -12,9 +13,9 @@ namespace :nico do | |||||
| doc.at('meta[name="thumbnail"]')&.[]('content').presence | doc.at('meta[name="thumbnail"]')&.[]('content').presence | ||||
| end | end | ||||
| def sync_post_tags! post, desired_tag_ids | |||||
| def sync_post_tags! post, desired_tag_ids, current_ids: nil | |||||
| current_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set | |||||
| desired_ids = desired_tag_ids.compact.to_set | desired_ids = desired_tag_ids.compact.to_set | ||||
| current_ids = post.tags.pluck(:id).to_set | |||||
| to_add = desired_ids - current_ids | to_add = desired_ids - current_ids | ||||
| to_remove = current_ids - desired_ids | to_remove = current_ids - desired_ids | ||||
| @@ -43,12 +44,12 @@ namespace :nico do | |||||
| data = JSON.parse(stdout) | data = JSON.parse(stdout) | ||||
| data.each do |datum| | data.each do |datum| | ||||
| post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post| | |||||
| post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)} | |||||
| } | |||||
| code = datum['code'] | |||||
| post = Post.where('url REGEXP ?', "nicovideo\\.jp/watch/#{ Regexp.escape(code) }([^0-9]|$)") | |||||
| .first | |||||
| unless post | unless post | ||||
| title = datum['title'] | title = datum['title'] | ||||
| url = "https://www.nicovideo.jp/watch/#{ datum['code'] }" | |||||
| url = "https://www.nicovideo.jp/watch/#{ code }" | |||||
| thumbnail_base = fetch_thumbnail.(url) rescue nil | thumbnail_base = fetch_thumbnail.(url) rescue nil | ||||
| post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) | post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) | ||||
| if thumbnail_base.present? | if thumbnail_base.present? | ||||
| @@ -62,21 +63,19 @@ namespace :nico do | |||||
| sync_post_tags!(post, [Tag.tagme.id]) | sync_post_tags!(post, [Tag.tagme.id]) | ||||
| end | end | ||||
| kept_tags = post.tags.reload | |||||
| kept_non_nico_ids = kept_tags.where.not(category: 'nico').pluck(:id).to_set | |||||
| kept_ids = PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set | |||||
| kept_non_nico_ids = post.tags.where.not(category: 'nico').pluck(:id).to_set | |||||
| desired_nico_ids = [] | desired_nico_ids = [] | ||||
| desired_non_nico_ids = [] | desired_non_nico_ids = [] | ||||
| datum['tags'].each do |raw| | datum['tags'].each do |raw| | ||||
| name = "nico:#{ raw }" | name = "nico:#{ raw }" | ||||
| tag = Tag.find_or_initialize_by(name:) do |t| | |||||
| t.category = 'nico' | |||||
| end | |||||
| tag.save! if tag.new_record? | |||||
| tag = Tag.find_or_create_by_tag_name!(name, category: 'nico') | |||||
| desired_nico_ids << tag.id | desired_nico_ids << tag.id | ||||
| unless tag.in?(kept_tags) | |||||
| desired_non_nico_ids.concat(tag.linked_tags.pluck(:id)) | |||||
| desired_nico_ids.concat(tag.linked_tags.pluck(:id)) | |||||
| unless tag.id.in?(kept_ids) | |||||
| linked_ids = tag.linked_tags.pluck(:id) | |||||
| desired_non_nico_ids.concat(linked_ids) | |||||
| desired_nico_ids.concat(linked_ids) | |||||
| end | end | ||||
| end | end | ||||
| desired_nico_ids.uniq! | desired_nico_ids.uniq! | ||||
| @@ -89,7 +88,7 @@ namespace :nico do | |||||
| end | end | ||||
| desired_all_ids.uniq! | desired_all_ids.uniq! | ||||
| sync_post_tags!(post, desired_all_ids) | |||||
| sync_post_tags!(post, desired_all_ids, current_ids: kept_ids) | |||||
| end | end | ||||
| end | end | ||||
| end | end | ||||