| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | |||||
| @@ -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,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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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 | ||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -0,0 +1,6 @@ | |||||
| namespace :post do | |||||
| desc '投稿同期(ニコニコ以外)' | |||||
| task sync: :environment do | |||||
| Youtube::Sync.new.sync! | |||||
| end | |||||
| end | |||||
| @@ -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 | |||||
| @@ -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, | ||||
| @@ -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, | ||||
| @@ -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) | ||||
| @@ -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 | ||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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, | ||||
| @@ -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 | |||||
| @@ -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/>}/> | ||||
| @@ -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}/> | ||||
| @@ -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)) | ||||
| @@ -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 | ||||
| @@ -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', | ||||
| @@ -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, | ||||
| @@ -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`) | |||||
| @@ -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=""> </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 | |||||
| @@ -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> | ||||
| @@ -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: | ||||
| @@ -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}/> | ||||
| @@ -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 } | ||||