diff --git a/backend/Gemfile b/backend/Gemfile index bb5460b..303b937 100644 --- a/backend/Gemfile +++ b/backend/Gemfile @@ -63,3 +63,5 @@ gem 'diff-lcs' gem 'dotenv-rails' gem 'whenever', require: false + +gem 'discard' diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock index 8494a53..2c08f92 100644 --- a/backend/Gemfile.lock +++ b/backend/Gemfile.lock @@ -90,6 +90,8 @@ GEM crass (1.0.6) date (3.4.1) diff-lcs (1.6.2) + discard (1.4.0) + activerecord (>= 4.2, < 9.0) dotenv (3.1.8) dotenv-rails (3.1.8) dotenv (= 3.1.8) @@ -420,6 +422,7 @@ DEPENDENCIES bootsnap brakeman diff-lcs + discard dotenv-rails gollum image_processing (~> 1.14) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 81a1c0e..96af1c1 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -1,8 +1,6 @@ -require 'open-uri' -require 'nokogiri' - - class PostsController < ApplicationController + Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) + # GET /posts def index limit = params[:limit].presence&.to_i @@ -80,8 +78,9 @@ class PostsController < ApplicationController post.thumbnail.attach(thumbnail) if post.save post.resized_thumbnail! - post.tags = Tag.normalise_tags(tag_names) - post.tags = Tag.expand_parent_tags(post.tags) + 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] } }), status: :created else @@ -114,10 +113,11 @@ class PostsController < ApplicationController original_created_before = params[:original_created_before] post = Post.find(params[:id].to_i) - tags = post.tags.where(category: 'nico').to_a + - Tag.normalise_tags(tag_names, with_tagme: false) - tags = Tag.expand_parent_tags(tags) - if post.update(title:, tags:, original_created_from:, original_created_before:) + if post.update(title:, original_created_from:, original_created_before:) + tags = post.tags.where(category: 'nico').to_a + + Tag.normalise_tags(tag_names, with_tagme: false) + tags = Tag.expand_parent_tags(tags) + sync_post_tags!(post, tags) json = post.as_json json['tags'] = build_tag_tree_for(post.tags) render json:, status: :ok @@ -130,12 +130,54 @@ class PostsController < ApplicationController def destroy end + def changes + id = params[:id] + page = (params[:page].presence || 1).to_i + limit = (params[:limit].presence || 20).to_i + + page = 1 if page < 1 + limit = 1 if limit < 1 + + offset = (page - 1) * limit + + pts = PostTag.with_discarded + pts = pts.where(post_id: id) if id.present? + pts = pts.includes(:post, :tag, :created_user, :deleted_user) + + events = [] + pts.each do |pt| + events << Event.new( + post: pt.post, + tag: pt.tag, + user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name }, + change_type: 'add', + timestamp: pt.created_at) + + if pt.discarded_at + events << Event.new( + post: pt.post, + tag: pt.tag, + user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name }, + change_type: 'remove', + timestamp: pt.discarded_at) + end + end + events.sort_by!(&:timestamp) + events.reverse! + + render json: { changes: events.slice(offset, limit).as_json, count: events.size } + end + private def filtered_posts tag_names = params[:tags]&.split(' ') match_type = params[:match] - tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all + if tag_names.present? + filter_posts_by_tags(tag_names, match_type) + else + Post.all + end end def filter_posts_by_tags tag_names, match_type @@ -150,6 +192,30 @@ class PostsController < ApplicationController posts.distinct end + def sync_post_tags! post, desired_tags + desired_tags.each do |t| + t.save! if t.new_record? + end + + desired_ids = desired_tags.map(&:id).to_set + current_ids = post.tags.pluck(:id).to_set + + to_add = desired_ids - current_ids + to_remove = current_ids - desired_ids + + Tag.where(id: to_add).find_each do |tag| + begin + PostTag.create!(post:, tag:, created_user: current_user) + rescue ActiveRecord::RecordNotUnique + ; + end + end + + PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| + pt.discard_by!(current_user) + end + end + def build_tag_tree_for tags tags = tags.to_a tag_ids = tags.map(&:id) diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index f32f753..6dd565b 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -1,11 +1,13 @@ -require 'mini_magick' - - class Post < ApplicationRecord + require 'mini_magick' + belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id' belongs_to :uploaded_user, class_name: 'User', optional: true - has_many :post_tags, dependent: :destroy - has_many :tags, through: :post_tags + + has_many :post_tags, dependent: :destroy, inverse_of: :post + has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post + has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' + has_many :tags, through: :active_post_tags has_many :user_post_views, dependent: :destroy has_many :post_similarities_as_post, class_name: 'PostSimilarity', diff --git a/backend/app/models/post_tag.rb b/backend/app/models/post_tag.rb index 9dbd756..91a739d 100644 --- a/backend/app/models/post_tag.rb +++ b/backend/app/models/post_tag.rb @@ -1,7 +1,25 @@ class PostTag < ApplicationRecord + include Discard::Model + belongs_to :post belongs_to :tag, counter_cache: :post_count + belongs_to :created_user, class_name: 'User', optional: true + belongs_to :deleted_user, class_name: 'User', optional: true validates :post_id, presence: true validates :tag_id, presence: true + validates :post_id, uniqueness: { + scope: :tag_id, + conditions: -> { where(discarded_at: nil) } } + + def discard_by! deleted_user + return self if discarded? + + transaction do + update!(discarded_at: Time.current, deleted_user:) + Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)') + end + + self + end end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 8a505e2..d496802 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -1,6 +1,8 @@ class Tag < ApplicationRecord - has_many :post_tags, dependent: :destroy - has_many :posts, through: :post_tags + has_many :post_tags, dependent: :delete_all, inverse_of: :tag + has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag + has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' + has_many :posts, through: :active_post_tags has_many :tag_aliases, dependent: :destroy has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy @@ -43,13 +45,13 @@ class Tag < ApplicationRecord 'meta:' => 'meta' }.freeze def self.tagme - @tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| + @tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag| tag.category = 'meta' end end def self.bot - @bot ||= Tag.find_or_initialize_by(name: 'bot操作') do |tag| + @bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag| tag.category = 'meta' end end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 709f1d0..0733991 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -4,6 +4,7 @@ Rails.application.routes.draw do get 'tags/autocomplete', to: 'tags#autocomplete' get 'tags/name/:name', to: 'tags#show_by_name' get 'posts/random', to: 'posts#random' + get 'posts/changes', to: 'posts#changes' post 'posts/:id/viewed', to: 'posts#viewed' delete 'posts/:id/viewed', to: 'posts#unviewed' get 'preview/title', to: 'preview#title' diff --git a/backend/db/migrate/20251011200300_add_discard_to_post_tags.rb b/backend/db/migrate/20251011200300_add_discard_to_post_tags.rb new file mode 100644 index 0000000..f825a37 --- /dev/null +++ b/backend/db/migrate/20251011200300_add_discard_to_post_tags.rb @@ -0,0 +1,36 @@ +class AddDiscardToPostTags < ActiveRecord::Migration[8.0] + def up + execute <<~SQL + DELETE + pt1 + FROM + post_tags pt1 + INNER JOIN + post_tags pt2 + ON + pt1.post_id = pt2.post_id + AND pt1.tag_id = pt2.tag_id + AND pt1.id > pt2.id + ; + SQL + + add_column :post_tags, :discarded_at, :datetime + add_index :post_tags, :discarded_at + + add_column :post_tags, :is_active, :boolean, + as: 'discarded_at IS NULL', stored: true + + add_column :post_tags, :active_unique_key, :string, + as: "CASE WHEN discarded_at IS NULL THEN CONCAT(post_id, ':', tag_id) ELSE NULL END", + stored: true + + add_index :post_tags, :active_unique_key, unique: true, name: 'idx_post_tags_active_unique' + + add_index :post_tags, [:post_id, :discarded_at] + add_index :post_tags, [:tag_id, :discarded_at] + end + + def down + raise ActiveRecord::IrreversibleMigration, '戻せません.' + end +end diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index 9fe28fb..d20150c 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -5,12 +5,32 @@ namespace :nico do require 'open-uri' require 'nokogiri' - fetch_thumbnail = -> url { + fetch_thumbnail = -> url do html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read doc = Nokogiri::HTML(html) doc.at('meta[name="thumbnail"]')&.[]('content').presence - } + end + + def sync_post_tags! post, desired_tag_ids + 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 + + Tag.where(id: to_add.to_a).find_each do |tag| + begin + PostTag.create!(post:, tag:) + rescue ActiveRecord::RecordNotUnique + ; + end + end + + PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| + pt.discard_by!(nil) + end + end mysql_user = ENV['MYSQL_USER'] mysql_pass = ENV['MYSQL_PASS'] @@ -19,43 +39,57 @@ namespace :nico do { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, 'python3', "#{ nizika_nico_path }/get_videos.py") - if status.success? - 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)} - } - unless post - title = datum['title'] - url = "https://www.nicovideo.jp/watch/#{ datum['code'] }" - thumbnail_base = fetch_thumbnail.(url) || '' rescue '' - post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) - if thumbnail_base.present? - post.thumbnail.attach( - io: URI.open(thumbnail_base), - filename: File.basename(URI.parse(thumbnail_base).path), - content_type: 'image/jpeg') - end - post.save! - post.resized_thumbnail! + abort unless status.success? + + 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)} + } + unless post + title = datum['title'] + url = "https://www.nicovideo.jp/watch/#{ datum['code'] }" + thumbnail_base = fetch_thumbnail.(url) || '' rescue '' + post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil) + if thumbnail_base.present? + post.thumbnail.attach( + io: URI.open(thumbnail_base), + filename: File.basename(URI.parse(thumbnail_base).path), + content_type: 'image/jpeg') end + post.save! + post.resized_thumbnail! + 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 - current_tags = post.tags.where(category: 'nico').pluck(:name).sort - new_tags = datum['tags'].map { |tag| "nico:#{ tag }" }.sort - if current_tags != new_tags - post.tags.destroy(post.tags.where(name: current_tags)) - tags_to_add = [] - new_tags.each do |name| - tag = Tag.find_or_initialize_by(name:) do |t| - t.category = 'nico' - end - tags_to_add.concat([tag] + tag.linked_tags) - end - tags_to_add << Tag.tagme if post.tags.size < 10 - tags_to_add << Tag.bot - post.tags = (post.tags + tags_to_add).uniq + 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? + 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)) + end + end + desired_nico_ids.uniq! + + desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids + desired_non_nico_ids.concat(kept_non_nico_ids.to_a) + desired_non_nico_ids.uniq! + if kept_non_nico_ids.to_set != desired_non_nico_ids.to_set + desired_all_ids << Tag.bot.id end + desired_all_ids.uniq! + + sync_post_tags!(post, desired_all_ids) end end end diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b241081..43fbc44 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "camelcase-keys": "^9.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.26", "humps": "^2.0.1", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", @@ -3573,6 +3574,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.26", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", + "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5216,6 +5244,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 747f7ed..cbe44ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "camelcase-keys": "^9.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.26", "humps": "^2.0.1", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", diff --git a/frontend/scripts/generate-sitemap.js b/frontend/scripts/generate-sitemap.js index f30786c..bf6c9fe 100644 --- a/frontend/scripts/generate-sitemap.js +++ b/frontend/scripts/generate-sitemap.js @@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`, { params: { ...(tagName && { tags: tagName, match: 'all', limit: '20' }) } })).data.posts -const fetchPostIds = async () => (await fetchPosts ()).map (post => post.id) const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) @@ -33,7 +32,7 @@ const createPostListOutlet = async tagName => `
| 投稿 | +変更 | +日時 | +
|---|---|---|
|
+
+ |
+
+ |
+
+ {change.user ? (
+
+ {change.user.name}
+ ) : 'bot 操作'}
+ + {change.timestamp} + |
+