Compare commits

...

11 Commits

Author SHA1 Message Date
みてるぞ bce04488ed #318 2026-04-23 00:05:27 +09:00
みてるぞ c9df340978 #318 2026-04-22 23:40:00 +09:00
みてるぞ 3da9b447d4 #318 2026-04-22 23:32:05 +09:00
みてるぞ 6b0d262040 #318 2026-04-22 02:06:15 +09:00
みてるぞ 8ff1819d5a 同期バグ修正 (#324) (#325)
#324

Reviewed-on: #325
2026-04-19 23:04:15 +09:00
みてるぞ bde7d33949 タグ履歴 (#309) (#319)
#309

#309

#309

#309

#309

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

#309

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #319
2026-04-19 20:21:51 +09:00
みてるぞ 5c7580d571 ニコタグ連携バグ修正 (#294) (#316)
#294

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #316
2026-04-19 16:44:20 +09:00
みてるぞ 48f823a7c8 履歴画面変更(#308) (#315)
Merge branch 'main' into feature/308

#308

#308

#308

#308

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #315
2026-04-18 05:43:33 +09:00
みてるぞ bd11e37fd3 緊急対応(#312) (#313)
'backend/config/storage.yml' を更新

Reviewed-on: #313
2026-04-15 20:21:51 +09:00
みてるぞ e72ec608f4 利用規約(#95) (#311)
#95

#95

#95

#95

#95

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

#95

#95

#95

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #311
2026-04-14 12:31:48 +09:00
みてるぞ a3914fb22a posts 履歴管理(RSpec 修正)(#264) (#310)
Merge remote-tracking branch 'origin/main' into feature/264

Merge branch 'feature/264' of https://git.miteruzo.com/miteruzo/btrc-hub into feature/264

#264

Merge branch 'main' into feature/264

#264

#264

#264

#264

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #310
2026-04-11 22:13:19 +09:00
64 changed files with 3778 additions and 643 deletions
@@ -30,15 +30,22 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i
tag = Tag.find(id)
return head :bad_request if tag.category != 'nico'
return head :bad_request unless tag.nico?
linked_tag_names = params[:tags].to_s.split(' ')
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false)
return head :bad_request if linked_tags.any? { |t| t.category == 'nico' }
linked_tag_names = params[:tags].to_s.split
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }
ApplicationRecord.transaction do
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags
tag.save!
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
end
end
@@ -0,0 +1,119 @@
class PostVersionsController < ApplicationController
def index
post_id = params[:post].presence
tag_id = params[:tag].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
tag_name =
if tag_id
TagName.joins(:tag).find_by(tag: { id: tag_id })
end
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
if tag_name
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
kw: "% #{ escaped } %")
end
count = q.except(:select, :order, :limit, :offset).count
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions), count: }
end
private
def serialise_versions rows
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
rows.map do |row|
cur_tags = split_tags(row.tags)
prev_tags = split_tags(row.attributes['prev_tags'])
{
post_id: row.post_id,
version_no: row.version_no,
event_type: row.event_type,
title: {
current: row.title,
prev: row.attributes['prev_title']
},
url: {
current: row.url,
prev: row.attributes['prev_url']
},
thumbnail: {
current: nil,
prev: nil
},
thumbnail_base: {
current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base']
},
tags: build_version_tags(cur_tags, prev_tags),
original_created_from: {
current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601
},
original_created_before: {
current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601
},
created_at: row.created_at.iso8601,
created_by_user:
if row.created_by_user_id
{
id: row.created_by_user_id,
name: users_by_id[row.created_by_user_id]
}
end
}
end
end
def build_version_tags(cur_tags, prev_tags)
(cur_tags | prev_tags).map do |name|
type =
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
{
name:,
type:
}
end
end
def split_tags(tags)
tags.to_s.split(/\s+/).reject(&:blank?)
end
end
+15 -8
View File
@@ -44,7 +44,7 @@ class PostsController < ApplicationController
filtered_posts
.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"))
.preload(tags: { tag_name: :wiki_page })
.preload(tags: [:materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
end
def random
post = filtered_posts.preload(tags: { tag_name: :wiki_page })
post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
.order('RAND()')
.first
return head :not_found unless post
@@ -104,7 +104,7 @@ class PostsController < ApplicationController
end
def show
post = Post.includes(tags: { 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
render json: PostRepr.base(post, current_user)
@@ -128,9 +128,11 @@ class PostsController < ApplicationController
original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail)
ActiveRecord::Base.transaction do
ApplicationRecord.transaction do
post.save!
tags = Tag.normalise_tags(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
post.resized_thumbnail!
@@ -170,10 +172,15 @@ class PostsController < ApplicationController
post = Post.find(params[:id].to_i)
ActiveRecord::Base.transaction do
ApplicationRecord.transaction do
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title:, original_created_from:, original_created_before:)
tags = post.tags.where(category: 'nico').to_a +
Tag.normalise_tags(tag_names, with_tagme: false)
normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
@@ -204,7 +211,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user,
tag: { tag_name: :wiki_page })
tag: [:materials, { tag_name: :wiki_page }])
events = []
pts.each do |pt|
@@ -7,7 +7,16 @@ class TagChildrenController < ApplicationController
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?
Tag.find(parent_id).children << Tag.find(child_id) rescue nil
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
TagImplication.find_or_create_by!(parent_tag: parent, tag: child)
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
end
head :no_content
end
@@ -20,7 +29,16 @@ class TagChildrenController < ApplicationController
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?
Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
TagImplication.find_by(parent_tag: parent, tag: child)&.destroy!
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
end
head :no_content
end
+101 -7
View File
@@ -66,7 +66,7 @@ class TagsController < ApplicationController
.offset(offset)
.to_a
render json: { tags: TagRepr.base(tags), count: q.size }
render json: { tags: TagRepr.many(tags), count: q.size }
end
def with_depth
@@ -209,6 +209,52 @@ class TagsController < ApplicationController
render json: build_tag_children(tag)
end
def update_all
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag = Tag.find_by(id: params[:id])
return head :not_found unless tag
name = params[:name].to_s.strip
category = params[:category].to_s.strip
return head :unprocessable_entity if name.blank? || category.blank?
if name != tag.name &&
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
return render json: { error: 'システム・タグの名称は変更できません.' },
status: :unprocessable_entity
end
if tag.nico? || category == 'nico'
return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
end
alias_names = params[:aliases].to_s.split.uniq
parent_names = params[:parent_tags].to_s.split.uniq
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
old_name = tag.name
tag.update!(category:)
tag.tag_name.update!(name:)
alias_names << old_name if name != old_name
alias_names.delete(name)
update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names)
tag.reload
record_tag_version!(tag, event_type: :update, created_by_user: current_user)
end
render json: TagRepr.base(tag.reload)
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
@@ -218,20 +264,26 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id])
if name.present?
tag.tag_name.update!(name:)
if tag.nico? || (category.present? && category == 'nico')
return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
end
if category.present?
tag.update!(category:)
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
tag.tag_name.update!(name:) if name.present?
tag.update!(category:) if category.present?
record_tag_version!(tag, event_type: :update, created_by_user: current_user)
end
render json: TagRepr.base(tag)
render json: TagRepr.base(tag.reload)
end
private
def build_tag_children(tag)
def build_tag_children tag
material = tag.materials.first
file = nil
content_type = nil
@@ -244,4 +296,46 @@ class TagsController < ApplicationController
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
material: material.as_json&.merge(file:, content_type:))
end
def record_tag_version! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end
def update_aliases! tag, alias_names
current_aliases = tag.tag_name.aliases.to_a
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
alias_tag_name.update!(canonical: nil)
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name)
end
end
def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
old_parent_tags = tag.parents.to_a
TagVersioning.record_tag_snapshots!((old_parent_tags + parent_tags).uniq,
created_by_user: current_user)
tag.reversed_tag_implications.destroy_all
parent_tags.each do |parent_tag|
next if parent_tag == tag
TagImplication.create!(tag:, parent_tag:)
end
end
end
+5 -1
View File
@@ -1,7 +1,11 @@
module MyDiscard
extend ActiveSupport::Concern
included { include Discard::Model }
included do
include Discard::Model
default_scope -> { kept }
end
class_methods do
def find_undiscard_or_create_by! attrs, &block
+7
View File
@@ -0,0 +1,7 @@
class NicoTagVersion < ApplicationRecord
include VersionRecord
belongs_to :tag
validates :name, presence: true
end
+1 -17
View File
@@ -1,29 +1,13 @@
class PostVersion < ApplicationRecord
before_update do
raise ActiveRecord::ReadOnlyRecord, '版は更新できません.'
end
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '版は削除できません.'
end
include VersionRecord
belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User', optional: true
enum :event_type, { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true, inclusion: { in: event_types.keys }
validates :url, presence: true
validate :validate_original_created_range
scope :chronological, -> { order(:version_no, :id) }
private
def validate_original_created_range
+31 -26
View File
@@ -8,8 +8,6 @@ class Tag < ApplicationRecord
;
end
default_scope -> { kept }
has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -36,6 +34,9 @@ class Tag < ApplicationRecord
has_many :deerjikists, dependent: :delete_all
has_many :materials
has_many :tag_versions
has_many :nico_tag_versions
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
@@ -78,27 +79,15 @@ class Tag < ApplicationRecord
def material_id = materials.first&.id
def self.tagme
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
end
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.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
def self.video = find_or_create_by_tag_name!('動画', category: :meta)
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
def self.bot
@bot ||= find_or_create_by_tag_name!('bot操作', category: :meta)
end
def self.no_deerjikist
@no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
end
def self.video
@video ||= find_or_create_by_tag_name!('動画', category: :meta)
end
def self.niconico
@niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta)
end
def self.normalise_tags tag_names, with_tagme: true, deny_nico: true
def self.normalise_tags tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
@@ -112,7 +101,7 @@ class Tag < ApplicationRecord
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if tags.all? { |t| !(t.deerjikist?) }
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq(&:id)
end
@@ -150,21 +139,25 @@ class Tag < ApplicationRecord
retry
end
def self.merge_tags! target_tag, source_tags
def self.merge_tags! target_tag, source_tags, created_by_user: nil
target_tag => Tag
affected_post_ids = Set.new
Tag.transaction do
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)
Array(source_tags).compact.uniq.each do |source_tag|
source_tag => Tag
next if source_tag == target_tag
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
source_tag.post_tags.kept.find_each do |source_pt|
post_id = source_pt.post_id
affected_post_ids << post_id
source_pt.discard_by!(nil)
source_pt.discard_by!(created_by_user)
unless PostTag.kept.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag)
end
@@ -176,6 +169,7 @@ class Tag < ApplicationRecord
raise ActiveRecord::RecordInvalid.new(source_tag_name)
end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.discard!
if source_tag.nico?
@@ -184,10 +178,13 @@ class Tag < ApplicationRecord
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
end
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end
Post.where(id: affected_post_ids.to_a).find_each do |post|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
end
# 投稿件数を再集計
@@ -197,6 +194,14 @@ class Tag < ApplicationRecord
target_tag.reload
end
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
def snapshot_linked_tag_names
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
private
def nico_tag_name_must_start_with_nico
-2
View File
@@ -1,8 +1,6 @@
class TagName < ApplicationRecord
include MyDiscard
default_scope -> { kept }
has_one :tag
has_one :wiki_page
+15
View File
@@ -0,0 +1,15 @@
class TagVersion < ApplicationRecord
include VersionRecord
belongs_to :tag
enum :category, { deerjikist: 'deerjikist',
meme: 'meme',
character: 'character',
general: 'general',
material: 'material',
meta: 'meta' }, validate: true
validates :name, presence: true
validates :category, presence: true
end
+19
View File
@@ -0,0 +1,19 @@
module VersionRecord
extend ActiveSupport::Concern
def readonly? = persisted?
included do
belongs_to :created_by_user, class_name: 'User', optional: true
enum :event_type, { create: 'create',
update: 'update',
discard: 'discard',
restore: 'restore' }, prefix: true, validate: true
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :event_type, presence: true
scope :chronological, -> { order(:version_no, :id) }
end
end
-2
View File
@@ -4,8 +4,6 @@ require 'set'
class WikiPage < ApplicationRecord
include MyDiscard
default_scope -> { kept }
has_many :wiki_revisions, dependent: :destroy
belongs_to :created_user, class_name: 'User'
belongs_to :updated_user, class_name: 'User'
+3 -4
View File
@@ -8,10 +8,9 @@ module TagRepr
module_function
def base tag
tag.as_json(BASE)
tag.as_json(BASE).merge(aliases: tag.snapshot_aliases,
parents: tag.parents.map { _1.as_json(BASE) })
end
def many tags
tags.map { |t| base(t) }
end
def many(tags) = tags.map { |t| base(t) }
end
@@ -0,0 +1,19 @@
class NicoTagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
end
def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
end
private
def version_class = NicoTagVersion
def version_association = :nico_tag_versions
def record_key = :tag
def snapshot_attributes
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end
end
+16 -42
View File
@@ -1,57 +1,31 @@
class PostVersionRecorder
class PostVersionRecorder < VersionRecorder
def self.record! post:, event_type:, created_by_user:
new(post:, event_type:, created_by_user:).record!
end
def initialize post:, event_type:, created_by_user:
@post = post
@event_type = event_type
@created_by_user = created_by_user
super(record: post, event_type:, created_by_user:)
end
def record!
@post.with_lock do
latest = @post.post_versions.order(version_no: :desc).first
attrs = snapshot_attributes
def self.ensure_snapshot! post, created_by_user:
return if post.post_versions.exists?
return latest if @event_type == :update && latest && same_snapshot?(latest, attrs)
PostVersion.create!(
post: @post,
version_no: (latest&.version_no || 0) + 1,
event_type: @event_type,
title: attrs[:title],
url: attrs[:url],
thumbnail_base: attrs[:thumbnail_base],
tags: attrs[:tags],
parent: attrs[:parent],
original_created_from: attrs[:original_created_from],
original_created_before: attrs[:original_created_before],
created_at: Time.current,
created_by_user: @created_by_user)
end
record!(post:, event_type: :create, created_by_user:)
end
private
def snapshot_attributes
{ title: @post.title,
url: @post.url,
thumbnail_base: @post.thumbnail_base,
tags: @post.snapshot_tag_names.join(' '),
parent: @post.parent,
original_created_from: @post.original_created_from,
original_created_before: @post.original_created_before }
end
def version_class = PostVersion
def version_association = :post_versions
def record_key = :post
def same_snapshot? version, attrs
true &&
version.title == attrs[:title] &&
version.url == attrs[:url] &&
version.thumbnail_base == attrs[:thumbnail_base] &&
version.tags == attrs[:tags] &&
version.parent_id == attrs[:parent]&.id &&
version.original_created_from == attrs[:original_created_from] &&
version.original_created_before == attrs[:original_created_before]
def snapshot_attributes
{ title: @record.title,
url: @record.url,
thumbnail_base: @record.thumbnail_base,
tags: @record.snapshot_tag_names.join(' '),
parent_id: @record.parent_id,
original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before }
end
end
@@ -0,0 +1,22 @@
class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
end
def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
end
private
def version_class = TagVersion
def version_association = :tag_versions
def record_key = :tag
def snapshot_attributes
{ name: @record.name,
category: @record.category,
aliases: @record.snapshot_aliases.join(' '),
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end
end
+38
View File
@@ -0,0 +1,38 @@
class TagVersioning
def self.record! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end
def self.ensure_snapshot! tag, created_by_user:
if tag.nico?
return if tag.nico_tag_versions.exists?
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
else
return if tag.tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
end
def self.record_tag_snapshot! tag, created_by_user:
event_type =
if tag.nico?
tag.nico_tag_versions.exists? ? :update : :create
else
tag.tag_versions.exists? ? :update : :create
end
record!(tag, event_type:, created_by_user:)
end
def self.record_tag_snapshots! tags, created_by_user:
tags.each do |tag|
record_tag_snapshot!(tag, created_by_user:)
end
end
end
+62
View File
@@ -0,0 +1,62 @@
class VersionRecorder
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
def initialize record:, event_type:, created_by_user:
@record = record
@event_type = event_type.to_s
@created_by_user = created_by_user
validate_event_type!
end
def record!
raise "#{ record_class.name } must be persisted" unless @record.persisted?
ApplicationRecord.transaction do
@record = record_class.unscoped.lock.find(@record.id)
latest = latest_version
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
return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
end
end
private
def latest_version = versions.order(version_no: :desc).first
def versions = @record.public_send(version_association)
def base_attributes latest
{ version_no: (latest&.version_no || 0) + 1,
event_type: @event_type,
created_at: Time.current,
created_by_user: @created_by_user }
end
def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v }
def validate_event_type!
return if EVENT_TYPES.include?(@event_type)
raise ArgumentError, "Invalid event_type: #{ @event_type }"
end
def version_class = raise NotImplementedError
def version_association = raise NotImplementedError
def record_key = raise NotImplementedError
def snapshot_attributes = raise NotImplementedError
def record_class = @record.class
end
+5 -1
View File
@@ -6,7 +6,7 @@ Rails.application.routes.draw do
delete ':child_id', action: :destroy
end
resources :tags, only: [:index, :show, :update] do
resources :tags, only: [:index, :show] do
collection do
get :autocomplete
get :'with-depth', action: :with_depth
@@ -19,6 +19,9 @@ Rails.application.routes.draw do
end
member do
put '', action: :update_all
patch '', action: :update
get :deerjikists
end
end
@@ -49,6 +52,7 @@ Rails.application.routes.draw do
collection do
get :random
get :changes
get :versions, to: 'post_versions#index'
end
member do
+8 -1
View File
@@ -1,12 +1,19 @@
env :PATH, '/root/.rbenv/shims:/root/.rbenv/bin:/usr/local/bin:/usr/bin:/bin'
set :path, '/var/www/btrc-hub/backend'
set :environment, 'production'
set :output, standard: '/var/log/btrc_hub_nico_sync.log',
error: '/var/log/btrc_hub_nico_sync_err.log'
every 1.day, at: '3:00 pm' do
job_type :rake,
'cd :path && set -a && . /etc/btrc-hub/backend.env && set +a && ' \
':environment_variable=:environment bundle exec rake :task --silent :output'
every 1.day, at: '11:00 am' do
rake 'nico:sync', environment: 'production'
end
every 1.day, at: '0:00 am' do
rake 'post_similarity:calc', environment: 'production'
rake 'tag_similarity:calc', environment: 'production'
end
+1
View File
@@ -13,3 +13,4 @@ r2:
secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %>
bucket: <%= ENV['R2_BUCKET'] %>
region: auto
request_checksum_calculation: when_required
@@ -2,15 +2,15 @@ require 'set'
class CreatePostVersions < ActiveRecord::Migration[8.0]
class Post < ApplicationRecord
class Post < ActiveRecord::Base
self.table_name = 'posts'
end
class PostTag < ApplicationRecord
class PostTag < ActiveRecord::Base
self.table_name = 'post_tags'
end
class PostVersion < ApplicationRecord
class PostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
@@ -0,0 +1,156 @@
class CreateTagVersions < ActiveRecord::Migration[8.0]
class Tag < ActiveRecord::Base
self.table_name = 'tags'
end
class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class TagImplication < ActiveRecord::Base
self.table_name = 'tag_implications'
end
class TagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class NicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
class NicoTagRelation < ActiveRecord::Base
self.table_name = 'nico_tag_relations'
end
def up
create_table :tag_versions do |t|
t.references :tag, null: false, foreign_key: true, index: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, null: false
t.string :category, null: false
t.text :aliases, null: false
t.text :parent_tag_ids, null: false
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }, index: false
t.index [:tag_id, :version_no], unique: true
t.index :created_at
t.index [:tag_id, :created_at], order: { created_at: :desc }
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
t.check_constraint 'version_no > 0',
name: 'tag_versions_version_no_positive'
end
create_table :nico_tag_versions do |t|
t.references :tag, null: false, foreign_key: true, index: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, null: false
t.text :linked_tags, null: false
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }, index: false
t.index [:tag_id, :version_no], unique: true
t.index :created_at
t.index [:tag_id, :created_at], order: { created_at: :desc }
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
t.check_constraint 'version_no > 0',
name: 'nico_tag_versions_version_no_positive'
end
TagVersion.reset_column_information
say_with_time 'Backfilling tag_versions' do
Tag.where(discarded_at: nil)
.where.not(category: 'nico')
.find_in_batches(batch_size: 500) do |tags|
tag_ids = tags.map(&:id)
tag_implication_rows_by_tag_id =
TagImplication
.where(tag_id: tag_ids)
.pluck(:tag_id, :parent_tag_id)
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end
tag_name_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.where(tags: { id: tag_ids })
.pluck('tags.id', 'tag_names.name')
.each_with_object({ }) do |row, h|
h[row[0]] = row[1]
end
tag_alias_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.canonical_id')
.where(tags: { id: tag_ids })
.where(tag_names: { discarded_at: nil })
.pluck('tags.id', 'tag_names.name')
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end
TagVersion.insert_all(tags.map { |tag|
{ tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: tag_name_rows_by_tag_id[tag.id],
category: tag.category,
aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '),
parent_tag_ids: tag_implication_rows_by_tag_id[tag.id].sort.join(' '),
created_at: tag.created_at,
created_by_user_id: nil }
})
end
end
NicoTagVersion.reset_column_information
say_with_time 'Backfilling nico_tag_versions' do
Tag.where(discarded_at: nil, category: 'nico')
.find_in_batches(batch_size: 500) do |tags|
tag_ids = tags.map(&:id)
tag_name_rows_by_tag_id =
TagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.where(tags: { id: tag_ids })
.pluck('tags.id', 'tag_names.name')
.each_with_object({ }) do |row, h|
h[row[0]] = row[1]
end
nico_tag_relation_rows_by_tag_id =
NicoTagRelation
.joins('INNER JOIN tags nico_tags ON nico_tags.id = nico_tag_relations.nico_tag_id')
.joins('INNER JOIN tags linked_tags ON linked_tags.id = nico_tag_relations.tag_id')
.joins('INNER JOIN tag_names ON tag_names.id = linked_tags.tag_name_id')
.where(nico_tags: { id: tag_ids })
.where(linked_tags: { discarded_at: nil })
.where(tag_names: { discarded_at: nil })
.pluck('nico_tags.id', 'tag_names.name')
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
h[row[0]] << row[1]
end
NicoTagVersion.insert_all(tags.map { |tag|
{ tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: tag_name_rows_by_tag_id[tag.id],
linked_tags: nico_tag_relation_rows_by_tag_id[tag.id].sort.join(' '),
created_at: tag.created_at,
created_by_user_id: nil }
})
end
end
end
def down
drop_table :nico_tag_versions
drop_table :tag_versions
end
end
+37 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) 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|
t.string "name", null: false
t.string "record_type", null: false
@@ -104,6 +104,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id"
end
create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.text "linked_tags", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "target_post_id", null: false
@@ -216,6 +231,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
end
create_table "tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.string "category", null: false
t.text "aliases", null: false
t.text "parent_tag_ids", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false
@@ -377,6 +409,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tags", "posts"
@@ -394,6 +428,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users"
+5
View File
@@ -115,6 +115,10 @@ namespace :nico do
datum['tags'].each do |raw|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
desired_nico_tag_based_ids << tag.id
# 新たに記載される外部タグと連携される内部タグを記載
@@ -149,6 +153,7 @@ namespace :nico do
if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
end
+65
View File
@@ -145,5 +145,70 @@ RSpec.describe Tag, type: :model do
expect(target_tag.reload.post_count).to eq(0)
end
end
def snapshot_tags(post)
post.snapshot_tag_names.join(' ')
end
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
PostVersion.create!(
post: post,
version_no: version_no,
event_type: event_type,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
created_by_user: created_by_user
)
end
context 'when post versions are enabled' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
let!(:unaffected_post) do
Post.create!(url: 'https://example.com/posts/2', title: 'unaffected post')
end
before do
create_post_version_for!(post_record)
create_post_version_for!(unaffected_post)
end
it 'creates an update post_version only for affected posts' do
expect {
described_class.merge_tags!(target_tag, [source_tag])
}.to change(PostVersion, :count).by(1)
affected_versions = post_record.reload.post_versions.order(:version_no)
expect(affected_versions.pluck(:version_no)).to eq([1, 2])
latest = affected_versions.last
expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
expect(unaffected_post.reload.post_versions.count).to eq(1)
end
end
context 'when the source tag has no active post_tags' do
let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end
before do
create_post_version_for!(another_post)
end
it 'does not create any post_version' do
expect {
described_class.merge_tags!(target_tag, [source_tag])
}.not_to change(PostVersion, :count)
end
end
end
end
@@ -0,0 +1,74 @@
require 'rails_helper'
RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
it 'makes TagVersion read only after create' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
category: tag.category,
aliases: '',
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.update!(name: 'changed')
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
it 'prevents TagVersion destroy' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
category: tag.category,
aliases: '',
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.destroy!
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!(
tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
linked_tags: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.update!(name: 'nico:changed')
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
linked_tags: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.destroy!
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
end
+55
View File
@@ -14,6 +14,7 @@ RSpec.describe 'NicoTags', type: :request do
describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:tag, :nico) }
it '401 when not logged in' do
@@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:bad_request)
end
it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin)
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
expect {
patch "/tags/nico/#{nico_tag.id}", params: {
tags: " #{linked_a.name}\n#{linked_b.name} "
}
}.to change(TagVersion, :count).by(2)
.and change(NicoTagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
names = json.map { |t| t['name'] }
expect(names).to match_array(['nico_linked_a', 'nico_linked_b'])
linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a',
'nico_linked_b'
])
expect(versions.last.created_by_user_id).to eq(admin.id)
end
it '400 when linked tag normalises to nico tag' do
sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:bad_request)
end
end
end
+267 -6
View File
@@ -1,8 +1,8 @@
include ActiveSupport::Testing::TimeHelpers
require 'rails_helper'
require 'set'
include ActiveSupport::Testing::TimeHelpers
RSpec.describe 'Posts API', type: :request do
# create / update で thumbnail.attach は走るが、
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
@@ -756,6 +756,218 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe 'GET /posts/versions' do
let(:member) { create(:user, :member, name: 'version member') }
let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) }
let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) }
let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) }
let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) }
let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) }
let!(:tag_name2) { TagName.create!(name: 'spec_tag_2') }
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) }
def snapshot_tags(post)
post.snapshot_tag_names.join(' ')
end
def create_post_version!(post, version_no:, event_type:, created_by_user:, created_at:)
PostVersion.create!(
post: post,
version_no: version_no,
event_type: event_type,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: created_at,
created_by_user: created_by_user
)
end
let!(:v1) do
travel_to(t_v1) do
create_post_version!(
post_record,
version_no: 1,
event_type: 'create',
created_by_user: member,
created_at: t_v1
)
end
end
let!(:v2) do
post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
PostTag.create!(post: post_record, tag: tag2, created_user: member)
post_record.update!(
title: 'updated spec post',
original_created_from: oc_from,
original_created_before: oc_before
)
travel_to(t_v2) do
create_post_version!(
post_record.reload,
version_no: 2,
event_type: 'update',
created_by_user: member,
created_at: t_v2
)
end
end
let!(:other_post_version) do
other_post = Post.create!(
title: 'other versioned post',
url: 'https://example.com/other-versioned'
)
PostTag.create!(post: other_post, tag: tag)
travel_to(t_other) do
create_post_version!(
other_post,
version_no: 1,
event_type: 'create',
created_by_user: member,
created_at: t_other
)
end
end
it 'returns versions for the specified post in reverse chronological order' do
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
expect(json).to include('versions', 'count')
expect(json.fetch('count')).to eq(2)
versions = json.fetch('versions')
expect(versions.map { |v| v['post_id'] }.uniq).to eq([post_record.id])
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
latest = versions.first
expect(latest).to include(
'post_id' => post_record.id,
'version_no' => 2,
'event_type' => 'update',
'created_by_user' => {
'id' => member.id,
'name' => member.name
}
)
expect(latest.fetch('title')).to eq(
'current' => 'updated spec post',
'prev' => 'spec post'
)
expect(latest.fetch('url')).to eq(
'current' => 'https://example.com/spec',
'prev' => 'https://example.com/spec'
)
expect(latest.fetch('thumbnail')).to eq(
'current' => nil,
'prev' => nil
)
expect(latest.fetch('thumbnail_base')).to eq(
'current' => nil,
'prev' => nil
)
expect(latest.fetch('tags')).to include(
{ 'name' => 'spec_tag_2', 'type' => 'added' },
{ 'name' => 'spec_tag', 'type' => 'removed' }
)
expect(latest.fetch('original_created_from')).to eq(
'current' => oc_from.iso8601,
'prev' => nil
)
expect(latest.fetch('original_created_before')).to eq(
'current' => oc_before.iso8601,
'prev' => nil
)
expect(latest.fetch('created_at')).to eq(t_v2.iso8601)
first = versions.second
expect(first).to include(
'post_id' => post_record.id,
'version_no' => 1,
'event_type' => 'create',
'created_by_user' => {
'id' => member.id,
'name' => member.name
}
)
expect(first.fetch('title')).to eq(
'current' => 'spec post',
'prev' => nil
)
expect(first.fetch('tags')).to include(
{ 'name' => 'spec_tag', 'type' => 'added' }
)
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end
it 'filters versions by tag when the current snapshot includes the tag' do
get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
versions = json.fetch('versions')
expect(versions.size).to eq(1)
expect(versions[0]['post_id']).to eq(post_record.id)
expect(versions[0]['version_no']).to eq(2)
expect(versions[0]['tags']).to include(
{ 'name' => 'spec_tag_2', 'type' => 'added' }
)
end
it 'filters versions by tag when the tag exists in either current or previous snapshot' do
get '/posts/versions', params: { post: post_record.id, tag: tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
versions = json.fetch('versions')
expect(versions.map { |v| v['post_id'] }).to all(eq(post_record.id))
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
latest = versions[0]
first = versions[1]
expect(latest['tags']).to include(
{ 'name' => 'spec_tag', 'type' => 'removed' }
)
expect(first['tags']).to include(
{ 'name' => 'spec_tag', 'type' => 'added' }
)
end
it 'returns empty when tag does not exist' do
get '/posts/versions', params: { tag: 999_999_999 }
expect(response).to have_http_status(:ok)
expect(json.fetch('versions')).to eq([])
expect(json.fetch('count')).to eq(0)
end
it 'clamps page and limit to at least 1' do
get '/posts/versions', params: { post: post_record.id, page: 0, limit: 0 }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
versions = json.fetch('versions')
expect(versions.size).to eq(1)
expect(versions[0]['version_no']).to eq(2)
end
end
describe 'POST /posts/:id/viewed' do
let(:user) { create(:user) }
@@ -870,15 +1082,16 @@ RSpec.describe 'Posts API', type: :request do
it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
sign_in_as(member)
create_post_version_for!(post_record)
expect do
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
create_post_version_for!(post_record.reload)
expect {
put "/posts/#{post_record.id}", params: {
title: post_record.title,
tags: 'spec_tag'
}
end.not_to change(PostVersion, :count)
}.not_to change(PostVersion, :count)
expect(response).to have_http_status(:ok)
version = post_record.reload.post_versions.order(:version_no).last
@@ -918,4 +1131,52 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'tag versioning from post write actions' do
let(:member) { create(:user, :member) }
it 'creates tag snapshot for normalised tags on POST /posts' do
sign_in_as(member)
expect {
post '/posts', params: {
title: 'tag versioned post',
url: 'https://example.com/tag-versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload
}
}.to change { tag.reload.tag_versions.count }.by(1)
expect(response).to have_http_status(:created)
version = tag.reload.tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('spec_tag')
expect(version.category).to eq('general')
expect(version.created_by_user_id).to eq(member.id)
end
it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
sign_in_as(member)
tag_name2 = TagName.create!(name: 'spec_tag_2')
tag2 = Tag.create!(tag_name: tag_name2, category: :general)
expect {
put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'spec_tag_2'
}
}.to change { tag2.reload.tag_versions.count }.by(1)
expect(response).to have_http_status(:ok)
version = tag2.reload.tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('spec_tag_2')
expect(version.created_by_user_id).to eq(member.id)
end
end
end
+78 -6
View File
@@ -58,15 +58,47 @@ RSpec.describe "TagChildren", type: :request do
end
end
context "when Tag.find raises (invalid ids) it still returns 204" do
context "when Tag.find raises (invalid ids)" do
before { stub_current_user(admin) }
let(:parent_id) { -1 }
let(:child_id) { -1 }
it "returns 204 (rescue nil)" do
it "returns 404" do
do_request
expect(response).to have_http_status(:no_content)
expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
end
@@ -116,17 +148,57 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:no_content)
end
it 'records create and update versions for child tag' do
expect {
do_request
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:no_content)
versions = child.reload.tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
expect(versions.second.parent_tag_ids).to eq('')
expect(versions.second.created_by_user_id).to eq(admin.id)
end
end
context "when Tag.find raises (invalid ids) it still returns 204" do
context "when Tag.find raises (invalid ids)" do
before { stub_current_user(admin) }
let(:parent_id) { -1 }
let(:child_id) { -1 }
it "returns 204 (rescue nil)" do
it "returns 404" do
do_request
expect(response).to have_http_status(:no_content)
expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
end
+374 -6
View File
@@ -1,7 +1,6 @@
require 'cgi'
require 'rails_helper'
RSpec.describe 'Tags API', type: :request do
let!(:tn) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tn, category: :general) }
@@ -197,6 +196,30 @@ RSpec.describe 'Tags API', type: :request do
expect(response_tags.size).to eq(1)
expect(response_names).to eq(['norm_a'])
end
it 'returns aliases and parent tags' do
parent_tag = Tag.create!(
tag_name: TagName.create!(name: 'index_parent_tag'),
category: :meme
)
TagImplication.create!(tag:, parent_tag:)
get '/tags', params: { name: 'spec_tag' }
expect(response).to have_http_status(:ok)
row = response_tags.find { |t| t['name'] == 'spec_tag' }
expect(row['aliases']).to include('unko')
expect(row['parents'].map { |t| t['name'] }).to include('index_parent_tag')
parent = row['parents'].find { |t| t['name'] == 'index_parent_tag' }
expect(parent).to include(
'id' => parent_tag.id,
'name' => 'index_parent_tag',
'category' => 'meme'
)
end
end
describe 'GET /tags/:id' do
@@ -220,6 +243,28 @@ RSpec.describe 'Tags API', type: :request do
expect(json).to have_key('created_at')
expect(json).to have_key('updated_at')
end
it 'returns aliases and parent tags' do
parent_tag = Tag.create!(
tag_name: TagName.create!(name: 'show_parent_tag'),
category: :character
)
TagImplication.create!(tag:, parent_tag:)
request
expect(response).to have_http_status(:ok)
expect(json['aliases']).to include('unko')
expect(json['parents'].map { |t| t['name'] }).to include('show_parent_tag')
parent = json['parents'].find { |t| t['name'] == 'show_parent_tag' }
expect(parent).to include(
'id' => parent_tag.id,
'name' => 'show_parent_tag',
'category' => 'character'
)
end
end
context 'when tag does not exist' do
@@ -359,14 +404,72 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('meta')
end
it '存在しない id だと RecordNotFound になる(通常は 404' do
it '存在しない id なら 404 を返す' do
patch '/tags/999999999', params: { name: 'x' }
expect(response.status).to be_in([404, 500])
expect(response).to have_http_status(:not_found)
end
it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do
patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' }
expect(response.status).to be_in([422, 500])
it 'nico category への変更は 422 を返す' do
patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
it 'creates initial and update tag versions when name and category change' do
expect {
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
versions = tag.reload.tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.name).to eq('spec_tag')
expect(versions.first.category).to eq('general')
expect(versions.first.aliases.split).to include('unko')
expect(versions.second.name).to eq('new_tag_name')
expect(versions.second.category).to eq('meme')
expect(versions.second.created_by_user_id).to eq(member_user.id)
end
it 'returns 422 when changing normal tag category to nico' do
expect {
patch "/tags/#{tag.id}", params: { category: 'nico' }
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.category).to eq('general')
end
it 'returns 422 when updating nico tag name' do
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
expect(nico_tag.category).to eq('nico')
end
it 'returns 422 when changing nico tag category to normal category' do
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.category).to eq('nico')
end
end
end
@@ -510,4 +613,269 @@ RSpec.describe 'Tags API', type: :request do
expect(response).to have_http_status(:not_found)
end
end
describe 'PUT /tags/:id' do
context '未ログイン' do
before { stub_current_user(nil) }
it '401 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:unauthorized)
end
end
context 'ログインしてゐるが member でない' do
before { stub_current_user(non_member_user) }
it '403 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:forbidden)
end
end
context 'member' do
before { stub_current_user(member_user) }
it '存在しない id なら 404 を返す' do
put '/tags/999999999', params: {
name: 'new',
category: 'general',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:not_found)
end
it 'name が空なら 422 を返す' do
put "/tags/#{ tag.id }", params: {
name: '',
category: 'general',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
end
it 'category が空なら 422 を返す' do
put "/tags/#{ tag.id }", params: {
name: 'new',
category: '',
aliases: '',
parent_tags: '',
}
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
it 'name, category, aliases, parent tags をまとめて更新できる' do
old_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_old_parent'),
category: :general
)
kept_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_kept_parent'),
category: :general
)
TagImplication.create!(tag:, parent_tag: old_parent)
TagImplication.create!(tag:, parent_tag: kept_parent)
put "/tags/#{ tag.id }", params: {
name: 'put_renamed_tag',
category: 'meme',
aliases: 'put_alias_a put_alias_b put_alias_a',
parent_tags: 'put_kept_parent put_new_parent',
}
expect(response).to have_http_status(:ok)
tag.reload
expect(tag.name).to eq('put_renamed_tag')
expect(tag.category).to eq('meme')
expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name)
expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name)
old_name_alias = TagName.find_by(name: 'spec_tag')
expect(old_name_alias).to be_present
expect(old_name_alias.canonical).to eq(tag.tag_name)
expect(alias_tn.reload.canonical).to be_nil
expect(tag.parents.map(&:name)).to contain_exactly(
'put_kept_parent',
'put_new_parent'
)
expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist
expect(json['name']).to eq('put_renamed_tag')
expect(json['category']).to eq('meme')
expect(json['aliases']).to contain_exactly(
'put_alias_a',
'put_alias_b',
'spec_tag'
)
expect(json['parents'].map { |t| t['name'] }).to contain_exactly(
'put_kept_parent',
'put_new_parent'
)
end
it 'aliases に現在名を指定しても alias には残さない' do
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'spec_tag put_alias_self_test',
parent_tags: '',
}
expect(response).to have_http_status(:ok)
tag.reload
expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name)
expect(json['aliases']).to include('put_alias_self_test')
expect(json['aliases']).not_to include('spec_tag')
end
it 'parent_tags に自分自身を指定しても自己参照は作らない' do
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: 'spec_tag',
}
expect(response).to have_http_status(:ok)
expect(TagImplication.where(tag:, parent_tag: tag)).not_to exist
expect(tag.reload.parents).to eq([])
end
it 'initial and update tag versions を作成する' do
expect {
put "/tags/#{ tag.id }", params: {
name: 'put_versioned_tag',
category: 'meta',
aliases: '',
parent_tags: '',
}
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
versions = tag.reload.tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.name).to eq('spec_tag')
expect(versions.first.category).to eq('general')
expect(versions.first.aliases.split).to include('unko')
expect(versions.second.name).to eq('put_versioned_tag')
expect(versions.second.category).to eq('meta')
expect(versions.second.aliases.split).to include('spec_tag')
expect(versions.second.created_by_user_id).to eq(member_user.id)
end
it 'parent tag の snapshot も作成する' do
old_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
category: :general
)
new_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_snapshot_new_parent'),
category: :general
)
TagImplication.create!(tag:, parent_tag: old_parent)
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: new_parent.name,
}
expect(response).to have_http_status(:ok)
expect(old_parent.reload.tag_versions.map(&:event_type)).to include('create')
expect(new_parent.reload.tag_versions.map(&:event_type)).to include('create')
end
it 'normal tag を nico category には変更できない' do
expect {
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'nico',
aliases: '',
parent_tags: '',
}
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
it 'nico tag は更新できない' do
nico_tag = Tag.create!(
tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
category: :nico
)
expect {
put "/tags/#{ nico_tag.id }", params: {
name: 'nico:put_update_all_renamed',
category: 'nico',
aliases: '',
parent_tags: '',
}
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
expect(nico_tag.category).to eq('nico')
end
it 'system tag の name は変更できない' do
system_tag = Tag.tagme
old_name = system_tag.name
old_category = system_tag.category
expect {
put "/tags/#{ system_tag.id }", params: {
name: 'put_system_tag_renamed',
category: old_category,
aliases: '',
parent_tags: '',
}
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(system_tag.reload.name).to eq(old_name)
expect(system_tag.category).to eq(old_category)
end
end
end
end
+228
View File
@@ -90,4 +90,232 @@ RSpec.describe "nico:sync" do
expect(active_names).to include("nico:NEW")
expect(active_names).not_to include("nico:OLD")
end
def snapshot_tags(post)
post.snapshot_tag_names.join(' ')
end
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
PostVersion.create!(
post: post,
version_no: version_no,
event_type: event_type,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
parent: post.parent,
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: Time.current,
created_by_user: created_by_user
)
end
it '新規 post 作成時に version 1 を作る' do
Tag.bot
Tag.tagme
Tag.niconico
Tag.video
Tag.no_deerjikist
stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.to change(PostVersion, :count).by(1)
post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9')
version = post.post_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.created_by_user).to be_nil
expect(version.tags).to eq(snapshot_tags(post.reload))
end
it '既存 post の内容または tags が変わったとき update version を作る' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
kept_general = create_tag!('spec_kept', category: 'general')
PostTag.create!(post: post, tag: kept_general)
create_post_version_for!(post)
linked = create_tag!('spec_linked', category: 'general')
nico = create_tag!('nico:AAA', category: 'nico')
link_nico_to_tag!(nico, linked)
Tag.bot
Tag.tagme
Tag.no_deerjikist
stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.to change(PostVersion, :count).by(1)
version = post.reload.post_versions.order(:version_no).last
expect(version.version_no).to eq(2)
expect(version.event_type).to eq('update')
expect(version.created_by_user).to be_nil
expect(version.tags).to eq(snapshot_tags(post.reload))
end
it '既存 post に差分が無いときは新しい version を作らない' do
nico = create_tag!('nico:AAA', category: 'nico')
no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta')
post = Post.create!(
title: 't',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil,
original_created_from: Time.iso8601('2026-01-01T03:34:00Z'),
original_created_before: Time.iso8601('2026-01-01T03:35:00Z')
)
PostTag.create!(post: post, tag: nico)
PostTag.create!(post: post, tag: no_deerjikist)
create_post_version_for!(post)
stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.not_to change(PostVersion, :count)
version = post.reload.post_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.tags).to eq(snapshot_tags(post.reload))
end
it '新規 nico tag に nico tag version を作る' do
Tag.bot
Tag.tagme
Tag.niconico
Tag.video
Tag.no_deerjikist
stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.to change(NicoTagVersion, :count).by(1)
nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
version = nico_tag.nico_tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
expect(version.event_type).to eq('create')
expect(version.name).to eq('nico:AAA')
expect(version.created_by_user).to be_nil
end
it '既存 post に version が無い場合は create snapshot を補う' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
kept_general = create_tag!('spec_kept_without_version', category: 'general')
PostTag.create!(post: post, tag: kept_general)
Tag.bot
Tag.tagme
Tag.no_deerjikist
stub_python([{
'code' => 'sm9',
'title' => 'changed title',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.to change { post.reload.post_versions.count }.by(1)
versions = post.reload.post_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create'])
expect(versions.first.title).to eq('changed title')
expect(versions.first.tags).to eq(snapshot_tags(post.reload))
end
it '既存 version がある post には update version を作る' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
kept_general = create_tag!('spec_kept_with_version', category: 'general')
PostTag.create!(post: post, tag: kept_general)
PostVersionRecorder.record!(
post: post,
event_type: :create,
created_by_user: nil
)
Tag.bot
Tag.tagme
Tag.no_deerjikist
stub_python([{
'code' => 'sm9',
'title' => 'changed title',
'tags' => ['AAA'],
'uploaded_at' => '2026-01-01 12:34:56'
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
expect {
run_rake_task('nico:sync')
}.to change { post.reload.post_versions.count }.by(1)
versions = post.reload.post_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.title).to eq('old')
expect(versions.second.title).to eq('changed title')
expect(versions.second.tags).to eq(snapshot_tags(post.reload))
end
end
+925 -181
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -15,6 +15,8 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@mdx-js/react": "^3.1.1",
"@mdx-js/rollup": "^3.1.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-toast": "^1.2.14",
@@ -37,13 +39,15 @@
"react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0",
"zustand": "^5.0.8",
"unist-util-visit-parents": "^6.0.1"
"unist-util-visit-parents": "^6.0.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.19",
"@types/axios": "^0.14.4",
"@types/markdown-it": "^14.1.2",
"@types/mdx": "^2.0.13",
"@types/node": "^24.0.13",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
+15 -6
View File
@@ -1,4 +1,4 @@
import { AnimatePresence, LayoutGroup } from 'framer-motion'
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { BrowserRouter,
Navigate,
@@ -15,8 +15,10 @@ import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostListPage from '@/pages/posts/PostListPage'
@@ -24,6 +26,7 @@ import PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage'
import TagDetailPage from '@/pages/tags/TagDetailPage'
import TagListPage from '@/pages/tags/TagListPage'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
@@ -44,7 +47,6 @@ const RouteTransitionWrapper = ({ user, setUser }: {
const location = useLocation ()
return (
<LayoutGroup id="gallery-shared">
<AnimatePresence mode="wait">
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
@@ -54,6 +56,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
@@ -70,10 +73,11 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
</AnimatePresence>
</LayoutGroup>)
</AnimatePresence>)
}
@@ -131,10 +135,15 @@ export default (() => {
<>
<RouteBlockerOverlay/>
<BrowserRouter>
<div className="flex flex-col h-dvh w-full">
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</div>
</motion.div>
</LayoutGroup>
<Toaster/>
</BrowserRouter>
</>)
@@ -90,7 +90,9 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }:
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
{...attributes}
{...listeners}>
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<motion.div
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div>
</div>)
+1 -1
View File
@@ -62,7 +62,7 @@ export default (({ post, onSave }: Props) => {
<Label></Label>
<input type="text"
className="w-full border rounded p-2"
value={title}
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
</div>
+1 -1
View File
@@ -56,7 +56,7 @@ export default (({ posts, onClick }: Props) => {
cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}}
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
+6 -2
View File
@@ -313,7 +313,9 @@ export default (({ post, sp }: Props) => {
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
<div className="my-3" key={cat}>
<SubsectionTitle>
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}>
<motion.div
layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>
@@ -325,7 +327,9 @@ export default (({ post, sp }: Props) => {
</ul>
</div>))}
{post && (
<motion.div layoutId={`post-info-${ sp }`}>
<motion.div
layoutId={`post-info-${ sp }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<SectionTitle></SectionTitle>
<ul>
<li>Id.: {post.id}</li>
+3 -1
View File
@@ -65,7 +65,9 @@ export default (({ posts, onClick }: Props) => {
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<motion.div layoutId={`tag-${ tag.id }`}>
<motion.div
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>
</li>))) : [])}
+212 -80
View File
@@ -8,65 +8,30 @@ import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { Menu, User } from '@/types'
import type { Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null }
export default (({ user }: Props) => {
const location = useLocation ()
const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
const navRef = useRef<HTMLDivElement | null> (null)
const measure = () => {
const nav = navRef.current
const el = itemsRef.current[activeIdx]
if (!(nav) || !(el) || activeIdx < 0)
return
const navRect = nav.getBoundingClientRect ()
const elRect = el.getBoundingClientRect ()
setHl ({ left: elRect.left - navRect.left,
width: elRect.width,
visible: true })
}
const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
left: 0,
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '')
const { data: wikiPage } = useQuery ({
enabled: Boolean (wikiIdStr),
queryKey: wikiKeys.show (wikiIdStr, { }),
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
const effectiveTitle = wikiPage?.title ?? ''
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
export const menuOutline = ({ tag, wikiId, user, pathName }: {
tag?: Tag | null
wikiId: number | null
user: User | null,
pathName: string }): Menu => {
const postCount = tag?.postCount ?? 0
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId)
const wikiTitle = location.pathname.split ('/')[2] ?? ''
const menu: Menu = [
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? ''
const tagFlg = /^\/tags\/\d+/.test (pathName)
return [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
@@ -75,16 +40,19 @@ export default (({ user }: Props) => {
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' },
{ name: '別名タグ', to: '/tags/aliases', visible: false },
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
// { name: '素材', to: '/materials', subMenu: [
// { name: '一覧', to: '/materials' },
// { name: '検索', to: '/materials/search', visible: false },
// { name: '追加', to: '/materials/new' },
// { name: '履歴', to: '/materials/changes', visible: false },
// { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg },
{ name: `広場 (${ postCount || 0 })`,
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
visible: tagFlg },
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`, visible: false }] },
{ name: '素材', to: '/materials', visible: false, subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' },
{ name: '履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
@@ -101,12 +69,69 @@ export default (({ user }: Props) => {
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'ユーザ', to: '/users/settings', subMenu: [
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
{ name: '法規', visible: false, subMenu: [
{ name: '利用規約', to: '/tos' }] }]
}
const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
export default (({ user }: Props) => {
const location = useLocation ()
const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
const navRef = useRef<HTMLDivElement | null> (null)
const measure = (idx: number) => {
const nav = navRef.current
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
if (!(nav) || !(el))
{
setHL ({ left: 0, width: 0, visible: true })
return
}
const navRect = nav.getBoundingClientRect ()
const elRect = el.getBoundingClientRect ()
setHL ({ left: elRect.left - navRect.left,
width: elRect.width,
visible: true })
}
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
left: 0,
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '')
const { data: wikiPage } = useQuery ({
enabled: Boolean (wikiIdStr),
queryKey: wikiKeys.show (wikiIdStr, { }),
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? ''
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -119,28 +144,24 @@ export default (({ user }: Props) => {
const dir = dirRef.current
useLayoutEffect (() => {
if (activeIdx < 0)
return
const raf = requestAnimationFrame (measure)
const onResize = () => requestAnimationFrame (measure)
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
addEventListener ('resize', onResize)
return () => {
cancelAnimationFrame (raf)
removeEventListener ('resize', onResize)
}
}, [activeIdx])
})
useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe ()
}, [])
}, [activeIdx])
useEffect (() => {
setMenuOpen (false)
setOpenItemIdx (menu.findIndex (item => (
location.pathname.startsWith (item.base || item.to))))
setOpenItemIdx (activeIdx)
}, [location])
return (
@@ -167,9 +188,16 @@ export default (({ user }: Props) => {
transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/>
{menu.map ((item, i) => (
{visibleMenu.map ((item, i) => (
<motion.div
key={item.to}
layoutId={`menu-${ item.name }`}
animate={{ opacity: moreVsbl ? 0 : 1 }}
transition={{ opacity: { duration: .12 },
layout: { duration: .2, ease: 'easeOut' } }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink
key={i}
to={item.to}
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el
@@ -177,7 +205,22 @@ export default (({ user }: Props) => {
className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</PrefetchLink>))}
</PrefetchLink>
</motion.div>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
onClick={() => setMoreVsbl (false)}
onMouseEnter={() => {
setMoreVsbl (true)
measure (-1)
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
&raquo;
</PrefetchLink>
</div>
</div>
@@ -195,8 +238,75 @@ export default (({ user }: Props) => {
</a>
</nav>
<div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
items-center w-full min-h-[40px] overflow-hidden">
<AnimatePresence initial={false}>
<motion.div
key="submenu-shell"
layout
className="relative hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950"
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx)
}}>
{moreVsbl
? (
menu.map ((item, i) => (
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3">
<motion.div
transition={{ duration: .2, ease: 'easeOut' }}
{...((item.visible ?? true)
? { layoutId: `menu-${ item.name }` }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } })}
className="z-10 h-full flex items-center px-3 font-bold w-24">
<h2>{item.name}</h2>
</motion.div>
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<motion.div
key={`c-${ i }-${ j }`}
transition={{ duration: .2, ease: 'easeOut' }}
{...((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } })}>
{subItem.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }-${ j }`}
transition={{ duration: .2, ease: 'easeOut' }}
{...((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } })}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)}
className="h-full flex items-center px-3">
{subItem.name}
</PrefetchLink>
</motion.div>)))}
</div>
</div>)))
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
&& (
<div className="relative h-[40px]">
<AnimatePresence initial={false} custom={dir}>
<motion.div
key={activeIdx}
@@ -209,22 +319,34 @@ export default (({ user }: Props) => {
animate="centre"
exit="exit"
transition={{ duration: .2, ease: 'easeOut' }}>
{(menu[activeIdx]?.subMenu ?? [])
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
? (
<motion.div
key={`c-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component}
</motion.div>)
: (
<PrefetchLink
<motion.div
key={`l-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
className="h-full flex items-center px-3">
{item.name}
</PrefetchLink>)))}
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>
</div>))}
</motion.div>
</AnimatePresence>
</div>
<AnimatePresence initial={false}>
{menuOpen && (
@@ -241,7 +363,7 @@ export default (({ user }: Props) => {
exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}>
<Separator/>
{menu.map ((item, i) => (
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
@@ -294,6 +416,16 @@ export default (({ user }: Props) => {
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
+1 -7
View File
@@ -4,8 +4,6 @@ import ReactMarkdown from 'react-markdown'
import remarkGFM from 'remark-gfm'
import PrefetchLink from '@/components/PrefetchLink'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import { wikiKeys } from '@/lib/queryKeys'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
import { fetchWikiPages } from '@/lib/wiki'
@@ -16,11 +14,7 @@ import type { Components } from 'react-markdown'
type Props = { title: string
body?: string }
const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>,
h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>,
ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>,
ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
a: (({ href, children }) => (
const mdComponents = { a: (({ href, children }) => (
['/', '.'].some (e => href?.startsWith (e))
? <PrefetchLink to={href!}>{children}</PrefetchLink>
: (
@@ -1,9 +1,11 @@
import React from 'react'
import { cn } from '@/lib/utils'
type Props = { children: React.ReactNode }
import type { ComponentPropsWithoutRef, FC } from 'react'
type Props = ComponentPropsWithoutRef<'h2'>
export default ({ children }: Props) => (
<h2 className="text-xl my-4">
export default (({ children, className, ...rest }: Props) => (
<h2 {...rest} className={cn ('text-xl my-4', className)}>
{children}
</h2>)
</h2>)) satisfies FC<Props>
+7 -3
View File
@@ -1,3 +1,5 @@
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
@@ -8,7 +10,9 @@ type Props = {
export default (({ children, className }: Props) => (
<main className={cn ('flex-1 overflow-y-auto p-4 md:h-[calc(100dvh-88px)]',
className)}>
<motion.main
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout="position">
{children}
</main>)) satisfies FC<Props>
</motion.main>)) satisfies FC<Props>
@@ -1,3 +1,4 @@
import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async'
import type { FC, ReactNode } from 'react'
@@ -6,10 +7,10 @@ type Props = { children: ReactNode }
export default (({ children }: Props) => (
<div
className="p-4 w-full md:w-64 md:h-full
md:h-[calc(100dvh-88px)] md:overflow-y-auto
sidebar">
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar">
<Helmet>
<style>
{`
@@ -26,4 +27,4 @@ export default (({ children }: Props) => (
</Helmet>
{children}
</div>)) satisfies FC<Props>
</motion.div>)) satisfies FC<Props>
+5 -5
View File
@@ -1,6 +1,6 @@
import { apiDelete, apiGet, apiPost } from '@/lib/api'
import type { FetchPostsParams, Post, PostTagChange } from '@/types'
import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async (
@@ -29,15 +29,15 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po
export const fetchPostChanges = async (
{ id, tag, page, limit }: {
id?: string
{ post, tag, page, limit }: {
post?: string
tag?: string
page: number
limit: number },
): Promise<{
changes: PostTagChange[]
versions: PostVersion[]
count: number }> =>
await apiGet ('/posts/changes', { params: { ...(id && { id }),
await apiGet ('/posts/versions', { params: { ...(post && { post }),
...(tag && { tag }),
page, limit } })
+17 -1
View File
@@ -14,6 +14,7 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id')
const mWiki = match<{ title: string }> ('/wiki/:title')
const mTag = match<{ id: string }> ('/tags/:id')
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
@@ -169,6 +170,19 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
}
const prefetchTagShow: Prefetcher = async (qc, url) => {
const m = mTag (url.pathname)
if (!(m))
return
const { id } = m.params
await qc.prefetchQuery ({
queryKey: tagsKeys.show (id),
queryFn: () => fetchTag (id) })
}
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
run: prefetchPostsIndex },
@@ -180,7 +194,9 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
&& Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow },
{ test: u => u.pathname === '/tags', run: prefetchTagsIndex }]
{ test: u => u.pathname === '/tags', run: prefetchTagsIndex },
{ test: u => u.pathname !== '/tags/nico' && Boolean (mTag (u.pathname)),
run: prefetchTagShow }]
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
+1 -1
View File
@@ -5,7 +5,7 @@ export const postsKeys = {
index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', id] as const,
changes: (p: { id?: string; tag?: string; page: number; limit: number }) =>
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
['posts', 'changes', p] as const }
export const tagsKeys = {
+4
View File
@@ -0,0 +1,4 @@
import type { MDXComponents } from 'mdx/types'
export const useMDXComponents = (): MDXComponents => ({ })
+46
View File
@@ -0,0 +1,46 @@
import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink'
import { menuOutline } from '@/components/TopNav'
import SectionTitle from '@/components/common/SectionTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import type { FC } from 'react'
import type { User } from '@/types'
export default (() => {
const menu = menuOutline (
{ tag: null, wikiId: null, user: { } as User, pathName: location.pathname })
return (
<MainArea className="md:flex">
<Helmet>
<title>{`メニュー | ${ SITE_TITLE }`}</title>
</Helmet>
{[...Array (Math.ceil (menu.length / 4)).keys ()].map (i => (
<div key={i} className="flex-1 mx-16">
{menu.slice (4 * i, 4 * (i + 1)).map ((item, j) => (
<section key={j}>
<SectionTitle className="font-bold">{item.name}</SectionTitle>
<ul>
{item.subMenu
.filter (subItem => (subItem.visible ?? true))
.map ((subItem, k) => ('name' in subItem && (
<li key={k}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}>
{subItem.name}
</PrefetchLink>
</li>)))}
</ul>
</section>))}
</div>))}
</MainArea>)
}) satisfies FC
+134
View File
@@ -0,0 +1,134 @@
import { Helmet } from 'react-helmet-async'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { dateString } from '@/lib/utils'
export const lastUpdatedAt = dateString ('2026-04-12', 'hour')
<MainArea>
<Helmet>
<title>{`利用規約 | ${ SITE_TITLE }`}</title>
</Helmet>
<article className="prose dark:prose-invert mx-auto p-4">
# 利用規約
最終更新日: {lastUpdatedAt}
この利用規約(以下「本規約」)は、ぼざクリ タグ広場(以下「本サービス」)の利用条件を定めるものです。利用者は、本サービスを利用した時点で、本規約に同意したものとみなされます。
## 第 1 条 本サービスの位置づけ
1. 本サービスは、タグ・Wiki・外部リンクの整理を中心とする知識共有基盤です。
2. 本サービスの中心価値は、コンテンツそのものの再配布ではなく、タグを軸にした整理、検索、再発見、および周辺知識の蓄積にあります。
3. 本サービスは、運営上の必要に応じて、機能、公開範囲、名称、URL、表示内容その他の仕様を変更することがあります。
## 第 2 条 公開方針と利用者区分
1. 本サービスは、初回一般公開時点では、**誰でも閲覧できる一方で、投稿・編輯は申請制** とします。
2. 初回一般公開時点では、通常の農奴は閲覧のみを行えます。
3. 投稿、タグ編輯、Wiki 編輯その他の耕作行為は、運営が承認した利用者(以下「耕作員」)に限って認めます。
4. 独裁者は、耕作員に加えて、差戻、削除、利用制限、その他の管理操作を行えます。
5. 運営は、履歴管理、差戻、BAN 運用、監査導線その他の運営装備がじゅうぶんに整ったと判断した場合、農奴に一部の編輯権限を開放することがあります。
6. 利用者区分、権限範囲、申請条件、承認基準、承認後の取扱いは、運営が必要に応じて定め、変更できます。
## 第 3 条 利用開始と引継ぎコード
1. 本サービスでは、一般的な Id. / パスワード方式ではなく、運営が別途定める認証情報または引継ぎコードを用いる場合があります。
2. 利用者は、自身に割り当てられた引継ぎコード、認証情報、端末上の保存情報を自己の責任で管理するものとします。
3. 利用者は、自己の引継ぎコードまたは認証情報を第 3 者に譲渡、貸与、共有、漏洩してはなりません。
4. 引継ぎコードの漏洩、第 3 者利用、紛失、盗用その他の事故によって利用者または第 3 者に生じた損害について、運営は責任を負いません。
5. 運営は、本人確認、濫用対策、監査対応または保守のため、利用情報とアクセス元情報を関聯づけて扱うことがあります。
## 第 4 条 申請制編輯の基本ルール
1. 耕作員は、タグ整理基盤の品質維持を最優先し、個人的な所有主張ではなく、検索性、再利用性、可読性、整合性を重視して編輯しなければなりません。
2. 耕作員は、主観的な好悪、内輪ネタ、報復、私怨、対立誘導のためにタグや Wiki を操作してはなりません。
3. 耕作員は、誤りの修正、体系の整理、リンクの保守、知識の補足を目的として編輯を行うものとします。
4. 運営は、申請内容、過去の行動、編輯品質、聯絡可能性、運営負荷その他の事情を考慮して、承認、保留、拒否、取消を行えます。
5. 耕作員資格は権利ではなく、運営が本サービスの維持のために付与する可撤回の権限です。
## 第 5 条 禁止事項
利用者は、以下の行為をしてはなりません。
1. 法令または公序良俗に違反する行為。
2. 犯罪を助長し、またはこれに結びつく行為。
3. 著作権、著作者人格権、商標権、肖像権、パブリシティ権、プライバシー権その他第 3 者の権利を侵害する行為。
4. 無断転載、違法アップロード、違法複製物、海賊版、権限のない転載先への誘導、またはそれらを正当化、拡散、補助する行為。
5. 実在人物に関する名誉毀損、侮辱、差別、脅迫、晒し、つきまとい、嫌がらせ、私刑の扇動その他の加害行為。
6. 個人情報、非公開情報、秘匿されるべき情報を本人の承諾なく掲載、送信、共有、推測可能な形で開示する行為。
7. 虚偽の情報、誤解を招く情報、出典を偽装した情報、意図的なミスリード、荒らし目的のタグづけ、関係のないタグの大量付与、分類妨碍、検索妨碍その他の品質破壊行為。
8. マルウェア、フィッシング、詐欺、誘導広告、悪質なリダイレクト、危険な外部リンクその他利用者または運営に危害を与える行為。
9. 本サービスの趣旨に照らして不相当な政治的扇動、宗教勧誘、商業宣伝、連鎖的勧誘、スパム、同一内容の反復送信。
10. 未成年の安全に反する行為、児童性的搾取、違法または著しく不適切な性的表現、過度に露骨な性表現や残虐表現を、一般公開導線に無警告で流し込む行為。
11. 運営、他の利用者、外部サービスまたは第 3 者に著しい負担、不利益、混乱を生じさせる行為。
12. 前各号のいずれかを試みる行為、教唆する行為、容易にする行為。
13. その他、運営が本サービスの目的または安全な運営に照らして不適切と判断する行為。
## 第 6 条 投稿、タグ、Wiki 等の取扱い
1. 利用者は、自らが投稿、編輯、登録、送信または変更する情報について、必要な権利を有し、または適法に利用できる状態でなければなりません。
2. 利用者は、自らが行った投稿、タグづけ、Wiki 編輯、説明文、コメント、関聯づけその他の行為について責任を負います。
3. 利用者は、運営に対し、本サービスの運営、表示、複製、保存、配信、整形、引用、履歴表示、差戻、バックアップ、障碍対応および弘報のために必要な範囲で、当該利用者生成情報を無償で利用する非独占的な権利を許諾するものとします。
4. 前項の許諾は、本サービスの運営上必要な範囲に限られ、利用者の権利帰属自体を運営へ移転するものではありません。
5. 運営は、分類整合性、表記統一、誤記修正、別名統合、差戻その他の理由により、投稿、タグ、Wiki その他の内容を編輯、非表示化、削除、統合、分割または凍結できます。
## 第 7 条 外部リンクと埋め込み
1. 本サービスは、外部サイトへのリンク、外部コンテンツの埋め込みまたはそれらに関するメタデータを表示する場合があります。
2. 外部リンク先または埋め込み先の権利、利用条件、公開範囲、削除方針、広告、追跡、Cookie その他の取扱いは、当該外部サービスの定めに従います。
3. 運営は、外部リンク先の適法性、安全性、継続性、正確性、品質、可用性、または内容の完全性を保証しません。
4. 外部権利者からの申立て、運営判断、法令対応または安全性確保のため、運営は外部リンク、埋め込み、サムネイル、説明文その他の表示を制限、差替え、非表示または削除できます。
## 第 8 条 履歴、差戻、削除
1. 本サービスでは、保守、監査、荒らし対策、説明責任その他の目的で、投稿、タグ、Wiki その他の変更履歴を保持し、表示し、または内部的に参照することがあります。
2. 利用者は、一度行った編輯が、後に差戻、修正、非表示化または削除されることがあることをあらかじめ承諾するものとします。
3. 利用者が削除を希望した場合でも、法令上、保守上、監査上、紛争対応上またはバックアップ上の必要により、直ちに完全消去できないことがあります。
4. 運営は、本サービス全体の健全性を維持するため、説明の有無を問わず、履歴の表示範囲、保存期間、差戻方針、削除方針を定め、変更できます。
## 第 9 条 利用制限、資格取消、BAN
1. 運営は、利用者が次のいずれかに該当すると判断した場合、事前の通知なく、または通知後に、投稿・編輯の制限、耕作員資格の取消、コンテンツの非表示または削除、引継ぎコードの失効、ユーザ BAN、IP BAN その他必要な措置を行えます。
- 本規約に違反した場合
- 本サービスの趣旨に反する運用妨碍、荒らし、品質破壊行為を行った場合
- 運営からの確認、修正要請、停止要請に合理的理由なく応じない場合
- 登録情報、申請内容または説明に虚偽がある場合
- 安全性、法令順守、運営継続の観点から措置が必要と判断された場合
2. 運営は、前項の措置について、その理由、基準、証拠または内部判断過程を常に開示する義務を負いません。
3. 利用制限または資格取消後も、運営は、必要に応じて履歴、ログ、申請記録、通報記録その他のデータを保持できます。
## 第 10 条 未成年の利用
1. 運営は、未成年の安全確保の観点から、年齢に応じた表示制限、導線制御、非表示化、削除、申請拒否その他の措置を行えます。
2. 利用者は、未成年が閲覧しうる一般公開面において、未成年に不適切な内容を無警告で流し込まないものとします。
## 第 11 条 お問い合わせ、通報、御意見番
1. 利用者は、本サービスが別途案内する問い合わせ、通報または御意見板の導線を通じて、バグ報告、問題報告、削除要請その他の聯絡を行えます。
2. 運営は、すべての問い合わせに回答する義務を負わず、回答期限、対応結果または対応方法を保証しません。
## 第 12 条 免責
1. 運営は、本サービスについて、特定目的適合性、完全性、正確性、継続性、安全性、無瑕疵性、または利用者の期待への適合を保証しません。
2. 運営は、外部リンク先、外部埋め込み先、第 3 者投稿、利用者同士の紛争、通信障碍、データ消失、誤分類、誤リンク、誤記、差戻、機能停止または仕様変更によって生じた損害について、責任を負いません。
3. 本サービスは、予告なく停止、終了、変更または縮小されることがあります。
## 第 13 条 規約の変更
1. 運営は、法令改正、機能追加、運用方針の変更、安全対策、表現調整その他の理由により、本規約を変更できます。
2. 変更後の本規約は、本サービス上に掲載された時点または運営が別途定める時点から効力を生じます。
3. 変更後に利用を継続した利用者は、変更後の本規約に同意したものとみなされます。
## 第 14 条 準拠法および管轄
1. 本規約および本サービスの利用には、日本法を準拠法とします。
2. 本規約または本サービスに関して生じた一切の紛争については、運営の所在地を管轄する裁判所を第 1 審の専属的合意管轄裁判所とします。ただし、法令に別段の定めがある場合はこの限りではありません。
## 附則
本規約は、{lastUpdatedAt} から適用します。
</article>
</MainArea>
@@ -6,8 +6,7 @@ import type { FC } from 'react'
export default (() => (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden
md:h-[calc(100dvh-88px)]">
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<MaterialSidebar/>
<Outlet/>
</div>)) satisfies FC
+3 -4
View File
@@ -93,11 +93,10 @@ export default (({ user }: Props) => {
: 'bg-gray-500 hover:bg-gray-600')
return (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden
md:h-[calc(100dvh-88px)]">
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet>
{(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet>
@@ -117,7 +116,7 @@ export default (({ user }: Props) => {
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
<img src={post.thumbnail || post.thumbnailBase}
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
className="object-cover w-full h-full"/>
+141 -29
View File
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect } from 'react'
import { Helmet } from 'react-helmet-async'
@@ -9,15 +9,30 @@ import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import { fetchPostChanges } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn, dateString } from '@/lib/utils'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
<>
{(diff.prev && diff.prev !== diff.current) && (
<>
<del className="text-red-600 dark:text-red-400">
{diff.prev}
</del>
{diff.current && <br/>}
</>)}
{diff.current}
</>)
export default (() => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
@@ -36,15 +51,17 @@ export default (() => {
: { data: null }
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.changes ({ ...(id && { id }),
queryKey: postsKeys.changes ({ ...(id && { post: id }),
...(tagId && { tag: tagId }),
page, limit }),
queryFn: () => fetchPostChanges ({ ...(id && { id }),
queryFn: () => fetchPostChanges ({ ...(id && { post: id }),
...(tagId && { tag: tagId }),
page, limit }) })
const changes = data?.changes ?? []
const changes = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
const qc = useQueryClient ()
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
@@ -65,76 +82,171 @@ export default (() => {
{loading ? 'Loading...' : (
<>
<table className="table-auto w-full border-collapse">
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse">
<colgroup>
{/* 投稿 */}
<col className="w-64"/>
{/* 版 */}
<col className="w-40"/>
{/* タイトル */}
<col className="w-96"/>
{/* URL */}
<col className="w-96"/>
{/* タグ */}
<col className="w-[48rem]"/>
{/* オリジナルの投稿日時 */}
<col className="w-96"/>
{/* 更新日時 */}
<col className="w-64"/>
{/* (差戻ボタン) */}
<col className="w-20"/>
</colgroup>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<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 text-left">URL</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"/>
</tr>
</thead>
<tbody>
{changes.map ((change, i) => {
const withPost = i === 0 || change.post.id !== changes[i - 1].post.id
const withPost = i === 0 || change.postId !== changes[i - 1].postId
if (withPost)
{
rowsCnt = 1
for (let j = i + 1;
(j < changes.length
&& change.post.id === changes[j].post.id);
&& change.postId === changes[j].postId);
++j)
++rowsCnt
}
let layoutId: string | undefined = `page-${ change.post.id }`
let layoutId: string | undefined = `page-${ change.postId }`
if (layoutIds.includes (layoutId))
layoutId = undefined
else
layoutIds.push (layoutId)
return (
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag?.id }`}
<tr key={`${ change.postId }.${ change.versionNo }`}
className={cn ('even:bg-gray-100 dark:even:bg-gray-700',
withPost && 'border-t')}>
{withPost && (
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.post.id }`}>
<PrefetchLink to={`/posts/${ change.postId }`}>
<motion.div
layoutId={layoutId}
transition={{ type: 'spring',
stiffness: 500,
damping: 40,
mass: .5 }}>
<img src={change.post.thumbnail
|| change.post.thumbnailBase
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={change.thumbnail.current
|| change.thumbnailBase.current
|| undefined}
alt={change.post.title || change.post.url}
title={change.post.title || change.post.url || undefined}
alt={change.title.current || change.url.current}
title={change.title.current || change.url.current || undefined}
className="w-40"/>
</motion.div>
</PrefetchLink>
</td>)}
<td className="p-2">{change.postId}.{change.versionNo}</td>
<td className="p-2 break-all">{renderDiff (change.title)}</td>
<td className="p-2 break-all">{renderDiff (change.url)}</td>
<td className="p-2">
{change.tag
? <TagLink tag={change.tag} withWiki={false} withCount={false}/>
: '(マスタ削除済のタグ) '}
{`${ change.changeType === 'add' ? '記載' : '消除' }`}
{change.tags.map ((tag, i) => (
tag.type === 'added'
? (
<ins
key={i}
className="mr-2 text-green-600 dark:text-green-400">
{tag.name}
</ins>)
: (
tag.type === 'removed'
? (
<del
key={i}
className="mr-2 text-red-600 dark:text-red-400">
{tag.name}
</del>)
: (
<span key={i} className="mr-2">
{tag.name}
</span>))))}
</td>
<td className="p-2">
{change.user
{change.versionNo === 1
? originalCreatedAtString (change.originalCreatedFrom.current,
change.originalCreatedBefore.current)
: renderDiff ({
current: originalCreatedAtString (
change.originalCreatedFrom.current,
change.originalCreatedBefore.current),
prev: originalCreatedAtString (
change.originalCreatedFrom.prev,
change.originalCreatedBefore.prev) })}
</td>
<td className="p-2">
{change.createdByUser
? (
<PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name}
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
{change.createdByUser.name
|| `名もなきニジラー(#${ change.createdByUser.id }`}
</PrefetchLink>)
: 'bot 操作'}
<br/>
{dateString (change.timestamp)}
{dateString (change.createdAt)}
</td>
<td className="p-2">
<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>
</td>
</tr>)
})}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages}/>
</>)}
+1 -2
View File
@@ -70,8 +70,7 @@ export default (() => {
return (
<div
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden
md:h-[calc(100dvh-88px)]"
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
ref={containerRef}>
<Helmet>
<title>
+2 -2
View File
@@ -289,7 +289,7 @@ export default (() => {
{results.map (row => (
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div
layoutId={`page-${ row.id }`}
transition={{ type: 'spring',
@@ -304,7 +304,7 @@ export default (() => {
</PrefetchLink>
</td>
<td className="p-2 truncate">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
{row.title}
</PrefetchLink>
</td>
+158
View File
@@ -0,0 +1,158 @@
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 { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
import type { Category, Tag } from '@/types'
export default (() => {
const { id } = useParams ()
const tagId = String (id ?? '')
const tagKey = tagsKeys.show (tagId)
const { data: tag, isLoading: loading } = useQuery ({
queryKey: tagKey,
queryFn: () => fetchTag (tagId) })
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category> ('general')
const [aliases, setAliases] = useState ('')
const [parentTags, setParentTags] = useState ('')
const [disabled, setDisabled] = useState (true)
const qc = useQueryClient ()
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
const formData = new FormData
formData.append ('name', name)
formData.append ('category', category)
formData.append ('aliases', aliases)
formData.append ('parent_tags', parentTags)
try
{
const data = await apiPut<Tag> (`/tags/${ id }`, formData)
setName (data.name)
setCategory (data.category as Category)
setAliases (data.aliases.join (' '))
setParentTags (data.parents.map (t => t.name).join (' '))
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
}
catch
{
toast ({ description: '更新に失敗しました.' })
}
}
useEffect (() => {
if (!(tag))
{
setDisabled (true)
return
}
setName (tag.name)
setCategory (tag.category as Category)
setAliases (tag.aliases.join (' '))
setParentTags (tag.parents.map (t => t.name).join (' '))
setDisabled (tag.category === 'nico')
}, [tag])
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">
{/* 名称 */}
<div>
<Label></Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={name}
onChange={e => setName (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* カテゴリ */}
<div>
<Label></Label>
<select
disabled={disabled}
value={category ?? ''}
onChange={e => setCategory(e.target.value as Category)}
className="w-full border p-2 rounded">
{CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico')
.map (cat => (
<option key={cat} value={cat}>
{CATEGORY_NAMES[cat]}
</option>))}
</select>
</div>
{/* 別名 */}
<div>
<Label></Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={aliases}
onChange={e => setAliases (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 上位タグ */}
<div>
<Label></Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={parentTags}
onChange={e => setParentTags (e.target.value)}
className="w-full border p-2 rounded"/>
</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
+30 -13
View File
@@ -205,13 +205,15 @@ export default (() => {
{loading ? 'Loading...' : (results.length > 0 ? (
<div className="mt-4">
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse">
<table className="w-full min-w-[2000px] table-fixed border-collapse">
<colgroup>
<col className="w-72"/>
<col className="w-48"/>
<col className="w-16"/>
<col className="w-44"/>
<col className="w-44"/>
<col className="w-48"/>
<col className="w-72"/>
<col className="w-48"/>
<col className="w-56"/>
<col className="w-56"/>
<col className="w-16"/>
</colgroup>
@@ -224,13 +226,6 @@ export default (() => {
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="category"
label="カテゴリ"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="post_count"
@@ -238,6 +233,15 @@ export default (() => {
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="category"
label="カテゴリ"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchTagsOrderField>
by="created_at"
@@ -260,10 +264,23 @@ export default (() => {
{results.map (row => (
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<TagLink tag={row} withCount={false}/>
<TagLink
tag={row}
to={`/tags/${ encodeURIComponent (row.id) }`}
withCount={false}/>
</td>
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
<td className="p-2 text-right">{row.postCount}</td>
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
<td className="p-2">{row.aliases.join (' ')}</td>
<td className="p-2">
{row.parents.map (t => (
<span key={t.id} className="mr-2">
<TagLink
tag={t}
withWiki={false}
withCount={false}/>
</span>))}
</td>
<td className="p-2">{dateString (row.createdAt)}</td>
<td className="p-2">{dateString (row.updatedAt)}</td>
<td className="p-2">
@@ -232,7 +232,7 @@ export default (() => {
return <ErrorScreen status={status}/>
return (
<div className="md:flex md:flex-1">
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet>
{theatre && (
<title>
+8 -7
View File
@@ -7,7 +7,6 @@ import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import PageTitle from '@/components/common/PageTitle'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
@@ -107,21 +106,23 @@ export default () => {
</PrefetchLink>) : '(最新)'}
</div>)}
<PageTitle>
<article className="prose dark:prose-invert mx-auto p-4">
<h1 className="prose-a:no-underline">
<TagLink tag={tag ?? defaultTag}
withWiki={false}
withCount={false}
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
</PageTitle>
<div className="prose mx-auto p-4">
{loading ? 'Loading...' : <WikiBody title={title} body={wikiPage?.body}/>}
</div>
</h1>
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
{(!(version) && posts.length > 0) && (
<div className="not-prose">
<TabGroup>
<Tab name="広場">
<PostList posts={posts}/>
</Tab>
</TabGroup>)}
</TabGroup>
</div>)}
</article>
</MainArea>)
}
+38 -8
View File
@@ -63,10 +63,20 @@ export type Material = {
export type Menu = MenuItem[]
export type MenuItem = {
export type MenuInvisibleItem = {
name: ReactNode
to?: string
base?: string
visible: false
subMenu: SubMenuItem[] }
export type MenuItem = MenuVisibleItem | MenuInvisibleItem
export type MenuVisibleItem = {
name: ReactNode
to: string
base?: string
visible?: true
subMenu: SubMenuItem[] }
export type NicoTag = Tag & {
@@ -107,9 +117,9 @@ export type NiconicoViewerHandle = {
export type Post = {
id: number
url: string
title: string
thumbnail: string
thumbnailBase: string
title: string | null
thumbnail: string | null
thumbnailBase: string | null
tags: Tag[]
viewed: boolean
related: Post[]
@@ -117,7 +127,7 @@ export type Post = {
originalCreatedBefore: string | null
createdAt: string
updatedAt: string
uploadedUser: { id: number; name: string } | null }
uploadedUser: { id: number; name: string | null } | null }
export type PostTagChange = {
post: Post
@@ -126,10 +136,28 @@ export type PostTagChange = {
changeType: 'add' | 'remove'
timestamp: string }
export type SubMenuItem =
| { component: ReactNode
export type PostVersion = {
postId: number
versionNo: number
eventType: 'create' | 'update' | 'discard' | 'restore'
title: { current: string | null; prev: string | null }
url: { current: string; prev: string | null }
thumbnail: { current: string | null; prev: string | null }
thumbnailBase: { current: string | null; prev: string | null }
tags: { name: string; type: 'context' | 'added' | 'removed' }[]
originalCreatedFrom: { current: string | null; prev: string | null }
originalCreatedBefore: { current: string | null; prev: string | null }
createdAt: string
createdByUser: { id: number; name: string | null } | null }
export type SubMenuComponentItem = {
component: ReactNode
visible: boolean }
| { name: ReactNode
export type SubMenuItem = SubMenuComponentItem | SubMenuStringItem
export type SubMenuStringItem = {
name: ReactNode
to: string
visible?: boolean }
@@ -137,6 +165,8 @@ export type Tag = {
id: number
name: string
category: Category
aliases: string[]
parents: Tag[]
postCount: number
createdAt: string
updatedAt: string
+2 -2
View File
@@ -8,7 +8,7 @@ import { DARK_COLOUR_SHADE,
const colours = Object.values (TAG_COLOUR)
export default {
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'],
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
@@ -24,4 +24,4 @@ export default {
'rainbow-scroll': {
'0%': { backgroundPosition: '0% 50%' },
'100%': { backgroundPosition: '200% 50%' } } } } },
plugins: [] } satisfies Config
plugins: [require ('@tailwindcss/typography')] } satisfies Config
+3 -2
View File
@@ -1,11 +1,12 @@
import { defineConfig } from 'vite'
import mdx from '@mdx-js/rollup'
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig ({
plugins: [react()],
plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()],
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
server: { host: true,
port: 5173,