From 5145db250d92199e6ad3953fdfd4169033102748 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 12 Jan 2026 01:05:43 +0900 Subject: [PATCH] #215 --- backend/app/controllers/posts_controller.rb | 39 ++++++++++--------- backend/app/controllers/tags_controller.rb | 28 +++++++------ .../app/controllers/wiki_pages_controller.rb | 19 +++++---- backend/app/models/post_tag.rb | 4 ++ backend/app/models/tag.rb | 32 +++++++++------ backend/app/models/tag_name.rb | 9 +++++ backend/app/models/wiki_page.rb | 11 +++++- backend/lib/tasks/sync_nico.rake | 31 +++++++-------- 8 files changed, 108 insertions(+), 65 deletions(-) create mode 100644 backend/app/models/tag_name.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 0792bf7..d153e23 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -1,7 +1,6 @@ class PostsController < ApplicationController Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) - # GET /posts def index page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i @@ -18,7 +17,7 @@ class PostsController < ApplicationController 'posts.created_at)' q = filtered_posts - .preload(:tags) + .preload(tags: :tag_name) .with_attached_thumbnail .select("posts.*, #{ sort_sql } AS sort_ts") .order(Arel.sql("#{ sort_sql } DESC")) @@ -36,7 +35,8 @@ class PostsController < ApplicationController end 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'] = if post.thumbnail.attached? rails_storage_proxy_url(post.thumbnail, only_path: false) @@ -48,19 +48,19 @@ class PostsController < ApplicationController end def random - post = filtered_posts.order('RAND()').first + post = filtered_posts.preload(tags: :tag_name).order('RAND()').first return head :not_found unless post viewed = current_user&.viewed?(post) || false 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:)) end - # GET /posts/1 def show - post = Post.includes(:tags).find(params[:id]) + post = Post.includes(tags: :tag_name).find(params[:id]) return head :not_found unless post viewed = current_user&.viewed?(post) || false @@ -73,7 +73,6 @@ class PostsController < ApplicationController render json: end - # POST /posts def create return head :unauthorized unless current_user return head :forbidden unless current_user.member? @@ -96,7 +95,8 @@ class PostsController < ApplicationController tags = Tag.normalise_tags(tag_names) tags = Tag.expand_parent_tags(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 else render json: { errors: post.errors.full_messages }, status: :unprocessable_entity @@ -117,7 +117,6 @@ class PostsController < ApplicationController head :no_content end - # PATCH/PUT /posts/1 def update return head :unauthorized unless current_user return head :forbidden unless current_user.member? @@ -153,7 +152,7 @@ class PostsController < ApplicationController pts = PostTag.with_discarded 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 = [] pts.each do |pt| @@ -192,15 +191,15 @@ class PostsController < ApplicationController end def filter_posts_by_tags tag_names, match_type - posts = Post.joins(:tags) + posts = Post.joins(tags: :tag_name) + if match_type == 'any' - posts = posts.where(tags: { name: tag_names }).distinct + posts.where(tag_names: { name: tag_names }).distinct 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 - posts.distinct end def sync_post_tags! post, desired_tags @@ -251,7 +250,8 @@ class PostsController < ApplicationController return nil unless tag 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 if memo.key?(tag_id) @@ -263,7 +263,8 @@ class PostsController < ApplicationController 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 root_ids.filter_map { |id| build_node.call(id, []) } diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 7c1b250..45fbd34 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -1,33 +1,39 @@ class TagsController < ApplicationController def index 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 def autocomplete q = params[:q].to_s.strip 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 }%") - .order('post_count DESC, name ASC') + .order(Arel.sql('post_count DESC, tag_names.name ASC')) .limit(20)) render json: tags end def show - tag = Tag.find(params[:id]) + tag = Tag.find_by(id: params[:id]) render json: tag end 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 render json: tag else diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index c4dedf0..e4ed5f2 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -6,15 +6,19 @@ class WikiPagesController < ApplicationController end 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 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 def exists - if WikiPage.exists?(params[:id]) + if WikiPage.exists?(id: params[:id]) head :no_content else head :not_found @@ -22,7 +26,8 @@ class WikiPagesController < ApplicationController end 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 else head :not_found @@ -115,11 +120,11 @@ class WikiPagesController < ApplicationController end 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? - q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") + q = q.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") end render json: q.limit(20) diff --git a/backend/app/models/post_tag.rb b/backend/app/models/post_tag.rb index 91a739d..702f66a 100644 --- a/backend/app/models/post_tag.rb +++ b/backend/app/models/post_tag.rb @@ -1,6 +1,10 @@ class PostTag < ApplicationRecord include Discard::Model + before_destroy do + raise ActiveRecord::ReadOnlyRecord, '消さないでください.' + end + belongs_to :post belongs_to :tag, counter_cache: :post_count belongs_to :created_user, class_name: 'User', optional: true diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index d496802..ee47af7 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -21,6 +21,10 @@ class Tag < ApplicationRecord dependent: :destroy 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', meme: 'meme', character: 'character', @@ -29,7 +33,6 @@ class Tag < ApplicationRecord nico: 'nico', meta: 'meta' } - validates :name, presence: true, length: { maximum: 255 } validates :category, presence: true, inclusion: { in: Tag.categories.keys } validate :nico_tag_name_must_start_with_nico @@ -44,31 +47,31 @@ class Tag < ApplicationRecord 'mtr:' => 'material', 'meta:' => 'meta' }.freeze + def name= val + (self.tag_name ||= build_tag_name).name = val + end + 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 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 def self.normalise_tags tag_names, with_tagme: true tags = tag_names.map do |name| pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] 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 - tag.category = cat - tag.save! + tag.update!(category: cat) end end end + tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) - tags.uniq + tags.uniq(&:id) end def self.expand_parent_tags tags @@ -94,6 +97,13 @@ class Tag < ApplicationRecord (result + tags).uniq { |t| t.id } 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 def nico_tag_name_must_start_with_nico diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb new file mode 100644 index 0000000..2efd022 --- /dev/null +++ b/backend/app/models/tag_name.rb @@ -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 diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 256d4df..5434be3 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -11,7 +11,16 @@ class WikiPage < ApplicationRecord foreign_key: :redirect_page_id, 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 wiki_revisions.order(id: :desc).first diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index d09a424..86bd941 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -4,6 +4,7 @@ namespace :nico do require 'open3' require 'open-uri' require 'nokogiri' + require 'set' fetch_thumbnail = -> url do 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 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 - current_ids = post.tags.pluck(:id).to_set to_add = desired_ids - current_ids to_remove = current_ids - desired_ids @@ -43,12 +44,12 @@ namespace :nico do data = JSON.parse(stdout) 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 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 post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) if thumbnail_base.present? @@ -62,21 +63,19 @@ namespace :nico do sync_post_tags!(post, [Tag.tagme.id]) 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_non_nico_ids = [] datum['tags'].each do |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 - 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 desired_nico_ids.uniq! @@ -89,7 +88,7 @@ namespace :nico do end 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