Compare commits

..

2 Commits

Author SHA1 Message Date
みてるぞ 478bc15ad0 #321 2026-04-24 02:20:12 +09:00
みてるぞ 2ad25b3950 #321 2026-04-24 01:01:05 +09:00
87 changed files with 646 additions and 5518 deletions
@@ -1,16 +1,14 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
before_action :reject_banned_ip_address!
before_action :authenticate_user before_action :authenticate_user
before_action :reject_banned_user!
def current_user = @current_user def current_user
@current_user
end
private private
def authenticate_user def authenticate_user
code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE'] code = request.headers['X-Transfer-Code'] || request.headers['HTTP_X_TRANSFER_CODE']
return if code.blank?
@current_user = User.find_by(inheritance_code: code) @current_user = User.find_by(inheritance_code: code)
end end
@@ -24,17 +22,4 @@ class ApplicationController < ActionController::API
s.in?(['', '1', 'true', 'on', 'yes']) s.in?(['', '1', 'true', 'on', 'yes'])
end end
end end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
head :forbidden
end
def reject_banned_user!
return unless current_user&.banned?
head :forbidden
end
end end
@@ -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, linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false,
with_no_deerjikist: 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
+21 -308
View File
@@ -44,7 +44,7 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .preload(tags: [:materials, { tag_name: :wiki_page }])
.with_attached_thumbnail .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
end end
def random def random
post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
.order('RAND()') .order('RAND()')
.first .first
return head :not_found unless post return head :not_found unless post
@@ -104,12 +104,12 @@ class PostsController < ApplicationController
end end
def show def show
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
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: PostRepr.many(post.related(limit: 20))) related: post.related(limit: 20))
end end
def create def create
@@ -123,36 +123,28 @@ 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) if thumbnail.present? post.thumbnail.attach(thumbnail)
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
@@ -173,76 +165,35 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
force = bool?(:force)
merge = bool?(:merge)
return head :bad_request if force && merge
base_version_no = parse_base_version_no
return head :bad_request if !(force) && !(base_version_no)
title = params[:title].presence title = params[:title].presence
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 = nil post = Post.find(params[:id].to_i)
conflict_json = nil
ApplicationRecord.transaction do ApplicationRecord.transaction do
post = Post.lock.find(params[:id].to_i) PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
base_version = nil post.update!(title:, original_created_from:, original_created_before:)
base_snapshot = nil
current_snapshot = nil
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
base_snapshot = post_snapshot_from_version(base_version) normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false)
current_snapshot = post_snapshot_from_record(post) TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
end
incoming_snapshot = post_incoming_snapshot(title:,
original_created_from:,
original_created_before:,
tag_names:,
parent_post_ids:)
snapshot_to_apply = tags = post.tags.nico.to_a + normalised_tags
if force || post.version_no == base_version_no || current_snapshot == base_snapshot tags = Tag.expand_parent_tags(tags)
incoming_snapshot sync_post_tags!(post, tags)
else PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }
if merge && conflicts.empty?
merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
else
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:,
changes:,
conflicts:)
raise ActiveRecord::Rollback
end
end
apply_post_snapshot!(post, snapshot_to_apply)
end end
return render json: conflict_json, status: :conflict if conflict_json
post.reload post.reload
json = PostRepr.base(post, current_user) 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
@@ -260,7 +211,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present? pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present? pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user, pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:deerjikists, :materials, { tag_name: :wiki_page }]) tag: [:materials, { tag_name: :wiki_page }])
events = [] events = []
pts.each do |pt| pts.each do |pt|
@@ -402,242 +353,4 @@ 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
def parse_base_version_no
version_no = Integer(params[:base_version_no], exception: false)
if version_no&.positive?
version_no
else
nil
end
end
def post_snapshot_from_version version
{ title: version.title,
original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version),
parent_post_ids: snapshot_parent_post_ids_from_version(version) }
end
def editable_tag_names_from_version version
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
end
def post_snapshot_from_record post
{ title: post.title,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post),
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
end
def editable_tag_names_from_post post
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
{ title:,
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: incoming_tag_names_for_snapshot(tag_names),
parent_post_ids: parent_post_ids.sort }
end
def snapshot_parent_post_ids_from_version version
if version.respond_to?(:parent_post_ids)
version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort
elsif version.respond_to?(:parent_id) && version.parent_id
[version.parent_id]
else
[]
end
end
def snapshot_time value
return nil if value.blank?
value = Time.zone.parse(value.to_s) if value in String
value&.in_time_zone&.iso8601(6)
rescue ArgumentError, TypeError
value.to_s
end
def incoming_tag_names_for_snapshot raw_tag_names
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
end
def post_conflict_json post:, base_version_no:, base_snapshot:,
current_snapshot:, incoming_snapshot:, changes:, conflicts:
{ error: 'conflict',
message: '競合が発生しました.',
post_id: post.id,
base_version_no:,
current_version_no: post.version_no,
base: base_snapshot,
current: current_snapshot,
mine: incoming_snapshot,
changes:,
conflicts:,
mergeable: conflicts.empty? }
end
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:tag_names, 'タグ',
base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:parent_post_ids, '親投稿',
base_snapshot, current_snapshot, incoming_snapshot)].compact
end
def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
base = base_snapshot[field]
current = current_snapshot[field]
mine = incoming_snapshot[field]
return nil if current == base && mine == base
{ field:, label:, base:, current:, mine:,
changed_by_current: current != base,
changed_by_me: mine != base,
conflict: scalar_snapshot_conflict?(base, current, mine) }
end
def scalar_snapshot_conflict? base, current, mine
current != base && mine != base && current != mine
end
def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
base = base_snapshot[field].to_a
current = current_snapshot[field].to_a
mine = incoming_snapshot[field].to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
if (added_by_current.empty? &&
removed_by_current.empty? &&
added_by_me.empty? &&
removed_by_me.empty?)
return nil
end
{ field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:,
added_by_me:, removed_by_me:,
changed_by_current: added_by_current.present? || removed_by_current.present?,
changed_by_me: added_by_me.present? || removed_by_me.present?,
conflict: set_snapshot_conflict?(added_by_current:,
removed_by_current:,
added_by_me:,
removed_by_me:) }
end
def set_snapshot_conflict? added_by_current:, removed_by_current:,
added_by_me:, removed_by_me:
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end
def apply_post_snapshot! post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h.merge([:tag_names, :parent_post_ids].map {
[_1, merge_set_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h)
end
def merge_scalar_snapshot_value base, current, mine
return mine if current == base
return current if mine == base || current == mine
raise ArgumentError, '競合してゐる項目はマージできません.'
end
def merge_set_snapshot_value base, current, mine
base = base.to_a
current = current.to_a
mine = mine.to_a
added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine
merged = base + added_by_current + added_by_me
merged -= removed_by_current
merged -= removed_by_me
merged.uniq.sort
end
end end
+11 -110
View File
@@ -1,7 +1,3 @@
require 'net/http'
require 'uri'
class TagsController < ApplicationController class TagsController < ApplicationController
def index def index
post_id = params[:post] post_id = params[:post]
@@ -186,8 +182,7 @@ 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: { tag: TagRepr.base(tag), render json: DeerjikistRepr.many(tag.deerjikists)
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
end end
def deerjikists_by_name def deerjikists_by_name
@@ -199,31 +194,7 @@ 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: { tag: TagRepr.base(tag), render json: DeerjikistRepr.many(tag.deerjikists)
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
@@ -267,26 +238,18 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
old_name = tag.name old_name = tag.name
name_changed = name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
tag.update!(category:) tag.update!(category:)
tag.tag_name.update!(name:) tag.tag_name.update!(name:)
alias_names << old_name if name_changed alias_names << old_name if name != old_name
alias_names.delete(name) alias_names.delete(name)
update_aliases!(tag, alias_names) update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names) update_parent_tags!(tag, parent_names)
tag.reload tag.reload
record_tag_version!(tag, event_type: :update, created_by_user: current_user)
record_tag_version!(
tag,
event_type: :update,
created_by_user: current_user,
name_changed:,
wiki_page:)
end end
render json: TagRepr.base(tag.reload) render json: TagRepr.base(tag.reload)
@@ -309,21 +272,10 @@ class TagsController < ApplicationController
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
old_name = tag.name
name_changed = name.present? && name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
tag.tag_name.update!(name:) if name.present? tag.tag_name.update!(name:) if name.present?
tag.update!(category:) if category.present? tag.update!(category:) if category.present?
tag.reload record_tag_version!(tag, event_type: :update, created_by_user: current_user)
record_tag_version!(
tag,
event_type: :update,
created_by_user: current_user,
name_changed:,
wiki_page:)
end end
render json: TagRepr.base(tag.reload) render json: TagRepr.base(tag.reload)
@@ -345,47 +297,17 @@ class TagsController < ApplicationController
material: material.as_json&.merge(file:, content_type:)) material: material.as_json&.merge(file:, content_type:))
end end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil def record_tag_version! tag, event_type:, created_by_user:
if tag.nico? if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return unless name_changed
wiki_page ||= tag.tag_name.wiki_page
return unless wiki_page&.wiki_versions&.exists?
WikiVersionRecorder.record!(
page: wiki_page,
event_type: :update,
created_by_user:)
end end
def update_aliases! tag, alias_names def update_aliases! tag, alias_names
alias_names = alias_names.uniq
affected_tags = [tag]
current_aliases = tag.tag_name.aliases.to_a current_aliases = tag.tag_name.aliases.to_a
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
affected_tags << alias_tag_name.canonical&.tag
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag
end
affected_tags.compact.uniq.each do |affected_tag|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user)
end
current_aliases.each do |alias_tag_name| current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name) next if alias_names.include?(alias_tag_name.name)
@@ -396,16 +318,12 @@ class TagsController < ApplicationController
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name) alias_tag_name.update!(canonical: tag.tag_name)
end end
affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
end
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, parent_tags = Tag.normalise_tags(parent_names, with_tagme: false,
with_no_deerjikist: false, with_no_deerjikist: false,
deny_nico: true) deny_nico: true)
old_parent_tags = tag.parents.to_a old_parent_tags = tag.parents.to_a
@@ -420,21 +338,4 @@ 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
+7 -20
View File
@@ -1,22 +1,18 @@
class UsersController < ApplicationController class UsersController < ApplicationController
def create def create
user = nil user = User.create!(inheritance_code: SecureRandom.uuid, role: 'guest')
User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user)
end
render json: { code: user.inheritance_code, render json: { code: user.inheritance_code,
user: user.slice(:id, :name, :inheritance_code, :role) }, user: user.slice(:id, :name, :inheritance_code, :role) }
status: :created
end end
def verify def verify
ip_bin = IPAddr.new(request.remote_ip).hton
ip_address = IpAddress.find_or_create_by!(ip_address: ip_bin)
user = User.find_by(inheritance_code: params[:code]) user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user return render json: { valid: false } unless user
return head :forbidden if user.banned?
attach_ip_address!(user) UserIp.find_or_create_by!(user:, ip_address:)
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
end end
@@ -45,18 +41,9 @@ class UsersController < ApplicationController
return head :bad_request if name.blank? return head :bad_request if name.blank?
if user.update(name:) if user.update(name:)
render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok render json: user.slice(:id, :name, :inheritance_code, :role), status: :created
else else
render json: user.errors, status: :unprocessable_entity render json: user.errors, status: :unprocessable_entity
end end
end end
private
def attach_ip_address! user
ip_bin = IPAddr.new(request.remote_ip).hton
ip_address = IpAddress.create_or_find_by!(ip_address: ip_bin)
UserIp.create_or_find_by!(user:, ip_address:)
end
end end
@@ -85,24 +85,22 @@ class WikiPagesController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
title = params[:title].to_s.strip name = params[:title]&.strip
body = params[:body].to_s body = params[:body].to_s
message = params[:message].presence
return head :unprocessable_entity if title.blank? || body.blank? return head :unprocessable_entity if name.blank? || body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title) tag_name = TagName.find_undiscard_or_create_by!(name:)
page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user)
if page.save
message = params[:message].presence
Wiki::Commit.content!(page:, body:, created_user: current_user, message:)
page = render json: WikiPageRepr.base(page), status: :created
Wiki::Commit.create_content!( else
tag_name:, render json: { errors: page.errors.full_messages },
body:, status: :unprocessable_entity
created_by_user: current_user, end
message:)
render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
head :unprocessable_entity
end end
def update def update
@@ -115,34 +113,19 @@ class WikiPagesController < ApplicationController
return head :unprocessable_entity if title.blank? || body.blank? return head :unprocessable_entity if title.blank? || body.blank?
page = WikiPage.find(params[:id]) page = WikiPage.find(params[:id])
base_revision_id = params[:base_revision_id].presence base_revision_id = page.current_revision.id
ApplicationRecord.transaction do if params[:title].present? && params[:title].strip != page.title
page.lock! return head :unprocessable_entity
old_title = page.title
tag = Tag.find_by(tag_name_id: page.tag_name_id)
if tag && title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
end
page.tag_name.update!(name: title) if title != old_title
message = params[:message].presence
Wiki::Commit.content!(page:,
body:,
created_user: current_user,
message:,
base_revision_id:)
if tag && title != old_title
tag.reload
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end
end end
message = params[:message].presence
Wiki::Commit.content!(page:,
body:,
created_user: current_user,
message:,
base_revision_id:)
head :ok head :ok
end end
+2 -6
View File
@@ -1,10 +1,6 @@
class IpAddress < ApplicationRecord class IpAddress < ApplicationRecord
validates :ip_address, presence: true, length: { maximum: 16 } validates :ip_address, presence: true, length: { maximum: 16 }
validates :banned, inclusion: { in: [true, false] }
has_many :user_ips, dependent: :destroy has_many :users
has_many :users, through: :user_ips
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end end
+5 -32
View File
@@ -1,6 +1,7 @@
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
@@ -12,24 +13,8 @@ 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
attribute :version_no, :integer, default: 1
before_validation :normalise_url before_validation :normalise_url
validates :url, presence: true, uniqueness: true validates :url, presence: true, uniqueness: true
@@ -37,29 +22,17 @@ 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? ? super(options).merge({ thumbnail: thumbnail.attached? ?
Rails.application.routes.url_helpers.rails_blob_url( Rails.application.routes.url_helpers.rails_blob_url(
thumbnail, only_path: false) : thumbnail, only_path: false) :
nil) nil })
rescue rescue
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
def related limit: nil def related limit: nil
ids = post_similarities.order(cos: :desc) ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit ids = ids.limit(limit) if limit
-19
View File
@@ -1,19 +0,0 @@
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
+3 -8
View File
@@ -40,8 +40,6 @@ class Tag < ApplicationRecord
belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name
attribute :version_no, :integer, default: 1
delegate :name, to: :tag_name, allow_nil: true delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true validates :tag_name, presence: true
@@ -81,18 +79,15 @@ 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, def self.normalise_tags tag_names, with_tagme: true,
with_no_deerjikist: true, with_no_deerjikist: true,
deny_nico: 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
+1 -5
View File
@@ -4,6 +4,7 @@ class User < ApplicationRecord
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
validates :inheritance_code, presence: true, length: { maximum: 64 } validates :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys } validates :role, presence: true, inclusion: { in: roles.keys }
validates :banned, inclusion: { in: [true, false] }
has_many :created_posts, has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
@@ -18,10 +19,5 @@ class User < ApplicationRecord
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed?(post) = user_post_views.exists?(post_id: post.id) def viewed?(post) = user_post_views.exists?(post_id: post.id)
def gte_member? = member? || admin? def gte_member? = member? || admin?
def banned? = banned_at.present?
def ban! = banned? || update!(banned_at: Time.current)
def unban! = update!(banned_at: nil)
end end
+5 -5
View File
@@ -13,13 +13,8 @@ class WikiPage < ApplicationRecord
foreign_key: :redirect_page_id, foreign_key: :redirect_page_id,
dependent: :nullify dependent: :nullify
has_many :wiki_versions
attribute :version_no, :integer, default: 1
belongs_to :tag_name belongs_to :tag_name
validates :tag_name, presence: true validates :tag_name, presence: true
validates :body, presence: true
def title = tag_name.name def title = tag_name.name
@@ -29,6 +24,11 @@ class WikiPage < ApplicationRecord
def current_revision = wiki_revisions.order(id: :desc).first def current_revision = wiki_revisions.order(id: :desc).first
def body
rev = current_revision
rev.body if rev&.content?
end
def resolve_redirect limit: 10 def resolve_redirect limit: 10
page = self page = self
visited = Set.new visited = Set.new
-8
View File
@@ -1,8 +0,0 @@
class WikiVersion < ApplicationRecord
include VersionRecord
belongs_to :wiki_page
validates :title, presence: true
validates :body, presence: true
end
+1 -2
View File
@@ -2,8 +2,7 @@
module PostRepr module PostRepr
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE }, BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze
methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze
module_function module_function
+1 -1
View File
@@ -3,7 +3,7 @@
module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze methods: [:name, :has_wiki, :material_id] }.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_post_ids: @record.snapshot_parent_post_ids.join(' '), parent_id: @record.parent_id,
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
+10 -35
View File
@@ -16,20 +16,19 @@ class VersionRecorder
@record = record_class.unscoped.lock.find(@record.id) @record = record_class.unscoped.lock.find(@record.id)
latest = latest_version latest = latest_version
validate_version_sequence!(latest) if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
attrs = snapshot_attributes attrs = snapshot_attributes
if @event_type == 'update' && latest && same_snapshot?(latest, attrs) return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
return latest
end
version = version_class.create!( version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
base_attributes(latest).merge(record_key => @record).merge(attrs))
update_record_version_no!(version.version_no)
version
end end
end end
@@ -46,31 +45,7 @@ class VersionRecorder
created_by_user: @created_by_user } created_by_user: @created_by_user }
end end
def update_record_version_no! version_no def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v }
@record.update_columns(version_no:)
@record.version_no = version_no
end
def validate_version_sequence! latest
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
end
if @event_type == 'create' && latest
raise "#{ version_class.name } create event already exists"
end
return unless latest
if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
"but latest #{ version_class.name } version_no is #{ latest.version_no }")
end
end
def same_snapshot? version, attrs
attrs.all? { |k, v| version.public_send(k) == v }
end
def validate_event_type! def validate_event_type!
return if EVENT_TYPES.include?(@event_type) return if EVENT_TYPES.include?(@event_type)
+40 -59
View File
@@ -7,31 +7,6 @@ module Wiki
; ;
end end
def self.create_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body)
page = WikiPage.new(tag_name:,
body: normalised,
created_user: created_by_user,
updated_user: created_by_user)
if normalised.blank?
page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, page
end
ActiveRecord::Base.transaction do
page.save!
new(page:, created_user: created_by_user).content!(
body: normalised,
message:,
base_revision_id: nil)
page
end
end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:) new(page:, created_user:).content!(body:, message:, base_revision_id:)
end end
@@ -46,12 +21,7 @@ module Wiki
end end
def content! body:, message:, base_revision_id: def content! body:, message:, base_revision_id:
normalised = self.class.normalise_body(body) normalised = normalise_body(body)
if normalised.blank?
@page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, @page
end
lines = split_lines(normalised) lines = split_lines(normalised)
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
@@ -67,19 +37,10 @@ module Wiki
current_id = @page.wiki_revisions.maximum(:id) current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i if current_id && current_id != base_revision_id.to_i
raise Conflict, raise Conflict,
"競合が発生してゐます" + "競合が発生してゐます(現在の Id.#{ current_id },ベース Id.#{ base_revision_id })."
"(現在の Id.#{ current_id },ベース Id.#{ base_revision_id })."
end end
end end
@page.update!(body: normalised)
WikiVersionRecorder.record!(
page: @page,
event_type: @page.wiki_versions.exists? ? :update : :create,
reason: message,
created_by_user: @created_user)
rev = WikiRevision.create!( rev = WikiRevision.create!(
wiki_page: @page, wiki_page: @page,
base_revision_id:, base_revision_id:,
@@ -93,45 +54,65 @@ module Wiki
rows = line_ids.each_with_index.map do |line_id, pos| rows = line_ids.each_with_index.map do |line_id, pos|
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end end
WikiRevisionLine.insert_all!(rows) if rows.any? WikiRevisionLine.insert_all!(rows)
rev rev
end end
end end
def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.' def redirect! redirect_page:, message:, base_revision_id:
ActiveRecord::Base.transaction do
@page.lock!
def self.normalise_body body if base_revision_id.present?
s = body.to_s current_id = @page.wiki_revisions.maximum(:id)
s.gsub!(/\r\n?/, "\n") if current_id && current_id != base_revision_id.to_i
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') raise Conflict,
s.gsub(/\n+$/, '') "競合が発生してゐます(現在の Id.#{ current_id },ベース Id.#{ base_revision_id })."
end
end
WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
created_user: @created_user,
kind: :redirect,
redirect_page:,
message:,
lines_count: 0,
tree_sha256: nil)
end
end end
private private
def split_lines(body) = body.split("\n") def normalise_body body
s = body.to_s
s.gsub!("\r\n", "\n")
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
end
def split_lines body
body.split("\n")
end
def upsert_lines! lines, line_shas def upsert_lines! lines, line_shas
now = Time.current now = Time.current
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
missing_by_sha = { } missing_rows = []
line_shas.each_with_index do |sha, i| line_shas.each_with_index do |sha, i|
next if id_by_sha.key?(sha) next if id_by_sha.key?(sha)
next if missing_by_sha.key?(sha)
missing_by_sha[sha] = { missing_rows << { sha256: sha,
sha256: sha, body: lines[i],
body: lines[i], created_at: now,
created_at: now, updated_at: now }
updated_at: now }
end end
if missing_by_sha.any? if missing_rows.any?
WikiLine.upsert_all(missing_by_sha.values) WikiLine.upsert_all(missing_rows)
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
end end
@@ -1,21 +0,0 @@
class WikiVersionRecorder < VersionRecorder
def self.record! page:, event_type:, reason: nil, created_by_user:
new(page:, event_type:, reason:, created_by_user:).record!
end
def initialize page:, event_type:, reason: nil, created_by_user:
@reason = reason
super(record: page, event_type:, created_by_user:)
end
private
def version_class = WikiVersion
def version_association = :wiki_versions
def record_key = :wiki_page
def snapshot_attributes = {
title: @record.title,
body: @record.body,
reason: @reason }
end
@@ -1,73 +0,0 @@
require 'json'
require 'net/http'
require 'uri'
module Youtube
class ApiClient
ENDPOINT = 'https://www.googleapis.com/youtube/v3'
def initialize api_key: ENV.fetch('YOUTUBE_API_KEY')
@api_key = api_key
end
def search_videos q:, published_after: nil, published_before: nil, page_token: nil
get_json('/search', {
part: 'snippet',
type: 'video',
q:,
order: 'date',
maxResults: 50,
regionCode: 'JP',
relevanceLanguage: 'ja',
publishedAfter: published_after&.iso8601,
publishedBefore: published_before&.iso8601,
pageToken: page_token }.compact)
end
def videos ids
return { 'items' => [] } if ids.empty?
get_json('/videos', part: 'snippet,status,contentDetails', id: ids.join(','))
end
def playlist_items playlist_id:, page_token: nil
get_json('/playlistItems', {
part: 'snippet,contentDetails,status',
playlistId: playlist_id,
maxResults: 50,
pageToken: page_token }.compact)
end
def channel id: nil, handle: nil
raise ArgumentError, 'id or handle is required' if id.present? == handle.present?
params = { part: 'snippet,contentDetails' }
params[:id] = id if id.present?
params[:forHandle] = handle if handle.present?
get_json('/channels', params)
end
private
def get_json path, params
uri = URI(ENDPOINT + path)
uri.query = URI.encode_www_form(params.merge(key: @api_key))
response = Net::HTTP.start(uri.host,
uri.port,
use_ssl: true,
open_timeout: 10,
read_timeout: 30) do |http|
http.get(uri)
end
unless response.is_a?(Net::HTTPSuccess)
raise "YouTube API error: #{ response.code } #{ response.body }"
end
JSON.parse(response.body)
end
end
end
-168
View File
@@ -1,168 +0,0 @@
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
@@ -1,32 +0,0 @@
require 'time'
module Youtube
class VideoItem
attr_reader :id, :title, :channel_id, :published_at, :thumbnail_url, :raw_tags
def initialize item
snippet = item.fetch('snippet')
@id = item.fetch('id')
@title = snippet['title']
@channel_id = snippet['channelId']
@published_at = Time.iso8601(snippet['publishedAt'])
@thumbnail_url = pick_thumbnail(snippet['thumbnails'] || { })
@raw_tags = snippet['tags'] || []
end
def url = "https://www.youtube.com/watch?v=#{ @id }"
private
def pick_thumbnail thumbnails
['maxres', 'standard', 'high', 'medium', 'default'].each do |key|
url = thumbnails.dig(key, 'url')
return url if url.present?
end
nil
end
end
end
-1
View File
@@ -24,7 +24,6 @@ Rails.application.routes.draw do
patch '', action: :update patch '', action: :update
get :deerjikists get :deerjikists
put :deerjikists, action: :update_deerjikists
end end
end end
-8
View File
@@ -17,11 +17,3 @@ 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
@@ -1,91 +0,0 @@
class CreateWikiVersions < ActiveRecord::Migration[8.0]
class WikiPage < ActiveRecord::Base
self.table_name = 'wiki_pages'
end
class WikiRevision < ActiveRecord::Base
self.table_name = 'wiki_revisions'
end
class WikiRevisionLine < ActiveRecord::Base
self.table_name = 'wiki_revision_lines'
end
class WikiLine < ActiveRecord::Base
self.table_name = 'wiki_lines'
end
class WikiVersion < ActiveRecord::Base
self.table_name = 'wiki_versions'
end
class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
def up
add_column :wiki_pages, :body, :text, after: :tag_name_id
create_table :wiki_versions do |t|
t.references :wiki_page, null: false, foreign_key: true
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :title, null: false
t.text :body, null: false
t.text :reason
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }
t.index [:wiki_page_id, :version_no], unique: true
t.check_constraint 'version_no > 0',
name: 'wiki_versions_version_no_positive'
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
name: 'wiki_versions_event_type_valid'
end
WikiPage.reset_column_information
WikiVersion.reset_column_information
say_with_time 'Backfilling wiki_versions' do
WikiPage.find_each do |page|
base_revision_id = nil
version_no = 1
title = TagName.find(page.tag_name_id).name
body = nil
loop do
rev = WikiRevision.where(wiki_page_id: page.id).find_by(base_revision_id:)
break unless rev
body = WikiRevisionLine.where(wiki_revision_id: rev.id).order(:position).map { |wrl|
WikiLine.find(wrl.wiki_line_id).body
}.join("\n")
WikiVersion.create!(
wiki_page_id: page.id,
version_no:,
event_type: version_no == 1 ? 'create' : 'update',
title:,
body:,
reason: rev.message,
created_at: rev.created_at,
created_by_user_id: rev.created_user_id)
version_no += 1
base_revision_id = rev.id
end
if body
page.update!(body:)
else
page.destroy!
end
end
end
change_column_null :wiki_pages, :body, false
end
def down
drop_table :wiki_versions
remove_column :wiki_pages, :body
end
end
@@ -1,24 +0,0 @@
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
@@ -1,16 +0,0 @@
class RenameBannedToBannedAtInUsersAndIpAddresses < ActiveRecord::Migration[8.0]
def up
[:users, :ip_addresses].each do
add_column _1, :banned_at, :datetime, after: :banned
add_index _1, :banned_at
remove_column _1, :banned
end
end
def down
[:ip_addresses, :users].each do
add_column _1, :banned, :boolean, null: false, default: false, after: :banned_at
remove_column _1, :banned_at
end
end
end
@@ -1,27 +0,0 @@
class AddVersionNoToPosts < ActiveRecord::Migration[8.0]
def up
add_column :posts, :version_no, :integer
execute <<~SQL
UPDATE
posts
SET
version_no = (
SELECT
MAX(version_no)
FROM
post_versions
WHERE
post_id = posts.id)
SQL
change_column_null :posts, :version_no, false
add_check_constraint :posts, 'version_no > 0', name: 'chk_posts_version_no_positive'
end
def down
remove_check_constraint :posts, name: 'chk_posts_version_no_positive'
remove_column :posts, :version_no
end
end
@@ -1,37 +0,0 @@
class AddVersionNoToTags < ActiveRecord::Migration[8.0]
def up
add_column :tags, :version_no, :integer
execute <<~SQL
UPDATE
tags
SET
version_no = (
CASE category
WHEN 'nico' THEN
(SELECT
MAX(version_no)
FROM
nico_tag_versions
WHERE
tag_id = tags.id)
ELSE
(SELECT
MAX(version_no)
FROM
tag_versions
WHERE
tag_id = tags.id)
END)
SQL
change_column_null :tags, :version_no, false
add_check_constraint :tags, 'version_no > 0', name: 'chk_tags_version_no_positive'
end
def down
remove_check_constraint :tags, name: 'chk_tags_version_no_positive'
remove_column :tags, :version_no
end
end
@@ -1,27 +0,0 @@
class AddVersionNoToWikiPages < ActiveRecord::Migration[8.0]
def up
add_column :wiki_pages, :version_no, :integer
execute <<~SQL
UPDATE
wiki_pages
SET
version_no = (
SELECT
MAX(version_no)
FROM
wiki_versions
WHERE
wiki_page_id = wiki_pages.id)
SQL
change_column_null :wiki_pages, :version_no, false
add_check_constraint :wiki_pages, 'version_no > 0', name: 'chk_wiki_pages_version_no_positive'
end
def down
remove_check_constraint :wiki_pages, name: 'chk_wiki_pages_version_no_positive'
remove_column :wiki_pages, :version_no
end
end
+9 -42
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -50,10 +50,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false t.binary "ip_address", limit: 16, null: false
t.datetime "banned_at" t.boolean "banned", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_ip_addresses_on_banned_at"
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end end
@@ -120,15 +119,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "target_post_id", null: false t.bigint "target_post_id", null: false
@@ -165,12 +155,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000 t.string "thumbnail_base", limit: 2000
t.text "tags", null: false t.text "tags", null: false
t.text "parent_post_ids", null: false t.bigint "parent_id"
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["parent_id"], name: "index_post_versions_on_parent_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["post_id"], name: "index_post_versions_on_post_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
@@ -181,15 +172,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.string "title" t.string "title"
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000 t.string "thumbnail_base", limit: 2000
t.bigint "parent_id"
t.bigint "uploaded_user_id" t.bigint "uploaded_user_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "version_no", null: false t.index ["parent_id"], name: "index_posts_on_parent_id"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true t.index ["url"], name: "index_posts_on_url", unique: true
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -264,10 +255,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end end
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -337,10 +326,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.string "name" t.string "name"
t.string "inheritance_code", limit: 64, null: false t.string "inheritance_code", limit: 64, null: false
t.string "role", null: false t.string "role", null: false
t.datetime "banned_at" t.boolean "banned", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_users_on_banned_at"
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -366,19 +354,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false t.bigint "tag_name_id", null: false
t.text "body", null: false
t.bigint "created_user_id", null: false t.bigint "created_user_id", null: false
t.bigint "updated_user_id", null: false t.bigint "updated_user_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -411,22 +396,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
end end
create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "title", null: false
t.text "body", null: false
t.text "reason"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
@@ -442,8 +411,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags" add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
@@ -451,7 +418,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id" add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts" add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "posts", column: "parent_id"
add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "posts", column: "parent_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
@@ -484,6 +453,4 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do
add_foreign_key "wiki_revisions", "wiki_pages" add_foreign_key "wiki_revisions", "wiki_pages"
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
add_foreign_key "wiki_versions", "wiki_pages"
end end
-23
View File
@@ -1,23 +0,0 @@
namespace :nico do
desc 'ニコニコ DB 逆連携'
task export: :environment do
require 'open3'
mysql_user = ENV.fetch('MYSQL_USER')
mysql_pass = ENV.fetch('MYSQL_PASS')
nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH')
videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map {
_1[%r{nicovideo\.jp/watch/([^/?#]+)}, 1]
}.uniq
next if videos.empty?
_, stderr, status = Open3.capture3(
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos,
chdir: nizika_nico_path)
raise stderr unless status.success?
end
end
-6
View File
@@ -1,6 +0,0 @@
namespace :post do
desc '投稿同期(ニコニコ以外)'
task sync: :environment do
Youtube::Sync.new.sync!
end
end
-10
View File
@@ -1,10 +0,0 @@
FactoryBot.define do
factory :ip_address do
ip_address { IPAddr.new('203.0.113.10').hton }
banned_at { nil }
trait :banned do
banned_at { Time.current }
end
end
end
+3 -12
View File
@@ -1,24 +1,15 @@
FactoryBot.define do FactoryBot.define do
factory :user do factory :user do
name { nil } name { "test-user" }
inheritance_code { SecureRandom.uuid } inheritance_code { SecureRandom.uuid }
role { 'guest' } role { "guest" }
banned_at { nil }
trait :guest do
role { 'guest' }
end
trait :member do trait :member do
role { 'member' } role { "member" }
end end
trait :admin do trait :admin do
role { 'admin' } role { 'admin' }
end end
trait :banned do
banned_at { Time.current }
end
end end
end end
-2
View File
@@ -3,7 +3,5 @@ FactoryBot.define do
title { "TestPage" } title { "TestPage" }
association :created_user, factory: :user association :created_user, factory: :user
association :updated_user, factory: :user association :updated_user, factory: :user
body { ' ' }
end end
end end
@@ -1,51 +0,0 @@
require 'rails_helper'
RSpec.describe PostImplication, type: :model do
let!(:post_record) do
Post.create!(
title: 'post',
url: 'https://example.com/post-implication-post'
)
end
let!(:parent_post) do
Post.create!(
title: 'parent post',
url: 'https://example.com/post-implication-parent'
)
end
it 'is valid with post and parent_post' do
implication = described_class.new(
post: post_record,
parent_post:
)
expect(implication).to be_valid
end
it 'does not allow same post as parent_post' do
implication = described_class.new(
post: post_record,
parent_post: post_record
)
expect(implication).not_to be_valid
expect(implication.errors[:parent_post_id]).to be_present
end
it 'does not allow duplicate pair' do
described_class.create!(
post: post_record,
parent_post:
)
duplicate = described_class.new(
post: post_record,
parent_post:
)
expect(duplicate).not_to be_valid
expect(duplicate.errors[:post_id]).to be_present
end
end
+1 -1
View File
@@ -19,7 +19,7 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url, url: post_record.url,
thumbnail_base: post_record.thumbnail_base, thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '), tags: post_record.snapshot_tag_names.join(' '),
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), parent: post_record.parent,
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,
+6 -8
View File
@@ -107,13 +107,11 @@ RSpec.describe Tag, type: :model do
context 'when the source tag_name has a wiki_page' do context 'when the source tag_name has a wiki_page' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
let!(:wiki_page) do let!(:wiki_page) do
admin = create_admin_user! WikiPage.create!(
tag_name: source_tag_name,
Wiki::Commit.create_content!( created_user: create_admin_user!,
tag_name: source_tag_name, updated_user: create_admin_user!
body: 'source wiki body', )
created_by_user: admin,
message: 'init')
end end
it 'rolls back the transaction' do it 'rolls back the transaction' do
@@ -161,7 +159,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_ids: post.snapshot_parent_post_ids.join(' '), parent: post.parent,
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,
File diff suppressed because it is too large Load Diff
+11 -41
View File
@@ -26,22 +26,17 @@ RSpec.describe 'TagVersions API', type: :request do
created_by_user:, created_by_user:,
created_at: created_at:
) )
version = TagVersion.create!(
TagVersion.create!( tag: tag,
tag: tag, version_no: version_no,
version_no: version_no, event_type: event_type,
event_type: event_type, name: name,
name: name, category: category,
category: category, aliases: Array(aliases).join(' '),
aliases: Array(aliases).join(' '), parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
parent_tag_ids: Array(parent_tags).map(&:id).join(' '), created_by_user: created_by_user,
created_by_user: created_by_user, created_at: created_at
created_at: created_at) )
tag.update_columns(version_no: version_no) if tag.has_attribute?(:version_no)
tag.version_no = version_no if tag.respond_to?(:version_no=)
version
end end
let!(:v1) do let!(:v1) do
@@ -219,30 +214,5 @@ RSpec.describe 'TagVersions API', type: :request do
expect(versions.size).to eq(1) expect(versions.size).to eq(1)
expect(versions.first['version_no']).to eq(2) expect(versions.first['version_no']).to eq(2)
end end
it 'does not create tag versions by wiki updates when tag has no versions yet' do
wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki')
wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general)
wiki_page =
Wiki::Commit.create_content!(
tag_name: wiki_tag_name,
body: 'before',
created_by_user: member,
message: 'init')
Wiki::Commit.content!(
page: wiki_page,
body: 'after',
created_user: member,
message: 'edit',
base_revision_id: wiki_page.current_revision.id)
get '/tags/versions', params: { id: wiki_tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('versions')).to eq([])
expect(json.fetch('count')).to eq(0)
end
end end
end end
@@ -1,160 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag and wiki history integrity', type: :request do
let(:member_user) { create(:user, role: 'member') }
def stub_current_user user
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
end
def create_tag! name:, category: :general
tag_name = TagName.create!(name:)
Tag.create!(tag_name:, category:)
end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body:,
created_by_user: user,
message: 'init')
end
before do
stub_current_user(member_user)
end
describe 'PATCH /tags/:id' do
it 'records wiki_version when tag name changes and tag has wiki' do
tag = create_tag!(name: 'patch_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
expect {
patch "/tags/#{ tag.id }", params: {
name: 'patch_tag_wiki_after',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
tag.reload
wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(tag.name).to eq('patch_tag_wiki_after')
expect(wiki_page.title).to eq('patch_tag_wiki_after')
expect(version).to have_attributes(
event_type: 'update',
title: 'patch_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
it 'does not record wiki_version when only category changes' do
tag = create_tag!(name: 'patch_tag_category_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = wiki_page.wiki_versions.count
expect {
patch "/tags/#{ tag.id }", params: {
category: 'meme',
}
}
.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
tag.reload
wiki_page.reload
expect(tag.name).to eq('patch_tag_category_only')
expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end
end
describe 'PUT /tags/:id' do
it 'records wiki_version when tag name changes and tag has wiki' do
tag = create_tag!(name: 'put_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_wiki_after',
category: 'general',
aliases: '',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
tag.reload
wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(tag.name).to eq('put_tag_wiki_after')
expect(wiki_page.title).to eq('put_tag_wiki_after')
expect(version).to have_attributes(
event_type: 'update',
title: 'put_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
it 'does not record wiki_version when only category changes' do
tag = create_tag!(name: 'put_tag_category_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = wiki_page.wiki_versions.count
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_category_only',
category: 'meme',
aliases: '',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
tag.reload
wiki_page.reload
expect(tag.name).to eq('put_tag_category_only')
expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end
it 'does not record wiki_version when only aliases change' do
tag = create_tag!(name: 'put_tag_alias_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = wiki_page.wiki_versions.count
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_alias_only',
category: 'general',
aliases: 'put_tag_alias_only_alias',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions)
end
end
end
+17 -227
View File
@@ -8,35 +8,23 @@ 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 with tag and empty deerjikists array' do it 'returns 200 and empty 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
@@ -46,27 +34,17 @@ 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 with tag and deerjikists array' do it 'returns 200 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(Hash) expect(json).to be_a(Array)
expect(json.size).to eq(2)
expect(json['tag']).to be_a(Hash) expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly(
expect(json['tag']['id']).to eq(tag.id) [platform1, code1],
expect(json['tag']['name']).to eq('deerjika') [platform2, code2],
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
@@ -75,7 +53,6 @@ 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
@@ -83,7 +60,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' }
@@ -93,7 +70,6 @@ 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
@@ -103,209 +79,23 @@ 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 with tag and deerjikists array' do it 'returns 200 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(Hash) expect(json).to be_a(Array)
expect(json.size).to eq(1)
expect(json['tag']).to be_a(Hash) expect(json[0]['platform']).to eq(platform1)
expect(json['tag']['id']).to eq(tag.id) expect(json[0]['code']).to eq(code1)
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
-176
View File
@@ -471,54 +471,6 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.category).to eq('nico') expect(nico_tag.reload.category).to eq('nico')
end end
it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
expect {
patch "/tags/#{ tag.id }", params: {
name: 'patch_wiki_renamed_tag',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'patch_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
it 'tag の category だけを変更しても wiki version は作成しない' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
before_wiki_version_count = wiki_page.reload.wiki_versions.count
expect {
patch "/tags/#{ tag.id }", params: {
category: 'meme',
}
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
end
end end
end end
@@ -924,134 +876,6 @@ RSpec.describe 'Tags API', type: :request do
expect(system_tag.reload.name).to eq(old_name) expect(system_tag.reload.name).to eq(old_name)
expect(system_tag.category).to eq(old_category) expect(system_tag.category).to eq(old_category)
end end
it 'wiki を持つ tag を更新すると wiki version も作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
Wiki::Commit.content!(
page: wiki_page,
body: 'wiki body before',
created_user: member_user,
message: 'init'
)
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_wiki_version_tag',
category: 'meme',
aliases: 'unko',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_version_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
old_owner = Tag.create!(
tag_name: TagName.create!(name: 'put_alias_old_owner'),
category: :general
)
stolen_alias = TagName.create!(
name: 'put_stolen_alias',
canonical: old_owner.tag_name
)
expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias')
expect {
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko put_stolen_alias',
parent_tags: '',
}
}
.to change { tag.reload.tag_versions.count }.by(2)
.and change { old_owner.reload.tag_versions.count }.by(2)
expect(response).to have_http_status(:ok)
expect(stolen_alias.reload.canonical).to eq(tag.tag_name)
expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias')
old_owner_versions = old_owner.tag_versions.order(:version_no)
expect(old_owner_versions.first.event_type).to eq('create')
expect(old_owner_versions.first.aliases.split).to include('put_stolen_alias')
expect(old_owner_versions.second.event_type).to eq('update')
expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
end
it 'parent_tags に指定すると循環する tag は 422 にする' do
pending '#332 で対応予定'
child = Tag.create!(
tag_name: TagName.create!(name: 'put_cycle_child'),
category: :general
)
TagImplication.create!(tag: child, parent_tag: tag)
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: child.name,
}
expect(response).to have_http_status(:unprocessable_entity)
expect(TagImplication.where(tag:, parent_tag: child)).not_to exist
end
it 'tag の name を変更すると対応する wiki version を作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_wiki_renamed_tag',
category: 'general',
aliases: 'unko',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end
end end
end end
end end
+72 -228
View File
@@ -1,266 +1,110 @@
require 'rails_helper' require "rails_helper"
RSpec.describe 'Users', type: :request do
let(:remote_ip) { '203.0.113.10' }
before do RSpec.describe "Users", type: :request do
allow_any_instance_of(ActionDispatch::Request) describe "POST /users" do
.to receive(:remote_ip) it "creates guest user and returns code" do
.and_return(remote_ip) post "/users"
end expect(response).to have_http_status(:ok)
expect(json["code"]).to be_present
def auth_headers(user) expect(json["user"]["role"]).to eq("guest")
{ 'X-Transfer-Code' => user.inheritance_code }
end
describe 'POST /users' do
it 'creates guest user, IpAddress and UserIp, and returns code' do
expect {
post '/users'
}.to change(User, :count).by(1)
.and change(IpAddress, :count).by(1)
.and change(UserIp, :count).by(1)
expect(response).to have_http_status(:created)
expect(json['code']).to be_present
expect(json['user']['role']).to eq('guest')
user = User.last
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(user.role).to eq('guest')
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end
it 'returns 403 and does not create user when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
expect {
post '/users'
}.not_to change(User, :count)
expect(response).to have_http_status(:forbidden)
expect(UserIp.count).to eq(0)
end end
end end
describe 'POST /users/code/renew' do describe "POST /users/code/renew" do
it 'returns 401 when not logged in' do it "returns 401 when not logged in" do
post '/users/code/renew' sign_out
post "/users/code/renew"
expect(response).to have_http_status(:unauthorized)
end
end
describe "PUT /users/:id" do
let(:user) { create(:user, name: "old-name", role: "guest") }
it "returns 401 when current_user id mismatch" do
sign_in_as(create(:user))
put "/users/#{user.id}", params: { name: "new-name" }
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
it 'returns 403 when current user is banned' do it "returns 400 when name is blank" do
user = create(:user, :banned) sign_in_as(user)
put "/users/#{user.id}", params: { name: " " }
post '/users/code/renew', headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
end
it 'returns 403 when current IP address is banned' do
user = create(:user)
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
post '/users/code/renew', headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
end
end
describe 'PUT /users/:id' do
let(:user) { create(:user, name: 'old-name', role: 'guest') }
it 'returns 401 when current_user id mismatch' do
other_user = create(:user)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(other_user)
expect(response).to have_http_status(:unauthorized)
end
it 'returns 400 when name is blank' do
put "/users/#{user.id}",
params: { name: ' ' },
headers: auth_headers(user)
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end end
it 'updates name and returns user slice' do it "updates name and returns 201 with user slice" do
put "/users/#{user.id}", sign_in_as(user)
params: { name: 'new-name' }, put "/users/#{user.id}", params: { name: "new-name" }
headers: auth_headers(user)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:created)
expect(json['id']).to eq(user.id) expect(json["id"]).to eq(user.id)
expect(json['name']).to eq('new-name') expect(json["name"]).to eq("new-name")
user.reload user.reload
expect(user.name).to eq('new-name') expect(user.name).to eq("new-name")
end
it 'returns 403 when current user is banned' do
user.update!(banned_at: Time.current)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
user.reload
expect(user.name).to eq('old-name')
end
it 'returns 403 when current IP address is banned' do
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
put "/users/#{user.id}",
params: { name: 'new-name' },
headers: auth_headers(user)
expect(response).to have_http_status(:forbidden)
user.reload
expect(user.name).to eq('old-name')
end end
end end
describe 'POST /users/verify' do describe "POST /users/verify" do
it 'returns valid:false when code not found' do it "returns valid:false when code not found" do
post '/users/verify', params: { code: 'nope' } post "/users/verify", params: { code: "nope" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(false) expect(json["valid"]).to eq(false)
end end
it 'returns 403 when current IP address is banned' do it "creates IpAddress and UserIp, and returns valid:true with user slice" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest') user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
IpAddress.create!( # request.remote_ip を固定
ip_address: IPAddr.new(remote_ip).hton, allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
banned_at: Time.current
)
expect { expect {
post '/users/verify', params: { code: user.inheritance_code } post "/users/verify", params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:forbidden)
end
it 'returns 403 when verified user is banned' do
user = create(
:user,
:banned,
inheritance_code: SecureRandom.uuid,
role: 'guest'
)
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:forbidden)
end
it 'creates IpAddress and UserIp, and returns valid:true with user slice' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1)
.and change(IpAddress, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true)
expect(json['user']['id']).to eq(user.id)
expect(json['user']['inheritance_code']).to eq(user.inheritance_code)
expect(json['user']['role']).to eq('guest')
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
expect(ip_address).to be_present
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
end
it 'is idempotent for same user and same IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true)
end
it 'creates another UserIp for same user and different IP address' do
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
post '/users/verify', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
allow_any_instance_of(ActionDispatch::Request)
.to receive(:remote_ip)
.and_return('203.0.113.11')
expect {
post '/users/verify', params: { code: user.inheritance_code }
}.to change(UserIp, :count).by(1) }.to change(UserIp, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['valid']).to eq(true) expect(json["valid"]).to eq(true)
expect(json["user"]["id"]).to eq(user.id)
expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code)
expect(json["user"]["role"]).to eq("guest")
# ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる)
expect(IpAddress.count).to be >= 1
end
it "is idempotent for same user+ip (does not create duplicate UserIp)" do
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
post "/users/verify", params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok)
expect {
post "/users/verify", params: { code: user.inheritance_code }
}.not_to change(UserIp, :count)
expect(response).to have_http_status(:ok)
expect(json["valid"]).to eq(true)
end end
end end
describe 'GET /users/me' do describe "GET /users/me" do
it 'returns 404 when code not found' do it "returns 404 when code not found" do
get '/users/me', params: { code: 'nope' } get "/users/me", params: { code: "nope" }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns user slice when found' do it "returns user slice when found" do
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest') user = create(:user, inheritance_code: SecureRandom.uuid, name: "me", role: "guest")
get "/users/me", params: { code: user.inheritance_code }
get '/users/me', params: { code: user.inheritance_code }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['id']).to eq(user.id) expect(json["id"]).to eq(user.id)
expect(json['name']).to eq('me') expect(json["name"]).to eq("me")
expect(json['inheritance_code']).to eq(user.inheritance_code) expect(json["inheritance_code"]).to eq(user.inheritance_code)
expect(json['role']).to eq('guest') expect(json["role"]).to eq("guest")
end
it 'returns 403 when current IP address is banned' do
user = create(:user, inheritance_code: SecureRandom.uuid)
IpAddress.create!(
ip_address: IPAddr.new(remote_ip).hton,
banned_at: Time.current
)
get '/users/me', params: { code: user.inheritance_code }
expect(response).to have_http_status(:forbidden)
end end
end end
end end
@@ -1,27 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki body search', type: :request do
let!(:user) { create_member_user! }
it 'searches wiki pages by body text' do
pending '#336 で対応予定'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
body: 'unique body keyword for wiki search',
created_by_user: user,
message: 'init')
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
body: 'ordinary body',
created_by_user: user,
message: 'init')
get '/wiki/search', params: { body: 'unique body keyword' }
expect(response).to have_http_status(:ok)
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
end
end
@@ -1,42 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki conflict handling', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
it 'returns 409 when base_revision_id is stale' do
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_conflict_request'),
body: 'first',
created_by_user: user,
message: 'init')
stale_id = page.current_revision.id
Wiki::Commit.content!(
page:,
body: 'second',
created_user: user,
message: 'other edit',
base_revision_id: stale_id)
put "/wiki/#{ page.id }",
params: {
title: 'wiki_conflict_request',
body: 'third',
message: 'stale',
base_revision_id: stale_id,
},
headers: auth_headers(user)
expect(response).to have_http_status(:conflict)
page.reload
expect(page.body).to eq('second')
expect(page.current_revision.message).to eq('other edit')
end
end
@@ -1,196 +0,0 @@
require 'cgi'
require 'rails_helper'
RSpec.describe 'Wiki history integrity', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message:)
end
describe 'POST /wiki' do
it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
expect {
post '/wiki',
params: {
title: 'wiki_history_create_atomic',
body: "a\nb\nc",
message: 'initial commit',
},
headers: auth_headers(user)
}
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('wiki_history_create_atomic')
expect(page.body).to eq("a\nb\nc")
expect(revision).to be_content
expect(revision.message).to eq('initial commit')
expect(revision.lines_count).to eq(3)
expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'wiki_history_create_atomic',
body: "a\nb\nc",
reason: 'initial commit',
created_by_user_id: user.id
)
end
it 'returns 422 and creates nothing when normalised body is blank' do
expect {
post '/wiki',
params: {
title: 'wiki_history_blank_body',
body: "\r\n\r\n",
message: 'blank',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist
end
it 'returns 422 and creates no partial page when title already exists' do
create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first')
expect {
post '/wiki',
params: {
title: 'wiki_history_duplicate_title',
body: 'second',
message: 'duplicate',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1)
end
end
describe 'PUT /wiki/:id' do
it 'updates body and records wiki_revision and wiki_version' do
page = create_wiki_page(title: 'wiki_history_update_body', body: 'before')
current_id = page.current_revision.id
expect {
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_update_body',
body: 'after',
message: 'edit body',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('wiki_history_update_body')
expect(page.body).to eq('after')
expect(version).to have_attributes(
event_type: 'update',
title: 'wiki_history_update_body',
body: 'after',
reason: 'edit body',
created_by_user_id: user.id
)
end
it 'renames title and records wiki_version with new title' do
page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before')
current_id = page.current_revision.id
expect {
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_rename_after',
body: 'after',
message: 'rename',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('wiki_history_rename_after')
expect(page.body).to eq('after')
expect(version).to have_attributes(
event_type: 'update',
title: 'wiki_history_rename_after',
body: 'after',
reason: 'rename',
created_by_user_id: user.id
)
end
it 'does not change title, body, revision, or version on stale base_revision_id' do
page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first')
stale_id = page.current_revision.id
Wiki::Commit.content!(
page:,
body: 'second',
created_user: user,
message: 'other edit',
base_revision_id: stale_id)
page.reload
current_title = page.title
current_body = page.body
revision_count = page.wiki_revisions.count
version_count = page.wiki_versions.count
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_conflict_renamed',
body: 'third',
message: 'stale edit',
base_revision_id: stale_id,
},
headers: auth_headers(user)
expect(response).to have_http_status(:conflict)
page.reload
expect(page.title).to eq(current_title)
expect(page.body).to eq(current_body)
expect(page.wiki_revisions.count).to eq(revision_count)
expect(page.wiki_versions.count).to eq(version_count)
end
end
end
@@ -1,37 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki restore', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
it 'restores wiki page to previous version' do
pending '#337 で対応予定'
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_restore_page'),
body: 'v1',
created_by_user: user,
message: 'init')
v1 = page.wiki_versions.order(:version_no).last
Wiki::Commit.content!(
page:,
body: 'v2',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
post "/wiki/#{ page.id }/restore",
params: { version_no: v1.version_no },
headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('v1')
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
end
end
+75 -211
View File
@@ -4,19 +4,13 @@ require 'securerandom'
RSpec.describe 'Wiki API', type: :request do RSpec.describe 'Wiki API', type: :request do
def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tn) { TagName.create!(name: 'spec_wiki_title') } let!(:tn) { TagName.create!(name: 'spec_wiki_title') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( WikiPage.create!(tag_name: tn, created_user: user, updated_user: user).tap do |p|
tag_name: tn, Wiki::Commit.content!(page: p, body: 'init', created_user: user, message: 'init')
body: 'init', end
created_by_user: user,
message: 'init')
end end
describe 'GET /wiki' do describe 'GET /wiki' do
@@ -43,12 +37,11 @@ RSpec.describe 'Wiki API', type: :request do
context 'when wiki page exists' do context 'when wiki page exists' do
it 'returns wiki page with title' do it 'returns wiki page with title' do
request request
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to include( expect(json).to include(
'id' => page.id, 'id' => page.id,
'title' => 'spec_wiki_title') 'title' => 'spec_wiki_title')
end end
end end
@@ -57,7 +50,6 @@ RSpec.describe 'Wiki API', type: :request do
it 'returns 404' do it 'returns 404' do
request request
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
@@ -107,34 +99,25 @@ RSpec.describe 'Wiki API', type: :request do
end end
.to change(WikiPage, :count).by(1) .to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
page_id = json.fetch('id') page_id = json.fetch('id')
expect(json.fetch('title')).to eq('TestPage') expect(json.fetch('title')).to eq('TestPage')
created_page = WikiPage.find(page_id) page = WikiPage.find(page_id)
version = created_page.wiki_versions.order(:version_no).last rev = page.current_revision
expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'TestPage',
body: "a\nb\nc",
created_by_user_id: member.id
)
rev = created_page.current_revision
expect(rev).to be_present expect(rev).to be_present
expect(rev).to be_content expect(rev).to be_content
expect(rev.message).to eq('init') expect(rev.message).to eq('init')
expect(created_page.body).to eq("a\nb\nc") # body が復元できること
expect(page.body).to eq("a\nb\nc")
# 行数とリレーションの整合
expect(rev.lines_count).to eq(3) expect(rev.lines_count).to eq(3)
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c']) expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c])
end end
it 'reuses existing WikiLine rows by sha256' do it 'reuses existing WikiLine rows by sha256' do
@@ -152,41 +135,6 @@ RSpec.describe 'Wiki API', type: :request do
# "a" の WikiLine が増殖しない(1行のはず) # "a" の WikiLine が増殖しない(1行のはず)
expect(WikiLine.where(body: 'a').count).to eq(1) expect(WikiLine.where(body: 'a').count).to eq(1)
end end
it 'deduplicates duplicated new lines before upsert' do
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
post endpoint,
params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
headers: auth_headers(member)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
expect(rev.lines_count).to eq(2)
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(rev.wiki_revision_lines.count).to eq(2)
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end
it 'normalises CRLF and strips trailing newlines' do
post endpoint,
params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' },
headers: auth_headers(member)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(rev.lines_count).to eq(2)
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
end
end end
end end
@@ -198,14 +146,17 @@ RSpec.describe 'Wiki API', type: :request do
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end end
let!(:test_tag_name) { TagName.create!(name: 'TestPage') } #let!(:page) { create(:wiki_page, title: 'TestPage') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( build(:wiki_page, title: 'TestPage').tap do |p|
tag_name: test_tag_name, puts p.errors.full_messages unless p.valid?
body: "a\nb", p.save!
created_by_user: member, end
message: 'init') end
before do
# 初期版を 1 つ作っておく(更新が“2版目”になるように)
Wiki::Commit.content!(page: page, body: "a\nb", created_user: member, message: 'init')
end end
context 'when not logged in' do context 'when not logged in' do
@@ -231,6 +182,14 @@ RSpec.describe 'Wiki API', type: :request do
headers: auth_headers(member) headers: auth_headers(member)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'returns 422 when title mismatched (if you forbid rename here)' do
put "/wiki/#{page.id}",
params: { title: 'OtherTitle', body: 'x' },
headers: auth_headers(member)
# 君の controller 例だと title 変更は 422 にしてた
expect(response).to have_http_status(:unprocessable_entity)
end
end end
context 'when success' do context 'when success' do
@@ -241,18 +200,7 @@ RSpec.describe 'Wiki API', type: :request do
put "/wiki/#{page.id}", put "/wiki/#{page.id}",
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
headers: auth_headers(member) headers: auth_headers(member)
end end.to change(WikiRevision, :count).by(1)
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
version = page.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'TestPage',
body: "x\ny",
created_by_user_id: member.id
)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -263,60 +211,25 @@ RSpec.describe 'Wiki API', type: :request do
expect(page.body).to eq("x\ny") expect(page.body).to eq("x\ny")
expect(rev.base_revision_id).to eq(current_id) expect(rev.base_revision_id).to eq(current_id)
end end
it 'wiki body だけを変更しても tag version は作成しない' do
linked_tag_name = TagName.create!(name: 'wiki_body_only_tag')
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)
TagVersionRecorder.record!(
tag: linked_tag,
event_type: :create,
created_by_user: member)
linked_page =
Wiki::Commit.create_content!(
tag_name: linked_tag_name,
body: 'before',
created_by_user: member,
message: 'init')
current_id = linked_page.current_revision.id
before_count = linked_tag.reload.tag_versions.count
expect {
put "/wiki/#{ linked_page.id }",
params: {
title: 'wiki_body_only_tag',
body: 'after',
message: 'edit',
base_revision_id: current_id,
},
headers: auth_headers(member)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(linked_tag.reload.tag_versions.count).to eq(before_count)
end
end end
context 'when conflict' do # TODO: コンフリクト未実装のため,実装したらコメント外す.
it 'returns 409 when base_revision_id mismatches' do # context 'when conflict' do
# 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める # it 'returns 409 when base_revision_id mismatches' do
Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit') # # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
page.reload # Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit')
# page.reload
stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id # stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id
put "/wiki/#{page.id}", # put "/wiki/#{page.id}",
params: { title: 'TestPage', body: 'x', base_revision_id: stale_id }, # params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
headers: auth_headers(member) # headers: auth_headers(member)
expect(response).to have_http_status(:conflict) # expect(response).to have_http_status(:conflict)
json = JSON.parse(response.body) # json = JSON.parse(response.body)
expect(json['error']).to eq('conflict') # expect(json['error']).to eq('conflict')
end # end
end # end
context 'when page not found' do context 'when page not found' do
it 'returns 404' do it 'returns 404' do
@@ -348,17 +261,14 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
Wiki::Commit.create_content!( # 追加で検索ヒット用
tag_name: TagName.create!(name: 'spec_wiki_title_2'), TagName.create!(name: 'spec_wiki_title_2')
body: 'search body 2', WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'),
created_by_user: user, created_user: user, updated_user: user)
message: 'init')
Wiki::Commit.create_content!( TagName.create!(name: 'unrelated_title')
tag_name: TagName.create!(name: 'unrelated_title'), WikiPage.create!(tag_name: TagName.find_by!(name: 'unrelated_title'),
body: 'unrelated body', created_user: user, updated_user: user)
created_by_user: user,
message: 'init')
end end
it 'returns up to 20 pages filtered by title like' do it 'returns up to 20 pages filtered by title like' do
@@ -368,9 +278,7 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to be_an(Array) expect(json).to be_an(Array)
titles = json.map { |p| p['title'] } titles = json.map { |p| p['title'] }
expect(titles).to include('spec_wiki_title', 'spec_wiki_title_2')
expect(titles).to include('spec_wiki_title')
expect(titles).to include('spec_wiki_title_2')
expect(titles).not_to include('unrelated_title') expect(titles).not_to include('unrelated_title')
end end
@@ -421,12 +329,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'returns empty array when page has no revisions and filtered by id' do it 'returns empty array when page has no revisions and filtered by id' do
# 別ページを作って revision 無し # 別ページを作って revision 無し
tn2 = TagName.create!(name: 'spec_no_rev') tn2 = TagName.create!(name: 'spec_no_rev')
# 異常データ: revision 無し WikiPage を直接作る p2 = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)
p2 = WikiPage.create!(
tag_name: tn2,
body: 'init',
created_user: user,
updated_user: user)
get "/wiki/changes?id=#{p2.id}" get "/wiki/changes?id=#{p2.id}"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -495,68 +398,29 @@ RSpec.describe 'Wiki API', type: :request do
expect(json['older_revision_id']).to eq(rev_a.id) expect(json['older_revision_id']).to eq(rev_a.id)
expect(json['newer_revision_id']).to eq(page.current_revision.id) expect(json['newer_revision_id']).to eq(page.current_revision.id)
end end
end
describe 'Wiki::Commit.redirect!' do it 'returns 422 when "to" is redirect revision' do
it 'raises because redirect revisions are deprecated' do # redirect revision を作る
target_tag_name = TagName.create!(name: 'redirect_deprecated_target') tn2 = TagName.create!(name: 'redirect_target')
target = target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)
Wiki::Commit.create_content!(
tag_name: target_tag_name,
body: 'target',
created_by_user: user,
message: 'init')
expect { Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir')
Wiki::Commit.redirect!( redirect_rev = page.current_revision
page: page, expect(redirect_rev).to be_redirect
redirect_page: target,
created_user: user, get "/wiki/#{page.id}/diff?from=#{rev_a.id}&to=#{redirect_rev.id}"
message: 'redirect', expect(response).to have_http_status(:unprocessable_entity)
base_revision_id: page.current_revision.id end
)
}.to raise_error(RuntimeError, '廃止しました.') it 'returns 422 when "from" is redirect revision' do
tn2 = TagName.create!(name: 'redirect_target2')
target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)
Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir2')
redirect_rev = page.current_revision
get "/wiki/#{page.id}/diff?from=#{redirect_rev.id}&to=#{rev_b.id}"
expect(response).to have_http_status(:unprocessable_entity)
end end
end end
it 'wiki title を変更すると対応する tag の version を作成する' do
linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version')
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)
linked_page =
Wiki::Commit.create_content!(
tag_name: linked_tag_name,
body: 'before',
created_by_user: user,
message: 'init')
current_id = linked_page.current_revision.id
expect {
put "/wiki/#{ linked_page.id }",
params: {
title: 'wiki_linked_tag_for_version_renamed',
body: 'after',
message: 'rename',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change { linked_tag.reload.tag_versions.count }.by(2)
expect(response).to have_http_status(:ok)
linked_tag.reload
expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed')
versions = linked_tag.tag_versions.order(:version_no)
expect(versions.first.event_type).to eq('create')
expect(versions.first.name).to eq('wiki_linked_tag_for_version')
expect(versions.second.event_type).to eq('update')
expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed')
end
end end
@@ -1,62 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki title collision', type: :request do
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
def create_wiki_page title:, body:
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end
it 'returns 422 when renaming wiki title to existing title' do
source = create_wiki_page(title: 'wiki_collision_source', body: 'source body')
create_wiki_page(title: 'wiki_collision_target', body: 'target body')
source_revision_count = source.wiki_revisions.count
source_version_count = source.wiki_versions.count
old_title = source.title
old_body = source.body
put "/wiki/#{ source.id }",
params: {
title: 'wiki_collision_target',
body: 'new body',
message: 'rename collision',
base_revision_id: source.current_revision.id,
},
headers: auth_headers(user)
expect(response).to have_http_status(:unprocessable_entity)
source.reload
expect(source.title).to eq(old_title)
expect(source.body).to eq(old_body)
expect(source.wiki_revisions.count).to eq(source_revision_count)
expect(source.wiki_versions.count).to eq(source_version_count)
end
it 'returns 422 when creating wiki with existing title' do
create_wiki_page(title: 'wiki_collision_create', body: 'already exists')
expect {
post '/wiki',
params: {
title: 'wiki_collision_create',
body: 'new body',
message: 'duplicate create',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
end
@@ -1,85 +0,0 @@
require 'rails_helper'
RSpec.describe VersionRecorder do
let(:member) { create(:user, :member) }
let(:post_record) do
Post.create!(
title: 'version recorder post',
url: 'https://example.com/version-recorder-post')
end
it 'updates record version_no when creating the first version' do
version =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect(version.version_no).to eq(1)
expect(post_record.reload.version_no).to eq(1)
end
it 'updates record version_no when creating the next version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated version recorder post')
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version.version_no).to eq(2)
expect(post_record.reload.version_no).to eq(2)
end
it 'does not create a new version or advance version_no when snapshot is unchanged' do
first =
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
expect {
version =
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
expect(version).to eq(first)
}.not_to change(PostVersion, :count)
expect(post_record.reload.version_no).to eq(1)
end
it 'raises when record version_no is older than the latest version' do
PostVersionRecorder.record!(
post: post_record,
event_type: :create,
created_by_user: member)
post_record.update!(title: 'updated once')
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
post_record.update_columns(version_no: 1)
post_record.update!(title: 'updated with stale version_no')
expect {
PostVersionRecorder.record!(
post: post_record.reload,
event_type: :update,
created_by_user: member)
}.to raise_error(RuntimeError, /version_no/)
end
end
@@ -1,173 +0,0 @@
require 'digest'
require 'rails_helper'
RSpec.describe Wiki::Commit do
let(:user) { create_member_user! }
def create_page title:, body: 'initial body'
described_class.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end
describe '.create_content!' do
it 'creates page, revision, and version with normalised body' do
expect {
described_class.create_content!(
tag_name: TagName.create!(name: 'commit_integrity_create'),
body: "a\r\nb\r\n\r\n",
created_by_user: user,
message: 'init')
}
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(revision.lines_count).to eq(2)
expect(version.body).to eq("a\nb")
expect(version.reason).to eq('init')
end
it 'rejects body that becomes blank after normalisation' do
tag_name = TagName.create!(name: 'commit_integrity_blank')
expect {
described_class.create_content!(
tag_name:,
body: "\r\n\r\n",
created_by_user: user,
message: 'blank')
}
.to raise_error(ActiveRecord::RecordInvalid)
expect(WikiPage.where(tag_name:)).not_to exist
end
end
describe '.content!' do
it 'updates page body, revision, and version' do
page = create_page(title: 'commit_integrity_update', body: 'before')
current_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_id)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq('after')
expect(version.body).to eq('after')
expect(version.reason).to eq('edit')
end
it 'does not record tag_version on body-only wiki update' do
tag_name = TagName.create!(name: 'commit_integrity_linked_tag')
tag = Tag.create!(tag_name:, category: :general)
page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
TagVersionRecorder.record!(
tag:,
event_type: :create,
created_by_user: user)
before_count = tag.reload.tag_versions.count
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
expect(tag.reload.tag_versions.count).to eq(before_count)
end
it 'raises conflict and leaves page, revision, and version unchanged' do
page = create_page(title: 'commit_integrity_conflict', body: 'first')
stale_id = page.current_revision.id
described_class.content!(
page:,
body: 'second',
created_user: user,
message: 'second',
base_revision_id: stale_id)
page.reload
before_body = page.body
before_revision_count = page.wiki_revisions.count
before_version_count = page.wiki_versions.count
expect {
described_class.content!(
page:,
body: 'third',
created_user: user,
message: 'stale',
base_revision_id: stale_id)
}
.to raise_error(Wiki::Commit::Conflict)
page.reload
expect(page.body).to eq(before_body)
expect(page.wiki_revisions.count).to eq(before_revision_count)
expect(page.wiki_versions.count).to eq(before_version_count)
end
it 'deduplicates duplicated missing wiki lines' do
page = create_page(title: 'commit_integrity_dedup', body: 'before')
duplicated = 'commit_integrity_duplicate_line'
described_class.content!(
page:,
body: "#{ duplicated }\n#{ duplicated }",
created_user: user,
message: 'dedup',
base_revision_id: page.current_revision.id)
revision = page.reload.current_revision
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(revision.wiki_revision_lines.count).to eq(2)
expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end
end
describe '.redirect!' do
it 'raises because redirect revisions are deprecated' do
page = create_page(title: 'commit_integrity_redirect_source', body: 'source')
target = create_page(title: 'commit_integrity_redirect_target', body: 'target')
expect {
described_class.redirect!(
page:,
redirect_page: target,
created_user: user,
message: 'redirect',
base_revision_id: page.current_revision.id)
}
.to raise_error(RuntimeError, '廃止しました.')
end
end
end
-150
View File
@@ -1,150 +0,0 @@
require 'rails_helper'
RSpec.describe Wiki::Commit do
let(:user) { create_member_user! }
def create_page(title: 'commit_spec_page', body: 'initial body')
tag_name = TagName.create!(name: title)
Wiki::Commit.create_content!(
tag_name:,
body:,
created_by_user: user,
message: 'init')
end
describe '.content!' do
it 'stores normalised body in wiki_pages and wiki_versions' do
page = create_page(title: 'commit_normalised_page')
described_class.content!(
page:,
body: "a\r\nb\r\n\r\n",
created_user: user,
message: 'init'
)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(page.current_revision.lines_count).to eq(2)
end
it 'deduplicates duplicated missing wiki lines before upsert' do
page = create_page(title: 'commit_duplicate_line_page')
duplicated = 'commit_duplicate_line'
described_class.content!(
page:,
body: "#{ duplicated }\n#{ duplicated }",
created_user: user,
message: 'init'
)
page.reload
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(page.current_revision.lines_count).to eq(2)
expect(page.current_revision.wiki_revision_lines.count).to eq(2)
end
it 'raises conflict when base_revision_id is stale' do
page = create_page(title: 'commit_conflict_page')
first = described_class.content!(
page:,
body: 'first',
created_user: user,
message: 'first'
)
described_class.content!(
page:,
body: 'second',
created_user: user,
message: 'second',
base_revision_id: first.id
)
expect {
described_class.content!(
page:,
body: 'third',
created_user: user,
message: 'third',
base_revision_id: first.id
)
}.to raise_error(Wiki::Commit::Conflict)
end
it 'does not record tag version when corresponding tag has no versions' do
tag_name = TagName.create!(name: 'commit_linked_tag_without_versions')
tag = Tag.create!(tag_name:, category: :general)
page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
expect(tag.reload.tag_versions.count).to eq(0)
current_revision_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_revision_id)
}.to change(WikiVersion, :count).by(1)
expect(tag.reload.tag_versions.count).to eq(0)
end
it 'does not record tag version when corresponding tag has no versions' do
tag_name = TagName.create!(name: 'commit_linked_tag_without_versions')
tag = Tag.create!(tag_name:, category: :general)
page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
current_revision_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_revision_id)
}.to change(WikiVersion, :count).by(1)
expect(tag.reload.tag_versions.count).to eq(0)
end
end
describe '.redirect!' do
it 'raises because redirect revisions are deprecated' do
page = create_page(title: 'commit_redirect_source')
target = create_page(title: 'commit_redirect_target')
expect {
described_class.redirect!(
page:,
redirect_page: target,
created_user: user,
message: 'redirect'
)
}.to raise_error(RuntimeError, '廃止しました.')
end
end
end
@@ -1,99 +0,0 @@
require 'rails_helper'
RSpec.describe WikiVersionRecorder do
let(:user) { create_member_user! }
def create_page title:, body: 'body'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end
describe '.record!' do
it 'records title, body, reason, user, and version number' do
page = create_page(title: 'wiki_version_recorder_basic', body: 'body')
expect {
described_class.record!(
page:,
event_type: :update,
reason: 'manual reason',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)
version = page.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
version_no: 2,
event_type: 'update',
title: 'wiki_version_recorder_basic',
body: 'body',
reason: 'manual reason',
created_by_user_id: user.id
)
end
it 'does not create duplicated update version for identical snapshot' do
page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body')
described_class.record!(
page:,
event_type: :update,
reason: nil,
created_by_user: user)
before_count = page.reload.wiki_versions.count
described_class.record!(
page:,
event_type: :update,
reason: nil,
created_by_user: user)
expect(page.reload.wiki_versions.count).to eq(before_count)
end
it 'creates update version when title changes' do
page = create_page(title: 'wiki_version_recorder_title_before', body: 'body')
page.tag_name.update!(name: 'wiki_version_recorder_title_after')
expect {
described_class.record!(
page:,
event_type: :update,
reason: 'rename',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)
version = page.wiki_versions.order(:version_no).last
expect(version.title).to eq('wiki_version_recorder_title_after')
expect(version.body).to eq('body')
expect(version.reason).to eq('rename')
end
it 'creates update version when body changes' do
page = create_page(title: 'wiki_version_recorder_body', body: 'before')
page.update!(body: 'after')
expect {
described_class.record!(
page:,
event_type: :update,
reason: 'body',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)
version = page.wiki_versions.order(:version_no).last
expect(version.title).to eq('wiki_version_recorder_body')
expect(version.body).to eq('after')
expect(version.reason).to eq('body')
end
end
end
@@ -1,130 +0,0 @@
require 'rails_helper'
RSpec.describe Youtube::ApiClient do
let(:api_key) { 'test-api-key' }
let(:client) { described_class.new(api_key:) }
describe '#search_videos' do
it 'calls YouTube search API with expected params' do
published_after = Time.zone.parse('2026-05-01 00:00:00')
published_before = Time.zone.parse('2026-05-02 00:00:00')
expect(client).to receive(:get_json).with(
'/search',
{
part: 'snippet',
type: 'video',
q: 'ぼざろクリーチャー',
order: 'date',
maxResults: 50,
regionCode: 'JP',
relevanceLanguage: 'ja',
publishedAfter: published_after.iso8601,
publishedBefore: published_before.iso8601,
pageToken: 'NEXT'
}
).and_return({ 'items' => [] })
client.search_videos(
q: 'ぼざろクリーチャー',
published_after:,
published_before:,
page_token: 'NEXT'
)
end
it 'omits nil optional params' do
expect(client).to receive(:get_json).with(
'/search',
hash_excluding(:publishedAfter, :publishedBefore, :pageToken)
).and_return({ 'items' => [] })
client.search_videos(q: 'ぼざろクリーチャー')
end
end
describe '#videos' do
it 'returns empty items when ids are empty' do
expect(client).not_to receive(:get_json)
expect(client.videos([])).to eq({ 'items' => [] })
end
it 'calls videos API with comma separated ids' do
expect(client).to receive(:get_json).with(
'/videos',
{
part: 'snippet,status,contentDetails',
id: 'video-1,video-2'
}
).and_return({ 'items' => [] })
client.videos(['video-1', 'video-2'])
end
end
describe '#playlist_items' do
it 'calls playlistItems API with page token' do
expect(client).to receive(:get_json).with(
'/playlistItems',
{
part: 'snippet,contentDetails,status',
playlistId: 'PL123',
maxResults: 50,
pageToken: 'NEXT'
}
).and_return({ 'items' => [] })
client.playlist_items(playlist_id: 'PL123', page_token: 'NEXT')
end
it 'omits page token when nil' do
expect(client).to receive(:get_json).with(
'/playlistItems',
{
part: 'snippet,contentDetails,status',
playlistId: 'PL123',
maxResults: 50
}
).and_return({ 'items' => [] })
client.playlist_items(playlist_id: 'PL123')
end
end
describe '#channel' do
it 'calls channels API by id' do
expect(client).to receive(:get_json).with(
'/channels',
{
part: 'snippet,contentDetails',
id: 'UC123'
}
).and_return({ 'items' => [] })
client.channel(id: 'UC123')
end
it 'calls channels API by handle' do
expect(client).to receive(:get_json).with(
'/channels',
{
part: 'snippet,contentDetails',
forHandle: '@some_handle'
}
).and_return({ 'items' => [] })
client.channel(handle: '@some_handle')
end
it 'raises when neither id nor handle is given' do
expect { client.channel }.to raise_error(ArgumentError, 'id or handle is required')
end
it 'raises when both id and handle are given' do
expect do
client.channel(id: 'UC123', handle: '@some_handle')
end.to raise_error(ArgumentError, 'id or handle is required')
end
end
end
-310
View File
@@ -1,310 +0,0 @@
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
@@ -1,93 +0,0 @@
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
+4 -2
View File
@@ -2,12 +2,14 @@ module TestRecords
def create_member_user! def create_member_user!
User.create!(name: 'spec user', User.create!(name: 'spec user',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'member') role: 'member',
banned: false)
end end
def create_admin_user! def create_admin_user!
User.create!(name: 'spec admin', User.create!(name: 'spec admin',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'admin') role: 'admin',
banned: false)
end end
end end
-100
View File
@@ -1,100 +0,0 @@
require 'rails_helper'
require 'rake'
require 'open3'
RSpec.describe 'nico:export' do
let(:task) { Rake::Task['nico:export'] }
let(:success_status) { instance_double(Process::Status, success?: true) }
let(:failure_status) { instance_double(Process::Status, success?: false) }
def create_post(url)
Post.create!(url:)
end
before(:all) do
Rails.application.load_tasks unless Rake::Task.task_defined?('nico:export')
end
before do
task.reenable
allow(ENV).to receive(:fetch).with('MYSQL_USER').and_return('mysql-user')
allow(ENV).to receive(:fetch).with('MYSQL_PASS').and_return('mysql-pass')
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico')
end
describe 'export' do
it 'exports nicovideo ids to shared nico DB' do
create_post('https://www.nicovideo.jp/watch/sm12345?ref=foo')
create_post('https://www.nicovideo.jp/watch/so67890#comments')
create_post('https://www.nicovideo.jp/watch/nm24680')
create_post('https://example.com/watch/sm99999')
expect(Open3).to receive(:capture3) do |env, *args, **kwargs|
expect(env).to eq(
{
'MYSQL_USER' => 'mysql-user',
'MYSQL_PASS' => 'mysql-pass',
},
)
expect(args.take(3)).to eq(
[
'python3',
'-m',
'tracked_videos.put_bulk_upsert',
],
)
expect(args.drop(3)).to contain_exactly(
'sm12345',
'so67890',
'nm24680',
)
expect(kwargs).to eq(chdir: '/srv/nizika-nico')
['', '', success_status]
end
task.invoke
end
it 'deduplicates video ids' do
create_post('https://www.nicovideo.jp/watch/sm12345')
create_post('https://www.nicovideo.jp/watch/sm12345?from=1')
expect(Open3).to receive(:capture3) do |_env, *args, **_kwargs|
expect(args.drop(3)).to eq(['sm12345'])
['', '', success_status]
end
task.invoke
end
it 'does not call python when there are no nicovideo posts' do
create_post('https://example.com/watch/sm12345')
expect(Open3).not_to receive(:capture3)
task.invoke
end
it 'raises stderr when python command fails' do
create_post('https://www.nicovideo.jp/watch/sm12345')
allow(Open3).to receive(:capture3).and_return(
[
'',
'bulk upsert failed',
failure_status,
],
)
expect {
task.invoke
}.to raise_error(RuntimeError, 'bulk upsert failed')
end
end
end
+1 -1
View File
@@ -104,7 +104,7 @@ RSpec.describe "nico:sync" do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent: post.parent,
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
created_at: Time.current, created_at: Time.current,
-25
View File
@@ -1,25 +0,0 @@
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 -17
View File
@@ -8,10 +8,8 @@ import { BrowserRouter,
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
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'
@@ -60,7 +58,6 @@ 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/>}/>
@@ -139,21 +136,17 @@ export default (() => {
return ( return (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
<BrowserRouter> <BrowserRouter>
<DialogueProvider> <LayoutGroup>
<LayoutGroup> <motion.div
<motion.div layout="position"
layout="position" transition={{ layout: { duration: .2, ease: 'easeOut' } }}
transition={{ layout: { duration: .2, ease: 'easeOut' } }} className="flex flex-col h-dvh w-full overflow-y-hidden">
className="flex flex-col h-dvh w-full overflow-y-hidden"> <TopNav user={user}/>
<TopNav user={user}/> <RouteTransitionWrapper user={user} setUser={setUser}/>
<RouteTransitionWrapper user={user} setUser={setUser}/> </motion.div>
</motion.div> </LayoutGroup>
</LayoutGroup> <Toaster/>
<Toaster/>
</DialogueProvider>
</BrowserRouter> </BrowserRouter>
</>) </>)
}) satisfies FC }) satisfies FC
+23 -105
View File
@@ -3,12 +3,10 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea' 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 { useDialogue } from '@/components/dialogues/DialogueProvider'
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 { updatePost } from '@/lib/posts'
import type { FC, FormEvent } from 'react' import type { FC } from 'react'
import type { Post, Tag } from '@/types' import type { Post, Tag } from '@/types'
@@ -33,86 +31,24 @@ type Props = { post: Post
export default (({ post, onSave }: Props) => { export default (({ post, onSave }: Props) => {
const [disabled, setDisabled] = useState (false)
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
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 [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)
const [tags, setTags] = useState<string> ('')
const dialogue = useDialogue () const handleSubmit = async () => {
const data = await apiPut<Post> (
const update = async (...args: Parameters<typeof updatePost>) => { `/posts/${ post.id }`,
try { title, tags, original_created_from: originalCreatedFrom,
{ original_created_before: originalCreatedBefore },
const data = await updatePost (...args) { headers: { 'Content-Type': 'multipart/form-data' } })
onSave ({ ...post, onSave ({ ...post,
versionNo: data.versionNo, title: data.title,
title: data.title, tags: data.tags,
tags: data.tags, originalCreatedFrom: data.originalCreatedFrom,
parentPosts: data.parentPosts, originalCreatedBefore: data.originalCreatedBefore } as Post)
childPosts: data.childPosts,
siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
}
catch (e)
{
const response = (e as any)?.response
if (response?.status !== 409)
{
toast ({ description: '更新はできなかったよ……' })
return
}
const action = await dialogue.choice ({
title: '競合が発生しました.',
description: (
<div>
<p></p>
<p>?</p>
</div>),
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })
if (action === 'merge')
{
// TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true })
return
}
if (action === 'overwrite')
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true })
return
}
}
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
setDisabled (true)
try
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}
finally
{
setDisabled (false)
}
} }
useEffect (() => { useEffect (() => {
@@ -120,48 +56,30 @@ export default (({ post, onSave }: Props) => {
}, [post]) }, [post])
return ( return (
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> <div className="max-w-xl pt-2 space-y-4">
{/* タイトル */} {/* タイトル */}
<div> <div>
<Label></Label> <Label></Label>
<input <input type="text"
type="text" className="w-full border rounded p-2"
disabled={disabled} value={title ?? ''}
className="w-full border rounded p-2" onChange={ev => setTitle (ev.target.value)}/>
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
</div>
{/* 親投稿 */}
<div>
<Label>稿</Label>
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div> </div>
{/* タグ */} {/* タグ */}
<PostFormTagsArea <PostFormTagsArea tags={tags} setTags={setTags}/>
disabled={disabled}
tags={tags}
setTags={setTags}/>
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
disabled={disabled}
originalCreatedFrom={originalCreatedFrom} originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom} setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore} originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/> setOriginalCreatedBefore={setOriginalCreatedBefore}/>
{/* 送信 */} {/* 送信 */}
<Button <Button onClick={handleSubmit}
type="submit" className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
disabled={disabled}>
</Button> </Button>
</form>) </div>)
}) satisfies FC<Props> }) satisfies FC<Props>
+5 -13
View File
@@ -3,7 +3,6 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed' import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
@@ -17,8 +16,6 @@ type Props = {
export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
const dialogue = useDialogue ()
const url = new URL (post.url) const url = new URL (post.url)
switch (url.hostname.split ('.').slice (-2).join ('.')) switch (url.hostname.split ('.').slice (-2).join ('.'))
@@ -85,17 +82,12 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
height={360}/>) height={360}/>)
: ( : (
<div> <div>
<a href="#" onClick={async e => { <a href="#" onClick={e => {
e.preventDefault () e.preventDefault ()
setFramed (confirm ('未確認の外部ページを表示します。\n'
setFramed (await dialogue.confirm ({ + '悪意のあるスクリプトが実行される可能性があります。\n'
title: '未確認の外部ページを表示します', + '表示しますか?'))
description: ( return
<div>
<p></p>
<p>?</p>
</div>),
confirmText: '表示' }))
}}> }}>
</a> </a>
+3 -4
View File
@@ -7,7 +7,7 @@ import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react' import type { FC, SyntheticEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -31,12 +31,12 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
`${ value.slice (0, start) }${ text }${ value.slice (end) }` `${ value.slice (0, start) }${ text }${ value.slice (end) }`
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & { type Props = {
tags: string tags: string
setTags: (tags: string) => void } setTags: (tags: string) => void }
export default (({ tags, setTags, ...rest }: Props) => { export default (({ tags, setTags }: Props) => {
const ref = useRef<HTMLTextAreaElement> (null) const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -76,7 +76,6 @@ export default (({ tags, setTags, ...rest }: Props) => {
<div className="relative w-full"> <div className="relative w-full">
<Label></Label> <Label></Label>
<TextArea <TextArea
{...rest}
ref={ref} ref={ref}
value={tags} value={tags}
onChange={ev => setTags (ev.target.value)} onChange={ev => setTags (ev.target.value)}
+2 -5
View File
@@ -3,7 +3,6 @@ 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'
@@ -40,10 +39,8 @@ export default (({ posts, onClick }: Props) => {
<motion.div <motion.div
ref={cardRef} ref={cardRef}
layoutId={layoutId} layoutId={layoutId}
className={cn ('w-full h-full overflow-hidden rounded-xl shadow', className="w-full h-full overflow-hidden rounded-xl shadow
'transform-gpu will-change-transform', transform-gpu will-change-transform"
(post.childPosts ?? []).length > 0 && 'ring-4 ring-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))
@@ -5,15 +5,13 @@ import { Button } from '@/components/ui/button'
import type { FC } from 'react' import type { FC } from 'react'
type Props = { type Props = {
disabled?: boolean
originalCreatedFrom: string | null originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void } setOriginalCreatedBefore: (x: string | null) => void }
export default (({ disabled, export default (({ originalCreatedFrom,
originalCreatedFrom,
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore }: Props) => ( setOriginalCreatedBefore }: Props) => (
@@ -23,7 +21,6 @@ export default (({ disabled,
<div className="w-80"> <div className="w-80">
<DateTimeField <DateTimeField
className="mr-2" className="mr-2"
disabled={disabled ?? false}
value={originalCreatedFrom ?? undefined} value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom} onChange={setOriginalCreatedFrom}
onBlur={ev => { onBlur={ev => {
@@ -43,7 +40,6 @@ export default (({ disabled,
<div> <div>
<Button <Button
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedFrom (null) setOriginalCreatedFrom (null)
}}> }}>
@@ -55,7 +51,6 @@ export default (({ disabled,
<div className="w-80"> <div className="w-80">
<DateTimeField <DateTimeField
className="mr-2" className="mr-2"
disabled={disabled}
value={originalCreatedBefore ?? undefined} value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/> onChange={setOriginalCreatedBefore}/>
@@ -63,7 +58,6 @@ export default (({ disabled,
<div> <div>
<Button <Button
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedBefore (null) setOriginalCreatedBefore (null)
}}> }}>
+14 -32
View File
@@ -45,9 +45,9 @@ export default (({ tag,
<> <>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className="mr-1"> <span className="mr-1">
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) {(tag.materialId != null || tag.hasWiki)
? ( ? (
tag.materialId == null && !(tag.hasDeerjikists) tag.materialId == null
? ( ? (
<PrefetchLink <PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`} to={`/wiki/${ encodeURIComponent (tag.name) }`}
@@ -55,19 +55,11 @@ export default (({ tag,
? ?
</PrefetchLink>) </PrefetchLink>)
: ( : (
tag.materialId != null <PrefetchLink
? ( to={`/materials/${ tag.materialId }`}
<PrefetchLink className={linkClass}>
to={`/materials/${ tag.materialId }`} ?
className={linkClass}> </PrefetchLink>))
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
?
</PrefetchLink>)))
: ( : (
['character', 'material'].includes (tag.category) ['character', 'material'].includes (tag.category)
? ( ? (
@@ -79,23 +71,13 @@ export default (({ tag,
! !
</PrefetchLink>) </PrefetchLink>)
: ( : (
tag.category === 'deerjikist' <PrefetchLink
? ( to={`/wiki/${ encodeURIComponent (tag.name) }`}
<PrefetchLink className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
to={`/tags/${ tag.id }/deerjikists`} dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
className="animate-[wiki-blink_.25s_steps(2,end)_infinite] title={`${ tag.name } Wiki が存在しません.`}>
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]" !
title={`${ tag.name } に関する情報が存在しません.`}> </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>)))}
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
+3 -3
View File
@@ -36,12 +36,12 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' }, { name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new' }, { name: '追加', to: '/posts/new' },
{ name: '全体履歴', to: '/posts/changes' }, { name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' }, { name: 'マスタ', to: '/tags' },
{ name: 'ニコニコ連携', to: '/tags/nico' }, { name: 'ニコニコ連携', to: '/tags/nico' },
{ name: '全体履歴', to: '/tags/changes' }, { name: '履歴', to: '/tags/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg }, { component: <Separator/>, visible: tagFlg },
{ name: `広場 (${ postCount || 0 })`, { name: `広場 (${ postCount || 0 })`,
@@ -53,7 +53,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '一覧', to: '/materials' }, { name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false }, { name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' }, { name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false }, { name: '履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' }, { name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react' import type { FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0') const pad = (n: number): string => n.toString ().padStart (2, '0')
@@ -18,14 +18,14 @@ const toDateTimeLocalValue = (d: Date) => {
} }
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & { type Props = {
value?: string value?: string
onChange?: (isoUTC: string | null) => void onChange?: (isoUTC: string | null) => void
className?: string className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }
export default (({ value, onChange, className, onBlur, ...rest }: Props) => { export default (({ value, onChange, className, onBlur }: Props) => {
const [local, setLocal] = useState ('') const [local, setLocal] = useState ('')
useEffect (() => { useEffect (() => {
@@ -34,7 +34,6 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
return ( return (
<input <input
{...rest}
className={cn ('border rounded p-2', className)} className={cn ('border rounded p-2', className)}
type="datetime-local" type="datetime-local"
value={local} value={local}
@@ -1,187 +0,0 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import type { FC, ReactNode } from 'react'
type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: DialogueVariant }
type AlertOptions = { title: string
description?: ReactNode
okText?: string }
type Choice<T extends string> = { value: T
label: string
variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }
type DialogueRequest =
| { id: number
kind: 'confirm'
options: ConfirmOptions
resolve: (value: boolean) => void }
| { id: number
kind: 'alert'
options: AlertOptions
resolve: () => void }
| { id: number
kind: 'choice'
options: ChoiceOptions<string>
resolve: (value: string | null) => void }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }
const DialogueContext = createContext<DialogueAPI | null> (null)
let nextDialogueId = 1
type Props = { children: ReactNode }
export default (({ children }: Props) => {
const [queue, setQueue] = useState<DialogueRequest[]> ([])
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
const id = nextDialogueId
++nextDialogueId
setQueue (q => [...q, { ...request, id } as DialogueRequest])
}, [])
const closeActive = useCallback ((result?: unknown) => {
setQueue (q => {
const [active, ...rest] = q
if (!(active))
return rest
switch (active.kind)
{
case 'confirm':
active.resolve (Boolean (result))
break
case 'alert':
active.resolve ()
break
case 'choice':
active.resolve ((result ?? null) as string | null)
break
}
return rest
})
}, [])
const api = useMemo<DialogueAPI> (() => ({
confirm: options => new Promise<boolean> (resolve => {
push ({ kind: 'confirm', options, resolve })
}),
alert: options => new Promise<void> (resolve => {
push ({ kind: 'alert', options, resolve })
}),
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })
}) }), [push])
const active = queue[0]
return (
<DialogueContext.Provider value={api}>
{children}
<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
<DialogContent className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeActive (false)}>
{active.options.cancelText ?? '取消'}
</Button>
<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}
{active.kind === 'alert' && (
<Button onClick={() => closeActive ()}>
{active.options.okText ?? '確定'}
</Button>)}
{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeActive (null)}>
{active.options.cancelText ?? '取消'}
</Button>
{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</DialogContent>)}
</Dialog>
</DialogueContext.Provider>)
}) satisfies FC<Props>
export const useDialogue = () => {
const dialogue = useContext (DialogueContext)
if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')
return dialogue
}
+16 -29
View File
@@ -4,47 +4,34 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva ( const buttonVariants = cva(
[ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-md text-sm font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
].join (' '),
{ {
variants: { variants: {
variant: { variant: {
default: default: "bg-primary text-primary-foreground hover:bg-primary/90",
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
destructive: destructive:
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600', "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: outline:
'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800', "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: secondary:
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700', "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
ghost: link: "text-primary underline-offset-4 hover:underline",
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300',
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: "h-10 px-4 py-2",
sm: 'h-9 rounded-md px-3', sm: "h-9 rounded-md px-3",
lg: 'h-11 rounded-md px-8', lg: "h-11 rounded-md px-8",
icon: 'h-10 w-10', icon: "h-10 w-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}) }
)
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+11 -15
View File
@@ -37,29 +37,25 @@ const DialogContent = React.forwardRef<
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn ( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg', 'fixed left-[50%] top-[50%] z-50 w-[90%] grid max-w-lg',
'translate-x-[-50%] translate-y-[-50%]', 'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border', 'gap-4 border bg-gray-300/80 dark:bg-gray-700/80',
'bg-background p-6 text-foreground shadow-2xl', 'p-6 shadow-lg duration-200',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out', 'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2',
'data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2',
'data-[state=open]:slide-in-from-top-[48%] rounded-lg',
className)} className)}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 bg-red-500 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close <X className="h-3 w-3" />
className={cn ( <span className="sr-only">Close</span>
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only"></span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
@@ -1,12 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, import { Dialog,
DialogContent, DialogContent,
DialogDescription, DialogTitle } from '@/components/ui/dialog'
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
@@ -19,16 +16,10 @@ type Props = { visible: boolean
export default ({ visible, onVisibleChange, setUser }: Props) => { export default ({ visible, onVisibleChange, setUser }: Props) => {
const dialogue = useDialogue ()
const [inputCode, setInputCode] = useState ('') const [inputCode, setInputCode] = useState ('')
const handleTransfer = async () => { const handleTransfer = async () => {
if (!(await dialogue.confirm ({ if (!(confirm ('引継ぎを行ってもよろしいですか?\n現在のアカウントからはログアウトされます.')))
title: '引継ぎを行ってもよろしいですか?',
description: '現在のアカウントからはログアウトされます.',
confirmText: '引継ぐ',
variant: 'danger' })))
return return
try try
@@ -53,18 +44,14 @@ export default ({ visible, onVisibleChange, setUser }: Props) => {
return ( return (
<Dialog open={visible} onOpenChange={onVisibleChange}> <Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="px-6 pp-6 pt-7"> <DialogContent>
<DialogHeader className="pl-8"> <DialogTitle></DialogTitle>
<DialogTitle></DialogTitle> <div className="flex gap-2">
<DialogDescription asChild> <Input placeholder="引継ぎコードを入力"
<div className="flex gap-2"> value={inputCode}
<Input placeholder="引継ぎコードを入力" onChange={ev => setInputCode (ev.target.value)}/>
value={inputCode} <Button onClick={handleTransfer}></Button>
onChange={ev => setInputCode (ev.target.value)}/> </div>
<Button onClick={handleTransfer}></Button>
</div>
</DialogDescription>
</DialogHeader>
</DialogContent> </DialogContent>
</Dialog>) </Dialog>)
} }
@@ -1,10 +1,6 @@
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, import { Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog' DialogTitle } from '@/components/ui/dialog'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
@@ -18,20 +14,11 @@ type Props = { visible: boolean
export default ({ visible, onVisibleChange, user, setUser }: Props) => { export default ({ visible, onVisibleChange, user, setUser }: Props) => {
const dialogue = useDialogue ()
const handleChange = async () => { const handleChange = async () => {
if (!(user)) if (!(user))
return return
if (!(await dialogue.confirm ({ if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.')))
title: '引継ぎコードを再発行しますか?',
description: (
<div>
<p></p>
</div>),
confirmText: '再発行',
variant: 'danger' })))
return return
const data = await apiPost<{ code: string }> ('/users/code/renew', { }, const data = await apiPost<{ code: string }> ('/users/code/renew', { },
@@ -46,26 +33,21 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => {
return ( return (
<Dialog open={visible} onOpenChange={onVisibleChange}> <Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="px-6 pb-6 pt-7"> <DialogContent>
<DialogHeader className="pl-8"> <DialogTitle></DialogTitle>
<DialogTitle></DialogTitle> <div>
<p></p>
<DialogDescription asChild> <div className="m-2">{user?.inheritanceCode}</div>
<div> <p className="mt-1 text-sm text-red-500">
<p></p> !
<div className="m-2">{user?.inheritanceCode}</div> </p>
<p className="mt-1 text-sm text-destructive"> <div className="my-4">
! <Button onClick={handleChange}
</p> className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400">
</div>
</DialogDescription> </Button>
</DialogHeader> </div>
</div>
<DialogFooter>
<Button onClick={handleChange} variant="destructive">
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog>) </Dialog>)
} }
+1 -6
View File
@@ -1,4 +1,4 @@
import type { Category, Platform } from 'types' import type { Category } 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,11 +31,6 @@ 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',
+28 -50
View File
@@ -6,56 +6,6 @@
@layer base @layer base
{ {
:root
{
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
}
.dark
{
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 62.8% 45%;
--destructive-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
body body
{ {
@apply overflow-x-clip; @apply overflow-x-clip;
@@ -104,6 +54,34 @@ body
min-height: 100dvh; min-height: 100dvh;
} }
h1
{
font-size: 3.2em;
line-height: 1.1;
}
button
{
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover
{
border-color: #646cff;
}
button:focus,
button:focus-visible
{
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) @media (prefers-color-scheme: light)
{ {
:root :root
+1 -25
View File
@@ -1,4 +1,4 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api' import { apiDelete, apiGet, apiPost } from '@/lib/api'
import type { FetchPostsParams, Post, PostVersion } from '@/types' import type { FetchPostsParams, Post, PostVersion } from '@/types'
@@ -42,30 +42,6 @@ export const fetchPostChanges = async (
page, limit } }) page, limit } })
export const updatePost = async (
post: { id: number
title: string | null
tags: string
parentPostIds: string
originalCreatedFrom: string | null
originalCreatedBefore: string | null },
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }
) =>
await apiPut<Post> (
`/posts/${ post.id }`,
{ title: post.title,
tags: post.tags,
parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore },
{ params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
force: force ? '1' : '0',
merge: merge ? '1' : '0' } })
export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => {
await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`)
} }
+5 -6
View File
@@ -9,12 +9,11 @@ export const postsKeys = {
['posts', 'changes', p] as const } ['posts', 'changes', p] as const }
export const tagsKeys = { export const tagsKeys = {
root: ['tags'] as const, root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const, index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
show: (name: string) => ['tags', name] as const, show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) => changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const, ['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 -7
View File
@@ -1,6 +1,6 @@
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types' import type { FetchTagsParams, Tag, TagVersion } from '@/types'
export const fetchTags = async ( export const fetchTags = async (
@@ -56,9 +56,3 @@ 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`)
@@ -1,155 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { PLATFORM_NAMES, PLATFORMS } from '@/consts'
import { apiPut } from '@/lib/api'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchDeerjikistsByTag } from '@/lib/tags'
import { cn } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
import type { Deerjikist, Platform } from '@/types'
export default (() => {
const { id } = useParams ()
const tagId = String (id ?? '')
const tagKey = tagsKeys.deerjikists (tagId)
const { data: qData, isLoading: loading } =
useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) })
const tag = qData?.tag
const deerjikists = qData?.deerjikists ?? []
const [data, setData] =
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
const [disabled, setDisabled] = useState (true)
const qc = useQueryClient ()
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
try
{
setDisabled (true)
setData (await apiPut<Deerjikist[]> (`/tags/${ id }/deerjikists`, data))
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
}
catch
{
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
}
finally
{
setDisabled (false)
}
}
useEffect (() => {
if (!(tag))
{
setDisabled (true)
return
}
setData (deerjikists)
setDisabled (false)
}, [tag, deerjikists])
return (
<MainArea>
{(loading || !(tag)) ? 'Loading...' : (
<div className="max-w-xl">
<PageTitle>
<TagLink tag={tag} withWiki={false} withCount={false}/>
</PageTitle>
<form onSubmit={handleSubmit} className="my-4 space-y-2">
{data.map ((datum, i) => (
<fieldset key={i} className="min-w-0 rounded-lg border border-gray-300
dark:border-gray-700 p-4">
<legend className="px-2 text-sm font-semibold text-gray-700
dark:text-gray-300">
<button
type="button"
disabled={disabled}
onClick={() => setData (prev => [...prev.slice (0, i),
...prev.slice (i + 1)])}>
#{i + 1}
</button>
</legend>
{/* プラットフォーム */}
<div>
<Label></Label>
<select
className="w-full border p-2 rounded"
disabled={disabled}
value={datum.platform ?? ''}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i],
platform: (e.target.value || null) as Platform | null }
return rtn
})}>
<option value="">&nbsp;</option>
{PLATFORMS.map (p => (
<option key={p} value={p}>
{PLATFORM_NAMES[p]}
</option>))}
</select>
</div>
{/* コード */}
<div>
<Label></Label>
<input
type="text"
disabled={disabled}
className="w-full border p-2 rounded"
value={datum.code}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value }
return rtn
})}/>
</div>
</fieldset>
))}
<div className="py-3">
<button
type="button"
disabled={disabled}
onClick={() => setData (prev => [...prev, { platform: null, code: '' }])}>
+
</button>
</div>
<div className="py-3">
<button
type="submit"
disabled={disabled}
className={cn ('px-4 py-2 rounded',
(disabled
? 'text-gray-300 bg-gray-500'
: 'text-white bg-blue-500'))}>
</button>
</div>
</form>
</div>
)}
</MainArea>)
}) satisfies FC
+2 -29
View File
@@ -21,7 +21,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable'
import type { FC } from 'react' import type { FC } from 'react'
import type { NiconicoViewerHandle, Post, User } from '@/types' import type { NiconicoViewerHandle, User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
@@ -108,34 +108,6 @@ 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 }`}
@@ -174,6 +146,7 @@ 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>
+38 -77
View File
@@ -8,18 +8,16 @@ import TagLink from '@/components/TagLink'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchPostChanges, updatePost } from '@/lib/posts' import { apiPut } from '@/lib/api'
import { fetchPostChanges } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags' import { fetchTag } from '@/lib/tags'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils' import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react' import type { FC } from 'react'
import type { PostVersion } from '@/types'
const renderDiff = (diff: { current: string | null; prev: string | null }) => ( const renderDiff = (diff: { current: string | null; prev: string | null }) => (
@@ -36,8 +34,6 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
export default (() => { export default (() => {
const dialogue = useDialogue ()
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const id = query.get ('id') const id = query.get ('id')
@@ -66,48 +62,6 @@ export default (() => {
const qc = useQueryClient () const qc = useQueryClient ()
const handleRevert = async (e: MouseEvent<HTMLAnchorElement>, change: PostVersion) => {
e.preventDefault ()
if (!(await dialogue.confirm ({
title: '差戻の確認',
description: `${ change.title.current || change.url.current }』を版 ${
change.versionNo } \nよろしいですか?`,
confirmText: '差戻' })))
return
try
{
const id = change.postId
const title = change.title.current
const tags =
change.tags
.filter (t => t.type !== 'removed')
.map (t => t.name)
.filter (t => t.slice (0, 5) !== 'nico:')
.join (' ')
const parentPostIds =
(change.parentPosts ?? [])
.filter (p => p.type !== 'removed')
.map (p => p.id)
.join (' ')
const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ force: true })
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '差戻しました.' })
}
catch
{
toast ({ description: '差戻に失敗……' })
}
}
useEffect (() => { useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search]) }, [location.search])
@@ -141,8 +95,6 @@ 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"/>
{/* 更新日時 */} {/* 更新日時 */}
@@ -158,8 +110,6 @@ 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"/>
@@ -230,29 +180,6 @@ 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,
@@ -277,7 +204,41 @@ export default (() => {
{dateString (change.createdAt)} {dateString (change.createdAt)}
</td> </td>
<td className="p-2"> <td className="p-2">
<a href="#" onClick={async e => await handleRevert (e, change)}> <a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(confirm (
`${ change.title.current
|| change.url.current } ${
change.versionNo } \nよろしいですか?`)))
return
try
{
await apiPut (
`/posts/${ change.postId }`,
{ title: change.title.current,
tags: change.tags
.filter (t => t.type !== 'removed')
.map (t => t.name)
.filter (t => t.slice (0, 5) !== 'nico:')
.join (' '),
original_created_from:
change.originalCreatedFrom.current,
original_created_before:
change.originalCreatedBefore.current })
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '差戻しました.' })
}
catch
{
toast ({ description: '差戻に失敗……' })
}
}}>
</a> </a>
</td> </td>
-12
View File
@@ -29,7 +29,6 @@ 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)
@@ -47,7 +46,6 @@ 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)
@@ -179,16 +177,6 @@ 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}/>
+2 -17
View File
@@ -1,6 +1,5 @@
import { CATEGORIES, import { CATEGORIES,
FETCH_POSTS_ORDER_FIELDS, FETCH_POSTS_ORDER_FIELDS,
PLATFORMS,
USER_ROLES, USER_ROLES,
ViewFlagBehavior } from '@/consts' ViewFlagBehavior } from '@/consts'
@@ -8,8 +7,6 @@ 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]
@@ -117,19 +114,13 @@ 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
versionNo: number
url: string url: string
title: string | null title: string | null
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
@@ -147,18 +138,13 @@ export type PostTagChange = {
export type PostVersion = { export type PostVersion = {
postId: number postId: number
latestVersionNo: number
versionNo: number versionNo: number
eventType: 'create' | 'update' | 'discard' | 'restore' eventType: 'create' | 'update' | 'discard' | 'restore'
title: { current: string | null; prev: string | null } title: { current: string | null; prev: string | null }
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 tags: { name: string; type: 'context' | 'added' | 'removed' }[]
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
@@ -185,8 +171,7 @@ export type Tag = {
createdAt: string createdAt: string
updatedAt: string updatedAt: string
hasWiki: boolean hasWiki: boolean
materialId: number | null materialId: number
hasDeerjikists: boolean
children?: Tag[] children?: Tag[]
matchedAlias?: string | null } matchedAlias?: string | null }
+1 -16
View File
@@ -19,22 +19,7 @@ export default {
'rainbow-scroll': 'rainbow-scroll .25s linear infinite' }, 'rainbow-scroll': 'rainbow-scroll .25s linear infinite' },
colors: { colors: {
red: { 925: '#5f1414', red: { 925: '#5f1414',
975: '#230505' }, 975: '#230505' } },
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))' },
secondary: { DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))' },
destructive: { DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))' },
muted: { DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))' } },
keyframes: { keyframes: {
'rainbow-scroll': { 'rainbow-scroll': {
'0%': { backgroundPosition: '0% 50%' }, '0%': { backgroundPosition: '0% 50%' },