Browse Source

Merge remote-tracking branch 'origin/main' into feature/327

feature/327
みてるぞ 2 days ago
parent
commit
5693ead4c4
38 changed files with 2117 additions and 172 deletions
  1. +2
    -2
      backend/app/controllers/nico_tags_controller.rb
  2. +59
    -8
      backend/app/controllers/posts_controller.rb
  3. +51
    -5
      backend/app/controllers/tags_controller.rb
  4. +30
    -5
      backend/app/models/post.rb
  5. +19
    -0
      backend/app/models/post_implication.rb
  6. +6
    -3
      backend/app/models/tag.rb
  7. +2
    -1
      backend/app/representations/post_repr.rb
  8. +1
    -1
      backend/app/representations/tag_repr.rb
  9. +1
    -1
      backend/app/services/post_version_recorder.rb
  10. +73
    -0
      backend/app/services/youtube/api_client.rb
  11. +168
    -0
      backend/app/services/youtube/sync.rb
  12. +32
    -0
      backend/app/services/youtube/video_item.rb
  13. +1
    -0
      backend/config/routes.rb
  14. +8
    -0
      backend/config/schedule.rb
  15. +24
    -0
      backend/db/migrate/20260427214800_create_post_implications.rb
  16. +6
    -0
      backend/lib/tasks/sync_posts.rake
  17. +51
    -0
      backend/spec/models/post_implication_spec.rb
  18. +1
    -1
      backend/spec/models/post_version_spec.rb
  19. +1
    -1
      backend/spec/models/tag_spec.rb
  20. +458
    -88
      backend/spec/requests/posts_spec.rb
  21. +227
    -17
      backend/spec/requests/tags_deerjikists_spec.rb
  22. +130
    -0
      backend/spec/services/youtube/api_client_spec.rb
  23. +310
    -0
      backend/spec/services/youtube/sync_spec.rb
  24. +93
    -0
      backend/spec/services/youtube/video_item_spec.rb
  25. +1
    -1
      backend/spec/tasks/nico_sync_spec.rb
  26. +25
    -0
      backend/spec/tasks/post_sync_spec.rb
  27. +2
    -0
      frontend/src/App.tsx
  28. +36
    -11
      frontend/src/components/PostEditForm.tsx
  29. +5
    -2
      frontend/src/components/PostList.tsx
  30. +32
    -14
      frontend/src/components/TagLink.tsx
  31. +6
    -1
      frontend/src/consts.ts
  32. +6
    -5
      frontend/src/lib/queryKeys.ts
  33. +7
    -1
      frontend/src/lib/tags.ts
  34. +155
    -0
      frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx
  35. +29
    -2
      frontend/src/pages/posts/PostDetailPage.tsx
  36. +32
    -0
      frontend/src/pages/posts/PostHistoryPage.tsx
  37. +12
    -0
      frontend/src/pages/posts/PostNewPage.tsx
  38. +15
    -2
      frontend/src/types.ts

+ 2
- 2
backend/app/controllers/nico_tags_controller.rb View File

@@ -33,8 +33,8 @@ class NicoTagsController < ApplicationController
return head :bad_request unless tag.nico? return head :bad_request unless tag.nico?


linked_tag_names = params[:tags].to_s.split 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? } return head :bad_request if linked_tags.any? { |t| t.nico? }


ApplicationRecord.transaction do ApplicationRecord.transaction do


+ 59
- 8
backend/app/controllers/posts_controller.rb View File

@@ -109,7 +109,7 @@ class PostsController < ApplicationController


render json: PostRepr.base(post, current_user) render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post.tags), .merge(tags: build_tag_tree_for(post.tags),
related: post.related(limit: 20))
related: PostRepr.many(post.related(limit: 20)))
end end


def create def create
@@ -123,28 +123,36 @@ class PostsController < ApplicationController
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] 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, post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:) original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail)
post.thumbnail.attach(thumbnail) if thumbnail.present?


ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
tags = Tag.normalise_tags(tag_names)

tags = Tag.normalise_tags!(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)


tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)

sync_parent_posts!(post, parent_post_ids)

post.resized_thumbnail! post.resized_thumbnail!

PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end end


post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
rescue ActiveRecord::RecordInvalid
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request 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 end


def viewed def viewed
@@ -169,6 +177,7 @@ class PostsController < ApplicationController
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids


post = Post.find(params[:id].to_i) post = Post.find(params[:id].to_i)


@@ -177,12 +186,15 @@ class PostsController < ApplicationController


post.update!(title:, original_created_from:, original_created_before:) 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) TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)


tags = post.tags.nico.to_a + normalised_tags tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)

sync_parent_posts!(post, parent_post_ids)

PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end end


@@ -190,10 +202,12 @@ class PostsController < ApplicationController
json = post.as_json json = post.as_json
json['tags'] = build_tag_tree_for(post.tags) json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok render json:, status: :ok
rescue ActiveRecord::RecordInvalid
render json: post.errors, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request 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 end


def changes def changes
@@ -353,4 +367,41 @@ class PostsController < ApplicationController


root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
end 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 end

+ 51
- 5
backend/app/controllers/tags_controller.rb View File

@@ -1,3 +1,7 @@
require 'net/http'
require 'uri'


class TagsController < ApplicationController class TagsController < ApplicationController
def index def index
post_id = params[:post] post_id = params[:post]
@@ -182,7 +186,8 @@ class TagsController < ApplicationController
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless tag return head :not_found unless tag


render json: DeerjikistRepr.many(tag.deerjikists)
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end end


def deerjikists_by_name def deerjikists_by_name
@@ -194,7 +199,31 @@ class TagsController < ApplicationController
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
return head :not_found unless tag 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 end


def materials_by_name def materials_by_name
@@ -374,9 +403,9 @@ class TagsController < ApplicationController
end end


def update_parent_tags! tag, parent_names 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 old_parent_tags = tag.parents.to_a


@@ -391,4 +420,21 @@ class TagsController < ApplicationController
TagImplication.create!(tag:, parent_tag:) TagImplication.create!(tag:, parent_tag:)
end end
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[
/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})"/,
1]
return canonical if canonical

html[/"channelId":"(UC[a-zA-Z0-9_-]{22})"/, 1] || html[/\bUC[a-zA-Z0-9_-]{22}\b/]
rescue
nil
end
end end

+ 30
- 5
backend/app/models/post.rb View File

@@ -1,7 +1,6 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'mini_magick' require 'mini_magick'


belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true


has_many :post_tags, dependent: :destroy, inverse_of: :post 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_similarities, dependent: :delete_all
has_many :post_versions 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 has_one_attached :thumbnail


before_validation :normalise_url before_validation :normalise_url
@@ -22,17 +35,29 @@ class Post < ApplicationRecord
validate :validate_original_created_range validate :validate_original_created_range
validate :url_must_be_http_url 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 = { } 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 rescue
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end


def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') 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 def related limit: nil
ids = post_similarities.order(cos: :desc) ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit ids = ids.limit(limit) if limit


+ 19
- 0
backend/app/models/post_implication.rb View File

@@ -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

+ 6
- 3
backend/app/models/tag.rb View File

@@ -79,15 +79,18 @@ class Tag < ApplicationRecord


def material_id = materials.first&.id def material_id = materials.first&.id


def has_deerjikists = deerjikists.present?

def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) 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.video = find_or_create_by_tag_name!('動画', category: :meta)
def self.niconico = 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,
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:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end


+ 2
- 1
backend/app/representations/post_repr.rb View File

@@ -2,7 +2,8 @@




module PostRepr 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 module_function




+ 1
- 1
backend/app/representations/tag_repr.rb View File

@@ -3,7 +3,7 @@


module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki, :material_id] }.freeze
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze


module_function module_function




+ 1
- 1
backend/app/services/post_version_recorder.rb View File

@@ -24,7 +24,7 @@ class PostVersionRecorder < VersionRecorder
url: @record.url, url: @record.url,
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
tags: @record.snapshot_tag_names.join(' '), 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_from: @record.original_created_from,
original_created_before: @record.original_created_before } original_created_before: @record.original_created_before }
end end


+ 73
- 0
backend/app/services/youtube/api_client.rb View File

@@ -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

+ 168
- 0
backend/app/services/youtube/sync.rb View File

@@ -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

+ 32
- 0
backend/app/services/youtube/video_item.rb View File

@@ -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

+ 1
- 0
backend/config/routes.rb View File

@@ -24,6 +24,7 @@ Rails.application.routes.draw do
patch '', action: :update patch '', action: :update


get :deerjikists get :deerjikists
put :deerjikists, action: :update_deerjikists
end end
end end




+ 8
- 0
backend/config/schedule.rb View File

@@ -17,3 +17,11 @@ every 1.day, at: '0:00 am' do
rake 'post_similarity:calc', environment: 'production' rake 'post_similarity:calc', environment: 'production'
rake 'tag_similarity:calc', environment: 'production' rake 'tag_similarity:calc', environment: 'production'
end end

every 1.day, at: '7:50 am' do
rake 'nico:export', environment: 'production'
end

every :hour do
rake 'post:sync', environment: 'production'
end

+ 24
- 0
backend/db/migrate/20260427214800_create_post_implications.rb View File

@@ -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

+ 6
- 0
backend/lib/tasks/sync_posts.rake View File

@@ -0,0 +1,6 @@
namespace :post do
desc '投稿同期(ニコニコ以外)'
task sync: :environment do
Youtube::Sync.new.sync!
end
end

+ 51
- 0
backend/spec/models/post_implication_spec.rb View File

@@ -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

+ 1
- 1
backend/spec/models/post_version_spec.rb View File

@@ -19,7 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url, url: post_record.url,
thumbnail_base: post_record.thumbnail_base, thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '), 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_from: post_record.original_created_from,
original_created_before: post_record.original_created_before, original_created_before: post_record.original_created_before,
created_at: Time.current, created_at: Time.current,


+ 1
- 1
backend/spec/models/tag_spec.rb View File

@@ -161,7 +161,7 @@ RSpec.describe Tag, type: :model do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), 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_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at: Time.current, created_at: Time.current,


+ 458
- 88
backend/spec/requests/posts_spec.rb View File

@@ -15,6 +15,31 @@ RSpec.describe 'Posts API', type: :request do
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg') Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end 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_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } 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(json).to have_key('viewed')
expect([true, false]).to include(json['viewed']) expect([true, false]).to include(json['viewed'])
end 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 end


context 'when post does not exist' do 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 it '401 when not logged in' do
sign_out 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) expect(response).to have_http_status(:unauthorized)
end end


it '403 when not member' do it '403 when not member' do
sign_in_as(create(:user, role: 'guest')) 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) expect(response).to have_http_status(:forbidden)
end end


it '201 and creates post + tags when member' do it '201 and creates post + tags when member' do
sign_in_as(member) sign_in_as(member)


post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post', title: 'new post',
url: 'https://example.com/new', url: 'https://example.com/new',
tags: 'spec_tag', # 既存タグ名を投げる tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload thumbnail: dummy_upload
}
)


expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(json).to include('id', 'title', 'url') 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 it '201 and creates post + tags when member and tags have aliases' do
sign_in_as(member) sign_in_as(member)


post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post', title: 'new post',
url: 'https://example.com/new', url: 'https://example.com/new',
tags: 'manko', # 既存タグ名を投げる tags: 'manko', # 既存タグ名を投げる
thumbnail: dummy_upload thumbnail: dummy_upload
}
)


expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(json).to include('id', 'title', 'url') expect(json).to include('id', 'title', 'url')
@@ -533,13 +620,14 @@ RSpec.describe 'Posts API', type: :request do
it 'return 400' do it 'return 400' do
sign_in_as(member) 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
end end


@@ -547,11 +635,11 @@ RSpec.describe 'Posts API', type: :request do
it 'returns 422' do it 'returns 422' do
sign_in_as(member) sign_in_as(member)


post '/posts', params: {
post '/posts', params: post_write_params(
title: 'new post', title: 'new post',
url: ' ', url: ' ',
tags: 'spec_tag', # 既存タグ名を投げる tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload }
thumbnail: dummy_upload)


expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
@@ -561,14 +649,154 @@ RSpec.describe 'Posts API', type: :request do
it 'returns 422' do it 'returns 422' do
sign_in_as(member) 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 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(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 end
end end
@@ -578,13 +806,13 @@ RSpec.describe 'Posts API', type: :request do


it '401 when not logged in' do it '401 when not logged in' do
sign_out 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) expect(response).to have_http_status(:unauthorized)
end end


it '403 when not member' do it '403 when not member' do
sign_in_as(create(:user, role: 'guest')) 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) expect(response).to have_http_status(:forbidden)
end end


@@ -595,10 +823,9 @@ RSpec.describe 'Posts API', type: :request do
tn2 = TagName.create!(name: 'spec_tag_2') tn2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tn2, category: :general) 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(response).to have_http_status(:ok)
expect(json).to have_key('tags') expect(json).to have_key('tags')
@@ -619,11 +846,178 @@ RSpec.describe 'Posts API', type: :request do
it 'return 400' do it 'return 400' do
sign_in_as(member) 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', 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 end
end end
@@ -773,20 +1167,20 @@ RSpec.describe 'Posts API', type: :request do
post.snapshot_tag_names.join(' ') post.snapshot_tag_names.join(' ')
end 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!( PostVersion.create!(
post: post,
version_no: version_no,
event_type: event_type,
post:,
version_no:,
event_type:,
title: post.title, title: post.title,
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), 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_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at: created_at,
created_by_user: created_by_user
created_at:,
created_by_user:
) )
end end


@@ -1015,33 +1409,15 @@ RSpec.describe 'Posts API', type: :request do
post.snapshot_tag_names.join(' ') post.snapshot_tag_names.join(' ')
end 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 it 'creates version 1 on POST /posts' do
sign_in_as(member) sign_in_as(member)


expect do 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) end.to change(PostVersion, :count).by(1)


expect(response).to have_http_status(:created) 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) Tag.create!(tag_name: tag_name2, category: :general)


expect do 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) end.to change(PostVersion, :count).by(1)


expect(response).to have_http_status(:ok) 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) create_post_version_for!(post_record.reload)


expect { 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) }.not_to change(PostVersion, :count)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)


@@ -1104,12 +1478,11 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member) sign_in_as(member)


expect do 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) end.not_to change(PostVersion, :count)


expect(response).to have_http_status(:unprocessable_entity) 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) create_post_version_for!(post_record)


expect do 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) end.not_to change(PostVersion, :count)


expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
@@ -1139,12 +1511,11 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member) sign_in_as(member)


expect { 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) }.to change { tag.reload.tag_versions.count }.by(1)


expect(response).to have_http_status(:created) 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) tag2 = Tag.create!(tag_name: tag_name2, category: :general)


expect { 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) }.to change { tag2.reload.tag_versions.count }.by(1)


expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)


+ 227
- 17
backend/spec/requests/tags_deerjikists_spec.rb View File

@@ -8,23 +8,35 @@ RSpec.describe 'Tags deerjikists API', type: :request do


let!(:tag) { create(:tag, category: :deerjikist) } let!(:tag) { create(:tag, category: :deerjikist) }


let(:member) { create(:user, :member) }
let(:guest) { create(:user, role: :guest) }

before do before do
# show_by_name / deerjikists_by_name 用に名前を固定
tag.tag_name.update!(name: 'deerjika') tag.tag_name.update!(name: 'deerjika')
end end


describe 'GET /tags/:id/deerjikists' do describe 'GET /tags/:id/deerjikists' do
subject(:do_request) do subject(:do_request) do
get "/tags/#{ tag_id }/deerjikists"
get "/tags/#{tag_id}/deerjikists"
end end


let(:tag_id) { tag.id } let(:tag_id) { tag.id }


context 'when tag exists and has no deerjikists' do context 'when tag exists and has no deerjikists' do
it 'returns 200 and empty array' do
it 'returns 200 with tag and empty deerjikists array' do
do_request do_request

expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to eq([])

expect(json).to be_a(Hash)

expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)

expect(json['deerjikists']).to eq([])
end end
end end


@@ -34,17 +46,27 @@ RSpec.describe 'Tags deerjikists API', type: :request do
Deerjikist.create!(platform: platform2, code: code2, tag: tag) Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end end


it 'returns 200 and deerjikists array' do
it 'returns 200 with tag and deerjikists array' do
do_request do_request

expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)


expect(json).to be_a(Array)
expect(json.size).to eq(2)
expect(json).to be_a(Hash)


expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly(
[platform1, code1],
[platform2, code2],
)
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)

expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(2)

expect(json['deerjikists'].map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end end
end end


@@ -53,6 +75,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do


it 'returns 404' do it 'returns 404' do
do_request do_request

expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
@@ -60,7 +83,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do


describe 'GET /tags/name/:name/deerjikists' do describe 'GET /tags/name/:name/deerjikists' do
subject(:do_request) do subject(:do_request) do
get "/tags/name/#{ name }/deerjikists"
get "/tags/name/#{name}/deerjikists"
end end


let(:name) { 'deerjika' } let(:name) { 'deerjika' }
@@ -70,6 +93,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do


it 'returns 400' do it 'returns 400' do
do_request do_request

expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end
end end
@@ -79,23 +103,209 @@ RSpec.describe 'Tags deerjikists API', type: :request do


it 'returns 404' do it 'returns 404' do
do_request do_request

expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end


context 'when tag exists and has no deerjikists' do
it 'returns 200 with tag and empty deerjikists array' do
do_request

expect(response).to have_http_status(:ok)

expect(json).to be_a(Hash)

expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(false)

expect(json['deerjikists']).to eq([])
end
end

context 'when tag exists and has deerjikists' do context 'when tag exists and has deerjikists' do
before do before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag) Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end end


it 'returns 200 and deerjikists array' do
it 'returns 200 with tag and deerjikists array' do
do_request do_request

expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)


expect(json).to be_a(Array)
expect(json.size).to eq(1)
expect(json[0]['platform']).to eq(platform1)
expect(json[0]['code']).to eq(code1)
expect(json).to be_a(Hash)

expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag.id)
expect(json['tag']['name']).to eq('deerjika')
expect(json['tag']['category']).to eq('deerjikist')
expect(json['tag']['has_deerjikists']).to eq(true)

expect(json['deerjikists']).to be_a(Array)
expect(json['deerjikists'].size).to eq(1)

expect(json['deerjikists'][0]['platform']).to eq(platform1)
expect(json['deerjikists'][0]['code']).to eq(code1)
end
end
end

describe 'PUT /tags/:id/deerjikists' do
subject(:do_request) do
put "/tags/#{tag_id}/deerjikists", params: payload, as: :json
end

let(:tag_id) { tag.id }
let(:payload) do
[
{ platform: platform1, code: code1 },
{ platform: platform2, code: code2 },
]
end

context 'when not logged in' do
it 'returns 401' do
do_request

expect(response).to have_http_status(:unauthorized)
end
end

context 'when logged in but not member' do
before do
sign_in_as guest
end

it 'returns 403' do
do_request

expect(response).to have_http_status(:forbidden)
end
end

context 'when tag does not exist' do
let(:tag_id) { 9_999_999 }

before do
sign_in_as member
end

it 'returns 404' do
do_request

expect(response).to have_http_status(:not_found)
end
end

context 'when logged in as member' do
before do
sign_in_as member
end

context 'when tag has no deerjikists' do
it 'creates deerjikists and returns deerjikists array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(0).to(2)

expect(response).to have_http_status(:ok)

expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)

expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end

context 'when tag already has deerjikists' do
before do
Deerjikist.create!(platform: platform1, code: 'old-code-1', tag: tag)
Deerjikist.create!(platform: platform2, code: 'old-code-2', tag: tag)
end

it 'replaces deerjikists and returns deerjikists array' do
do_request

expect(response).to have_http_status(:ok)

expect(Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)

expect(Deerjikist.exists?(platform: platform1, code: 'old-code-1')).to eq(false)
expect(Deerjikist.exists?(platform: platform2, code: 'old-code-2')).to eq(false)

expect(json).to be_a(Array)
expect(json.map { |h| [h['platform'], h['code']] })
.to contain_exactly(
[platform1, code1],
[platform2, code2],
)
end
end

context 'when payload is empty array' do
let(:payload) { [] }

before do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
Deerjikist.create!(platform: platform2, code: code2, tag: tag)
end

it 'clears deerjikists and returns empty array' do
expect {
do_request
}.to change { Deerjikist.where(tag: tag).count }.from(2).to(0)

expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end

context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
[
{ platform: 'youtube', code: '@deerjika' },
]
end

before do
allow(Net::HTTP).to receive(:get).and_return(
%(<link rel="canonical" href="https://www.youtube.com/channel/#{channel_id}">),
)
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 end
end end


+ 130
- 0
backend/spec/services/youtube/api_client_spec.rb View File

@@ -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

+ 310
- 0
backend/spec/services/youtube/sync_spec.rb View File

@@ -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

+ 93
- 0
backend/spec/services/youtube/video_item_spec.rb View File

@@ -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

+ 1
- 1
backend/spec/tasks/nico_sync_spec.rb View File

@@ -104,7 +104,7 @@ RSpec.describe "nico:sync" do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), 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_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at: Time.current, created_at: Time.current,


+ 25
- 0
backend/spec/tasks/post_sync_spec.rb View File

@@ -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

+ 2
- 0
frontend/src/App.tsx View File

@@ -10,6 +10,7 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
@@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/posts/changes" element={<PostHistoryPage/>}/> <Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/> <Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/> <Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/> <Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage/>}/>


+ 36
- 11
frontend/src/components/PostEditForm.tsx View File

@@ -4,6 +4,7 @@ import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'


import type { FC } from 'react' import type { FC } from 'react'
@@ -35,20 +36,34 @@ export default (({ post, onSave }: Props) => {
useState<string | null> (post.originalCreatedBefore) useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] = const [originalCreatedFrom, setOriginalCreatedFrom] =
useState<string | null> (post.originalCreatedFrom) useState<string | null> (post.originalCreatedFrom)
const [title, setTitle] = useState (post.title)
const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('') const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title)


const handleSubmit = async () => { const handleSubmit = async () => {
const data = await apiPut<Post> (
`/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<Post> (
`/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 (() => { useEffect (() => {
@@ -66,6 +81,16 @@ export default (({ post, onSave }: Props) => {
onChange={ev => setTitle (ev.target.value)}/> onChange={ev => setTitle (ev.target.value)}/>
</div> </div>


{/* 親投稿 */}
<div>
<Label>親投稿</Label>
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* タグ */} {/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/> <PostFormTagsArea tags={tags} setTags={setTags}/>




+ 5
- 2
frontend/src/components/PostList.tsx View File

@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'


import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'


import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -39,8 +40,10 @@ export default (({ posts, onClick }: Props) => {
<motion.div <motion.div
ref={cardRef} ref={cardRef}
layoutId={layoutId} layoutId={layoutId}
className="w-full h-full overflow-hidden rounded-xl shadow
transform-gpu will-change-transform"
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'outline-4 outline-green-500',
(post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')}
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
onLayoutAnimationStart={() => { onLayoutAnimationStart={() => {
if (!(cardRef.current)) if (!(cardRef.current))


+ 32
- 14
frontend/src/components/TagLink.tsx View File

@@ -45,9 +45,9 @@ export default (({ tag,
<> <>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className="mr-1"> <span className="mr-1">
{(tag.materialId != null || tag.hasWiki)
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? ( ? (
tag.materialId == null
tag.materialId == null && !(tag.hasDeerjikists)
? ( ? (
<PrefetchLink <PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`} to={`/wiki/${ encodeURIComponent (tag.name) }`}
@@ -55,11 +55,19 @@ export default (({ tag,
? ?
</PrefetchLink>) </PrefetchLink>)
: ( : (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>))
tag.materialId != null
? (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
?
</PrefetchLink>)))
: ( : (
['character', 'material'].includes (tag.category) ['character', 'material'].includes (tag.category)
? ( ? (
@@ -71,13 +79,23 @@ export default (({ tag,
! !
</PrefetchLink>) </PrefetchLink>)
: ( : (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>))}
tag.category === 'deerjikist'
? (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } に関する情報が存在しません.`}>
!
</PrefetchLink>)
: (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>)))}
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span


+ 6
- 1
frontend/src/consts.ts View File

@@ -1,4 +1,4 @@
import type { Category } from 'types'
import type { Category, Platform } from 'types'


export const LIGHT_COLOUR_SHADE = 800 export const LIGHT_COLOUR_SHADE = 800
export const DARK_COLOUR_SHADE = 300 export const DARK_COLOUR_SHADE = 300
@@ -31,6 +31,11 @@ export const FETCH_POSTS_ORDER_FIELDS = [
'updated_at', 'updated_at',
] as const ] as const


export const PLATFORMS = ['nico', 'youtube'] as const

export const PLATFORM_NAMES: Record<Platform, string> =
{ nico: 'ニコニコ', youtube: 'YouTube' } as const

export const TAG_COLOUR = { export const TAG_COLOUR = {
deerjikist: 'rose', deerjikist: 'rose',
meme: 'purple', meme: 'purple',


+ 6
- 5
frontend/src/lib/queryKeys.ts View File

@@ -9,11 +9,12 @@ export const postsKeys = {
['posts', 'changes', p] as const } ['posts', 'changes', p] as const }


export const tagsKeys = { 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 = { export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,


+ 7
- 1
frontend/src/lib/tags.ts View File

@@ -1,6 +1,6 @@
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'


import type { FetchTagsParams, Tag, TagVersion } from '@/types'
import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types'




export const fetchTags = async ( export const fetchTags = async (
@@ -56,3 +56,9 @@ export const fetchTagChanges = async (
versions: TagVersion[] versions: TagVersion[]
count: number }> => count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) 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`)

+ 155
- 0
frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx View File

@@ -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<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
const [disabled, setDisabled] = useState (true)

const qc = useQueryClient ()

const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()

try
{
setDisabled (true)

setData (await apiPut<Deerjikist[]> (`/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 (
<MainArea>
{(loading || !(tag)) ? 'Loading...' : (
<div className="max-w-xl">
<PageTitle>
<TagLink tag={tag} withWiki={false} withCount={false}/>
</PageTitle>

<form onSubmit={handleSubmit} className="my-4 space-y-2">
{data.map ((datum, i) => (
<fieldset key={i} className="min-w-0 rounded-lg border border-gray-300
dark:border-gray-700 p-4">
<legend className="px-2 text-sm font-semibold text-gray-700
dark:text-gray-300">
<button
type="button"
disabled={disabled}
onClick={() => setData (prev => [...prev.slice (0, i),
...prev.slice (i + 1)])}>
#{i + 1}
</button>
</legend>

{/* プラットフォーム */}
<div>
<Label>プラットフォーム</Label>
<select
className="w-full border p-2 rounded"
disabled={disabled}
value={datum.platform ?? ''}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i],
platform: (e.target.value || null) as Platform | null }
return rtn
})}>
<option value="">&nbsp;</option>
{PLATFORMS.map (p => (
<option key={p} value={p}>
{PLATFORM_NAMES[p]}
</option>))}
</select>
</div>

{/* コード */}
<div>
<Label>コード</Label>
<input
type="text"
disabled={disabled}
className="w-full border p-2 rounded"
value={datum.code}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value }
return rtn
})}/>
</div>
</fieldset>
))}

<div className="py-3">
<button
type="button"
disabled={disabled}
onClick={() => setData (prev => [...prev, { platform: null, code: '' }])}>
+
</button>
</div>

<div className="py-3">
<button
type="submit"
disabled={disabled}
className={cn ('px-4 py-2 rounded',
(disabled
? 'text-gray-300 bg-gray-500'
: 'text-white bg-blue-500'))}>
更新
</button>
</div>
</form>
</div>
)}
</MainArea>)
}) satisfies FC

+ 29
- 2
frontend/src/pages/posts/PostDetailPage.tsx View File

@@ -21,7 +21,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable'


import type { FC } from 'react' import type { FC } from 'react'


import type { NiconicoViewerHandle, User } from '@/types'
import type { NiconicoViewerHandle, Post, User } from '@/types'


type Props = { user: User | null } type Props = { user: User | null }


@@ -108,6 +108,34 @@ export default (({ user }: Props) => {
{post {post
? ( ? (
<> <>
{(post.childPosts ?? []).length > 0 && (
<div className="mb-4 bg-green-200 dark:bg-green-800 text-sm p-2 rounded-md">
<p>この投稿には {post.childPosts!.length} 件の子投稿があります.</p>
<PostList posts={[{ ...post, childPosts: [{ } as Post] },
...post.childPosts!.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/>
</div>
)}
{(post.parentPosts ?? []).map (pp => {
const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`]
if (!(siblings))
return

return (
<div
key={pp.id}
className="mb-4 bg-yellow-200 dark:bg-yellow-800 text-sm p-2 rounded-md">
<p>
この投稿には 1 件の親投稿{
siblings.length > 1
&& `と ${ siblings.length - 1 } 件の姉妹投稿`}があります.
</p>
<PostList posts={[{ ...pp, childPosts: [{ } as Post] },
...siblings.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/>
</div>)
})}

{(post.thumbnail || post.thumbnailBase) && ( {(post.thumbnail || post.thumbnailBase) && (
<motion.div <motion.div
layoutId={`page-${ id }`} layoutId={`page-${ id }`}
@@ -146,7 +174,6 @@ export default (({ user }: Props) => {
(prev: any) => newPost ?? prev) (prev: any) => newPost ?? prev)
qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
}}/> }}/>
</Tab>)} </Tab>)}
</TabGroup> </TabGroup>


+ 32
- 0
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -95,6 +95,8 @@ export default (() => {
<col className="w-96"/> <col className="w-96"/>
{/* タグ */} {/* タグ */}
<col className="w-[48rem]"/> <col className="w-[48rem]"/>
{/* TODO: 親投稿 */}
{/* <col className="w-[48rem]"/> */}
{/* オリジナルの投稿日時 */} {/* オリジナルの投稿日時 */}
<col className="w-96"/> <col className="w-96"/>
{/* 更新日時 */} {/* 更新日時 */}
@@ -110,6 +112,8 @@ export default (() => {
<th className="p-2 text-left">タイトル</th> <th className="p-2 text-left">タイトル</th>
<th className="p-2 text-left">URL</th> <th className="p-2 text-left">URL</th>
<th className="p-2 text-left">タグ</th> <th className="p-2 text-left">タグ</th>
{/* TODO: 親投稿の履歴 */}
{/* <th className="p-2 text-left">親投稿</th> */}
<th className="p-2 text-left">オリジナルの投稿日時</th> <th className="p-2 text-left">オリジナルの投稿日時</th>
<th className="p-2 text-left">更新日時</th> <th className="p-2 text-left">更新日時</th>
<th className="p-2"/> <th className="p-2"/>
@@ -180,6 +184,29 @@ export default (() => {
{tag.name} {tag.name}
</span>))))} </span>))))}
</td> </td>
{/* TODO: 親投稿の履歴 */}
{/* <td className="p-2">
{change.parentPosts.map ((pp, i) => (
pp.type === 'added'
? (
<ins
key={i}
className="mr-2 text-green-600 dark:text-green-400">
{pp.title}
</ins>)
: (
pp.type === 'removed'
? (
<del
key={i}
className="mr-2 text-red-600 dark:text-red-400">
{pp.title}
</del>)
: (
<span key={i} className="mr-2">
{pp.title}
</span>))))}
</td> */}
<td className="p-2"> <td className="p-2">
{change.versionNo === 1 {change.versionNo === 1
? originalCreatedAtString (change.originalCreatedFrom.current, ? originalCreatedAtString (change.originalCreatedFrom.current,
@@ -225,6 +252,11 @@ export default (() => {
.map (t => t.name) .map (t => t.name)
.filter (t => t.slice (0, 5) !== 'nico:') .filter (t => t.slice (0, 5) !== 'nico:')
.join (' '), .join (' '),
parent_post_ids:
(change.parentPosts ?? [])
.filter (p => p.type !== 'removed')
.map (p => p.id)
.join (' '),
original_created_from: original_created_from:
change.originalCreatedFrom.current, change.originalCreatedFrom.current,
original_created_before: original_created_before:


+ 12
- 0
frontend/src/pages/posts/PostNewPage.tsx View File

@@ -29,6 +29,7 @@ export default (({ user }: Props) => {


const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('') const [tags, setTags] = useState ('')
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
@@ -46,6 +47,7 @@ export default (({ user }: Props) => {
formData.append ('title', title) formData.append ('title', title)
formData.append ('url', url) formData.append ('url', url)
formData.append ('tags', tags) formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds)
if (thumbnailFile) if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile) formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom) if (originalCreatedFrom)
@@ -177,6 +179,16 @@ export default (({ user }: Props) => {
className="mt-2 max-h-48 rounded border"/>)} className="mt-2 max-h-48 rounded border"/>)}
</div> </div>


{/* 親投稿 */}
<div>
<Label>親投稿</Label>
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* タグ */} {/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/> <PostFormTagsArea tags={tags} setTags={setTags}/>




+ 15
- 2
frontend/src/types.ts View File

@@ -1,5 +1,6 @@
import { CATEGORIES, import { CATEGORIES,
FETCH_POSTS_ORDER_FIELDS, FETCH_POSTS_ORDER_FIELDS,
PLATFORMS,
USER_ROLES, USER_ROLES,
ViewFlagBehavior } from '@/consts' ViewFlagBehavior } from '@/consts'


@@ -7,6 +8,8 @@ import type { ReactNode } from 'react'


export type Category = typeof CATEGORIES[number] export type Category = typeof CATEGORIES[number]


export type Deerjikist = { platform: Platform; code: string }

export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }` export type FetchPostsOrder = `${ FetchPostsOrderField }:${ 'asc' | 'desc' }`


export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number] export type FetchPostsOrderField = typeof FETCH_POSTS_ORDER_FIELDS[number]
@@ -114,6 +117,8 @@ export type NiconicoViewerHandle = {
showComments: () => void showComments: () => void
hideComments: () => void } hideComments: () => void }


export type Platform = typeof PLATFORMS[number]

export type Post = { export type Post = {
id: number id: number
url: string url: string
@@ -121,6 +126,9 @@ export type Post = {
thumbnail: string | null thumbnail: string | null
thumbnailBase: string | null thumbnailBase: string | null
tags: Tag[] tags: Tag[]
parentPosts?: Post[]
childPosts?: Post[]
siblingPosts?: Record<`${ number }`, Post[]>
viewed: boolean viewed: boolean
related: Post[] related: Post[]
originalCreatedFrom: string | null originalCreatedFrom: string | null
@@ -144,7 +152,11 @@ export type PostVersion = {
url: { current: string; prev: string | null } url: { current: string; prev: string | null }
thumbnail: { current: string | null; prev: string | null } thumbnail: { current: string | null; prev: string | null }
thumbnailBase: { 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 } originalCreatedFrom: { current: string | null; prev: string | null }
originalCreatedBefore: { current: string | null; prev: string | null } originalCreatedBefore: { current: string | null; prev: string | null }
createdAt: string createdAt: string
@@ -171,7 +183,8 @@ export type Tag = {
createdAt: string createdAt: string
updatedAt: string updatedAt: string
hasWiki: boolean hasWiki: boolean
materialId: number
materialId: number | null
hasDeerjikists: boolean
children?: Tag[] children?: Tag[]
matchedAlias?: string | null } matchedAlias?: string | null }




Loading…
Cancel
Save