From 5002859fc82cb05be4ea7c6796ca48d3f1b1da5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sat, 2 May 2026 17:56:14 +0900 Subject: [PATCH 1/3] =?UTF-8?q?YouTube=20=E3=81=AE=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E5=90=8C=E6=9C=9F=20(#314)=20(#340)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #314 #314 #314 #314 #314 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/340 --- backend/app/models/tag.rb | 1 + backend/app/services/youtube/api_client.rb | 73 +++++ backend/app/services/youtube/sync.rb | 168 ++++++++++ backend/app/services/youtube/video_item.rb | 32 ++ backend/config/schedule.rb | 8 + backend/lib/tasks/sync_posts.rake | 6 + .../spec/services/youtube/api_client_spec.rb | 130 ++++++++ backend/spec/services/youtube/sync_spec.rb | 310 ++++++++++++++++++ .../spec/services/youtube/video_item_spec.rb | 93 ++++++ backend/spec/tasks/post_sync_spec.rb | 25 ++ 10 files changed, 846 insertions(+) create mode 100644 backend/app/services/youtube/api_client.rb create mode 100644 backend/app/services/youtube/sync.rb create mode 100644 backend/app/services/youtube/video_item.rb create mode 100644 backend/lib/tasks/sync_posts.rake create mode 100644 backend/spec/services/youtube/api_client_spec.rb create mode 100644 backend/spec/services/youtube/sync_spec.rb create mode 100644 backend/spec/services/youtube/video_item_spec.rb create mode 100644 backend/spec/tasks/post_sync_spec.rb diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 54c3d68..acdc5c7 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -84,6 +84,7 @@ class Tag < ApplicationRecord def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) def self.video = find_or_create_by_tag_name!('動画', category: :meta) def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) + def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta) def self.normalise_tags tag_names, with_tagme: true, with_no_deerjikist: true, diff --git a/backend/app/services/youtube/api_client.rb b/backend/app/services/youtube/api_client.rb new file mode 100644 index 0000000..e38ca57 --- /dev/null +++ b/backend/app/services/youtube/api_client.rb @@ -0,0 +1,73 @@ +require 'json' +require 'net/http' +require 'uri' + + +module Youtube + class ApiClient + ENDPOINT = 'https://www.googleapis.com/youtube/v3' + + def initialize api_key: ENV.fetch('YOUTUBE_API_KEY') + @api_key = api_key + end + + def search_videos q:, published_after: nil, published_before: nil, page_token: nil + get_json('/search', { + part: 'snippet', + type: 'video', + q:, + order: 'date', + maxResults: 50, + regionCode: 'JP', + relevanceLanguage: 'ja', + publishedAfter: published_after&.iso8601, + publishedBefore: published_before&.iso8601, + pageToken: page_token }.compact) + end + + def videos ids + return { 'items' => [] } if ids.empty? + + get_json('/videos', part: 'snippet,status,contentDetails', id: ids.join(',')) + end + + def playlist_items playlist_id:, page_token: nil + get_json('/playlistItems', { + part: 'snippet,contentDetails,status', + playlistId: playlist_id, + maxResults: 50, + pageToken: page_token }.compact) + end + + def channel id: nil, handle: nil + raise ArgumentError, 'id or handle is required' if id.present? == handle.present? + + params = { part: 'snippet,contentDetails' } + params[:id] = id if id.present? + params[:forHandle] = handle if handle.present? + + get_json('/channels', params) + end + + private + + def get_json path, params + uri = URI(ENDPOINT + path) + uri.query = URI.encode_www_form(params.merge(key: @api_key)) + + response = Net::HTTP.start(uri.host, + uri.port, + use_ssl: true, + open_timeout: 10, + read_timeout: 30) do |http| + http.get(uri) + end + + unless response.is_a?(Net::HTTPSuccess) + raise "YouTube API error: #{ response.code } #{ response.body }" + end + + JSON.parse(response.body) + end + end +end diff --git a/backend/app/services/youtube/sync.rb b/backend/app/services/youtube/sync.rb new file mode 100644 index 0000000..2056dc2 --- /dev/null +++ b/backend/app/services/youtube/sync.rb @@ -0,0 +1,168 @@ +require 'open-uri' +require 'set' +require 'time' + + +module Youtube + class Sync + def initialize client: ApiClient.new + @client = client + end + + def sync! + video_ids = discover_video_ids + return if video_ids.empty? + + video_ids.each_slice(50) do |ids| + @client.videos(ids).fetch('items', []).each do |item| + sync_video!(VideoItem.new(item)) + end + end + end + + private + + def discover_video_ids + ids = Set.new + + query_terms.each do |q| + response = @client.search_videos(q:, published_after: sync_since) + + response.fetch('items', []).each do |item| + video_id = item.dig('id', 'videoId') + ids << video_id if video_id.present? + end + end + + playlist_ids.each do |playlist_id| + each_playlist_item(playlist_id) do |item| + video_id = item.dig('contentDetails', 'videoId') + video_id ||= item.dig('snippet', 'resourceId', 'videoId') + + ids << video_id if video_id.present? + end + end + + ids.to_a + end + + def sync_video! video + post = Post.where('url REGEXP ?', youtube_url_regexp(video.id)).first + + original_created_from = video.published_at.change(sec: 0) + original_created_before = original_created_from + 1.minute + + post_created = false + post_changed = false + + if post + post.assign_attributes(title: video.title, + original_created_from:, + original_created_before:, + thumbnail_base: video.thumbnail_url) + + post_changed = post.changed? + post.save! if post_changed + + attach_thumbnail_if_needed!(post, video.thumbnail_url) + else + post_created = true + post = Post.create!( + title: video.title, + url: video.url, + thumbnail_base: video.thumbnail_url, + uploaded_user_id: nil, + original_created_from:, + original_created_before:) + + attach_thumbnail_if_needed!(post, video.thumbnail_url) + + sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.youtube.id, Tag.video.id]) + end + + kept_tag_ids = post.tags.pluck(:id).to_set + desired_tag_ids = kept_tag_ids.to_a + + deerjikist = Deerjikist.find_by(platform: :youtube, code: video.channel_id) + if deerjikist + desired_tag_ids.delete(Tag.no_deerjikist.id) + desired_tag_ids << deerjikist.tag_id + elsif post.tags.where(category: :deerjikist).none? + desired_tag_ids << Tag.no_deerjikist.id + end + + desired_tag_ids.uniq! + + sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids) + + if post_created + PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) + elsif post_changed || kept_tag_ids != desired_tag_ids.to_set + PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil) + PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) + end + end + + def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil + current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set + desired_tag_ids = desired_tag_ids.compact.to_set + + to_add = desired_tag_ids - current_tag_ids + to_remove = current_tag_ids - desired_tag_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 + + def attach_thumbnail_if_needed! post, thumbnail_url + return if post.thumbnail.attached? + return if thumbnail_url.blank? + + post.thumbnail.attach( + io: URI.open(thumbnail_url), + filename: File.basename(URI.parse(thumbnail_url).path), + content_type: 'image/jpeg') + + post.resized_thumbnail! + end + + def youtube_url_regexp id + escaped = Regexp.escape(id) + "(youtube\\.com/watch\\?v=#{ escaped }|youtu\\.be/#{ escaped })([^A-Za-z0-9_-]|$)" + end + + def query_terms = ['ぼざろクリーチャーシリーズ', '伊地知ニジカ', '伊地知虹鹿'] + + def playlist_ids + ['PLrOch4zHkI5vu29b-f9umUQQ4tQkuWLPX', + 'PLrOch4zHkI5vOK0RaytQq6PbucxQkkL0K', + 'PLrOch4zHkI5tdwm9vSegiDQJOM-hgpcOC'] + end + + def sync_since = 14.days.ago + + def each_playlist_item playlist_id + page_token = nil + + loop do + response = @client.playlist_items(playlist_id:, page_token:) + + response.fetch('items', []).each do |item| + yield item + end + + page_token = response['nextPageToken'] + break if page_token.blank? + end + end + end +end diff --git a/backend/app/services/youtube/video_item.rb b/backend/app/services/youtube/video_item.rb new file mode 100644 index 0000000..fea2b15 --- /dev/null +++ b/backend/app/services/youtube/video_item.rb @@ -0,0 +1,32 @@ +require 'time' + + +module Youtube + class VideoItem + attr_reader :id, :title, :channel_id, :published_at, :thumbnail_url, :raw_tags + + def initialize item + snippet = item.fetch('snippet') + + @id = item.fetch('id') + @title = snippet['title'] + @channel_id = snippet['channelId'] + @published_at = Time.iso8601(snippet['publishedAt']) + @thumbnail_url = pick_thumbnail(snippet['thumbnails'] || { }) + @raw_tags = snippet['tags'] || [] + end + + def url = "https://www.youtube.com/watch?v=#{ @id }" + + private + + def pick_thumbnail thumbnails + ['maxres', 'standard', 'high', 'medium', 'default'].each do |key| + url = thumbnails.dig(key, 'url') + return url if url.present? + end + + nil + end + end +end diff --git a/backend/config/schedule.rb b/backend/config/schedule.rb index 23b6c73..1209ab1 100644 --- a/backend/config/schedule.rb +++ b/backend/config/schedule.rb @@ -17,3 +17,11 @@ every 1.day, at: '0:00 am' do rake 'post_similarity:calc', environment: 'production' rake 'tag_similarity:calc', environment: 'production' end + +every 1.day, at: '7:50 am' do + rake 'nico:export', environment: 'production' +end + +every :hour do + rake 'post:sync', environment: 'production' +end diff --git a/backend/lib/tasks/sync_posts.rake b/backend/lib/tasks/sync_posts.rake new file mode 100644 index 0000000..267d474 --- /dev/null +++ b/backend/lib/tasks/sync_posts.rake @@ -0,0 +1,6 @@ +namespace :post do + desc '投稿同期(ニコニコ以外)' + task sync: :environment do + Youtube::Sync.new.sync! + end +end diff --git a/backend/spec/services/youtube/api_client_spec.rb b/backend/spec/services/youtube/api_client_spec.rb new file mode 100644 index 0000000..5fb9298 --- /dev/null +++ b/backend/spec/services/youtube/api_client_spec.rb @@ -0,0 +1,130 @@ +require 'rails_helper' + +RSpec.describe Youtube::ApiClient do + let(:api_key) { 'test-api-key' } + let(:client) { described_class.new(api_key:) } + + describe '#search_videos' do + it 'calls YouTube search API with expected params' do + published_after = Time.zone.parse('2026-05-01 00:00:00') + published_before = Time.zone.parse('2026-05-02 00:00:00') + + expect(client).to receive(:get_json).with( + '/search', + { + part: 'snippet', + type: 'video', + q: 'ぼざろクリーチャー', + order: 'date', + maxResults: 50, + regionCode: 'JP', + relevanceLanguage: 'ja', + publishedAfter: published_after.iso8601, + publishedBefore: published_before.iso8601, + pageToken: 'NEXT' + } + ).and_return({ 'items' => [] }) + + client.search_videos( + q: 'ぼざろクリーチャー', + published_after:, + published_before:, + page_token: 'NEXT' + ) + end + + it 'omits nil optional params' do + expect(client).to receive(:get_json).with( + '/search', + hash_excluding(:publishedAfter, :publishedBefore, :pageToken) + ).and_return({ 'items' => [] }) + + client.search_videos(q: 'ぼざろクリーチャー') + end + end + + describe '#videos' do + it 'returns empty items when ids are empty' do + expect(client).not_to receive(:get_json) + + expect(client.videos([])).to eq({ 'items' => [] }) + end + + it 'calls videos API with comma separated ids' do + expect(client).to receive(:get_json).with( + '/videos', + { + part: 'snippet,status,contentDetails', + id: 'video-1,video-2' + } + ).and_return({ 'items' => [] }) + + client.videos(['video-1', 'video-2']) + end + end + + describe '#playlist_items' do + it 'calls playlistItems API with page token' do + expect(client).to receive(:get_json).with( + '/playlistItems', + { + part: 'snippet,contentDetails,status', + playlistId: 'PL123', + maxResults: 50, + pageToken: 'NEXT' + } + ).and_return({ 'items' => [] }) + + client.playlist_items(playlist_id: 'PL123', page_token: 'NEXT') + end + + it 'omits page token when nil' do + expect(client).to receive(:get_json).with( + '/playlistItems', + { + part: 'snippet,contentDetails,status', + playlistId: 'PL123', + maxResults: 50 + } + ).and_return({ 'items' => [] }) + + client.playlist_items(playlist_id: 'PL123') + end + end + + describe '#channel' do + it 'calls channels API by id' do + expect(client).to receive(:get_json).with( + '/channels', + { + part: 'snippet,contentDetails', + id: 'UC123' + } + ).and_return({ 'items' => [] }) + + client.channel(id: 'UC123') + end + + it 'calls channels API by handle' do + expect(client).to receive(:get_json).with( + '/channels', + { + part: 'snippet,contentDetails', + forHandle: '@some_handle' + } + ).and_return({ 'items' => [] }) + + client.channel(handle: '@some_handle') + end + + it 'raises when neither id nor handle is given' do + expect { client.channel }.to raise_error(ArgumentError, 'id or handle is required') + end + + it 'raises when both id and handle are given' do + expect do + client.channel(id: 'UC123', handle: '@some_handle') + end.to raise_error(ArgumentError, 'id or handle is required') + end + end +end diff --git a/backend/spec/services/youtube/sync_spec.rb b/backend/spec/services/youtube/sync_spec.rb new file mode 100644 index 0000000..df8009a --- /dev/null +++ b/backend/spec/services/youtube/sync_spec.rb @@ -0,0 +1,310 @@ +require 'rails_helper' + +RSpec.describe Youtube::Sync do + let(:client) { instance_double(Youtube::ApiClient) } + let(:sync) { described_class.new(client:) } + + before do + allow(PostVersionRecorder).to receive(:record!) + allow(PostVersionRecorder).to receive(:ensure_snapshot!) + allow(sync).to receive(:attach_thumbnail_if_needed!) + end + + describe '#sync!' do + it 'returns without fetching video details when no video ids are discovered' do + allow(sync).to receive(:query_terms).and_return([]) + allow(sync).to receive(:playlist_ids).and_return([]) + + expect(client).not_to receive(:videos) + + sync.sync! + end + + it 'discovers ids from search and all playlist pages' do + allow(sync).to receive(:query_terms).and_return(['ぼざろクリーチャー']) + allow(sync).to receive(:playlist_ids).and_return(['PL123']) + allow(sync).to receive(:sync_since).and_return(Time.zone.parse('2026-05-01 00:00:00')) + + allow(client).to receive(:search_videos).with( + q: 'ぼざろクリーチャー', + published_after: Time.zone.parse('2026-05-01 00:00:00') + ).and_return({ + 'items' => [ + { + 'id' => { + 'videoId' => 'search-video-1' + } + } + ] + }) + + allow(client).to receive(:playlist_items).with( + playlist_id: 'PL123', + page_token: nil + ).and_return({ + 'items' => [ + { + 'contentDetails' => { + 'videoId' => 'playlist-video-1' + } + } + ], + 'nextPageToken' => 'NEXT' + }) + + allow(client).to receive(:playlist_items).with( + playlist_id: 'PL123', + page_token: 'NEXT' + ).and_return({ + 'items' => [ + { + 'snippet' => { + 'resourceId' => { + 'videoId' => 'playlist-video-2' + } + } + } + ] + }) + + expect(client).to receive(:videos).with( + satisfy do |ids| + ids.sort == ['playlist-video-1', 'playlist-video-2', 'search-video-1'] + end + ).and_return({ 'items' => [] }) + + sync.sync! + end + + it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do + Tag.tagme + Tag.bot + Tag.youtube + Tag.video + Tag.no_deerjikist + + allow(sync).to receive(:query_terms).and_return([]) + allow(sync).to receive(:playlist_ids).and_return(['PL123']) + + allow(client).to receive(:playlist_items).with( + playlist_id: 'PL123', + page_token: nil + ).and_return({ + 'items' => [ + { + 'contentDetails' => { + 'videoId' => 'video-1' + } + } + ] + }) + + allow(client).to receive(:videos).with(['video-1']).and_return({ + 'items' => [ + youtube_video_item( + id: 'video-1', + title: 'YouTube テスト動画', + channel_id: 'UC_NO_MAPPING' + ) + ] + }) + + expect do + sync.sync! + end.to change(Post, :count).by(1) + + post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1') + tag_ids = post.tags.pluck(:id) + + expect(post.title).to eq('YouTube テスト動画') + expect(post.uploaded_user_id).to be_nil + expect(post.original_created_from).to eq(Time.zone.parse('2026-05-01 12:34:00')) + expect(post.original_created_before).to eq(Time.zone.parse('2026-05-01 12:35:00')) + + expect(tag_ids).to include(Tag.tagme.id) + expect(tag_ids).to include(Tag.bot.id) + expect(tag_ids).to include(Tag.youtube.id) + expect(tag_ids).to include(Tag.video.id) + expect(tag_ids).to include(Tag.no_deerjikist.id) + + expect(PostVersionRecorder).to have_received(:record!).with( + post:, + event_type: :create, + created_by_user: nil + ) + end + + it 'uses deerjikist tag when channel id is mapped' do + Tag.tagme + Tag.bot + Tag.youtube + Tag.video + Tag.no_deerjikist + + deerjikist_tag = Tag.find_or_create_by_tag_name!('テスト投稿者', category: :deerjikist) + Deerjikist.create!( + platform: 'youtube', + code: 'UC_MAPPED', + tag: deerjikist_tag + ) + + allow(sync).to receive(:query_terms).and_return([]) + allow(sync).to receive(:playlist_ids).and_return(['PL123']) + + allow(client).to receive(:playlist_items).with( + playlist_id: 'PL123', + page_token: nil + ).and_return({ + 'items' => [ + { + 'contentDetails' => { + 'videoId' => 'video-1' + } + } + ] + }) + + allow(client).to receive(:videos).with(['video-1']).and_return({ + 'items' => [ + youtube_video_item( + id: 'video-1', + title: 'YouTube テスト動画', + channel_id: 'UC_MAPPED' + ) + ] + }) + + sync.sync! + + post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1') + tag_ids = post.tags.pluck(:id) + + expect(tag_ids).to include(deerjikist_tag.id) + expect(tag_ids).not_to include(Tag.no_deerjikist.id) + end + + it 'removes no_deerjikist when deerjikist mapping is added later' do + Tag.no_deerjikist + + post = Post.create!( + title: '旧タイトル', + url: 'https://www.youtube.com/watch?v=video-1', + uploaded_user_id: nil, + original_created_from: Time.zone.parse('2026-05-01 00:00:00'), + original_created_before: Time.zone.parse('2026-05-01 00:01:00') + ) + PostTag.create!(post:, tag: Tag.no_deerjikist) + + deerjikist_tag = Tag.find_or_create_by_tag_name!('後から判明した投稿者', category: :deerjikist) + Deerjikist.create!( + platform: 'youtube', + code: 'UC_MAPPED_LATER', + tag: deerjikist_tag + ) + + allow(sync).to receive(:query_terms).and_return([]) + allow(sync).to receive(:playlist_ids).and_return(['PL123']) + + allow(client).to receive(:playlist_items).with( + playlist_id: 'PL123', + page_token: nil + ).and_return({ + 'items' => [ + { + 'contentDetails' => { + 'videoId' => 'video-1' + } + } + ] + }) + + allow(client).to receive(:videos).with(['video-1']).and_return({ + 'items' => [ + youtube_video_item( + id: 'video-1', + title: '新タイトル', + channel_id: 'UC_MAPPED_LATER' + ) + ] + }) + + sync.sync! + + post.reload + tag_ids = post.tags.pluck(:id) + + expect(post.title).to eq('新タイトル') + expect(tag_ids).to include(deerjikist_tag.id) + expect(tag_ids).not_to include(Tag.no_deerjikist.id) + + expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with( + post, + created_by_user: nil + ) + expect(PostVersionRecorder).to have_received(:record!).with( + post:, + event_type: :update, + created_by_user: nil + ) + end + + it 'matches existing youtu.be URL and does not create duplicate post' do + post = Post.create!( + title: '旧タイトル', + url: 'https://youtu.be/video-1', + uploaded_user_id: nil, + original_created_from: Time.zone.parse('2026-05-01 00:00:00'), + original_created_before: Time.zone.parse('2026-05-01 00:01:00') + ) + + allow(sync).to receive(:query_terms).and_return([]) + allow(sync).to receive(:playlist_ids).and_return(['PL123']) + + allow(client).to receive(:playlist_items).with( + playlist_id: 'PL123', + page_token: nil + ).and_return({ + 'items' => [ + { + 'contentDetails' => { + 'videoId' => 'video-1' + } + } + ] + }) + + allow(client).to receive(:videos).with(['video-1']).and_return({ + 'items' => [ + youtube_video_item( + id: 'video-1', + title: '新タイトル', + channel_id: 'UC_NO_MAPPING' + ) + ] + }) + + expect do + sync.sync! + end.not_to change(Post, :count) + + expect(post.reload.title).to eq('新タイトル') + end + end + + def youtube_video_item(id:, title:, channel_id:) + { + 'id' => id, + 'snippet' => { + 'title' => title, + 'channelId' => channel_id, + 'publishedAt' => '2026-05-01T12:34:56Z', + 'thumbnails' => { + 'high' => { + 'url' => "https://img.youtube.com/#{id}.jpg" + } + }, + 'tags' => ['tag-a', 'tag-b'] + } + } + end +end diff --git a/backend/spec/services/youtube/video_item_spec.rb b/backend/spec/services/youtube/video_item_spec.rb new file mode 100644 index 0000000..4db52da --- /dev/null +++ b/backend/spec/services/youtube/video_item_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +RSpec.describe Youtube::VideoItem do + describe '#initialize' do + it 'extracts fields from YouTube video API item' do + item = { + 'id' => 'video-1', + 'snippet' => { + 'title' => 'テスト動画', + 'channelId' => 'UC123', + 'publishedAt' => '2026-05-01T12:34:56Z', + 'tags' => ['tag-a', 'tag-b'], + 'thumbnails' => { + 'high' => { + 'url' => 'https://img.youtube.com/high.jpg' + }, + 'medium' => { + 'url' => 'https://img.youtube.com/medium.jpg' + } + } + } + } + + video = described_class.new(item) + + expect(video.id).to eq('video-1') + expect(video.title).to eq('テスト動画') + expect(video.channel_id).to eq('UC123') + expect(video.published_at).to eq(Time.iso8601('2026-05-01T12:34:56Z')) + expect(video.thumbnail_url).to eq('https://img.youtube.com/high.jpg') + expect(video.raw_tags).to eq(['tag-a', 'tag-b']) + expect(video.url).to eq('https://www.youtube.com/watch?v=video-1') + end + + it 'uses highest priority thumbnail' do + item = { + 'id' => 'video-1', + 'snippet' => { + 'title' => 'テスト動画', + 'channelId' => 'UC123', + 'publishedAt' => '2026-05-01T12:34:56Z', + 'thumbnails' => { + 'default' => { + 'url' => 'https://img.youtube.com/default.jpg' + }, + 'standard' => { + 'url' => 'https://img.youtube.com/standard.jpg' + }, + 'maxres' => { + 'url' => 'https://img.youtube.com/maxres.jpg' + } + } + } + } + + video = described_class.new(item) + + expect(video.thumbnail_url).to eq('https://img.youtube.com/maxres.jpg') + end + + it 'falls back to empty raw tags' do + item = { + 'id' => 'video-1', + 'snippet' => { + 'title' => 'テスト動画', + 'channelId' => 'UC123', + 'publishedAt' => '2026-05-01T12:34:56Z', + 'thumbnails' => {} + } + } + + video = described_class.new(item) + + expect(video.raw_tags).to eq([]) + end + + it 'returns nil thumbnail when no thumbnail exists' do + item = { + 'id' => 'video-1', + 'snippet' => { + 'title' => 'テスト動画', + 'channelId' => 'UC123', + 'publishedAt' => '2026-05-01T12:34:56Z', + 'thumbnails' => {} + } + } + + video = described_class.new(item) + + expect(video.thumbnail_url).to be_nil + end + end +end diff --git a/backend/spec/tasks/post_sync_spec.rb b/backend/spec/tasks/post_sync_spec.rb new file mode 100644 index 0000000..c9ce486 --- /dev/null +++ b/backend/spec/tasks/post_sync_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' +require 'rake' + +RSpec.describe 'post:sync' do + around do |example| + original_application = Rake.application + Rake.application = Rake::Application.new + + Rake::Task.define_task(:environment) + load Rails.root.join('lib/tasks/sync_posts.rake') + + example.run + ensure + Rake.application = original_application + end + + it 'runs Youtube::Sync' do + sync = instance_double(Youtube::Sync) + + expect(Youtube::Sync).to receive(:new).once.and_return(sync) + expect(sync).to receive(:sync!).once + + Rake::Task['post:sync'].invoke + end +end From dceed1caa10d031963477217ac41dc0e83c6e849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sun, 3 May 2026 03:21:35 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E8=A6=AA=E6=8A=95=E7=A8=BF=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=20(#46)=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge remote-tracking branch 'origin/main' into feature/046 #46 #46 #46 #46 #46 #46 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/339 --- .../app/controllers/nico_tags_controller.rb | 4 +- backend/app/controllers/posts_controller.rb | 67 ++- backend/app/controllers/tags_controller.rb | 6 +- backend/app/models/post.rb | 35 +- backend/app/models/post_implication.rb | 19 + backend/app/models/tag.rb | 6 +- backend/app/representations/post_repr.rb | 3 +- backend/app/services/post_version_recorder.rb | 2 +- ...20260427214800_create_post_implications.rb | 24 + backend/db/schema.rb | 20 +- backend/spec/models/post_implication_spec.rb | 51 ++ backend/spec/models/post_version_spec.rb | 2 +- backend/spec/models/tag_spec.rb | 2 +- backend/spec/requests/posts_spec.rb | 546 +++++++++++++++--- backend/spec/tasks/nico_sync_spec.rb | 2 +- frontend/src/components/PostEditForm.tsx | 47 +- frontend/src/components/PostList.tsx | 7 +- frontend/src/pages/posts/PostDetailPage.tsx | 31 +- frontend/src/pages/posts/PostHistoryPage.tsx | 32 + frontend/src/pages/posts/PostNewPage.tsx | 12 + frontend/src/types.ts | 9 +- 21 files changed, 790 insertions(+), 137 deletions(-) create mode 100644 backend/app/models/post_implication.rb create mode 100644 backend/db/migrate/20260427214800_create_post_implications.rb create mode 100644 backend/spec/models/post_implication_spec.rb diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index f0e33a4..087c592 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -33,8 +33,8 @@ class NicoTagsController < ApplicationController return head :bad_request unless tag.nico? linked_tag_names = params[:tags].to_s.split - linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false, - with_no_deerjikist: false) + linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false, + with_no_deerjikist: false) return head :bad_request if linked_tags.any? { |t| t.nico? } ApplicationRecord.transaction do diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 111052b..363b926 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -109,7 +109,7 @@ class PostsController < ApplicationController render json: PostRepr.base(post, current_user) .merge(tags: build_tag_tree_for(post.tags), - related: post.related(limit: 20)) + related: PostRepr.many(post.related(limit: 20))) end def create @@ -123,28 +123,36 @@ class PostsController < ApplicationController tag_names = params[:tags].to_s.split original_created_from = params[:original_created_from] original_created_before = params[:original_created_before] + parent_post_ids = parse_parent_post_ids post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, original_created_from:, original_created_before:) - post.thumbnail.attach(thumbnail) + post.thumbnail.attach(thumbnail) if thumbnail.present? ApplicationRecord.transaction do post.save! - tags = Tag.normalise_tags(tag_names) + + tags = Tag.normalise_tags!(tag_names) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) + + sync_parent_posts!(post, parent_post_ids) + post.resized_thumbnail! + PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) end post.reload render json: PostRepr.base(post), status: :created - rescue ActiveRecord::RecordInvalid - render json: { errors: post.errors.full_messages }, status: :unprocessable_entity rescue Tag::NicoTagNormalisationError head :bad_request + rescue ArgumentError => e + render json: { errors: [e.message] }, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity end def viewed @@ -169,6 +177,7 @@ class PostsController < ApplicationController tag_names = params[:tags].to_s.split original_created_from = params[:original_created_from] original_created_before = params[:original_created_before] + parent_post_ids = parse_parent_post_ids post = Post.find(params[:id].to_i) @@ -177,12 +186,15 @@ class PostsController < ApplicationController post.update!(title:, original_created_from:, original_created_before:) - normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false) + normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false) TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) tags = post.tags.nico.to_a + normalised_tags tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) + + sync_parent_posts!(post, parent_post_ids) + PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) end @@ -190,10 +202,12 @@ class PostsController < ApplicationController json = post.as_json json['tags'] = build_tag_tree_for(post.tags) render json:, status: :ok - rescue ActiveRecord::RecordInvalid - render json: post.errors, status: :unprocessable_entity rescue Tag::NicoTagNormalisationError head :bad_request + rescue ArgumentError => e + render json: { errors: [e.message] }, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity end def changes @@ -353,4 +367,41 @@ class PostsController < ApplicationController root_ids.filter_map { |id| build_node.call(id, []) } end + + def parse_parent_post_ids + raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids) + + params[:parent_post_ids].to_s.split.map { |token| + id = Integer(token, exception: false) + raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0 + + id + }.uniq + end + + def sync_parent_posts! post, parent_post_ids + if parent_post_ids.include?(post.id) + post.errors.add(:base, '自分自身を親投稿にはできません.') + raise ActiveRecord::RecordInvalid, post + end + + existing_ids = Post.where(id: parent_post_ids).pluck(:id) + missing_ids = parent_post_ids - existing_ids + + if missing_ids.present? + post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }") + raise ActiveRecord::RecordInvalid, post + end + + current_ids = post.parent_posts.pluck(:id) + + ids_to_add = parent_post_ids - current_ids + ids_to_remove = current_ids - parent_post_ids + + PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all + + ids_to_add.each do |parent_post_id| + PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:) + end + end end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 3560e81..9b8bbe9 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -374,9 +374,9 @@ class TagsController < ApplicationController end def update_parent_tags! tag, parent_names - parent_tags = Tag.normalise_tags(parent_names, with_tagme: false, - with_no_deerjikist: false, - deny_nico: true) + parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false, + with_no_deerjikist: false, + deny_nico: true) old_parent_tags = tag.parents.to_a diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 901b1e3..d036fae 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -1,7 +1,6 @@ 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, inverse_of: :post @@ -13,6 +12,20 @@ class Post < ApplicationRecord has_many :post_similarities, dependent: :delete_all has_many :post_versions + has_many :parent_post_implications, + class_name: 'PostImplication', + foreign_key: :post_id, + dependent: :destroy, + inverse_of: :post + has_many :parents, through: :parent_post_implications, source: :parent_post + + has_many :child_post_implications, + class_name: 'PostImplication', + foreign_key: :parent_post_id, + dependent: :destroy, + inverse_of: :parent_post + has_many :children, through: :child_post_implications, source: :post + has_one_attached :thumbnail before_validation :normalise_url @@ -22,17 +35,29 @@ class Post < ApplicationRecord validate :validate_original_created_range validate :url_must_be_http_url + def parent_posts = parents + + def child_posts = children + + def sibling_posts + parent_post_ids = parent_posts.order(:id).pluck(:id) + + parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] } + end + def as_json options = { } - super(options).merge({ thumbnail: thumbnail.attached? ? - Rails.application.routes.url_helpers.rails_blob_url( - thumbnail, only_path: false) : - nil }) + super(options).merge(thumbnail: thumbnail.attached? ? + Rails.application.routes.url_helpers.rails_blob_url( + thumbnail, only_path: false) : + nil) rescue super(options).merge(thumbnail: nil) end def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + def snapshot_parent_post_ids = parents.order(:id).pluck(:id) + def related limit: nil ids = post_similarities.order(cos: :desc) ids = ids.limit(limit) if limit diff --git a/backend/app/models/post_implication.rb b/backend/app/models/post_implication.rb new file mode 100644 index 0000000..21c5a59 --- /dev/null +++ b/backend/app/models/post_implication.rb @@ -0,0 +1,19 @@ +class PostImplication < ApplicationRecord + self.primary_key = :post_id, :parent_post_id + + belongs_to :post, inverse_of: :parent_post_implications + belongs_to :parent_post, class_name: 'Post', inverse_of: :child_post_implications + + validates :post_id, presence: true, uniqueness: { scope: :parent_post_id } + validates :parent_post_id, presence: true + + validate :parent_post_mustnt_be_itself + + private + + def parent_post_mustnt_be_itself + if parent_post_id == post_id + errors.add :parent_post_id, '親投稿に同じ投稿を設定することはできません.' + end + end +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index acdc5c7..a38b048 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -86,9 +86,9 @@ class Tag < ApplicationRecord def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta) - def self.normalise_tags tag_names, with_tagme: true, - with_no_deerjikist: true, - deny_nico: true + def self.normalise_tags! tag_names, with_tagme: true, + with_no_deerjikist: true, + deny_nico: true if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } raise NicoTagNormalisationError end diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index bcba375..87f59f9 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -2,7 +2,8 @@ module PostRepr - BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze + BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE }, + methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze module_function diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb index 515d1d8..9e3fd15 100644 --- a/backend/app/services/post_version_recorder.rb +++ b/backend/app/services/post_version_recorder.rb @@ -24,7 +24,7 @@ class PostVersionRecorder < VersionRecorder url: @record.url, thumbnail_base: @record.thumbnail_base, tags: @record.snapshot_tag_names.join(' '), - parent_id: @record.parent_id, + parent_post_ids: @record.snapshot_parent_post_ids.join(' '), original_created_from: @record.original_created_from, original_created_before: @record.original_created_before } end diff --git a/backend/db/migrate/20260427214800_create_post_implications.rb b/backend/db/migrate/20260427214800_create_post_implications.rb new file mode 100644 index 0000000..b0a666f --- /dev/null +++ b/backend/db/migrate/20260427214800_create_post_implications.rb @@ -0,0 +1,24 @@ +class CreatePostImplications < ActiveRecord::Migration[8.0] + def up + create_table :post_implications, primary_key: [:post_id, :parent_post_id] do |t| + t.references :post, null: false, foreign_key: true, index: false + t.references :parent_post, null: false, foreign_key: { to_table: :posts } + t.timestamps + + t.check_constraint 'post_id <> parent_post_id', + name: 'chk_post_implications_no_self' + end + + add_column :post_versions, :parent_post_ids, :text, null: false, after: :parent_id + remove_column :post_versions, :parent_id, :bigint + remove_reference :posts, :parent, foreign_key: { to_table: :posts } + end + + def down + add_reference :posts, :parent, foreign_key: { to_table: :posts }, after: :thumbnail_base + add_column :post_versions, :parent_id, :bigint, after: :post_id + remove_column :post_versions, :parent_post_ids, :text + + drop_table :post_implications + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 6e1a67f..48ee45f 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do +ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -119,6 +119,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" end + create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "post_id", null: false + t.bigint "parent_post_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" + t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self" + end + create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false @@ -155,13 +164,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do t.string "url", limit: 768, null: false t.string "thumbnail_base", limit: 2000 t.text "tags", null: false - t.bigint "parent_id" + t.text "parent_post_ids", null: false t.datetime "original_created_from" t.datetime "original_created_before" t.datetime "created_at", null: false t.bigint "created_by_user_id" t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" - t.index ["parent_id"], name: "index_post_versions_on_parent_id" t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true t.index ["post_id"], name: "index_post_versions_on_post_id" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" @@ -172,13 +180,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do t.string "title" t.string "url", limit: 768, null: false t.string "thumbnail_base", limit: 2000 - t.bigint "parent_id" t.bigint "uploaded_user_id" t.datetime "created_at", null: false t.datetime "original_created_from" t.datetime "original_created_before" t.datetime "updated_at", null: false - t.index ["parent_id"], name: "index_posts_on_parent_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true end @@ -428,6 +434,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_versions", "tags" add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" + add_foreign_key "post_implications", "posts" + add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_tags", "posts" @@ -435,9 +443,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "deleted_user_id" add_foreign_key "post_versions", "posts" - add_foreign_key "post_versions", "posts", column: "parent_id" add_foreign_key "post_versions", "users", column: "created_by_user_id" - add_foreign_key "posts", "posts", column: "parent_id" add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "settings", "users" add_foreign_key "tag_implications", "tags" diff --git a/backend/spec/models/post_implication_spec.rb b/backend/spec/models/post_implication_spec.rb new file mode 100644 index 0000000..8a11600 --- /dev/null +++ b/backend/spec/models/post_implication_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe PostImplication, type: :model do + let!(:post_record) do + Post.create!( + title: 'post', + url: 'https://example.com/post-implication-post' + ) + end + + let!(:parent_post) do + Post.create!( + title: 'parent post', + url: 'https://example.com/post-implication-parent' + ) + end + + it 'is valid with post and parent_post' do + implication = described_class.new( + post: post_record, + parent_post: + ) + + expect(implication).to be_valid + end + + it 'does not allow same post as parent_post' do + implication = described_class.new( + post: post_record, + parent_post: post_record + ) + + expect(implication).not_to be_valid + expect(implication.errors[:parent_post_id]).to be_present + end + + it 'does not allow duplicate pair' do + described_class.create!( + post: post_record, + parent_post: + ) + + duplicate = described_class.new( + post: post_record, + parent_post: + ) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:post_id]).to be_present + end +end diff --git a/backend/spec/models/post_version_spec.rb b/backend/spec/models/post_version_spec.rb index d35ab4c..8b8548a 100644 --- a/backend/spec/models/post_version_spec.rb +++ b/backend/spec/models/post_version_spec.rb @@ -19,7 +19,7 @@ RSpec.describe PostVersion, type: :model do url: post_record.url, thumbnail_base: post_record.thumbnail_base, tags: post_record.snapshot_tag_names.join(' '), - parent: post_record.parent, + parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), original_created_from: post_record.original_created_from, original_created_before: post_record.original_created_before, created_at: Time.current, diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 1a1714b..a6d14fd 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -161,7 +161,7 @@ RSpec.describe Tag, type: :model do url: post.url, thumbnail_base: post.thumbnail_base, tags: snapshot_tags(post), - parent: post.parent, + parent_post_ids: post.snapshot_parent_post_ids.join(' '), original_created_from: post.original_created_from, original_created_before: post.original_created_before, created_at: Time.current, diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 3c59c9c..8dc0a16 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -15,6 +15,31 @@ RSpec.describe 'Posts API', type: :request do Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg') end + def post_write_params params = { } + { parent_post_ids: '' }.merge(params) + end + + def create_parent_post! title:, url: + Post.create!(title:, url:) + end + + def create_post_version_for! post + PostVersion.create!( + post:, + version_no: 1, + event_type: 'create', + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: post.snapshot_tag_names.join(' '), + parent_post_ids: post.snapshot_parent_post_ids.join(' '), + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: post.created_at, + created_by_user: post.uploaded_user + ) + end + let!(:tag_name) { TagName.create!(name: 'spec_tag') } let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } @@ -457,6 +482,65 @@ RSpec.describe 'Posts API', type: :request do expect(json).to have_key('viewed') expect([true, false]).to include(json['viewed']) end + + context 'when post has parent, child, and sibling posts' do + let!(:parent_post) do + create_parent_post!( + title: 'shared parent post', + url: 'https://example.com/shared-parent-post' + ) + end + + let!(:child_post) do + Post.create!( + title: 'child post', + url: 'https://example.com/show-child-post' + ) + end + + let!(:sibling_post) do + Post.create!( + title: 'sibling post', + url: 'https://example.com/show-sibling-post' + ) + end + + before do + PostImplication.create!( + post: post_record, + parent_post: + ) + + PostImplication.create!( + post: child_post, + parent_post: post_record + ) + + PostImplication.create!( + post: sibling_post, + parent_post: + ) + end + + it 'returns parent_posts, child_posts, and sibling_posts' do + get "/posts/#{post_record.id}" + + expect(response).to have_http_status(:ok) + + parent_ids = json.fetch('parent_posts').map { |p| p.fetch('id') } + child_ids = json.fetch('child_posts').map { |p| p.fetch('id') } + + expect(parent_ids).to include(parent_post.id) + expect(child_ids).to include(child_post.id) + + sibling_posts_by_parent = json.fetch('sibling_posts') + siblings = sibling_posts_by_parent.fetch(parent_post.id.to_s) + + sibling_ids = siblings.map { |p| p.fetch('id') } + expect(sibling_ids).to include(post_record.id) + expect(sibling_ids).to include(sibling_post.id) + end + end end context 'when post does not exist' do @@ -475,25 +559,28 @@ RSpec.describe 'Posts API', type: :request do it '401 when not logged in' do sign_out - post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload } + post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a', + thumbnail: dummy_upload) + expect(response).to have_http_status(:unauthorized) end it '403 when not member' do sign_in_as(create(:user, role: 'guest')) - post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload } + post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a', + thumbnail: dummy_upload) expect(response).to have_http_status(:forbidden) end it '201 and creates post + tags when member' do sign_in_as(member) - post '/posts', params: { + post '/posts', params: post_write_params( title: 'new post', url: 'https://example.com/new', tags: 'spec_tag', # 既存タグ名を投げる thumbnail: dummy_upload - } + ) expect(response).to have_http_status(:created) expect(json).to include('id', 'title', 'url') @@ -507,12 +594,12 @@ RSpec.describe 'Posts API', type: :request do it '201 and creates post + tags when member and tags have aliases' do sign_in_as(member) - post '/posts', params: { + post '/posts', params: post_write_params( title: 'new post', url: 'https://example.com/new', tags: 'manko', # 既存タグ名を投げる thumbnail: dummy_upload - } + ) expect(response).to have_http_status(:created) expect(json).to include('id', 'title', 'url') @@ -533,13 +620,14 @@ RSpec.describe 'Posts API', type: :request do it 'return 400' do sign_in_as(member) - post '/posts', params: { - title: 'new post', - url: 'https://example.com/nico_tag', - tags: 'nico:nico_tag', - thumbnail: dummy_upload } + post '/posts', params: post_write_params( + title: 'new post', + url: 'https://example.com/nico-tag-post', + tags: 'nico:nico_tag', + thumbnail: dummy_upload + ) - expect(response).to have_http_status(:bad_request) + expect(response).to have_http_status(:bad_request), response.body end end @@ -547,11 +635,11 @@ RSpec.describe 'Posts API', type: :request do it 'returns 422' do sign_in_as(member) - post '/posts', params: { + post '/posts', params: post_write_params( title: 'new post', url: ' ', tags: 'spec_tag', # 既存タグ名を投げる - thumbnail: dummy_upload } + thumbnail: dummy_upload) expect(response).to have_http_status(:unprocessable_entity) end @@ -561,14 +649,154 @@ RSpec.describe 'Posts API', type: :request do it 'returns 422' do sign_in_as(member) - post '/posts', params: { - title: 'new post', - url: 'ぼざクリタグ広場', - tags: 'spec_tag', # 既存タグ名を投げる + post '/posts', params: post_write_params( + title: 'new post', + url: 'ぼざクリタグ広場', + tags: 'spec_tag', # 既存タグ名を投げる + thumbnail: dummy_upload) + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when parent_post_ids is provided' do + let!(:parent_post_1) do + create_parent_post!( + title: 'parent post 1', + url: 'https://example.com/parent-post-1' + ) + end + + let!(:parent_post_2) do + create_parent_post!( + title: 'parent post 2', + url: 'https://example.com/parent-post-2' + ) + end + + it 'creates post implications for parent posts' do + sign_in_as(member) + + expect { + post '/posts', params: { + title: 'child post', + url: 'https://example.com/child-post', + tags: 'spec_tag', + parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}", + thumbnail: dummy_upload } + }.to change(PostImplication, :count).by(2) + + expect(response).to have_http_status(:created) + + created_post = Post.find(json.fetch('id')) + expect(created_post.parent_posts.order(:id).pluck(:id)).to eq( + [parent_post_1.id, parent_post_2.id].sort + ) + + expect(PostImplication.exists?( + post_id: created_post.id, + parent_post_id: parent_post_1.id + )).to be(true) + + expect(PostImplication.exists?( + post_id: created_post.id, + parent_post_id: parent_post_2.id + )).to be(true) + end + + it 'deduplicates parent_post_ids' do + sign_in_as(member) + + expect { + post '/posts', params: post_write_params( + title: 'dedup child post', + url: 'https://example.com/dedup-child-post', + tags: 'spec_tag', + parent_post_ids: "#{parent_post_1.id} #{parent_post_1.id}", + thumbnail: dummy_upload + ) + }.to change(PostImplication, :count).by(1) + + expect(response).to have_http_status(:created) + + created_post = Post.find(json.fetch('id')) + expect(created_post.parent_posts.pluck(:id)).to eq([parent_post_1.id]) + end + + it 'records parent_post_ids in post version' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'versioned child post', + url: 'https://example.com/versioned-child-post', + tags: 'spec_tag', + parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}", thumbnail: dummy_upload - } + ) + + expect(response).to have_http_status(:created) + + created_post = Post.find(json.fetch('id')) + version = PostVersion.find_by!(post: created_post, version_no: 1) + + expect(version.parent_post_ids.split.map(&:to_i)).to eq( + [parent_post_1.id, parent_post_2.id].sort + ) + end + end + + context 'when parent_post_ids is missing' do + it 'returns 422' do + sign_in_as(member) + + expect { + post '/posts', params: { + title: 'missing parent_post_ids', + url: 'https://example.com/missing-parent-post-ids', + tags: 'spec_tag', + thumbnail: dummy_upload } + }.not_to change(Post, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to be_present + end + end + + context 'when parent_post_ids includes invalid token' do + it 'returns 422 and does not create post' do + sign_in_as(member) + + expect { + post '/posts', params: post_write_params( + title: 'invalid parent ids', + url: 'https://example.com/invalid-parent-ids', + tags: 'spec_tag', + parent_post_ids: 'abc', + thumbnail: dummy_upload + ) + }.not_to change(Post, :count) expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to be_present + end + end + + context 'when parent_post_ids includes nonexistent post id' do + it 'returns 422 and does not create post implication' do + sign_in_as(member) + + expect { + post '/posts', params: post_write_params( + title: 'missing parent post', + url: 'https://example.com/missing-parent-post', + tags: 'spec_tag', + parent_post_ids: '999999999', + thumbnail: dummy_upload + ) + }.not_to change(PostImplication, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to be_present end end end @@ -578,13 +806,13 @@ RSpec.describe 'Posts API', type: :request do it '401 when not logged in' do sign_out - put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' } + put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag') expect(response).to have_http_status(:unauthorized) end it '403 when not member' do sign_in_as(create(:user, role: 'guest')) - put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' } + put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag') expect(response).to have_http_status(:forbidden) end @@ -595,10 +823,9 @@ RSpec.describe 'Posts API', type: :request do tn2 = TagName.create!(name: 'spec_tag_2') Tag.create!(tag_name: tn2, category: :general) - put "/posts/#{post_record.id}", params: { - title: 'updated title', - tags: 'spec_tag_2' - } + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag_2') expect(response).to have_http_status(:ok) expect(json).to have_key('tags') @@ -619,11 +846,178 @@ RSpec.describe 'Posts API', type: :request do it 'return 400' do sign_in_as(member) - put "/posts/#{ post_record.id }", params: { + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'nico:nico_tag' + ) + + expect(response).to have_http_status(:bad_request), response.body + end + end + + context 'when parent_post_ids is provided' do + let!(:old_parent_post) do + create_parent_post!( + title: 'old parent post', + url: 'https://example.com/old-parent-post' + ) + end + + let!(:new_parent_post_1) do + create_parent_post!( + title: 'new parent post 1', + url: 'https://example.com/new-parent-post-1' + ) + end + + let!(:new_parent_post_2) do + create_parent_post!( + title: 'new parent post 2', + url: 'https://example.com/new-parent-post-2' + ) + end + + before do + PostImplication.create!( + post: post_record, + parent_post: old_parent_post + ) + end + + it 'replaces parent posts' do + sign_in_as(member) + + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag', + parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" + ) + + expect(response).to have_http_status(:ok) + + expect(post_record.reload.parent_posts.order(:id).pluck(:id)).to eq( + [new_parent_post_1.id, new_parent_post_2.id].sort + ) + + expect(PostImplication.exists?( + post_id: post_record.id, + parent_post_id: old_parent_post.id + )).to be(false) + end + + it 'clears parent posts when parent_post_ids is blank' do + sign_in_as(member) + + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag', + parent_post_ids: '' + ) + + expect(response).to have_http_status(:ok) + expect(post_record.reload.parent_posts).to be_empty + end + + it 'records changed parent_post_ids in post version' do + sign_in_as(member) + create_post_version_for!(post_record.reload) + + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag', + parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" + ) + + expect(response).to have_http_status(:ok) + + version = post_record.reload.post_versions.order(:version_no).last + + expect(version.version_no).to eq(2) + expect(version.parent_post_ids.split.map(&:to_i)).to eq( + [new_parent_post_1.id, new_parent_post_2.id].sort + ) + end + end + + context 'when parent_post_ids is missing' do + it 'returns 422' do + sign_in_as(member) + + put "/posts/#{post_record.id}", params: { title: 'updated title', - tags: 'nico:nico_tag' } + tags: 'spec_tag' } - expect(response).to have_http_status(:bad_request) + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to be_present + end + end + + context 'when parent_post_ids includes invalid token' do + it 'returns 422 and does not change parent posts' do + sign_in_as(member) + + parent_post = create_parent_post!( + title: 'valid parent post', + url: 'https://example.com/valid-parent-post' + ) + + PostImplication.create!( + post: post_record, + parent_post: + ) + + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag', + parent_post_ids: 'abc' + ) + + expect(response).to have_http_status(:unprocessable_entity) + expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id]) + end + end + + context 'when parent_post_ids includes nonexistent post id' do + it 'returns 422 and does not change parent posts' do + sign_in_as(member) + + parent_post = create_parent_post!( + title: 'existing parent post', + url: 'https://example.com/existing-parent-post' + ) + + PostImplication.create!( + post: post_record, + parent_post: + ) + + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag', + parent_post_ids: '999999999' + ) + + expect(response).to have_http_status(:unprocessable_entity) + expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id]) + end + end + + context 'when parent_post_ids includes self id' do + it 'returns 422 and does not create self implication' do + sign_in_as(member) + + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag', + parent_post_ids: post_record.id.to_s + ) + + expect(response).to have_http_status(:unprocessable_entity) + + expect(PostImplication.exists?( + post_id: post_record.id, + parent_post_id: post_record.id + )).to be(false) end end end @@ -773,20 +1167,20 @@ RSpec.describe 'Posts API', type: :request do post.snapshot_tag_names.join(' ') end - def create_post_version!(post, version_no:, event_type:, created_by_user:, created_at:) + def create_post_version! post, version_no:, event_type:, created_by_user:, created_at: PostVersion.create!( - post: post, - version_no: version_no, - event_type: event_type, + post:, + version_no:, + event_type:, title: post.title, url: post.url, thumbnail_base: post.thumbnail_base, tags: snapshot_tags(post), - parent: post.parent, + parent_post_ids: post.snapshot_parent_post_ids.join(' '), original_created_from: post.original_created_from, original_created_before: post.original_created_before, - created_at: created_at, - created_by_user: created_by_user + created_at:, + created_by_user: ) end @@ -1015,33 +1409,15 @@ RSpec.describe 'Posts API', type: :request do post.snapshot_tag_names.join(' ') end - def create_post_version_for!(post) - PostVersion.create!( - post: post, - version_no: 1, - event_type: 'create', - title: post.title, - url: post.url, - thumbnail_base: post.thumbnail_base, - tags: snapshot_tags(post), - parent: post.parent, - original_created_from: post.original_created_from, - original_created_before: post.original_created_before, - created_at: post.created_at, - created_by_user: post.uploaded_user - ) - end - it 'creates version 1 on POST /posts' do sign_in_as(member) expect do - post '/posts', params: { - title: 'versioned post', - url: 'https://example.com/versioned-post', - tags: 'spec_tag', - thumbnail: dummy_upload - } + post '/posts', params: post_write_params( + title: 'versioned post', + url: 'https://example.com/versioned-post', + tags: 'spec_tag', + thumbnail: dummy_upload) end.to change(PostVersion, :count).by(1) expect(response).to have_http_status(:created) @@ -1064,10 +1440,9 @@ RSpec.describe 'Posts API', type: :request do Tag.create!(tag_name: tag_name2, category: :general) expect do - put "/posts/#{post_record.id}", params: { - title: 'updated title', - tags: 'spec_tag_2' - } + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag_2') end.to change(PostVersion, :count).by(1) expect(response).to have_http_status(:ok) @@ -1087,10 +1462,9 @@ RSpec.describe 'Posts API', type: :request do create_post_version_for!(post_record.reload) expect { - put "/posts/#{post_record.id}", params: { - title: post_record.title, - tags: 'spec_tag' - } + put "/posts/#{post_record.id}", params: post_write_params( + title: post_record.title, + tags: 'spec_tag') }.not_to change(PostVersion, :count) expect(response).to have_http_status(:ok) @@ -1104,12 +1478,11 @@ RSpec.describe 'Posts API', type: :request do sign_in_as(member) expect do - post '/posts', params: { - title: 'invalid post', - url: 'ぼざクリタグ広場', - tags: 'spec_tag', - thumbnail: dummy_upload - } + post '/posts', params: post_write_params( + title: 'invalid post', + url: 'ぼざクリタグ広場', + tags: 'spec_tag', + thumbnail: dummy_upload) end.not_to change(PostVersion, :count) expect(response).to have_http_status(:unprocessable_entity) @@ -1120,12 +1493,11 @@ RSpec.describe 'Posts API', type: :request do create_post_version_for!(post_record) expect do - put "/posts/#{post_record.id}", params: { - title: 'updated title', - tags: 'spec_tag', - original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, - original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601 - } + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag', + original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, + original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601) end.not_to change(PostVersion, :count) expect(response).to have_http_status(:unprocessable_entity) @@ -1139,12 +1511,11 @@ RSpec.describe 'Posts API', type: :request do sign_in_as(member) expect { - post '/posts', params: { - title: 'tag versioned post', - url: 'https://example.com/tag-versioned-post', - tags: 'spec_tag', - thumbnail: dummy_upload - } + post '/posts', params: post_write_params( + title: 'tag versioned post', + url: 'https://example.com/tag-versioned-post', + tags: 'spec_tag', + thumbnail: dummy_upload) }.to change { tag.reload.tag_versions.count }.by(1) expect(response).to have_http_status(:created) @@ -1164,10 +1535,9 @@ RSpec.describe 'Posts API', type: :request do tag2 = Tag.create!(tag_name: tag_name2, category: :general) expect { - put "/posts/#{post_record.id}", params: { - title: 'updated title', - tags: 'spec_tag_2' - } + put "/posts/#{post_record.id}", params: post_write_params( + title: 'updated title', + tags: 'spec_tag_2') }.to change { tag2.reload.tag_versions.count }.by(1) expect(response).to have_http_status(:ok) diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index 27e63e5..87b27d7 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -104,7 +104,7 @@ RSpec.describe "nico:sync" do url: post.url, thumbnail_base: post.thumbnail_base, tags: snapshot_tags(post), - parent: post.parent, + parent_post_ids: post.snapshot_parent_post_ids.join(' '), original_created_from: post.original_created_from, original_created_before: post.original_created_before, created_at: Time.current, diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 8c3411b..3ac2077 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -4,6 +4,7 @@ import PostFormTagsArea from '@/components/PostFormTagsArea' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import Label from '@/components/common/Label' import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' import { apiPut } from '@/lib/api' import type { FC } from 'react' @@ -35,20 +36,34 @@ export default (({ post, onSave }: Props) => { useState (post.originalCreatedBefore) const [originalCreatedFrom, setOriginalCreatedFrom] = useState (post.originalCreatedFrom) - const [title, setTitle] = useState (post.title) + const [parentPostIds, setParentPostIds] = + useState ((post.parentPosts ?? []).map (p => p.id).join (' ')) const [tags, setTags] = useState ('') + const [title, setTitle] = useState (post.title) const handleSubmit = async () => { - const data = await apiPut ( - `/posts/${ post.id }`, - { title, tags, original_created_from: originalCreatedFrom, - original_created_before: originalCreatedBefore }, - { headers: { 'Content-Type': 'multipart/form-data' } }) - onSave ({ ...post, - title: data.title, - tags: data.tags, - originalCreatedFrom: data.originalCreatedFrom, - originalCreatedBefore: data.originalCreatedBefore } as Post) + try + { + const data = await apiPut ( + `/posts/${ post.id }`, + { title, tags, parent_post_ids: parentPostIds, + original_created_from: originalCreatedFrom, + original_created_before: originalCreatedBefore }, + { headers: { 'Content-Type': 'multipart/form-data' } }) + onSave ({ ...post, + title: data.title, + tags: data.tags, + parentPosts: data.parentPosts, + childPosts: data.childPosts, + siblingPosts: data.siblingPosts, + originalCreatedFrom: data.originalCreatedFrom, + originalCreatedBefore: data.originalCreatedBefore } as Post) + toast ({ description: '更新しました.' }) + } + catch + { + toast ({ description: '更新はできなかったよ……' }) + } } useEffect (() => { @@ -66,6 +81,16 @@ export default (({ post, onSave }: Props) => { onChange={ev => setTitle (ev.target.value)}/> + {/* 親投稿 */} +
+ + setParentPostIds (e.target.value)} + className="w-full border p-2 rounded"/> +
+ {/* タグ */} diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index c072154..21a4410 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -3,6 +3,7 @@ import { useRef } from 'react' import { useLocation } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' +import { cn } from '@/lib/utils' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' import type { FC, MouseEvent } from 'react' @@ -39,8 +40,10 @@ export default (({ posts, onClick }: Props) => { 0 && 'outline-4 outline-green-500', + (post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')} whileHover={{ scale: 1.02 }} onLayoutAnimationStart={() => { if (!(cardRef.current)) diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 50a19d9..5cefce7 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -21,7 +21,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable' import type { FC } from 'react' -import type { NiconicoViewerHandle, User } from '@/types' +import type { NiconicoViewerHandle, Post, User } from '@/types' type Props = { user: User | null } @@ -108,6 +108,34 @@ export default (({ user }: Props) => { {post ? ( <> + {(post.childPosts ?? []).length > 0 && ( +
+

この投稿には {post.childPosts!.length} 件の子投稿があります.

+ ({ + ...p, parentPosts: [{ } as Post] }))]}/> +
+ )} + {(post.parentPosts ?? []).map (pp => { + const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`] + if (!(siblings)) + return + + return ( +
+

+ この投稿には 1 件の親投稿{ + siblings.length > 1 + && `と ${ siblings.length - 1 } 件の姉妹投稿`}があります. +

+ ({ + ...p, parentPosts: [{ } as Post] }))]}/> +
) + })} + {(post.thumbnail || post.thumbnailBase) && ( { (prev: any) => newPost ?? prev) qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) - toast ({ description: '更新しました.' }) }}/> )} diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index fb6b27e..2977527 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -95,6 +95,8 @@ export default (() => { {/* タグ */} + {/* TODO: 親投稿 */} + {/* */} {/* オリジナルの投稿日時 */} {/* 更新日時 */} @@ -110,6 +112,8 @@ export default (() => { タイトル URL タグ + {/* TODO: 親投稿の履歴 */} + {/* 親投稿 */} オリジナルの投稿日時 更新日時 @@ -180,6 +184,29 @@ export default (() => { {tag.name} ))))} + {/* TODO: 親投稿の履歴 */} + {/* + {change.parentPosts.map ((pp, i) => ( + pp.type === 'added' + ? ( + + {pp.title} + ) + : ( + pp.type === 'removed' + ? ( + + {pp.title} + ) + : ( + + {pp.title} + ))))} + */} {change.versionNo === 1 ? originalCreatedAtString (change.originalCreatedFrom.current, @@ -225,6 +252,11 @@ export default (() => { .map (t => t.name) .filter (t => t.slice (0, 5) !== 'nico:') .join (' '), + parent_post_ids: + (change.parentPosts ?? []) + .filter (p => p.type !== 'removed') + .map (p => p.id) + .join (' '), original_created_from: change.originalCreatedFrom.current, original_created_before: diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx index 5a2f77b..a5bcbf3 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -29,6 +29,7 @@ export default (({ user }: Props) => { const [originalCreatedBefore, setOriginalCreatedBefore] = useState (null) const [originalCreatedFrom, setOriginalCreatedFrom] = useState (null) + const [parentPostIds, setParentPostIds] = useState ('') const [tags, setTags] = useState ('') const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) const [thumbnailFile, setThumbnailFile] = useState (null) @@ -46,6 +47,7 @@ export default (({ user }: Props) => { formData.append ('title', title) formData.append ('url', url) formData.append ('tags', tags) + formData.append ('parent_post_ids', parentPostIds) if (thumbnailFile) formData.append ('thumbnail', thumbnailFile) if (originalCreatedFrom) @@ -177,6 +179,16 @@ export default (({ user }: Props) => { className="mt-2 max-h-48 rounded border"/>)} + {/* 親投稿 */} +
+ + setParentPostIds (e.target.value)} + className="w-full border p-2 rounded"/> +
+ {/* タグ */} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d5eb53e..51363c3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -121,6 +121,9 @@ export type Post = { thumbnail: string | null thumbnailBase: string | null tags: Tag[] + parentPosts?: Post[] + childPosts?: Post[] + siblingPosts?: Record<`${ number }`, Post[]> viewed: boolean related: Post[] originalCreatedFrom: string | null @@ -144,7 +147,11 @@ export type PostVersion = { url: { current: string; prev: string | null } thumbnail: { current: string | null; prev: string | null } thumbnailBase: { current: string | null; prev: string | null } - tags: { name: string; type: 'context' | 'added' | 'removed' }[] + tags: { name: string + type: 'context' | 'added' | 'removed' }[] + parentPosts: { id: number + title: string + type: 'context' | 'added' | 'removed' }[] originalCreatedFrom: { current: string | null; prev: string | null } originalCreatedBefore: { current: string | null; prev: string | null } createdAt: string From 52aa1615b6c1a33918798dae770da8a1681faf52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Mon, 4 May 2026 03:37:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E3=83=8B=E3=82=B8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E8=A9=B3=E7=B4=B0=E3=83=9A=E3=83=BC=E3=82=B8=E4=BD=9C=E6=88=90?= =?UTF-8?q?=20(#63)=20(#341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #63 #63 #63 #63 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/341 --- backend/app/controllers/tags_controller.rb | 50 +++- backend/app/models/tag.rb | 2 + backend/app/representations/tag_repr.rb | 2 +- backend/config/routes.rb | 1 + .../spec/requests/tags_deerjikists_spec.rb | 244 ++++++++++++++++-- frontend/src/App.tsx | 2 + frontend/src/components/TagLink.tsx | 46 +++- frontend/src/consts.ts | 7 +- frontend/src/lib/queryKeys.ts | 11 +- frontend/src/lib/tags.ts | 8 +- .../deerjikists/DeerjikistDetailPage.tsx | 155 +++++++++++ frontend/src/types.ts | 8 +- 12 files changed, 494 insertions(+), 42 deletions(-) create mode 100644 frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9b8bbe9..aed029f 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -1,3 +1,7 @@ +require 'net/http' +require 'uri' + + class TagsController < ApplicationController def index post_id = params[:post] @@ -182,7 +186,8 @@ class TagsController < ApplicationController .find_by(id: params[:id]) return head :not_found unless tag - render json: DeerjikistRepr.many(tag.deerjikists) + render json: { tag: TagRepr.base(tag), + deerjikists: DeerjikistRepr.many(tag.deerjikists) } end def deerjikists_by_name @@ -194,7 +199,31 @@ class TagsController < ApplicationController .find_by(tag_names: { name: }) return head :not_found unless tag - render json: DeerjikistRepr.many(tag.deerjikists) + render json: { tag: TagRepr.base(tag), + deerjikists: DeerjikistRepr.many(tag.deerjikists) } + end + + def update_deerjikists + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + tag = Tag.joins(:tag_name) + .includes(:tag_name, tag_name: :wiki_page) + .find_by(id: params[:id]) + return head :not_found unless tag + + ApplicationRecord.transaction do + tag.deerjikists = [] + params[:_json].each do + platform = _1[:platform] + code = normalise_deerjikist_code(platform, _1[:code]) + deerjikist = Deerjikist.find_or_initialize_by(platform:, code:) + deerjikist.tag = tag + deerjikist.save! + end + end + + render json: DeerjikistRepr.many(tag.reload.deerjikists) end def materials_by_name @@ -391,4 +420,21 @@ class TagsController < ApplicationController TagImplication.create!(tag:, parent_tag:) end end + + def normalise_deerjikist_code platform, code + return code if platform != 'youtube' || code[0] != '@' + + url = "https://www.youtube.com/#{ code }" + + html = Net::HTTP.get(URI(url)) + + canonical = html[ + /), + ) + end + + it 'normalises youtube handle to channel id' do + expect { + do_request + }.to change { Deerjikist.where(tag: tag).count }.from(0).to(1) + + expect(response).to have_http_status(:ok) + + expect(Net::HTTP).to have_received(:get) + + expect(Deerjikist.exists?(platform: 'youtube', code: channel_id, tag: tag)) + .to eq(true) + + expect(json).to be_a(Array) + expect(json.size).to eq(1) + expect(json[0]['platform']).to eq('youtube') + expect(json[0]['code']).to eq(channel_id) + end end end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f44d7d..f52209d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialListPage from '@/pages/materials/MaterialListPage' @@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 884c851..a68f8a9 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -45,9 +45,9 @@ export default (({ tag, <> {(linkFlg && withWiki) && ( - {(tag.materialId != null || tag.hasWiki) + {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) ? ( - tag.materialId == null + tag.materialId == null && !(tag.hasDeerjikists) ? ( ) : ( - - ? - )) + tag.materialId != null + ? ( + + ? + ) + : ( + + ? + ))) : ( ['character', 'material'].includes (tag.category) ? ( @@ -71,13 +79,23 @@ export default (({ tag, ! ) : ( - - ! - ))} + tag.category === 'deerjikist' + ? ( + + ! + ) + : ( + + ! + )))} )} {nestLevel > 0 && ( = + { nico: 'ニコニコ', youtube: 'YouTube' } as const + export const TAG_COLOUR = { deerjikist: 'rose', meme: 'purple', diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 97bae56..6ac3f21 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -9,11 +9,12 @@ export const postsKeys = { ['posts', 'changes', p] as const } export const tagsKeys = { - root: ['tags'] as const, - index: (p: FetchTagsParams) => ['tags', 'index', p] as const, - show: (name: string) => ['tags', name] as const, - changes: (p: { id?: string; page: number; limit: number }) => - ['tags', 'changes', p] as const } + root: ['tags'] as const, + index: (p: FetchTagsParams) => ['tags', 'index', p] as const, + show: (name: string) => ['tags', name] as const, + changes: (p: { id?: string; page: number; limit: number }) => + ['tags', 'changes', p] as const, + deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } export const wikiKeys = { root: ['wiki'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index e2c95c3..74eba45 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,6 +1,6 @@ import { apiGet } from '@/lib/api' -import type { FetchTagsParams, Tag, TagVersion } from '@/types' +import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types' export const fetchTags = async ( @@ -56,3 +56,9 @@ export const fetchTagChanges = async ( versions: TagVersion[] count: number }> => await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) + + +export const fetchDeerjikistsByTag = async ( + id: string, +): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> => + await apiGet (`/tags/${ id }/deerjikists`) diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx new file mode 100644 index 0000000..ee3651f --- /dev/null +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx @@ -0,0 +1,155 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' + +import TagLink from '@/components/TagLink' +import Label from '@/components/common/Label' +import PageTitle from '@/components/common/PageTitle' +import MainArea from '@/components/layout/MainArea' +import { toast } from '@/components/ui/use-toast' +import { PLATFORM_NAMES, PLATFORMS } from '@/consts' +import { apiPut } from '@/lib/api' +import { tagsKeys } from '@/lib/queryKeys' +import { fetchDeerjikistsByTag } from '@/lib/tags' +import { cn } from '@/lib/utils' + +import type { FC, FormEvent } from 'react' + +import type { Deerjikist, Platform } from '@/types' + + +export default (() => { + const { id } = useParams () + const tagId = String (id ?? '') + const tagKey = tagsKeys.deerjikists (tagId) + + const { data: qData, isLoading: loading } = + useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) + const tag = qData?.tag + const deerjikists = qData?.deerjikists ?? [] + + const [data, setData] = + useState<(Omit & { platform: Platform | null })[]> ([]) + const [disabled, setDisabled] = useState (true) + + const qc = useQueryClient () + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault () + + try + { + setDisabled (true) + + setData (await apiPut (`/tags/${ id }/deerjikists`, data)) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + + toast ({ description: '更新しました.' }) + } + catch + { + toast ({ title: '更新失敗', description: '入力内容を確認してください.' }) + } + finally + { + setDisabled (false) + } + } + + useEffect (() => { + if (!(tag)) + { + setDisabled (true) + return + } + + setData (deerjikists) + setDisabled (false) + }, [tag, deerjikists]) + + return ( + + {(loading || !(tag)) ? 'Loading...' : ( +
+ + + + +
+ {data.map ((datum, i) => ( +
+ + + + + {/* プラットフォーム */} +
+ + +
+ + {/* コード */} +
+ + setData (prev => { + const rtn = [...prev] + rtn[i] = { ...rtn[i], code: e.target.value } + return rtn + })}/> +
+
+ ))} + +
+ +
+ +
+ +
+
+
+ )} +
) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 51363c3..5fb8078 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,6 @@ import { CATEGORIES, FETCH_POSTS_ORDER_FIELDS, + PLATFORMS, USER_ROLES, ViewFlagBehavior } from '@/consts' @@ -7,6 +8,8 @@ import type { ReactNode } from 'react' export type Category = typeof CATEGORIES[number] +export type Deerjikist = { platform: Platform; code: string } + export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }` export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number] @@ -114,6 +117,8 @@ export type NiconicoViewerHandle = { showComments: () => void hideComments: () => void } +export type Platform = typeof PLATFORMS[number] + export type Post = { id: number url: string @@ -178,7 +183,8 @@ export type Tag = { createdAt: string updatedAt: string hasWiki: boolean - materialId: number + materialId: number | null + hasDeerjikists: boolean children?: Tag[] matchedAlias?: string | null }