Browse Source

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

feature/047
みてるぞ 3 days ago
parent
commit
efaeb5325e
100 changed files with 7856 additions and 705 deletions
  1. +2
    -2
      backend/Gemfile
  2. +21
    -0
      backend/Gemfile.lock
  3. +101
    -0
      backend/app/controllers/materials_controller.rb
  4. +13
    -6
      backend/app/controllers/nico_tags_controller.rb
  5. +119
    -0
      backend/app/controllers/post_versions_controller.rb
  6. +34
    -21
      backend/app/controllers/posts_controller.rb
  7. +20
    -2
      backend/app/controllers/tag_children_controller.rb
  8. +92
    -0
      backend/app/controllers/tag_versions_controller.rb
  9. +224
    -11
      backend/app/controllers/tags_controller.rb
  10. +24
    -7
      backend/app/controllers/users_controller.rb
  11. +39
    -15
      backend/app/controllers/wiki_pages_controller.rb
  12. +10
    -2
      backend/app/models/ip_address.rb
  13. +39
    -0
      backend/app/models/material.rb
  14. +5
    -1
      backend/app/models/my_discard.rb
  15. +7
    -0
      backend/app/models/nico_tag_version.rb
  16. +3
    -1
      backend/app/models/post.rb
  17. +21
    -0
      backend/app/models/post_version.rb
  18. +43
    -24
      backend/app/models/tag.rb
  19. +0
    -2
      backend/app/models/tag_name.rb
  20. +15
    -0
      backend/app/models/tag_version.rb
  21. +8
    -1
      backend/app/models/user.rb
  22. +19
    -0
      backend/app/models/version_record.rb
  23. +3
    -7
      backend/app/models/wiki_page.rb
  24. +8
    -0
      backend/app/models/wiki_version.rb
  25. +24
    -0
      backend/app/representations/material_repr.rb
  26. +4
    -5
      backend/app/representations/tag_repr.rb
  27. +19
    -0
      backend/app/services/nico_tag_version_recorder.rb
  28. +30
    -0
      backend/app/services/post_version_recorder.rb
  29. +22
    -0
      backend/app/services/tag_version_recorder.rb
  30. +38
    -0
      backend/app/services/tag_versioning.rb
  31. +62
    -0
      backend/app/services/version_recorder.rb
  32. +59
    -40
      backend/app/services/wiki/commit.rb
  33. +21
    -0
      backend/app/services/wiki_version_recorder.rb
  34. +73
    -0
      backend/app/services/youtube/api_client.rb
  35. +168
    -0
      backend/app/services/youtube/sync.rb
  36. +32
    -0
      backend/app/services/youtube/video_item.rb
  37. +1
    -2
      backend/config/environments/production.rb
  38. +2
    -0
      backend/config/environments/test.rb
  39. +10
    -1
      backend/config/routes.rb
  40. +16
    -1
      backend/config/schedule.rb
  41. +8
    -26
      backend/config/storage.yml
  42. +34
    -0
      backend/db/migrate/20260329034700_create_materials.rb
  43. +203
    -0
      backend/db/migrate/20260409123700_create_post_versions.rb
  44. +156
    -0
      backend/db/migrate/20260419035400_create_tag_versions.rb
  45. +91
    -0
      backend/db/migrate/20260426120600_create_wiki_versions.rb
  46. +141
    -6
      backend/db/schema.rb
  47. +23
    -0
      backend/lib/tasks/export_nico.rake
  48. +17
    -1
      backend/lib/tasks/sync_nico.rake
  49. +6
    -0
      backend/lib/tasks/sync_posts.rake
  50. +2
    -0
      backend/spec/factories/wiki_pages.rb
  51. +40
    -0
      backend/spec/models/post_version_spec.rb
  52. +71
    -5
      backend/spec/models/tag_spec.rb
  53. +74
    -0
      backend/spec/models/version_record_spec.rb
  54. +378
    -0
      backend/spec/requests/materials_spec.rb
  55. +55
    -0
      backend/spec/requests/nico_tags_spec.rb
  56. +384
    -2
      backend/spec/requests/posts_spec.rb
  57. +78
    -6
      backend/spec/requests/tag_children_spec.rb
  58. +243
    -0
      backend/spec/requests/tag_versions_spec.rb
  59. +160
    -0
      backend/spec/requests/tag_wiki_history_integrity_spec.rb
  60. +701
    -6
      backend/spec/requests/tags_spec.rb
  61. +2
    -3
      backend/spec/requests/users_spec.rb
  62. +27
    -0
      backend/spec/requests/wiki_body_search_pending_spec.rb
  63. +42
    -0
      backend/spec/requests/wiki_conflict_spec.rb
  64. +196
    -0
      backend/spec/requests/wiki_history_integrity_spec.rb
  65. +37
    -0
      backend/spec/requests/wiki_restore_pending_spec.rb
  66. +230
    -75
      backend/spec/requests/wiki_spec.rb
  67. +62
    -0
      backend/spec/requests/wiki_title_collision_spec.rb
  68. +173
    -0
      backend/spec/services/wiki/commit_integrity_spec.rb
  69. +150
    -0
      backend/spec/services/wiki/commit_spec.rb
  70. +99
    -0
      backend/spec/services/wiki_version_recorder_spec.rb
  71. +130
    -0
      backend/spec/services/youtube/api_client_spec.rb
  72. +310
    -0
      backend/spec/services/youtube/sync_spec.rb
  73. +93
    -0
      backend/spec/services/youtube/video_item_spec.rb
  74. +2
    -2
      backend/spec/support/test_records.rb
  75. +100
    -0
      backend/spec/tasks/nico_export_spec.rb
  76. +227
    -0
      backend/spec/tasks/nico_sync_spec.rb
  77. +25
    -0
      backend/spec/tasks/post_sync_spec.rb
  78. +936
    -192
      frontend/package-lock.json
  79. +6
    -2
      frontend/package.json
  80. +51
    -29
      frontend/src/App.tsx
  81. +3
    -1
      frontend/src/components/DraggableDroppableTagRow.tsx
  82. +97
    -0
      frontend/src/components/MaterialSidebar.tsx
  83. +1
    -1
      frontend/src/components/PostEditForm.tsx
  84. +1
    -1
      frontend/src/components/PostList.tsx
  85. +6
    -2
      frontend/src/components/TagDetailSidebar.tsx
  86. +38
    -59
      frontend/src/components/TagLink.tsx
  87. +4
    -2
      frontend/src/components/TagSidebar.tsx
  88. +244
    -103
      frontend/src/components/TopNav.tsx
  89. +0
    -1
      frontend/src/components/WikiBody.tsx
  90. +7
    -5
      frontend/src/components/common/SectionTitle.tsx
  91. +97
    -0
      frontend/src/components/common/TagInput.tsx
  92. +7
    -2
      frontend/src/components/layout/MainArea.tsx
  93. +26
    -5
      frontend/src/components/layout/SidebarComponent.tsx
  94. +5
    -3
      frontend/src/index.css
  95. +7
    -7
      frontend/src/lib/posts.ts
  96. +31
    -2
      frontend/src/lib/prefetchers.ts
  97. +6
    -4
      frontend/src/lib/queryKeys.ts
  98. +12
    -1
      frontend/src/lib/tags.ts
  99. +4
    -0
      frontend/src/mdx-components.tsx
  100. +46
    -0
      frontend/src/pages/MorePage.tsx

+ 2
- 2
backend/Gemfile View File

@@ -50,8 +50,6 @@ group :development, :test do
gem 'factory_bot_rails' gem 'factory_bot_rails'
end end




gem "mysql2", "~> 0.5.6" gem "mysql2", "~> 0.5.6"


gem "image_processing", "~> 1.14" gem "image_processing", "~> 1.14"
@@ -69,3 +67,5 @@ gem 'whenever', require: false
gem 'discard' gem 'discard'


gem "rspec-rails", "~> 8.0", :groups => [:development, :test] gem "rspec-rails", "~> 8.0", :groups => [:development, :test]

gem 'aws-sdk-s3', require: false

+ 21
- 0
backend/Gemfile.lock View File

@@ -73,6 +73,25 @@ GEM
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
ast (2.4.3) ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1231.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0) base64 (0.2.0)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin) bcrypt_pbkdf (1.1.1-arm64-darwin)
@@ -157,6 +176,7 @@ GEM
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2)
json (2.12.0) json (2.12.0)
jwt (2.10.1) jwt (2.10.1)
base64 base64
@@ -441,6 +461,7 @@ PLATFORMS
x86_64-linux-musl x86_64-linux-musl


DEPENDENCIES DEPENDENCIES
aws-sdk-s3
bootsnap bootsnap
brakeman brakeman
diff-lcs diff-lcs


+ 101
- 0
backend/app/controllers/materials_controller.rb View File

@@ -0,0 +1,101 @@
class MaterialsController < ApplicationController
def index
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_id = params[:tag_id].presence
parent_id = params[:parent_id].presence

q = Material.includes(:tag, :created_by_user).with_attached_file
q = q.where(tag_id:) if tag_id
q = q.where(parent_id:) if parent_id

count = q.count
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)

render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count }
end

def show
material =
Material
.includes(:tag)
.with_attached_file
.find_by(id: params[:id])
return head :not_found unless material

wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body

render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
end

def create
return head :unauthorized unless current_user

tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)

tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag

material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(file)

if material.save
render json: MaterialRepr.base(material, host: request.base_url), status: :created
else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end

def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?

material = Material.with_attached_file.find_by(id: params[:id])
return head :not_found unless material

tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)

tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag

material.update!(tag:, url:, updated_by_user: current_user)
if file
material.file.attach(file)
else
material.file.purge
end

if material.save
render json: MaterialRepr.base(material, host: request.base_url)
else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end

def destroy
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?

material = Material.find_by(id: params[:id])
return head :not_found unless material

material.discard
head :no_content
end
end

+ 13
- 6
backend/app/controllers/nico_tags_controller.rb View File

@@ -30,14 +30,21 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i id = params[:id].to_i


tag = Tag.find(id) 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? }


tag.linked_tags = linked_tags
tag.save!
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 render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
end end


+ 119
- 0
backend/app/controllers/post_versions_controller.rb View File

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

+ 34
- 21
backend/app/controllers/posts_controller.rb View File

@@ -44,7 +44,7 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: { tag_name: :wiki_page })
.preload(tags: [:materials, { tag_name: :wiki_page }])
.with_attached_thumbnail .with_attached_thumbnail


q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
end end


def random def random
post = filtered_posts.preload(tags: { tag_name: :wiki_page })
post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
.order('RAND()') .order('RAND()')
.first .first
return head :not_found unless post return head :not_found unless post
@@ -104,7 +104,7 @@ class PostsController < ApplicationController
end end


def show 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 return head :not_found unless post


render json: PostRepr.base(post, current_user) render json: PostRepr.base(post, current_user)
@@ -127,17 +127,22 @@ class PostsController < ApplicationController
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:) original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail) post.thumbnail.attach(thumbnail)
if post.save
post.resized_thumbnail!

ApplicationRecord.transaction do
post.save!
tags = Tag.normalise_tags(tag_names) tags = Tag.normalise_tags(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)

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

post.reload
render json: PostRepr.base(post), status: :created
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end end

post.reload
render json: PostRepr.base(post), status: :created
rescue ActiveRecord::RecordInvalid
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request head :bad_request
end end
@@ -166,19 +171,27 @@ class PostsController < ApplicationController
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]


post = Post.find(params[:id].to_i) post = Post.find(params[:id].to_i)
if 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)

ApplicationRecord.transaction do
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)

post.update!(title:, original_created_from:, original_created_before:)

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) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)

post.reload
json = post.as_json
json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok
else
render json: post.errors, status: :unprocessable_entity
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end end

post.reload
json = post.as_json
json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok
rescue ActiveRecord::RecordInvalid
render json: post.errors, status: :unprocessable_entity
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request head :bad_request
end end
@@ -198,7 +211,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present? pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present? pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user, pts = pts.includes(:post, :created_user, :deleted_user,
tag: { tag_name: :wiki_page })
tag: [:materials, { tag_name: :wiki_page }])


events = [] events = []
pts.each do |pt| pts.each do |pt|


+ 20
- 2
backend/app/controllers/tag_children_controller.rb View File

@@ -7,7 +7,16 @@ class TagChildrenController < ApplicationController
child_id = params[:child_id] child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank? 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 head :no_content
end end
@@ -20,7 +29,16 @@ class TagChildrenController < ApplicationController
child_id = params[:child_id] child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank? 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 head :no_content
end end


+ 92
- 0
backend/app/controllers/tag_versions_controller.rb View File

@@ -0,0 +1,92 @@
class TagVersionsController < ApplicationController
def index
tag_id = params[:id].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

q = TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id

count = q.except(:select, :order, :limit, :offset).count

versions = q.order(Arel.sql('tag_versions.created_at DESC, tag_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_aliases = split_values(row.aliases)
prev_aliases = split_values(row.attributes['prev_aliases'])

cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])

all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids)

tags_by_id =
Tag
.includes(:tag_name, :materials, { tag_name: :wiki_page })
.where(id: all_parent_tag_ids)
.index_by(&:id)

parent_tags =
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
.map do |h|
{ tag: TagRepr.base(tags_by_id[h[:tag_id]]),
type: h[:type] }
end

{ tag_id: row.tag_id,
version_no: row.version_no,
event_type: row.event_type,
name: { current: row.name, prev: row.attributes['prev_name'] },
category: { current: row.category, prev: row.attributes['prev_category'] },
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
parent_tags:,
created_at: row.created_at.iso8601,
created_by_user: row.created_by_user_id &&
{ id: row.created_by_user_id,
name: users_by_id[row.created_by_user_id] } }
end
end

def build_version_values cur_values, prev_values, key:
(cur_values | prev_values).map do |value|
type =
if cur_values.include?(value) && prev_values.include?(value)
'context'
elsif cur_values.include?(value)
'added'
else
'removed'
end

{ key => value, type: }
end
end

def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)

def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
end

+ 224
- 11
backend/app/controllers/tags_controller.rb View File

@@ -33,11 +33,11 @@ class TagsController < ApplicationController
else else
Tag.joins(:tag_name) Tag.joins(:tag_name)
end end
.includes(:tag_name, tag_name: :wiki_page)
.includes(:tag_name, :materials, tag_name: :wiki_page)
q = q.where(posts: { id: post_id }) if post_id.present? q = q.where(posts: { id: post_id }) if post_id.present?


q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(category: category) if category
q = q.where(category:) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0] q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1] q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
@@ -66,7 +66,45 @@ class TagsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a


render json: { tags: TagRepr.base(tags), count: q.size }
render json: { tags: TagRepr.many(tags), count: q.size }
end

def with_depth
parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0

tag_ids =
if parent_tag_id
TagImplication.where(parent_tag_id:).select(:tag_id)
else
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
end

tags =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(category: [:meme, :character, :material])
.where(id: tag_ids)
.order('tag_names.name')
.distinct
.to_a

has_children_tag_ids =
if tags.empty?
[]
else
TagImplication
.joins(:tag)
.where(parent_tag_id: tags.map(&:id),
tags: { category: [:meme, :character, :material] })
.distinct
.pluck(:parent_tag_id)
end

render json: tags.map { |tag|
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
}
end end


def autocomplete def autocomplete
@@ -90,7 +128,7 @@ class TagsController < ApplicationController
end end


base = Tag.joins(:tag_name) base = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.includes(:tag_name, :materials, tag_name: :wiki_page)
base = base.where('tags.post_count > 0') if present_only base = base.where('tags.post_count > 0') if present_only


canonical_hit = canonical_hit =
@@ -115,7 +153,7 @@ class TagsController < ApplicationController


def show def show
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(id: params[:id]) .find_by(id: params[:id])
if tag if tag
render json: TagRepr.base(tag) render json: TagRepr.base(tag)
@@ -129,7 +167,7 @@ class TagsController < ApplicationController
return head :bad_request if name.blank? return head :bad_request if name.blank?


tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
if tag if tag
render json: TagRepr.base(tag) render json: TagRepr.base(tag)
@@ -159,6 +197,72 @@ class TagsController < ApplicationController
render json: DeerjikistRepr.many(tag.deerjikists) render json: DeerjikistRepr.many(tag.deerjikists)
end end


def materials_by_name
name = params[:name].to_s.strip
return head :bad_request if name.blank?

tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
return head :not_found unless tag

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
name_changed = name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed

tag.update!(category:)
tag.tag_name.update!(name:)

alias_names << old_name if name_changed
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,
name_changed:,
wiki_page:)
end

render json: TagRepr.base(tag.reload)
end

def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
@@ -168,14 +272,123 @@ class TagsController < ApplicationController


tag = Tag.find(params[:id]) 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 end


if category.present?
tag.update!(category:)
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)

old_name = tag.name
name_changed = name.present? && name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed

tag.tag_name.update!(name:) if name.present?
tag.update!(category:) if category.present?

tag.reload

record_tag_version!(
tag,
event_type: :update,
created_by_user: current_user,
name_changed:,
wiki_page:)
end

render json: TagRepr.base(tag.reload)
end

private

def build_tag_children tag
material = tag.materials.first
file = nil
content_type = nil
if material&.file&.attached?
file = rails_storage_proxy_url(material.file, only_path: false)
content_type = material.file.blob.content_type
end end


render json: TagRepr.base(tag)
TagRepr.base(tag).merge(
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:, name_changed: false, wiki_page: nil
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end

TagVersionRecorder.record!(tag:, event_type:, created_by_user:)

return unless name_changed

wiki_page ||= tag.tag_name.wiki_page
return unless wiki_page&.wiki_versions&.exists?

WikiVersionRecorder.record!(
page: wiki_page,
event_type: :update,
created_by_user:)
end

def update_aliases! tag, alias_names
alias_names = alias_names.uniq

affected_tags = [tag]

current_aliases = tag.tag_name.aliases.to_a

current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)

affected_tags << alias_tag_name.canonical&.tag
end

alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag
end

affected_tags.compact.uniq.each do |affected_tag|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user)
end

current_aliases.each do |alias_tag_name|
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

affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
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
end end

+ 24
- 7
backend/app/controllers/users_controller.rb View File

@@ -1,18 +1,26 @@
class UsersController < ApplicationController class UsersController < ApplicationController
def create def create
user = User.create!(inheritance_code: SecureRandom.uuid, role: 'guest')
return head :unprocessable_entity if request.remote_ip.blank?

user = nil

User.transaction do
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
attach_ip_address!(user)
end

render json: { code: user.inheritance_code, render json: { code: user.inheritance_code,
user: user.slice(:id, :name, :inheritance_code, :role) }
user: user.slice(:id, :name, :inheritance_code, :role) },
status: :created
end end


def verify def verify
ip_bin = IPAddr.new(request.remote_ip).hton
ip_address = IpAddress.find_or_create_by!(ip_address: ip_bin)

user = User.find_by(inheritance_code: params[:code]) user = User.find_by(inheritance_code: params[:code])
return render json: { valid: false } unless user return render json: { valid: false } unless user


UserIp.find_or_create_by!(user:, ip_address:)
return head :unprocessable_entity if request.remote_ip.blank?

attach_ip_address!(user)


render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
end end
@@ -41,9 +49,18 @@ class UsersController < ApplicationController
return head :bad_request if name.blank? return head :bad_request if name.blank?


if user.update(name:) if user.update(name:)
render json: user.slice(:id, :name, :inheritance_code, :role), status: :created
render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok
else else
render json: user.errors, status: :unprocessable_entity render json: user.errors, status: :unprocessable_entity
end end
end end

private

def attach_ip_address! user
ip_bin = IPAddr.new(request.remote_ip).hton
ip_address = IpAddress.create_or_find_by!(ip_address: ip_bin)

UserIp.create_or_find_by!(user:, ip_address:)
end
end end

+ 39
- 15
backend/app/controllers/wiki_pages_controller.rb View File

@@ -85,22 +85,24 @@ class WikiPagesController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?


name = params[:title]&.strip
title = params[:title].to_s.strip
body = params[:body].to_s body = params[:body].to_s
message = params[:message].presence


return head :unprocessable_entity if name.blank? || body.blank?
return head :unprocessable_entity if title.blank? || body.blank?


tag_name = TagName.find_undiscard_or_create_by!(name:)
page = WikiPage.new(tag_name:, created_user: current_user, updated_user: current_user)
if page.save
message = params[:message].presence
Wiki::Commit.content!(page:, body:, created_user: current_user, message:)
tag_name = TagName.find_undiscard_or_create_by!(name: title)


render json: WikiPageRepr.base(page).merge(body:), status: :created
else
render json: { errors: page.errors.full_messages },
status: :unprocessable_entity
end
page =
Wiki::Commit.create_content!(
tag_name:,
body:,
created_by_user: current_user,
message:)

render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
head :unprocessable_entity
end end


def update def update
@@ -113,10 +115,32 @@ class WikiPagesController < ApplicationController
return head :unprocessable_entity if title.blank? || body.blank? return head :unprocessable_entity if title.blank? || body.blank?


page = WikiPage.find(params[:id]) page = WikiPage.find(params[:id])
base_revision_id = page.current_revision.id
base_revision_id = params[:base_revision_id].presence


if params[:title].present? && params[:title].strip != page.title
return head :unprocessable_entity
ApplicationRecord.transaction do
page.lock!

old_title = page.title

tag = Tag.find_by(tag_name_id: page.tag_name_id)

if tag && title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
end

page.tag_name.update!(name: title) if title != old_title

message = params[:message].presence
Wiki::Commit.content!(page:,
body:,
created_user: current_user,
message:,
base_revision_id:)

if tag && title != old_title
tag.reload
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end
end end


message = params[:message].presence message = params[:message].presence


+ 10
- 2
backend/app/models/ip_address.rb View File

@@ -1,6 +1,14 @@
class IpAddress < ApplicationRecord class IpAddress < ApplicationRecord
validates :ip_address, presence: true, length: { maximum: 16 } validates :ip_address, presence: true, length: { maximum: 16 }
validates :banned, inclusion: { in: [true, false] }


has_many :users
has_many :user_ips, dependent: :destroy
has_many :users, through: :user_ips

def banned? = banned_at?
def banned = banned?

def banned= value
bool = ActiveModel::Type::Boolean.new.cast(value)
self.banned_at = bool ? banned_at || Time.current : nil
end
end end

+ 39
- 0
backend/app/models/material.rb View File

@@ -0,0 +1,39 @@
class Material < ApplicationRecord
include MyDiscard

default_scope -> { kept }

belongs_to :parent, class_name: 'Material', optional: true
has_many :children, class_name: 'Material', foreign_key: :parent_id, dependent: :nullify

belongs_to :tag, optional: true
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true

has_one_attached :file, dependent: :purge

validates :tag_id, presence: true, uniqueness: true

validate :file_must_be_attached
validate :tag_must_be_material_category

def content_type
return nil unless file&.attached?

file.blob.content_type
end

private

def file_must_be_attached
return if url.present? || file.attached?

errors.add(:url, 'URL かファイルのどちらかは必須です.')
end

def tag_must_be_material_category
return if tag.blank? || tag.character? || tag.material?

errors.add(:tag, '素材カテゴリのタグを指定してください.')
end
end

+ 5
- 1
backend/app/models/my_discard.rb View File

@@ -1,7 +1,11 @@
module MyDiscard module MyDiscard
extend ActiveSupport::Concern extend ActiveSupport::Concern


included { include Discard::Model }
included do
include Discard::Model

default_scope -> { kept }
end


class_methods do class_methods do
def find_undiscard_or_create_by! attrs, &block def find_undiscard_or_create_by! attrs, &block


+ 7
- 0
backend/app/models/nico_tag_version.rb View File

@@ -0,0 +1,7 @@
class NicoTagVersion < ApplicationRecord
include VersionRecord

belongs_to :tag

validates :name, presence: true
end

+ 3
- 1
backend/app/models/post.rb View File

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


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


has_many :post_tags, dependent: :destroy, inverse_of: :post has_many :post_tags, dependent: :destroy, inverse_of: :post
@@ -11,6 +10,7 @@ class Post < ApplicationRecord


has_many :user_post_views, dependent: :delete_all has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions


has_one_attached :thumbnail has_one_attached :thumbnail


@@ -30,6 +30,8 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end


def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')

def related limit: nil def related limit: nil
ids = post_similarities.order(cos: :desc) ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit ids = ids.limit(limit) if limit


+ 21
- 0
backend/app/models/post_version.rb View File

@@ -0,0 +1,21 @@
class PostVersion < ApplicationRecord
include VersionRecord

belongs_to :post

validates :url, presence: true

validate :validate_original_created_range

private

def validate_original_created_range
f = original_created_from
b = original_created_before
return if f.blank? || b.blank?

if f >= b
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
end
end
end

+ 43
- 24
backend/app/models/tag.rb View File

@@ -1,3 +1,6 @@
require 'set'


class Tag < ApplicationRecord class Tag < ApplicationRecord
include MyDiscard include MyDiscard


@@ -5,8 +8,6 @@ class Tag < ApplicationRecord
; ;
end end


default_scope -> { kept }

has_many :post_tags, inverse_of: :tag has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', 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' has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -31,6 +32,10 @@ class Tag < ApplicationRecord
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all


has_many :deerjikists, dependent: :delete_all has_many :deerjikists, dependent: :delete_all
has_many :materials

has_many :tag_versions
has_many :nico_tag_versions


belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name
@@ -72,27 +77,18 @@ class Tag < ApplicationRecord


def has_wiki = wiki_page.present? def has_wiki = wiki_page.present?


def self.tagme
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
end

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 material_id = materials.first&.id


def self.niconico
@niconico ||= 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.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)


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:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
@@ -106,7 +102,7 @@ class Tag < ApplicationRecord
end end


tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) 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) tags.uniq(&:id)
end end


@@ -144,18 +140,25 @@ class Tag < ApplicationRecord
retry retry
end end


def self.merge_tags! target_tag, source_tags
def self.merge_tags! target_tag, source_tags, created_by_user: nil
target_tag => Tag target_tag => Tag


affected_post_ids = Set.new

Tag.transaction do Tag.transaction do
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)

Array(source_tags).compact.uniq.each do |source_tag| Array(source_tags).compact.uniq.each do |source_tag|
source_tag => Tag source_tag => Tag


next if source_tag == target_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| source_tag.post_tags.kept.find_each do |source_pt|
post_id = source_pt.post_id post_id = source_pt.post_id
source_pt.discard_by!(nil)
affected_post_ids << post_id
source_pt.discard_by!(created_by_user)
unless PostTag.kept.exists?(post_id:, tag: target_tag) unless PostTag.kept.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag)
end end
@@ -167,6 +170,7 @@ class Tag < ApplicationRecord
raise ActiveRecord::RecordInvalid.new(source_tag_name) raise ActiveRecord::RecordInvalid.new(source_tag_name)
end end


TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.discard! source_tag.discard!


if source_tag.nico? if source_tag.nico?
@@ -175,6 +179,13 @@ class Tag < ApplicationRecord
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current) updated_at: Time.current)
end 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.ensure_snapshot!(post, created_by_user:)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
end end


# 投稿件数を再集計 # 投稿件数を再集計
@@ -184,6 +195,14 @@ class Tag < ApplicationRecord
target_tag.reload target_tag.reload
end 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 private


def nico_tag_name_must_start_with_nico def nico_tag_name_must_start_with_nico


+ 0
- 2
backend/app/models/tag_name.rb View File

@@ -1,8 +1,6 @@
class TagName < ApplicationRecord class TagName < ApplicationRecord
include MyDiscard include MyDiscard


default_scope -> { kept }

has_one :tag has_one :tag
has_one :wiki_page has_one :wiki_page




+ 15
- 0
backend/app/models/tag_version.rb 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

+ 8
- 1
backend/app/models/user.rb View File

@@ -4,7 +4,6 @@ class User < ApplicationRecord
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
validates :inheritance_code, presence: true, length: { maximum: 64 } validates :inheritance_code, presence: true, length: { maximum: 64 }
validates :role, presence: true, inclusion: { in: roles.keys } validates :role, presence: true, inclusion: { in: roles.keys }
validates :banned, inclusion: { in: [true, false] }


has_many :created_posts, has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
@@ -18,6 +17,14 @@ class User < ApplicationRecord
has_many :updated_wiki_pages, has_many :updated_wiki_pages,
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify


def banned? = banned_at?
def banned = banned?

def banned= value
bool = ActiveModel::Type::Boolean.new.cast(value)
self.banned_at = bool ? (banned_at || Time.current) : nil
end

def viewed?(post) = user_post_views.exists?(post_id: post.id) def viewed?(post) = user_post_views.exists?(post_id: post.id)
def gte_member? = member? || admin? def gte_member? = member? || admin?
end end

+ 19
- 0
backend/app/models/version_record.rb 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

+ 3
- 7
backend/app/models/wiki_page.rb View File

@@ -4,8 +4,6 @@ require 'set'
class WikiPage < ApplicationRecord class WikiPage < ApplicationRecord
include MyDiscard include MyDiscard


default_scope -> { kept }

has_many :wiki_revisions, dependent: :destroy has_many :wiki_revisions, dependent: :destroy
belongs_to :created_user, class_name: 'User' belongs_to :created_user, class_name: 'User'
belongs_to :updated_user, class_name: 'User' belongs_to :updated_user, class_name: 'User'
@@ -17,8 +15,11 @@ class WikiPage < ApplicationRecord


has_many :assets, class_name: 'WikiAsset', dependent: :destroy has_many :assets, class_name: 'WikiAsset', dependent: :destroy


has_many :wiki_versions

belongs_to :tag_name belongs_to :tag_name
validates :tag_name, presence: true validates :tag_name, presence: true
validates :body, presence: true


def title = tag_name.name def title = tag_name.name


@@ -28,11 +29,6 @@ class WikiPage < ApplicationRecord


def current_revision = wiki_revisions.order(id: :desc).first def current_revision = wiki_revisions.order(id: :desc).first


def body
rev = current_revision
rev.body if rev&.content?
end

def resolve_redirect limit: 10 def resolve_redirect limit: 10
page = self page = self
visited = Set.new visited = Set.new


+ 8
- 0
backend/app/models/wiki_version.rb View File

@@ -0,0 +1,8 @@
class WikiVersion < ApplicationRecord
include VersionRecord

belongs_to :wiki_page

validates :title, presence: true
validates :body, presence: true
end

+ 24
- 0
backend/app/representations/material_repr.rb View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true


module MaterialRepr
BASE = { only: [:id, :url, :created_at, :updated_at],
methods: [:content_type],
include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE,
updated_by_user: UserRepr::BASE } }.freeze

module_function

def base material, host:
material.as_json(BASE).merge(
file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:)
end)
end

def many materials, host:
materials.map { |m| base(m, host:) }
end
end

+ 4
- 5
backend/app/representations/tag_repr.rb View File

@@ -3,15 +3,14 @@


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


module_function module_function


def base tag 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 end


def many tags
tags.map { |t| base(t) }
end
def many(tags) = tags.map { |t| base(t) }
end end

+ 19
- 0
backend/app/services/nico_tag_version_recorder.rb View File

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

+ 30
- 0
backend/app/services/post_version_recorder.rb View File

@@ -0,0 +1,30 @@
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:
super(record: post, event_type:, created_by_user:)
end

def self.ensure_snapshot! post, created_by_user:
return if post.post_versions.exists?

record!(post:, event_type: :create, created_by_user:)
end

private

def version_class = PostVersion
def version_association = :post_versions
def record_key = :post

def snapshot_attributes
{ title: @record.title,
url: @record.url,
thumbnail_base: @record.thumbnail_base,
tags: @record.snapshot_tag_names.join(' '),
original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before }
end
end

+ 22
- 0
backend/app/services/tag_version_recorder.rb View File

@@ -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
- 0
backend/app/services/tag_versioning.rb 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
- 0
backend/app/services/version_recorder.rb 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

+ 59
- 40
backend/app/services/wiki/commit.rb View File

@@ -7,6 +7,31 @@ module Wiki
; ;
end end


def self.create_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body)

page = WikiPage.new(tag_name:,
body: normalised,
created_user: created_by_user,
updated_user: created_by_user)

if normalised.blank?
page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, page
end

ActiveRecord::Base.transaction do
page.save!

new(page:, created_user: created_by_user).content!(
body: normalised,
message:,
base_revision_id: nil)

page
end
end

def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:) new(page:, created_user:).content!(body:, message:, base_revision_id:)
end end
@@ -21,7 +46,12 @@ module Wiki
end end


def content! body:, message:, base_revision_id: def content! body:, message:, base_revision_id:
normalised = normalise_body(body)
normalised = self.class.normalise_body(body)
if normalised.blank?
@page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, @page
end

lines = split_lines(normalised) lines = split_lines(normalised)


line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
@@ -37,10 +67,19 @@ module Wiki
current_id = @page.wiki_revisions.maximum(:id) current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i if current_id && current_id != base_revision_id.to_i
raise Conflict, raise Conflict,
"競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end end
end end


@page.update!(body: normalised)

WikiVersionRecorder.record!(
page: @page,
event_type: @page.wiki_versions.exists? ? :update : :create,
reason: message,
created_by_user: @created_user)

rev = WikiRevision.create!( rev = WikiRevision.create!(
wiki_page: @page, wiki_page: @page,
base_revision_id:, base_revision_id:,
@@ -54,65 +93,45 @@ module Wiki
rows = line_ids.each_with_index.map do |line_id, pos| rows = line_ids.each_with_index.map do |line_id, pos|
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end end
WikiRevisionLine.insert_all!(rows)
WikiRevisionLine.insert_all!(rows) if rows.any?


rev rev
end end
end end


def redirect! redirect_page:, message:, base_revision_id:
ActiveRecord::Base.transaction do
@page.lock!
def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.'


if base_revision_id.present?
current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Conflict,
"競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end

WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
created_user: @created_user,
kind: :redirect,
redirect_page:,
message:,
lines_count: 0,
tree_sha256: nil)
end
end

private

def normalise_body body
def self.normalise_body body
s = body.to_s s = body.to_s
s.gsub!("\r\n", "\n")
s.gsub!(/\r\n?/, "\n")
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
s.gsub(/\n+$/, '')
end end


def split_lines body
body.split("\n")
end
private

def split_lines(body) = body.split("\n")


def upsert_lines! lines, line_shas def upsert_lines! lines, line_shas
now = Time.current now = Time.current


id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h


missing_rows = []
missing_by_sha = { }

line_shas.each_with_index do |sha, i| line_shas.each_with_index do |sha, i|
next if id_by_sha.key?(sha) next if id_by_sha.key?(sha)
next if missing_by_sha.key?(sha)


missing_rows << { sha256: sha,
body: lines[i],
created_at: now,
updated_at: now }
missing_by_sha[sha] = {
sha256: sha,
body: lines[i],
created_at: now,
updated_at: now }
end end


if missing_rows.any?
WikiLine.upsert_all(missing_rows)
if missing_by_sha.any?
WikiLine.upsert_all(missing_by_sha.values)
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
end end




+ 21
- 0
backend/app/services/wiki_version_recorder.rb View File

@@ -0,0 +1,21 @@
class WikiVersionRecorder < VersionRecorder
def self.record! page:, event_type:, reason: nil, created_by_user:
new(page:, event_type:, reason:, created_by_user:).record!
end

def initialize page:, event_type:, reason: nil, created_by_user:
@reason = reason
super(record: page, event_type:, created_by_user:)
end

private

def version_class = WikiVersion
def version_association = :wiki_versions
def record_key = :wiki_page

def snapshot_attributes = {
title: @record.title,
body: @record.body,
reason: @reason }
end

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

@@ -0,0 +1,73 @@
require 'json'
require 'net/http'
require 'uri'


module Youtube
class ApiClient
ENDPOINT = 'https://www.googleapis.com/youtube/v3'

def initialize api_key: ENV.fetch('YOUTUBE_API_KEY')
@api_key = api_key
end

def search_videos q:, published_after: nil, published_before: nil, page_token: nil
get_json('/search', {
part: 'snippet',
type: 'video',
q:,
order: 'date',
maxResults: 50,
regionCode: 'JP',
relevanceLanguage: 'ja',
publishedAfter: published_after&.iso8601,
publishedBefore: published_before&.iso8601,
pageToken: page_token }.compact)
end

def videos ids
return { 'items' => [] } if ids.empty?

get_json('/videos', part: 'snippet,status,contentDetails', id: ids.join(','))
end

def playlist_items playlist_id:, page_token: nil
get_json('/playlistItems', {
part: 'snippet,contentDetails,status',
playlistId: playlist_id,
maxResults: 50,
pageToken: page_token }.compact)
end

def channel id: nil, handle: nil
raise ArgumentError, 'id or handle is required' if id.present? == handle.present?

params = { part: 'snippet,contentDetails' }
params[:id] = id if id.present?
params[:forHandle] = handle if handle.present?

get_json('/channels', params)
end

private

def get_json path, params
uri = URI(ENDPOINT + path)
uri.query = URI.encode_www_form(params.merge(key: @api_key))

response = Net::HTTP.start(uri.host,
uri.port,
use_ssl: true,
open_timeout: 10,
read_timeout: 30) do |http|
http.get(uri)
end

unless response.is_a?(Net::HTTPSuccess)
raise "YouTube API error: #{ response.code } #{ response.body }"
end

JSON.parse(response.body)
end
end
end

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

@@ -0,0 +1,168 @@
require 'open-uri'
require 'set'
require 'time'


module Youtube
class Sync
def initialize client: ApiClient.new
@client = client
end

def sync!
video_ids = discover_video_ids
return if video_ids.empty?

video_ids.each_slice(50) do |ids|
@client.videos(ids).fetch('items', []).each do |item|
sync_video!(VideoItem.new(item))
end
end
end

private

def discover_video_ids
ids = Set.new

query_terms.each do |q|
response = @client.search_videos(q:, published_after: sync_since)

response.fetch('items', []).each do |item|
video_id = item.dig('id', 'videoId')
ids << video_id if video_id.present?
end
end

playlist_ids.each do |playlist_id|
each_playlist_item(playlist_id) do |item|
video_id = item.dig('contentDetails', 'videoId')
video_id ||= item.dig('snippet', 'resourceId', 'videoId')

ids << video_id if video_id.present?
end
end

ids.to_a
end

def sync_video! video
post = Post.where('url REGEXP ?', youtube_url_regexp(video.id)).first

original_created_from = video.published_at.change(sec: 0)
original_created_before = original_created_from + 1.minute

post_created = false
post_changed = false

if post
post.assign_attributes(title: video.title,
original_created_from:,
original_created_before:,
thumbnail_base: video.thumbnail_url)

post_changed = post.changed?
post.save! if post_changed

attach_thumbnail_if_needed!(post, video.thumbnail_url)
else
post_created = true
post = Post.create!(
title: video.title,
url: video.url,
thumbnail_base: video.thumbnail_url,
uploaded_user_id: nil,
original_created_from:,
original_created_before:)

attach_thumbnail_if_needed!(post, video.thumbnail_url)

sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.youtube.id, Tag.video.id])
end

kept_tag_ids = post.tags.pluck(:id).to_set
desired_tag_ids = kept_tag_ids.to_a

deerjikist = Deerjikist.find_by(platform: :youtube, code: video.channel_id)
if deerjikist
desired_tag_ids.delete(Tag.no_deerjikist.id)
desired_tag_ids << deerjikist.tag_id
elsif post.tags.where(category: :deerjikist).none?
desired_tag_ids << Tag.no_deerjikist.id
end

desired_tag_ids.uniq!

sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids)

if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
end

def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set

to_add = desired_tag_ids - current_tag_ids
to_remove = current_tag_ids - desired_tag_ids

Tag.where(id: to_add.to_a).find_each do |tag|
begin
PostTag.create!(post:, tag:)
rescue ActiveRecord::RecordNotUnique
;
end
end

PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
end
end

def attach_thumbnail_if_needed! post, thumbnail_url
return if post.thumbnail.attached?
return if thumbnail_url.blank?

post.thumbnail.attach(
io: URI.open(thumbnail_url),
filename: File.basename(URI.parse(thumbnail_url).path),
content_type: 'image/jpeg')

post.resized_thumbnail!
end

def youtube_url_regexp id
escaped = Regexp.escape(id)
"(youtube\\.com/watch\\?v=#{ escaped }|youtu\\.be/#{ escaped })([^A-Za-z0-9_-]|$)"
end

def query_terms = ['ぼざろクリーチャーシリーズ', '伊地知ニジカ', '伊地知虹鹿']

def playlist_ids
['PLrOch4zHkI5vu29b-f9umUQQ4tQkuWLPX',
'PLrOch4zHkI5vOK0RaytQq6PbucxQkkL0K',
'PLrOch4zHkI5tdwm9vSegiDQJOM-hgpcOC']
end

def sync_since = 14.days.ago

def each_playlist_item playlist_id
page_token = nil

loop do
response = @client.playlist_items(playlist_id:, page_token:)

response.fetch('items', []).each do |item|
yield item
end

page_token = response['nextPageToken']
break if page_token.blank?
end
end
end
end

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

@@ -0,0 +1,32 @@
require 'time'


module Youtube
class VideoItem
attr_reader :id, :title, :channel_id, :published_at, :thumbnail_url, :raw_tags

def initialize item
snippet = item.fetch('snippet')

@id = item.fetch('id')
@title = snippet['title']
@channel_id = snippet['channelId']
@published_at = Time.iso8601(snippet['publishedAt'])
@thumbnail_url = pick_thumbnail(snippet['thumbnails'] || { })
@raw_tags = snippet['tags'] || []
end

def url = "https://www.youtube.com/watch?v=#{ @id }"

private

def pick_thumbnail thumbnails
['maxres', 'standard', 'high', 'medium', 'default'].each do |key|
url = thumbnails.dig(key, 'url')
return url if url.present?
end

nil
end
end
end

+ 1
- 2
backend/config/environments/production.rb View File

@@ -18,8 +18,7 @@ Rails.application.configure do
# Enable serving of images, stylesheets, and JavaScripts from an asset server. # Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com" # config.asset_host = "http://assets.example.com"


# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
config.active_storage.service = :r2


# Assume all access to the app is happening through a SSL-terminating reverse proxy. # Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true config.assume_ssl = true


+ 2
- 0
backend/config/environments/test.rb View File

@@ -50,4 +50,6 @@ Rails.application.configure do


# Raise error when a before_action's only/except options reference missing actions. # Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true

Rails.application.routes.default_url_options[:host] = 'www.example.com'
end end

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

@@ -6,17 +6,23 @@ Rails.application.routes.draw do
delete ':child_id', action: :destroy delete ':child_id', action: :destroy
end end


resources :tags, only: [:index, :show, :update] do
resources :tags, only: [:index, :show] do
collection do collection do
get :autocomplete get :autocomplete
get :'with-depth', action: :with_depth
get :versions, to: 'tag_versions#index'


scope :name do scope :name do
get ':name/deerjikists', action: :deerjikists_by_name get ':name/deerjikists', action: :deerjikists_by_name
get ':name/materials', action: :materials_by_name
get ':name', action: :show_by_name get ':name', action: :show_by_name
end end
end end


member do member do
put '', action: :update_all
patch '', action: :update

get :deerjikists get :deerjikists
end end
end end
@@ -49,6 +55,7 @@ Rails.application.routes.draw do
collection do collection do
get :random get :random
get :changes get :changes
get :versions, to: 'post_versions#index'
end end


member do member do
@@ -83,4 +90,6 @@ Rails.application.routes.draw do


resources :comments, controller: :theatre_comments, only: [:index, :create] resources :comments, controller: :theatre_comments, only: [:index, :create]
end end

resources :materials, only: [:index, :show, :create, :update, :destroy]
end end

+ 16
- 1
backend/config/schedule.rb View File

@@ -1,12 +1,27 @@
env :PATH, '/root/.rbenv/shims:/root/.rbenv/bin:/usr/local/bin:/usr/bin:/bin' 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', set :output, standard: '/var/log/btrc_hub_nico_sync.log',
error: '/var/log/btrc_hub_nico_sync_err.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' rake 'nico:sync', environment: 'production'
end end


every 1.day, at: '0:00 am' do every 1.day, at: '0:00 am' do
rake 'post_similarity:calc', environment: 'production' rake 'post_similarity:calc', environment: 'production'
rake 'tag_similarity:calc', environment: 'production'
end

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

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

+ 8
- 26
backend/config/storage.yml View File

@@ -6,29 +6,11 @@ local:
service: Disk service: Disk
root: <%= Rails.root.join("storage") %> root: <%= Rails.root.join("storage") %>


# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>

# Remember not to checkin your GCS keyfile to a repository
# google:
# service: GCS
# project: your_project
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
# bucket: your_own_bucket-<%= Rails.env %>

# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
# microsoft:
# service: AzureStorage
# storage_account_name: your_account_name
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
# container: your_container_name-<%= Rails.env %>

# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]
r2:
service: S3
endpoint: <%= ENV['R2_ENDPOINT'] %>
access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %>
secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %>
bucket: <%= ENV['R2_BUCKET'] %>
region: auto
request_checksum_calculation: when_required

+ 34
- 0
backend/db/migrate/20260329034700_create_materials.rb View File

@@ -0,0 +1,34 @@
class CreateMaterials < ActiveRecord::Migration[8.0]
def change
create_table :materials do |t|
t.string :url
t.references :parent, index: true, foreign_key: { to_table: :materials }
t.references :tag, index: true, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.datetime :discarded_at, index: true
t.virtual :active_url, type: :string,
as: 'IF(discarded_at IS NULL, url, NULL)',
stored: false

t.index :active_url, unique: true
end

create_table :material_versions do |t|
t.references :material, null: false, foreign_key: true
t.integer :version_no, null: false
t.string :url, index: true
t.references :parent, index: true, foreign_key: { to_table: :materials }
t.references :tag, index: true, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.datetime :discarded_at, index: true

t.index [:material_id, :version_no],
unique: true,
name: 'index_material_versions_on_material_id_and_version_no'
end
end
end

+ 203
- 0
backend/db/migrate/20260409123700_create_post_versions.rb View File

@@ -0,0 +1,203 @@
require 'set'


class CreatePostVersions < ActiveRecord::Migration[8.0]
class Post < ActiveRecord::Base
self.table_name = 'posts'
end

class PostTag < ActiveRecord::Base
self.table_name = 'post_tags'
end

class PostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end

def up
create_table :post_versions do |t|
t.references :post, null: false, foreign_key: true
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :title
t.string :url, limit: 768, null: false
t.string :thumbnail_base, limit: 2000
t.text :tags, null: false
t.references :parent, foreign_key: { to_table: :posts }
t.datetime :original_created_from
t.datetime :original_created_before
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }

t.index [:post_id, :version_no], unique: true
t.check_constraint 'version_no > 0',
name: 'post_versions_version_no_positive'
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
name: 'post_versions_event_type_valid'
end

PostVersion.reset_column_information

say_with_time 'Backfilling post_versions' do
Post.find_in_batches(batch_size: 500) do |posts|
post_ids = posts.map(&:id)

post_tag_rows_by_post_id =
PostTag
.joins('INNER JOIN tags ON tags.id = post_tags.tag_id')
.joins('INNER JOIN tag_names ON tag_names.id = tags.tag_name_id')
.where(post_id: post_ids)
.pluck('post_tags.post_id',
'post_tags.created_at',
'post_tags.discarded_at',
'post_tags.created_user_id',
'post_tags.deleted_user_id',
'tag_names.name')
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
post_id, created_at, discarded_at, created_user_id, deleted_user_id, tag_name = row
h[post_id] << { created_at:,
discarded_at:,
created_user_id:,
deleted_user_id:,
tag_name: }
end

rows = []

posts.each do |post|
post_tag_rows = post_tag_rows_by_post_id[post.id]

events = post_tag_rows.flat_map do |post_tag_row|
ary = [[post_tag_row[:created_at],
post_tag_row[:created_user_id],
:add,
post_tag_row[:tag_name]]]

if post_tag_row[:discarded_at]
ary << [post_tag_row[:discarded_at],
post_tag_row[:deleted_user_id],
:remove,
post_tag_row[:tag_name]]
end

ary
end

kind_order = { add: 0, remove: 1 }

events.sort_by! do |event_at, user_id, kind, tag_name|
[event_at, user_id || 0, kind_order.fetch(kind), tag_name]
end

event_buckets = bucket_events(events)

active_tags = Set.new
version_no = 0

if event_buckets.empty?
version_no += 1
rows << build_row(post:,
version_no:,
event_type: 'create',
created_at: post.created_at,
created_by_user_id: post.uploaded_user_id,
tags: [])
next
end

first_bucket = event_buckets.first
merge_first_bucket_into_create = first_bucket[:first_at] <= post.created_at + 1.second

if merge_first_bucket_into_create
event_buckets.shift
apply_bucket!(active_tags, first_bucket)

version_no += 1
rows << build_row(
post:,
version_no:,
event_type: 'create',
created_at: post.created_at,
created_by_user_id: post.uploaded_user_id || first_bucket[:user_ids].compact.first,
tags: active_tags.to_a.sort)
else
version_no += 1
rows << build_row(
post:,
version_no:,
event_type: 'create',
created_at: post.created_at,
created_by_user_id: post.uploaded_user_id,
tags: [])
end

event_buckets.each do |bucket|
apply_bucket!(active_tags, bucket)

version_no += 1
rows << build_row(
post:,
version_no:,
event_type: 'update',
created_at: bucket[:first_at],
created_by_user_id: bucket[:user_ids].compact.first,
tags: active_tags.to_a.sort)
end
end

PostVersion.insert_all!(rows) if rows.any?
end
end
end

def down
drop_table :post_versions
end

private

def bucket_events events
buckets = []

events.each do |event_at, user_id, kind, tag_name|
if buckets.empty? || event_at - buckets.last[:last_at] > 1.second
buckets << { first_at: event_at,
last_at: event_at,
user_ids: [user_id],
events: [[kind, tag_name]] }
else
bucket = buckets.last
bucket[:last_at] = event_at
bucket[:user_ids] << user_id
bucket[:events] << [kind, tag_name]
end
end

buckets
end

def apply_bucket! active_tags, bucket
bucket[:events].each do |kind, tag_name|
if kind == :add
active_tags.add(tag_name)
else
active_tags.delete(tag_name)
end
end
end

def build_row post:, version_no:, event_type:, created_at:, created_by_user_id:, tags:
{ post_id: post.id,
version_no:,
event_type:,
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: tags.join(' '),
parent_id: post.parent_id,
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at:,
created_by_user_id: }
end
end

+ 156
- 0
backend/db/migrate/20260419035400_create_tag_versions.rb View File

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

+ 91
- 0
backend/db/migrate/20260426120600_create_wiki_versions.rb View File

@@ -0,0 +1,91 @@
class CreateWikiVersions < ActiveRecord::Migration[8.0]
class WikiPage < ActiveRecord::Base
self.table_name = 'wiki_pages'
end

class WikiRevision < ActiveRecord::Base
self.table_name = 'wiki_revisions'
end

class WikiRevisionLine < ActiveRecord::Base
self.table_name = 'wiki_revision_lines'
end

class WikiLine < ActiveRecord::Base
self.table_name = 'wiki_lines'
end

class WikiVersion < ActiveRecord::Base
self.table_name = 'wiki_versions'
end

class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end

def up
add_column :wiki_pages, :body, :text, after: :tag_name_id

create_table :wiki_versions do |t|
t.references :wiki_page, null: false, foreign_key: true
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :title, null: false
t.text :body, null: false
t.text :reason
t.datetime :created_at, null: false
t.references :created_by_user, foreign_key: { to_table: :users }

t.index [:wiki_page_id, :version_no], unique: true
t.check_constraint 'version_no > 0',
name: 'wiki_versions_version_no_positive'
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
name: 'wiki_versions_event_type_valid'
end

WikiPage.reset_column_information
WikiVersion.reset_column_information

say_with_time 'Backfilling wiki_versions' do
WikiPage.find_each do |page|
base_revision_id = nil
version_no = 1
title = TagName.find(page.tag_name_id).name
body = nil
loop do
rev = WikiRevision.where(wiki_page_id: page.id).find_by(base_revision_id:)
break unless rev

body = WikiRevisionLine.where(wiki_revision_id: rev.id).order(:position).map { |wrl|
WikiLine.find(wrl.wiki_line_id).body
}.join("\n")

WikiVersion.create!(
wiki_page_id: page.id,
version_no:,
event_type: version_no == 1 ? 'create' : 'update',
title:,
body:,
reason: rev.message,
created_at: rev.created_at,
created_by_user_id: rev.created_user_id)

version_no += 1
base_revision_id = rev.id
end
if body
page.update!(body:)
else
page.destroy!
end
end
end

change_column_null :wiki_pages, :body, false
end

def down
drop_table :wiki_versions
remove_column :wiki_pages, :body
end
end

+ 141
- 6
backend/db/schema.rb View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.


ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do
ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -50,12 +50,52 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do


create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false t.binary "ip_address", limit: 16, null: false
t.boolean "banned", default: false, null: false
t.datetime "banned_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_ip_addresses_on_banned_at"
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end end


create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.integer "version_no", null: false
t.string "url"
t.bigint "parent_id"
t.bigint "tag_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
t.index ["material_id"], name: "index_material_versions_on_material_id"
t.index ["parent_id"], name: "index_material_versions_on_parent_id"
t.index ["tag_id"], name: "index_material_versions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
t.index ["url"], name: "index_material_versions_on_url"
end

create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "url"
t.bigint "parent_id"
t.bigint "tag_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
t.index ["active_url"], name: "index_materials_on_active_url", unique: true
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
t.index ["discarded_at"], name: "index_materials_on_discarded_at"
t.index ["parent_id"], name: "index_materials_on_parent_id"
t.index ["tag_id"], name: "index_materials_on_tag_id"
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
end

create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "nico_tag_id", null: false t.bigint "nico_tag_id", null: false
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
@@ -65,6 +105,30 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do
t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id" t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id"
end 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_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end

create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "target_post_id", null: false t.bigint "target_post_id", null: false
@@ -93,17 +157,35 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do
t.index ["tag_id"], name: "index_post_tags_on_tag_id" t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end end


create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "title"
t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000
t.text "tags", null: false
t.text "parent_post_ids", null: false
t.datetime "original_created_from"
t.datetime "original_created_before"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end

create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "title" t.string "title"
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000 t.string "thumbnail_base", limit: 2000
t.bigint "parent_id"
t.bigint "uploaded_user_id" t.bigint "uploaded_user_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["parent_id"], name: "index_posts_on_parent_id"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true t.index ["url"], name: "index_posts_on_url", unique: true
end end
@@ -156,6 +238,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
end 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| create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false t.string "category", default: "general", null: false
@@ -234,9 +333,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do
t.string "name" t.string "name"
t.string "inheritance_code", limit: 64, null: false t.string "inheritance_code", limit: 64, null: false
t.string "role", null: false t.string "role", null: false
t.boolean "banned", default: false, null: false
t.datetime "banned_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["banned_at"], name: "index_users_on_banned_at"
end end


create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -262,6 +362,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do


create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false t.bigint "tag_name_id", null: false
t.text "body", null: false
t.bigint "created_user_id", null: false t.bigint "created_user_id", null: false
t.bigint "updated_user_id", null: false t.bigint "updated_user_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -304,17 +405,47 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
end end


create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "title", null: false
t.text "body", null: false
t.text "reason"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end

add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id" add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "posts", "posts", column: "parent_id"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
@@ -322,6 +453,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do
add_foreign_key "tag_names", "tag_names", column: "canonical_id" add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id" 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 "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
@@ -345,4 +478,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do
add_foreign_key "wiki_revisions", "wiki_pages" add_foreign_key "wiki_revisions", "wiki_pages"
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
add_foreign_key "wiki_versions", "wiki_pages"
end end

+ 23
- 0
backend/lib/tasks/export_nico.rake View File

@@ -0,0 +1,23 @@
namespace :nico do
desc 'ニコニコ DB 逆連携'
task export: :environment do
require 'open3'

mysql_user = ENV.fetch('MYSQL_USER')
mysql_pass = ENV.fetch('MYSQL_PASS')
nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH')

videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map {
_1[%r{nicovideo\.jp/watch/([^/?#]+)}, 1]
}.uniq

next if videos.empty?

_, stderr, status = Open3.capture3(
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos,
chdir: nizika_nico_path)

raise stderr unless status.success?
end
end

+ 17
- 1
backend/lib/tasks/sync_nico.rake View File

@@ -61,6 +61,9 @@ namespace :nico do
original_created_from = original_created_at&.change(sec: 0) original_created_from = original_created_at&.change(sec: 0)
original_created_before = original_created_from&.+(1.minute) original_created_before = original_created_from&.+(1.minute)


post_created = false
post_changed = false

if post if post
attrs = { title:, original_created_from:, original_created_before: } attrs = { title:, original_created_from:, original_created_before: }


@@ -76,11 +79,13 @@ namespace :nico do
end end


post.assign_attributes(attrs) post.assign_attributes(attrs)
if post.changed?
post_changed = post.changed?
if post_changed
post.save! post.save!
post.resized_thumbnail! if post.thumbnail.attached? post.resized_thumbnail! if post.thumbnail.attached?
end end
else else
post_created = true
url = "https://www.nicovideo.jp/watch/#{ code }" url = "https://www.nicovideo.jp/watch/#{ code }"
thumbnail_base = fetch_thumbnail.(url) rescue nil thumbnail_base = fetch_thumbnail.(url) rescue nil
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
@@ -110,6 +115,10 @@ namespace :nico do
datum['tags'].each do |raw| datum['tags'].each do |raw|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }") name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(name, category: :nico) 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 desired_nico_tag_based_ids << tag.id


# 新たに記載される外部タグと連携される内部タグを記載 # 新たに記載される外部タグと連携される内部タグを記載
@@ -140,6 +149,13 @@ namespace :nico do
desired_all_tag_ids.uniq! desired_all_tag_ids.uniq!


sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids) sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)

if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || kept_tag_ids != desired_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 end
end end
end end

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

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

+ 2
- 0
backend/spec/factories/wiki_pages.rb View File

@@ -3,5 +3,7 @@ FactoryBot.define do
title { "TestPage" } title { "TestPage" }
association :created_user, factory: :user association :created_user, factory: :user
association :updated_user, factory: :user association :updated_user, factory: :user

body { ' ' }
end end
end end

+ 40
- 0
backend/spec/models/post_version_spec.rb View File

@@ -0,0 +1,40 @@
require 'rails_helper'

RSpec.describe PostVersion, type: :model do
let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }

let!(:post_record) do
Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post|
PostTag.create!(post: post, tag: tag)
end
end

let!(:post_version) do
PostVersion.create!(
post: post_record,
version_no: 1,
event_type: 'create',
title: post_record.title,
url: post_record.url,
thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '),
original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before,
created_at: Time.current,
created_by_user: nil
)
end

it 'is read only after create' do
expect do
post_version.update!(title: 'changed')
end.to raise_error(ActiveRecord::ReadOnlyRecord)
end

it 'cannot be destroyed' do
expect do
post_version.destroy!
end.to raise_error(ActiveRecord::ReadOnlyRecord)
end
end

+ 71
- 5
backend/spec/models/tag_spec.rb View File

@@ -107,11 +107,13 @@ RSpec.describe Tag, type: :model do
context 'when the source tag_name has a wiki_page' do context 'when the source tag_name has a wiki_page' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
let!(:wiki_page) do let!(:wiki_page) do
WikiPage.create!(
tag_name: source_tag_name,
created_user: create_admin_user!,
updated_user: create_admin_user!
)
admin = create_admin_user!

Wiki::Commit.create_content!(
tag_name: source_tag_name,
body: 'source wiki body',
created_by_user: admin,
message: 'init')
end end


it 'rolls back the transaction' do it 'rolls back the transaction' do
@@ -145,5 +147,69 @@ RSpec.describe Tag, type: :model do
expect(target_tag.reload.post_count).to eq(0) expect(target_tag.reload.post_count).to eq(0)
end end
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),
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
end end

+ 74
- 0
backend/spec/models/version_record_spec.rb View File

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

+ 378
- 0
backend/spec/requests/materials_spec.rb View File

@@ -0,0 +1,378 @@
require 'rails_helper'

RSpec.describe 'Materials API', type: :request do
let!(:member_user) { create(:user, :member) }
let!(:guest_user) { create(:user) }

def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
end

def response_materials
json.fetch('materials')
end

def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material|
material.file.attach(file) if file
material.save!
end
end

describe 'GET /materials' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }

let!(:material_a) do
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
end

let!(:material_b) do
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png'))
end

before do
old_time = Time.zone.local(2026, 3, 29, 1, 0, 0)
new_time = Time.zone.local(2026, 3, 29, 2, 0, 0)

material_a.update_columns(created_at: old_time, updated_at: old_time)
material_b.update_columns(created_at: new_time, updated_at: new_time)
end

it 'returns materials with count and metadata' do
get '/materials'

expect(response).to have_http_status(:ok)
expect(json).to include('materials', 'count')
expect(response_materials).to be_an(Array)
expect(json['count']).to eq(2)

row = response_materials.find { |m| m['id'] == material_b.id }
expect(row).to be_present
expect(row['tag']).to include(
'id' => tag_b.id,
'name' => 'material_index_b',
'category' => 'material'
)
expect(row['created_by_user']).to include(
'id' => member_user.id,
'name' => member_user.name
)
expect(row['content_type']).to eq('image/png')
end

it 'filters materials by tag_id' do
get '/materials', params: { tag_id: material_a.tag_id }

expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
end

it 'filters materials by parent_id' do
get '/materials', params: { parent_id: material_a.id }

expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_b.id])
end

it 'paginates and keeps total count' do
get '/materials', params: { page: 2, limit: 1 }

expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_a.id)
end

it 'normalises invalid page and limit' do
get '/materials', params: { page: 0, limit: 0 }

expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_b.id)
end
end

describe 'GET /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
end

it 'returns a material with file, tag, and content_type' do
get "/materials/#{ material.id }"

expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => material.id,
'content_type' => 'image/png'
)
expect(json['file']).to be_present
expect(json['tag']).to include(
'id' => tag.id,
'name' => 'material_show',
'category' => 'material'
)
end

it 'returns 404 when material does not exist' do
get '/materials/999999999'
expect(response).to have_http_status(:not_found)
end
end

describe 'POST /materials' do
context 'when not logged in' do
before { sign_out }

it 'returns 401' do
post '/materials', params: {
tag: 'material_create_unauthorized',
file: dummy_upload
}

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

context 'when logged in' do
before { sign_in_as(guest_user) }

it 'returns 400 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload }

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

it 'returns 400 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' }

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

it 'creates a material with an attached file' do
expect do
post '/materials', params: {
tag: 'material_create_new',
file: dummy_upload(filename: 'created.png')
}
end.to change(Material, :count).by(1)
.and change(Tag, :count).by(1)
.and change(TagName, :count).by(1)

expect(response).to have_http_status(:created)

material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_new')
expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(guest_user)
expect(material.updated_by_user).to eq(guest_user)
expect(material.file.attached?).to be(true)

expect(json['id']).to eq(material.id)
expect(json.dig('tag', 'name')).to eq('material_create_new')
expect(json['content_type']).to eq('image/png')
end

it 'returns 422 when the existing tag is not material/character' do
general_tag_name = TagName.create!(name: 'material_create_general_tag')
Tag.create!(tag_name: general_tag_name, category: :general)

post '/materials', params: {
tag: 'material_create_general_tag',
file: dummy_upload
}

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

it 'persists url-only material' do
expect do
post '/materials', params: {
tag: 'material_create_url_only',
url: 'https://example.com/material-source'
}
end.to change(Material, :count).by(1)

expect(response).to have_http_status(:created)

material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_url_only')
expect(material.url).to eq('https://example.com/material-source')
expect(material.file.attached?).to be(false)
end

it 'returns the original url for url-only material' do
post '/materials', params: {
tag: 'material_create_url_only_response',
url: 'https://example.com/material-source'
}

expect(response).to have_http_status(:created)
expect(json['url']).to eq('https://example.com/material-source')
end
end
end

describe 'PUT /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
end

context 'when not logged in' do
before { sign_out }

it 'returns 401' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
file: dummy_upload(filename: 'new.png')
}

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

context 'when logged in but not member' do
before { sign_in_as(guest_user) }

it 'returns 403' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
file: dummy_upload(filename: 'new.png')
}

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

context 'when member' do
before { sign_in_as(member_user) }

it 'returns 404 when material does not exist' do
put '/materials/999999999', params: {
tag: 'material_update_missing',
file: dummy_upload
}

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

it 'returns 400 when tag is blank' do
put "/materials/#{ material.id }", params: {
tag: ' ',
file: dummy_upload
}

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

it 'returns 400 when both file and url are blank' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload'
}

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

it 'updates tag, url, file, and updated_by_user' do
old_blob_id = material.file.blob.id

put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
url: 'https://example.com/updated-source',
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
}

expect(response).to have_http_status(:ok)

material.reload
expect(material.tag.name).to eq('material_update_new')
expect(material.tag.category).to eq('material')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true)
expect(material.file.blob.id).not_to eq(old_blob_id)
expect(material.file.blob.filename.to_s).to eq('updated.jpg')
expect(material.file.blob.content_type).to eq('image/jpeg')

expect(json['id']).to eq(material.id)
expect(json['file']).to be_present
expect(json['content_type']).to eq('image/jpeg')
expect(json.dig('tag', 'name')).to eq('material_update_new')
end

it 'purges the existing file when file is omitted and url is provided' do
old_blob_id = material.file.blob.id

put "/materials/#{ material.id }", params: {
tag: 'material_update_remove_file',
url: 'https://example.com/updated-source'
}

expect(response).to have_http_status(:ok)

material.reload
expect(material.tag.name).to eq('material_update_remove_file')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(false)

expect(
ActiveStorage::Blob.where(id: old_blob_id).exists?
).to be(false)

expect(json['id']).to eq(material.id)
expect(json['file']).to be_nil
expect(json['content_type']).to be_nil
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
expect(json['url']).to eq('https://example.com/updated-source')
end
end
end

describe 'DELETE /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
end

context 'when not logged in' do
before { sign_out }

it 'returns 401' do
delete "/materials/#{ material.id }"
expect(response).to have_http_status(:unauthorized)
end
end

context 'when logged in but not member' do
before { sign_in_as(guest_user) }

it 'returns 403' do
delete "/materials/#{ material.id }"
expect(response).to have_http_status(:forbidden)
end
end

context 'when member' do
before { sign_in_as(member_user) }

it 'returns 404 when material does not exist' do
delete '/materials/999999999'
expect(response).to have_http_status(:not_found)
end

it 'discards the material and returns 204' do
delete "/materials/#{ material.id }"

expect(response).to have_http_status(:no_content)
expect(Material.find_by(id: material.id)).to be_nil
expect(Material.with_discarded.find(material.id)).to be_discarded
end
end
end
end

+ 55
- 0
backend/spec/requests/nico_tags_spec.rb View File

@@ -14,6 +14,7 @@ RSpec.describe 'NicoTags', type: :request do


describe 'PATCH /tags/nico/:id' do describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:tag, :nico) } let(:nico_tag) { create(:tag, :nico) }


it '401 when not logged in' do 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' } patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
end 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
end end

+ 384
- 2
backend/spec/requests/posts_spec.rb View File

@@ -1,8 +1,8 @@
include ActiveSupport::Testing::TimeHelpers

require 'rails_helper' require 'rails_helper'
require 'set' require 'set'


include ActiveSupport::Testing::TimeHelpers

RSpec.describe 'Posts API', type: :request do RSpec.describe 'Posts API', type: :request do
# create / update で thumbnail.attach は走るが、 # create / update で thumbnail.attach は走るが、
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
@@ -756,6 +756,217 @@ RSpec.describe 'Posts API', type: :request do
end end
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),
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 describe 'POST /posts/:id/viewed' do
let(:user) { create(:user) } let(:user) { create(:user) }


@@ -795,4 +1006,175 @@ RSpec.describe 'Posts API', type: :request do
expect(user.reload.viewed?(post_record)).to be(false) expect(user.reload.viewed?(post_record)).to be(false)
end end
end end

describe 'post versioning' do
let(:member) { create(:user, :member) }

def snapshot_tags(post)
post.snapshot_tag_names.join(' ')
end

def create_post_version_for!(post)
PostVersion.create!(
post: post,
version_no: 1,
event_type: 'create',
title: post.title,
url: post.url,
thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post),
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
created_at: post.created_at,
created_by_user: post.uploaded_user
)
end

it 'creates version 1 on POST /posts' do
sign_in_as(member)

expect do
post '/posts', params: {
title: 'versioned post',
url: 'https://example.com/versioned-post',
tags: 'spec_tag',
thumbnail: dummy_upload
}
end.to change(PostVersion, :count).by(1)

expect(response).to have_http_status(:created)

created_post = Post.find(json.fetch('id'))
version = PostVersion.find_by!(post: created_post, version_no: 1)

expect(version.event_type).to eq('create')
expect(version.title).to eq('versioned post')
expect(version.url).to eq('https://example.com/versioned-post')
expect(version.created_by_user_id).to eq(member.id)
expect(version.tags).to eq(snapshot_tags(created_post))
end

it 'creates next version on PUT /posts/:id when snapshot changes' do
sign_in_as(member)
create_post_version_for!(post_record)

tag_name2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tag_name2, category: :general)

expect do
put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'spec_tag_2'
}
end.to change(PostVersion, :count).by(1)

expect(response).to have_http_status(:ok)

version = post_record.reload.post_versions.order(:version_no).last
expect(version.version_no).to eq(2)
expect(version.event_type).to eq('update')
expect(version.title).to eq('updated title')
expect(version.created_by_user_id).to eq(member.id)
expect(version.tags).to eq(snapshot_tags(post_record.reload))
end

it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
sign_in_as(member)

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

version = post_record.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_record))
end

it 'does not create a version when POST /posts is invalid' do
sign_in_as(member)

expect do
post '/posts', params: {
title: 'invalid post',
url: 'ぼざクリタグ広場',
tags: 'spec_tag',
thumbnail: dummy_upload
}
end.not_to change(PostVersion, :count)

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

it 'does not create a version when PUT /posts/:id is invalid' do
sign_in_as(member)
create_post_version_for!(post_record)

expect do
put "/posts/#{post_record.id}", params: {
title: 'updated title',
tags: 'spec_tag',
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601
}
end.not_to change(PostVersion, :count)

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 end

+ 78
- 6
backend/spec/requests/tag_children_spec.rb View File

@@ -58,15 +58,47 @@ RSpec.describe "TagChildren", type: :request do
end end
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) } before { stub_current_user(admin) }


let(:parent_id) { -1 } let(:parent_id) { -1 }
let(:child_id) { -1 } let(:child_id) { -1 }


it "returns 204 (rescue nil)" do
it "returns 404" do
do_request 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 end
end end
@@ -116,17 +148,57 @@ RSpec.describe "TagChildren", type: :request do


expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
end 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 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) } before { stub_current_user(admin) }


let(:parent_id) { -1 } let(:parent_id) { -1 }
let(:child_id) { -1 } let(:child_id) { -1 }


it "returns 204 (rescue nil)" do
it "returns 404" do
do_request 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 end
end end


+ 243
- 0
backend/spec/requests/tag_versions_spec.rb View File

@@ -0,0 +1,243 @@
require 'rails_helper'

RSpec.describe 'TagVersions API', type: :request do
let(:member) { create(:user, :member, name: 'version member') }

let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) }
let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) }

let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) }
let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) }
let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) }
let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) }

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) }

def create_tag_version!(
tag:,
version_no:,
event_type:,
name:,
category:,
aliases: [],
parent_tags: [],
created_by_user:,
created_at:
)
TagVersion.create!(
tag: tag,
version_no: version_no,
event_type: event_type,
name: name,
category: category,
aliases: Array(aliases).join(' '),
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user,
created_at: created_at
)
end

let!(:v1) do
create_tag_version!(
tag: tag,
version_no: 1,
event_type: 'create',
name: 'old_tag_name',
category: 'general',
aliases: ['alias_shared', 'alias_old'],
parent_tags: [parent_shared, parent_old],
created_by_user: member,
created_at: t_v1
)
end

let!(:v2) do
create_tag_version!(
tag: tag,
version_no: 2,
event_type: 'update',
name: 'new_tag_name',
category: 'meme',
aliases: ['alias_shared', 'alias_new'],
parent_tags: [parent_shared, parent_new],
created_by_user: member,
created_at: t_v2
)
end

let!(:other_v1) do
create_tag_version!(
tag: other_tag,
version_no: 1,
event_type: 'create',
name: 'other_tag_name',
category: 'general',
aliases: ['other_alias'],
parent_tags: [other_parent],
created_by_user: member,
created_at: t_other
)
end

describe 'GET /tags/versions' do
it 'returns all versions in reverse chronological order when id is omitted' do
get '/tags/versions'

expect(response).to have_http_status(:ok)
expect(json).to include('versions', 'count')
expect(json.fetch('count')).to eq(3)

versions = json.fetch('versions')

expect(versions.map { |v| [v['tag_id'], v['version_no']] }).to eq([
[other_tag.id, 1],
[tag.id, 2],
[tag.id, 1]
])
end

it 'returns versions for the specified tag with diffs' do
get '/tags/versions', params: { id: tag.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['tag_id'] }.uniq).to eq([tag.id])
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])

latest = versions.first
expect(latest).to include(
'tag_id' => tag.id,
'version_no' => 2,
'event_type' => 'update',
'created_by_user' => {
'id' => member.id,
'name' => member.name
}
)

expect(latest.fetch('name')).to eq(
'current' => 'new_tag_name',
'prev' => 'old_tag_name'
)
expect(latest.fetch('category')).to eq(
'current' => 'meme',
'prev' => 'general'
)
expect(latest.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'context' },
{ 'name' => 'alias_new', 'type' => 'added' },
{ 'name' => 'alias_old', 'type' => 'removed' }
)
expect(latest.fetch('parent_tags')).to include(
a_hash_including(
'type' => 'context',
'tag' => a_hash_including(
'id' => parent_shared.id
)
),
a_hash_including(
'type' => 'added',
'tag' => a_hash_including(
'id' => parent_new.id
)
),
a_hash_including(
'type' => 'removed',
'tag' => a_hash_including(
'id' => parent_old.id
)
)
)
expect(latest.fetch('created_at')).to eq(t_v2.iso8601)

first = versions.second
expect(first).to include(
'tag_id' => tag.id,
'version_no' => 1,
'event_type' => 'create',
'created_by_user' => {
'id' => member.id,
'name' => member.name
}
)
expect(first.fetch('name')).to eq(
'current' => 'old_tag_name',
'prev' => nil
)
expect(first.fetch('category')).to eq(
'current' => 'general',
'prev' => nil
)
expect(first.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'added' },
{ 'name' => 'alias_old', 'type' => 'added' }
)
expect(first.fetch('parent_tags')).to include(
a_hash_including(
'type' => 'added',
'tag' => a_hash_including(
'id' => parent_shared.id
)
),
a_hash_including(
'type' => 'added',
'tag' => a_hash_including(
'id' => parent_old.id
)
)
)
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end

it 'returns empty when the specified tag has no versions' do
fresh_tag = create(:tag, name: 'no_versions_tag', category: :general)

get '/tags/versions', params: { id: fresh_tag.id }

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 '/tags/versions', params: { id: tag.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.first['version_no']).to eq(2)
end

it 'does not create tag versions by wiki updates when tag has no versions yet' do
wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki')
wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general)

wiki_page =
Wiki::Commit.create_content!(
tag_name: wiki_tag_name,
body: 'before',
created_by_user: member,
message: 'init')

Wiki::Commit.content!(
page: wiki_page,
body: 'after',
created_user: member,
message: 'edit',
base_revision_id: wiki_page.current_revision.id)

get '/tags/versions', params: { id: wiki_tag.id }

expect(response).to have_http_status(:ok)
expect(json.fetch('versions')).to eq([])
expect(json.fetch('count')).to eq(0)
end
end
end

+ 160
- 0
backend/spec/requests/tag_wiki_history_integrity_spec.rb View File

@@ -0,0 +1,160 @@
require 'rails_helper'

RSpec.describe 'Tag and wiki history integrity', type: :request do
let(:member_user) { create(:user, role: 'member') }

def stub_current_user user
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
end

def create_tag! name:, category: :general
tag_name = TagName.create!(name:)
Tag.create!(tag_name:, category:)
end

def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body:,
created_by_user: user,
message: 'init')
end

before do
stub_current_user(member_user)
end

describe 'PATCH /tags/:id' do
it 'records wiki_version when tag name changes and tag has wiki' do
tag = create_tag!(name: 'patch_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')

expect {
patch "/tags/#{ tag.id }", params: {
name: 'patch_tag_wiki_after',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:ok)

tag.reload
wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last

expect(tag.name).to eq('patch_tag_wiki_after')
expect(wiki_page.title).to eq('patch_tag_wiki_after')

expect(version).to have_attributes(
event_type: 'update',
title: 'patch_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end

it 'does not record wiki_version when only category changes' do
tag = create_tag!(name: 'patch_tag_category_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')

before_wiki_versions = wiki_page.wiki_versions.count

expect {
patch "/tags/#{ tag.id }", params: {
category: 'meme',
}
}
.to change(TagVersion, :count).by(2)

expect(response).to have_http_status(:ok)

tag.reload
wiki_page.reload

expect(tag.name).to eq('patch_tag_category_only')
expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end
end

describe 'PUT /tags/:id' do
it 'records wiki_version when tag name changes and tag has wiki' do
tag = create_tag!(name: 'put_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')

expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_wiki_after',
category: 'general',
aliases: '',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:ok)

tag.reload
wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last

expect(tag.name).to eq('put_tag_wiki_after')
expect(wiki_page.title).to eq('put_tag_wiki_after')

expect(version).to have_attributes(
event_type: 'update',
title: 'put_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end

it 'does not record wiki_version when only category changes' do
tag = create_tag!(name: 'put_tag_category_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')

before_wiki_versions = wiki_page.wiki_versions.count

expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_category_only',
category: 'meme',
aliases: '',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)

expect(response).to have_http_status(:ok)

tag.reload
wiki_page.reload

expect(tag.name).to eq('put_tag_category_only')
expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end

it 'does not record wiki_version when only aliases change' do
tag = create_tag!(name: 'put_tag_alias_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')

before_wiki_versions = wiki_page.wiki_versions.count

expect {
put "/tags/#{ tag.id }", params: {
name: 'put_tag_alias_only',
category: 'general',
aliases: 'put_tag_alias_only_alias',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)

expect(response).to have_http_status(:ok)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions)
end
end
end

+ 701
- 6
backend/spec/requests/tags_spec.rb View File

@@ -1,7 +1,6 @@
require 'cgi' require 'cgi'
require 'rails_helper' require 'rails_helper'



RSpec.describe 'Tags API', type: :request do RSpec.describe 'Tags API', type: :request do
let!(:tn) { TagName.create!(name: 'spec_tag') } let!(:tn) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tn, category: :general) } let!(:tag) { Tag.create!(tag_name: tn, category: :general) }
@@ -19,6 +18,17 @@ RSpec.describe 'Tags API', type: :request do
response_tags.map { |t| t.fetch('name') } response_tags.map { |t| t.fetch('name') }
end end


def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
end

def create_material(tag, user:, filename: 'dummy.png', type: 'image/png', url: nil)
Material.new(tag:, url:, created_by_user: user, updated_by_user: user).tap do |material|
material.file.attach(dummy_material_upload(filename:, type:)) if filename
material.save!
end
end

describe 'GET /tags' do describe 'GET /tags' do
it 'returns tags with count and metadata' do it 'returns tags with count and metadata' do
get '/tags' get '/tags'
@@ -186,6 +196,30 @@ RSpec.describe 'Tags API', type: :request do
expect(response_tags.size).to eq(1) expect(response_tags.size).to eq(1)
expect(response_names).to eq(['norm_a']) expect(response_names).to eq(['norm_a'])
end 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 end


describe 'GET /tags/:id' do describe 'GET /tags/:id' do
@@ -209,6 +243,28 @@ RSpec.describe 'Tags API', type: :request do
expect(json).to have_key('created_at') expect(json).to have_key('created_at')
expect(json).to have_key('updated_at') expect(json).to have_key('updated_at')
end 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 end


context 'when tag does not exist' do context 'when tag does not exist' do
@@ -348,14 +404,653 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('meta') expect(tag.category).to eq('meta')
end end


it '存在しない id だと RecordNotFound になる(通常は 404)' do
it '存在しない id なら 404 を返す' do
patch '/tags/999999999', params: { name: 'x' } patch '/tags/999999999', params: { name: 'x' }
expect(response.status).to be_in([404, 500])
expect(response).to have_http_status(:not_found)
end 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

it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')

expect {
patch "/tags/#{ tag.id }", params: {
name: 'patch_wiki_renamed_tag',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:ok)

version = wiki_page.reload.wiki_versions.order(:version_no).last

expect(version).to have_attributes(
event_type: 'update',
title: 'patch_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end

it 'tag の category だけを変更しても wiki version は作成しない' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')

before_wiki_version_count = wiki_page.reload.wiki_versions.count

expect {
patch "/tags/#{ tag.id }", params: {
category: 'meme',
}
}.to change(TagVersion, :count).by(2)

expect(response).to have_http_status(:ok)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
end
end
end

describe 'GET /tags/with-depth' do
let!(:root_meme) do
Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme)
end

let!(:root_material) do
Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material)
end

let!(:hidden_general_root) do
Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general)
end

let!(:child_character) do
Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character)
end

let!(:grandchild_material) do
Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material)
end

let!(:child_general) do
Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general)
end

before do
TagImplication.create!(parent_tag: root_meme, tag: child_character)
TagImplication.create!(parent_tag: child_character, tag: grandchild_material)
TagImplication.create!(parent_tag: root_material, tag: child_general)
end

it 'returns only visible root tags and visible has_children flags' do
get '/tags/with-depth'

expect(response).to have_http_status(:ok)
expect(json.map { |t| t['name'] }).to eq([
'depth_a_root_meme',
'depth_b_root_material'
])

meme_row = json.find { |t| t['name'] == 'depth_a_root_meme' }
material_row = json.find { |t| t['name'] == 'depth_b_root_material' }

expect(meme_row['has_children']).to eq(true)
expect(meme_row['children']).to eq([])

expect(material_row['has_children']).to eq(false)
expect(material_row['children']).to eq([])

expect(json.map { |t| t['name'] }).not_to include('depth_hidden_general_root')
end

it 'returns children of the specified parent' do
get '/tags/with-depth', params: { parent: root_meme.id }

expect(response).to have_http_status(:ok)
expect(json.map { |t| t['name'] }).to eq(['depth_child_character'])

row = json.first
expect(row['category']).to eq('character')
expect(row['has_children']).to eq(true)
expect(row['children']).to eq([])
end
end

describe 'GET /tags/name/:name/materials' do
let!(:material_user) { create_member_user! }

let!(:root_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material)
end

let!(:child_a_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material)
end

let!(:child_b_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character)
end

let!(:grandchild_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material)
end

let!(:root_material) do
create_material(root_tag, user: material_user, filename: 'root.png')
end

let!(:child_a_material) do
create_material(child_a_tag, user: material_user, filename: 'child_a.png')
end

let!(:grandchild_material) do
create_material(grandchild_tag, user: material_user, filename: 'grandchild.png')
end

before do
TagImplication.create!(parent_tag: root_tag, tag: child_b_tag)
TagImplication.create!(parent_tag: root_tag, tag: child_a_tag)
TagImplication.create!(parent_tag: child_a_tag, tag: grandchild_tag)
end

it 'returns a tag tree with nested materials sorted by child name' do
get "/tags/name/#{ CGI.escape(root_tag.name) }/materials"

expect(response).to have_http_status(:ok)

expect(json).to include(
'id' => root_tag.id,
'name' => 'materials_root',
'category' => 'material'
)

expect(json['material']).to be_present
expect(json.dig('material', 'id')).to eq(root_material.id)
expect(json.dig('material', 'file')).to be_present
expect(json.dig('material', 'content_type')).to eq('image/png')

expect(json['children'].map { |t| t['name'] }).to eq([
'materials_child_a',
'materials_child_b'
])

child_a = json['children'].find { |t| t['name'] == 'materials_child_a' }
child_b = json['children'].find { |t| t['name'] == 'materials_child_b' }

expect(child_a.dig('material', 'id')).to eq(child_a_material.id)
expect(child_a['children'].map { |t| t['name'] }).to eq(['materials_grandchild'])
expect(child_a.dig('children', 0, 'material', 'id')).to eq(grandchild_material.id)

expect(child_b['material']).to be_nil
expect(child_b['children']).to eq([])
end

it 'returns 404 when the tag does not exist' do
get '/tags/name/no_such_tag_12345/materials'
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

it 'wiki を持つ tag を更新すると wiki version も作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')

Wiki::Commit.content!(
page: wiki_page,
body: 'wiki body before',
created_user: member_user,
message: 'init'
)

expect {
put "/tags/#{ tag.id }", params: {
name: 'put_wiki_version_tag',
category: 'meme',
aliases: 'unko',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:ok)

version = wiki_page.reload.wiki_versions.order(:version_no).last

expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_version_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end

it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
old_owner = Tag.create!(
tag_name: TagName.create!(name: 'put_alias_old_owner'),
category: :general
)
stolen_alias = TagName.create!(
name: 'put_stolen_alias',
canonical: old_owner.tag_name
)

expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias')

expect {
put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko put_stolen_alias',
parent_tags: '',
}
}
.to change { tag.reload.tag_versions.count }.by(2)
.and change { old_owner.reload.tag_versions.count }.by(2)

expect(response).to have_http_status(:ok)

expect(stolen_alias.reload.canonical).to eq(tag.tag_name)
expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias')

old_owner_versions = old_owner.tag_versions.order(:version_no)

expect(old_owner_versions.first.event_type).to eq('create')
expect(old_owner_versions.first.aliases.split).to include('put_stolen_alias')

expect(old_owner_versions.second.event_type).to eq('update')
expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
end

it 'parent_tags に指定すると循環する tag は 422 にする' do
pending '#332 で対応予定'

child = Tag.create!(
tag_name: TagName.create!(name: 'put_cycle_child'),
category: :general
)

TagImplication.create!(tag: child, parent_tag: tag)

put "/tags/#{ tag.id }", params: {
name: 'spec_tag',
category: 'general',
aliases: 'unko',
parent_tags: child.name,
}

expect(response).to have_http_status(:unprocessable_entity)

expect(TagImplication.where(tag:, parent_tag: child)).not_to exist
end

it 'tag の name を変更すると対応する wiki version を作成する' do
wiki_page =
Wiki::Commit.create_content!(
tag_name: tag.tag_name,
body: 'wiki body before',
created_by_user: member_user,
message: 'init')

expect {
put "/tags/#{ tag.id }", params: {
name: 'put_wiki_renamed_tag',
category: 'general',
aliases: 'unko',
parent_tags: '',
}
}
.to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:ok)

version = wiki_page.reload.wiki_versions.order(:version_no).last

expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
end end
end end


+ 2
- 3
backend/spec/requests/users_spec.rb View File

@@ -1,11 +1,10 @@
require "rails_helper" require "rails_helper"



RSpec.describe "Users", type: :request do RSpec.describe "Users", type: :request do
describe "POST /users" do describe "POST /users" do
it "creates guest user and returns code" do it "creates guest user and returns code" do
post "/users" post "/users"
expect(response).to have_http_status(:ok)
expect(response).to have_http_status(:created)
expect(json["code"]).to be_present expect(json["code"]).to be_present
expect(json["user"]["role"]).to eq("guest") expect(json["user"]["role"]).to eq("guest")
end end
@@ -38,7 +37,7 @@ RSpec.describe "Users", type: :request do
sign_in_as(user) sign_in_as(user)
put "/users/#{user.id}", params: { name: "new-name" } put "/users/#{user.id}", params: { name: "new-name" }


expect(response).to have_http_status(:created)
expect(response).to have_http_status(:ok)
expect(json["id"]).to eq(user.id) expect(json["id"]).to eq(user.id)
expect(json["name"]).to eq("new-name") expect(json["name"]).to eq("new-name")




+ 27
- 0
backend/spec/requests/wiki_body_search_pending_spec.rb View File

@@ -0,0 +1,27 @@
require 'rails_helper'

RSpec.describe 'Wiki body search', type: :request do
let!(:user) { create_member_user! }

it 'searches wiki pages by body text' do
pending '#336 で対応予定'

Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
body: 'unique body keyword for wiki search',
created_by_user: user,
message: 'init')

Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
body: 'ordinary body',
created_by_user: user,
message: 'init')

get '/wiki/search', params: { body: 'unique body keyword' }

expect(response).to have_http_status(:ok)
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
end
end

+ 42
- 0
backend/spec/requests/wiki_conflict_spec.rb View File

@@ -0,0 +1,42 @@
require 'rails_helper'

RSpec.describe 'Wiki conflict handling', type: :request do
let!(:user) { create_member_user! }

def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end

it 'returns 409 when base_revision_id is stale' do
page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_conflict_request'),
body: 'first',
created_by_user: user,
message: 'init')

stale_id = page.current_revision.id

Wiki::Commit.content!(
page:,
body: 'second',
created_user: user,
message: 'other edit',
base_revision_id: stale_id)

put "/wiki/#{ page.id }",
params: {
title: 'wiki_conflict_request',
body: 'third',
message: 'stale',
base_revision_id: stale_id,
},
headers: auth_headers(user)

expect(response).to have_http_status(:conflict)

page.reload
expect(page.body).to eq('second')
expect(page.current_revision.message).to eq('other edit')
end
end

+ 196
- 0
backend/spec/requests/wiki_history_integrity_spec.rb View File

@@ -0,0 +1,196 @@
require 'cgi'
require 'rails_helper'

RSpec.describe 'Wiki history integrity', type: :request do
let!(:user) { create_member_user! }

def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end

def create_wiki_page title:, body: 'body', message: 'init', user: self.user
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message:)
end

describe 'POST /wiki' do
it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
expect {
post '/wiki',
params: {
title: 'wiki_history_create_atomic',
body: "a\nb\nc",
message: 'initial commit',
},
headers: auth_headers(user)
}
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:created)

page = WikiPage.find(json.fetch('id'))
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last

expect(page.title).to eq('wiki_history_create_atomic')
expect(page.body).to eq("a\nb\nc")

expect(revision).to be_content
expect(revision.message).to eq('initial commit')
expect(revision.lines_count).to eq(3)

expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'wiki_history_create_atomic',
body: "a\nb\nc",
reason: 'initial commit',
created_by_user_id: user.id
)
end

it 'returns 422 and creates nothing when normalised body is blank' do
expect {
post '/wiki',
params: {
title: 'wiki_history_blank_body',
body: "\r\n\r\n",
message: 'blank',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist
end

it 'returns 422 and creates no partial page when title already exists' do
create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first')

expect {
post '/wiki',
params: {
title: 'wiki_history_duplicate_title',
body: 'second',
message: 'duplicate',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1)
end
end

describe 'PUT /wiki/:id' do
it 'updates body and records wiki_revision and wiki_version' do
page = create_wiki_page(title: 'wiki_history_update_body', body: 'before')
current_id = page.current_revision.id

expect {
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_update_body',
body: 'after',
message: 'edit body',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:ok)

page.reload
version = page.wiki_versions.order(:version_no).last

expect(page.title).to eq('wiki_history_update_body')
expect(page.body).to eq('after')

expect(version).to have_attributes(
event_type: 'update',
title: 'wiki_history_update_body',
body: 'after',
reason: 'edit body',
created_by_user_id: user.id
)
end

it 'renames title and records wiki_version with new title' do
page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before')
current_id = page.current_revision.id

expect {
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_rename_after',
body: 'after',
message: 'rename',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:ok)

page.reload
version = page.wiki_versions.order(:version_no).last

expect(page.title).to eq('wiki_history_rename_after')
expect(page.body).to eq('after')

expect(version).to have_attributes(
event_type: 'update',
title: 'wiki_history_rename_after',
body: 'after',
reason: 'rename',
created_by_user_id: user.id
)
end

it 'does not change title, body, revision, or version on stale base_revision_id' do
page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first')
stale_id = page.current_revision.id

Wiki::Commit.content!(
page:,
body: 'second',
created_user: user,
message: 'other edit',
base_revision_id: stale_id)

page.reload
current_title = page.title
current_body = page.body
revision_count = page.wiki_revisions.count
version_count = page.wiki_versions.count

put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_conflict_renamed',
body: 'third',
message: 'stale edit',
base_revision_id: stale_id,
},
headers: auth_headers(user)

expect(response).to have_http_status(:conflict)

page.reload
expect(page.title).to eq(current_title)
expect(page.body).to eq(current_body)
expect(page.wiki_revisions.count).to eq(revision_count)
expect(page.wiki_versions.count).to eq(version_count)
end
end
end

+ 37
- 0
backend/spec/requests/wiki_restore_pending_spec.rb View File

@@ -0,0 +1,37 @@
require 'rails_helper'

RSpec.describe 'Wiki restore', type: :request do
let!(:user) { create_member_user! }

def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end

it 'restores wiki page to previous version' do
pending '#337 で対応予定'

page =
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'wiki_restore_page'),
body: 'v1',
created_by_user: user,
message: 'init')

v1 = page.wiki_versions.order(:version_no).last

Wiki::Commit.content!(
page:,
body: 'v2',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)

post "/wiki/#{ page.id }/restore",
params: { version_no: v1.version_no },
headers: auth_headers(user)

expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('v1')
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
end
end

+ 230
- 75
backend/spec/requests/wiki_spec.rb View File

@@ -4,13 +4,19 @@ require 'securerandom'




RSpec.describe 'Wiki API', type: :request do RSpec.describe 'Wiki API', type: :request do
def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end

let!(:user) { create_member_user! } let!(:user) { create_member_user! }


let!(:tn) { TagName.create!(name: 'spec_wiki_title') } let!(:tn) { TagName.create!(name: 'spec_wiki_title') }
let!(:page) do let!(:page) do
WikiPage.create!(tag_name: tn, created_user: user, updated_user: user).tap do |p|
Wiki::Commit.content!(page: p, body: 'init', created_user: user, message: 'init')
end
Wiki::Commit.create_content!(
tag_name: tn,
body: 'init',
created_by_user: user,
message: 'init')
end end


describe 'GET /wiki' do describe 'GET /wiki' do
@@ -37,11 +43,12 @@ RSpec.describe 'Wiki API', type: :request do
context 'when wiki page exists' do context 'when wiki page exists' do
it 'returns wiki page with title' do it 'returns wiki page with title' do
request request

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


expect(json).to include( expect(json).to include(
'id' => page.id,
'title' => 'spec_wiki_title')
'id' => page.id,
'title' => 'spec_wiki_title')
end end
end end


@@ -50,6 +57,7 @@ RSpec.describe 'Wiki API', type: :request do


it 'returns 404' do it 'returns 404' do
request request

expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
@@ -97,8 +105,9 @@ RSpec.describe 'Wiki API', type: :request do
post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' }, post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' },
headers: auth_headers(member) headers: auth_headers(member)
end end
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)


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


@@ -106,16 +115,78 @@ RSpec.describe 'Wiki API', type: :request do
expect(json.fetch('title')).to eq('TestPage') expect(json.fetch('title')).to eq('TestPage')
expect(json.fetch('body')).to eq("a\nb\nc") expect(json.fetch('body')).to eq("a\nb\nc")


page = WikiPage.find(page_id)
rev = page.current_revision
created_page = WikiPage.find(page_id)
version = created_page.wiki_versions.order(:version_no).last

expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'TestPage',
body: "a\nb\nc",
created_by_user_id: member.id
)

rev = created_page.current_revision
expect(rev).to be_present expect(rev).to be_present
expect(rev).to be_content expect(rev).to be_content
expect(rev.message).to eq('init') expect(rev.message).to eq('init')


expect(page.body).to eq("a\nb\nc")
expect(created_page.body).to eq("a\nb\nc")

expect(rev.lines_count).to eq(3) expect(rev.lines_count).to eq(3)
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c])
expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
end

it 'reuses existing WikiLine rows by sha256' do
# 先に同じ行を作っておく
WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current)

post endpoint,
params: { title: 'Reuse', body: "a\na" },
headers: auth_headers(member)

page = WikiPage.find(JSON.parse(response.body).fetch('id'))
rev = page.current_revision
expect(rev.lines_count).to eq(2)

# "a" の WikiLine が増殖しない(1行のはず)
expect(WikiLine.where(body: 'a').count).to eq(1)
end

it 'deduplicates duplicated new lines before upsert' do
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'

post endpoint,
params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
headers: auth_headers(member)

expect(response).to have_http_status(:created)

page = WikiPage.find(json.fetch('id'))
rev = page.current_revision

expect(rev.lines_count).to eq(2)
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(rev.wiki_revision_lines.count).to eq(2)
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end

it 'normalises CRLF and strips trailing newlines' do
post endpoint,
params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' },
headers: auth_headers(member)

expect(response).to have_http_status(:created)

page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
version = page.wiki_versions.order(:version_no).last

expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(rev.lines_count).to eq(2)
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
end end
end end
end end
@@ -128,17 +199,14 @@ RSpec.describe 'Wiki API', type: :request do
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end end


#let!(:page) { create(:wiki_page, title: 'TestPage') }
let!(:page) do
build(:wiki_page, title: 'TestPage').tap do |p|
puts p.errors.full_messages unless p.valid?
p.save!
end
end
let!(:test_tag_name) { TagName.create!(name: 'TestPage') }


before do
# 初期版を 1 つ作っておく(更新が“2版目”になるように)
Wiki::Commit.content!(page: page, body: "a\nb", created_user: member, message: 'init')
let!(:page) do
Wiki::Commit.create_content!(
tag_name: test_tag_name,
body: "a\nb",
created_by_user: member,
message: 'init')
end end


context 'when not logged in' do context 'when not logged in' do
@@ -164,14 +232,6 @@ RSpec.describe 'Wiki API', type: :request do
headers: auth_headers(member) headers: auth_headers(member)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end end

it 'returns 422 when title mismatched (if you forbid rename here)' do
put "/wiki/#{page.id}",
params: { title: 'OtherTitle', body: 'x' },
headers: auth_headers(member)
# 君の controller 例だと title 変更は 422 にしてた
expect(response).to have_http_status(:unprocessable_entity)
end
end end


context 'when success' do context 'when success' do
@@ -182,7 +242,18 @@ RSpec.describe 'Wiki API', type: :request do
put "/wiki/#{page.id}", put "/wiki/#{page.id}",
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
headers: auth_headers(member) headers: auth_headers(member)
end.to change(WikiRevision, :count).by(1)
end
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)

version = page.wiki_versions.order(:version_no).last

expect(version).to have_attributes(
event_type: 'update',
title: 'TestPage',
body: "x\ny",
created_by_user_id: member.id
)


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


@@ -193,25 +264,60 @@ RSpec.describe 'Wiki API', type: :request do
expect(page.body).to eq("x\ny") expect(page.body).to eq("x\ny")
expect(rev.base_revision_id).to eq(current_id) expect(rev.base_revision_id).to eq(current_id)
end end

it 'wiki body だけを変更しても tag version は作成しない' do
linked_tag_name = TagName.create!(name: 'wiki_body_only_tag')
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)

TagVersionRecorder.record!(
tag: linked_tag,
event_type: :create,
created_by_user: member)

linked_page =
Wiki::Commit.create_content!(
tag_name: linked_tag_name,
body: 'before',
created_by_user: member,
message: 'init')

current_id = linked_page.current_revision.id
before_count = linked_tag.reload.tag_versions.count

expect {
put "/wiki/#{ linked_page.id }",
params: {
title: 'wiki_body_only_tag',
body: 'after',
message: 'edit',
base_revision_id: current_id,
},
headers: auth_headers(member)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)

expect(response).to have_http_status(:ok)
expect(linked_tag.reload.tag_versions.count).to eq(before_count)
end
end end


# TODO: コンフリクト未実装のため,実装したらコメント外す.
# context 'when conflict' do
# it 'returns 409 when base_revision_id mismatches' do
# # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
# Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit')
# page.reload
context 'when conflict' do
it 'returns 409 when base_revision_id mismatches' do
# 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit')
page.reload


# stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id
# put "/wiki/#{page.id}",
# params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
# headers: auth_headers(member)
stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id
put "/wiki/#{page.id}",
params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
headers: auth_headers(member)


# expect(response).to have_http_status(:conflict)
# json = JSON.parse(response.body)
# expect(json['error']).to eq('conflict')
# end
# end
expect(response).to have_http_status(:conflict)
json = JSON.parse(response.body)
expect(json['error']).to eq('conflict')
end
end


context 'when page not found' do context 'when page not found' do
it 'returns 404' do it 'returns 404' do
@@ -243,14 +349,17 @@ RSpec.describe 'Wiki API', type: :request do


describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
# 追加で検索ヒット用
TagName.create!(name: 'spec_wiki_title_2')
WikiPage.create!(tag_name: TagName.find_by!(name: 'spec_wiki_title_2'),
created_user: user, updated_user: user)
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'spec_wiki_title_2'),
body: 'search body 2',
created_by_user: user,
message: 'init')


TagName.create!(name: 'unrelated_title')
WikiPage.create!(tag_name: TagName.find_by!(name: 'unrelated_title'),
created_user: user, updated_user: user)
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: 'unrelated_title'),
body: 'unrelated body',
created_by_user: user,
message: 'init')
end end


it 'returns up to 20 pages filtered by title like' do it 'returns up to 20 pages filtered by title like' do
@@ -260,7 +369,9 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to be_an(Array) expect(json).to be_an(Array)


titles = json.map { |p| p['title'] } titles = json.map { |p| p['title'] }
expect(titles).to include('spec_wiki_title', 'spec_wiki_title_2')

expect(titles).to include('spec_wiki_title')
expect(titles).to include('spec_wiki_title_2')
expect(titles).not_to include('unrelated_title') expect(titles).not_to include('unrelated_title')
end end


@@ -311,7 +422,12 @@ RSpec.describe 'Wiki API', type: :request do
it 'returns empty array when page has no revisions and filtered by id' do it 'returns empty array when page has no revisions and filtered by id' do
# 別ページを作って revision 無し # 別ページを作って revision 無し
tn2 = TagName.create!(name: 'spec_no_rev') tn2 = TagName.create!(name: 'spec_no_rev')
p2 = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)
# 異常データ: revision 無し WikiPage を直接作る
p2 = WikiPage.create!(
tag_name: tn2,
body: 'init',
created_user: user,
updated_user: user)


get "/wiki/changes?id=#{p2.id}" get "/wiki/changes?id=#{p2.id}"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -380,29 +496,68 @@ RSpec.describe 'Wiki API', type: :request do
expect(json['older_revision_id']).to eq(rev_a.id) expect(json['older_revision_id']).to eq(rev_a.id)
expect(json['newer_revision_id']).to eq(page.current_revision.id) expect(json['newer_revision_id']).to eq(page.current_revision.id)
end end
end


it 'returns 422 when "to" is redirect revision' do
# redirect revision を作る
tn2 = TagName.create!(name: 'redirect_target')
target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)

Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir')
redirect_rev = page.current_revision
expect(redirect_rev).to be_redirect

get "/wiki/#{page.id}/diff?from=#{rev_a.id}&to=#{redirect_rev.id}"
expect(response).to have_http_status(:unprocessable_entity)
describe 'Wiki::Commit.redirect!' do
it 'raises because redirect revisions are deprecated' do
target_tag_name = TagName.create!(name: 'redirect_deprecated_target')
target =
Wiki::Commit.create_content!(
tag_name: target_tag_name,
body: 'target',
created_by_user: user,
message: 'init')

expect {
Wiki::Commit.redirect!(
page: page,
redirect_page: target,
created_user: user,
message: 'redirect',
base_revision_id: page.current_revision.id
)
}.to raise_error(RuntimeError, '廃止しました.')
end end
end


it 'returns 422 when "from" is redirect revision' do
tn2 = TagName.create!(name: 'redirect_target2')
target = WikiPage.create!(tag_name: tn2, created_user: user, updated_user: user)

Wiki::Commit.redirect!(page: page, redirect_page: target, created_user: user, message: 'redir2')
redirect_rev = page.current_revision

get "/wiki/#{page.id}/diff?from=#{redirect_rev.id}&to=#{rev_b.id}"
expect(response).to have_http_status(:unprocessable_entity)
end
it 'wiki title を変更すると対応する tag の version を作成する' do
linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version')
linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)

linked_page =
Wiki::Commit.create_content!(
tag_name: linked_tag_name,
body: 'before',
created_by_user: user,
message: 'init')

current_id = linked_page.current_revision.id

expect {
put "/wiki/#{ linked_page.id }",
params: {
title: 'wiki_linked_tag_for_version_renamed',
body: 'after',
message: 'rename',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change { linked_tag.reload.tag_versions.count }.by(2)

expect(response).to have_http_status(:ok)

linked_tag.reload
expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed')

versions = linked_tag.tag_versions.order(:version_no)

expect(versions.first.event_type).to eq('create')
expect(versions.first.name).to eq('wiki_linked_tag_for_version')

expect(versions.second.event_type).to eq('update')
expect(versions.second.name).to eq('wiki_linked_tag_for_version_renamed')
end end
end end

+ 62
- 0
backend/spec/requests/wiki_title_collision_spec.rb View File

@@ -0,0 +1,62 @@
require 'rails_helper'

RSpec.describe 'Wiki title collision', type: :request do
let!(:user) { create_member_user! }

def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end

def create_wiki_page title:, body:
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end

it 'returns 422 when renaming wiki title to existing title' do
source = create_wiki_page(title: 'wiki_collision_source', body: 'source body')
create_wiki_page(title: 'wiki_collision_target', body: 'target body')

source_revision_count = source.wiki_revisions.count
source_version_count = source.wiki_versions.count
old_title = source.title
old_body = source.body

put "/wiki/#{ source.id }",
params: {
title: 'wiki_collision_target',
body: 'new body',
message: 'rename collision',
base_revision_id: source.current_revision.id,
},
headers: auth_headers(user)

expect(response).to have_http_status(:unprocessable_entity)

source.reload

expect(source.title).to eq(old_title)
expect(source.body).to eq(old_body)
expect(source.wiki_revisions.count).to eq(source_revision_count)
expect(source.wiki_versions.count).to eq(source_version_count)
end

it 'returns 422 when creating wiki with existing title' do
create_wiki_page(title: 'wiki_collision_create', body: 'already exists')

expect {
post '/wiki',
params: {
title: 'wiki_collision_create',
body: 'new body',
message: 'duplicate create',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)

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

+ 173
- 0
backend/spec/services/wiki/commit_integrity_spec.rb View File

@@ -0,0 +1,173 @@
require 'digest'
require 'rails_helper'

RSpec.describe Wiki::Commit do
let(:user) { create_member_user! }

def create_page title:, body: 'initial body'
described_class.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end

describe '.create_content!' do
it 'creates page, revision, and version with normalised body' do
expect {
described_class.create_content!(
tag_name: TagName.create!(name: 'commit_integrity_create'),
body: "a\r\nb\r\n\r\n",
created_by_user: user,
message: 'init')
}
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)

page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last

expect(page.body).to eq("a\nb")
expect(revision.lines_count).to eq(2)
expect(version.body).to eq("a\nb")
expect(version.reason).to eq('init')
end

it 'rejects body that becomes blank after normalisation' do
tag_name = TagName.create!(name: 'commit_integrity_blank')

expect {
described_class.create_content!(
tag_name:,
body: "\r\n\r\n",
created_by_user: user,
message: 'blank')
}
.to raise_error(ActiveRecord::RecordInvalid)

expect(WikiPage.where(tag_name:)).not_to exist
end
end

describe '.content!' do
it 'updates page body, revision, and version' do
page = create_page(title: 'commit_integrity_update', body: 'before')
current_id = page.current_revision.id

expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_id)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)

page.reload
version = page.wiki_versions.order(:version_no).last

expect(page.body).to eq('after')
expect(version.body).to eq('after')
expect(version.reason).to eq('edit')
end

it 'does not record tag_version on body-only wiki update' do
tag_name = TagName.create!(name: 'commit_integrity_linked_tag')
tag = Tag.create!(tag_name:, category: :general)

page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')

TagVersionRecorder.record!(
tag:,
event_type: :create,
created_by_user: user)

before_count = tag.reload.tag_versions.count

described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)

expect(tag.reload.tag_versions.count).to eq(before_count)
end

it 'raises conflict and leaves page, revision, and version unchanged' do
page = create_page(title: 'commit_integrity_conflict', body: 'first')
stale_id = page.current_revision.id

described_class.content!(
page:,
body: 'second',
created_user: user,
message: 'second',
base_revision_id: stale_id)

page.reload
before_body = page.body
before_revision_count = page.wiki_revisions.count
before_version_count = page.wiki_versions.count

expect {
described_class.content!(
page:,
body: 'third',
created_user: user,
message: 'stale',
base_revision_id: stale_id)
}
.to raise_error(Wiki::Commit::Conflict)

page.reload
expect(page.body).to eq(before_body)
expect(page.wiki_revisions.count).to eq(before_revision_count)
expect(page.wiki_versions.count).to eq(before_version_count)
end

it 'deduplicates duplicated missing wiki lines' do
page = create_page(title: 'commit_integrity_dedup', body: 'before')
duplicated = 'commit_integrity_duplicate_line'

described_class.content!(
page:,
body: "#{ duplicated }\n#{ duplicated }",
created_user: user,
message: 'dedup',
base_revision_id: page.current_revision.id)

revision = page.reload.current_revision

expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(revision.wiki_revision_lines.count).to eq(2)
expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end
end

describe '.redirect!' do
it 'raises because redirect revisions are deprecated' do
page = create_page(title: 'commit_integrity_redirect_source', body: 'source')
target = create_page(title: 'commit_integrity_redirect_target', body: 'target')

expect {
described_class.redirect!(
page:,
redirect_page: target,
created_user: user,
message: 'redirect',
base_revision_id: page.current_revision.id)
}
.to raise_error(RuntimeError, '廃止しました.')
end
end
end

+ 150
- 0
backend/spec/services/wiki/commit_spec.rb View File

@@ -0,0 +1,150 @@
require 'rails_helper'

RSpec.describe Wiki::Commit do
let(:user) { create_member_user! }

def create_page(title: 'commit_spec_page', body: 'initial body')
tag_name = TagName.create!(name: title)

Wiki::Commit.create_content!(
tag_name:,
body:,
created_by_user: user,
message: 'init')
end

describe '.content!' do
it 'stores normalised body in wiki_pages and wiki_versions' do
page = create_page(title: 'commit_normalised_page')

described_class.content!(
page:,
body: "a\r\nb\r\n\r\n",
created_user: user,
message: 'init'
)

page.reload
version = page.wiki_versions.order(:version_no).last

expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(page.current_revision.lines_count).to eq(2)
end

it 'deduplicates duplicated missing wiki lines before upsert' do
page = create_page(title: 'commit_duplicate_line_page')
duplicated = 'commit_duplicate_line'

described_class.content!(
page:,
body: "#{ duplicated }\n#{ duplicated }",
created_user: user,
message: 'init'
)

page.reload

expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(page.current_revision.lines_count).to eq(2)
expect(page.current_revision.wiki_revision_lines.count).to eq(2)
end

it 'raises conflict when base_revision_id is stale' do
page = create_page(title: 'commit_conflict_page')

first = described_class.content!(
page:,
body: 'first',
created_user: user,
message: 'first'
)

described_class.content!(
page:,
body: 'second',
created_user: user,
message: 'second',
base_revision_id: first.id
)

expect {
described_class.content!(
page:,
body: 'third',
created_user: user,
message: 'third',
base_revision_id: first.id
)
}.to raise_error(Wiki::Commit::Conflict)
end

it 'does not record tag version when corresponding tag has no versions' do
tag_name = TagName.create!(name: 'commit_linked_tag_without_versions')
tag = Tag.create!(tag_name:, category: :general)

page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')

expect(tag.reload.tag_versions.count).to eq(0)

current_revision_id = page.current_revision.id

expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_revision_id)
}.to change(WikiVersion, :count).by(1)

expect(tag.reload.tag_versions.count).to eq(0)
end

it 'does not record tag version when corresponding tag has no versions' do
tag_name = TagName.create!(name: 'commit_linked_tag_without_versions')
tag = Tag.create!(tag_name:, category: :general)

page =
described_class.create_content!(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')

current_revision_id = page.current_revision.id

expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_revision_id)
}.to change(WikiVersion, :count).by(1)

expect(tag.reload.tag_versions.count).to eq(0)
end
end

describe '.redirect!' do
it 'raises because redirect revisions are deprecated' do
page = create_page(title: 'commit_redirect_source')
target = create_page(title: 'commit_redirect_target')

expect {
described_class.redirect!(
page:,
redirect_page: target,
created_user: user,
message: 'redirect'
)
}.to raise_error(RuntimeError, '廃止しました.')
end
end
end

+ 99
- 0
backend/spec/services/wiki_version_recorder_spec.rb View File

@@ -0,0 +1,99 @@
require 'rails_helper'

RSpec.describe WikiVersionRecorder do
let(:user) { create_member_user! }

def create_page title:, body: 'body'
Wiki::Commit.create_content!(
tag_name: TagName.create!(name: title),
body:,
created_by_user: user,
message: 'init')
end

describe '.record!' do
it 'records title, body, reason, user, and version number' do
page = create_page(title: 'wiki_version_recorder_basic', body: 'body')

expect {
described_class.record!(
page:,
event_type: :update,
reason: 'manual reason',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)

version = page.wiki_versions.order(:version_no).last

expect(version).to have_attributes(
version_no: 2,
event_type: 'update',
title: 'wiki_version_recorder_basic',
body: 'body',
reason: 'manual reason',
created_by_user_id: user.id
)
end

it 'does not create duplicated update version for identical snapshot' do
page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body')

described_class.record!(
page:,
event_type: :update,
reason: nil,
created_by_user: user)

before_count = page.reload.wiki_versions.count

described_class.record!(
page:,
event_type: :update,
reason: nil,
created_by_user: user)

expect(page.reload.wiki_versions.count).to eq(before_count)
end

it 'creates update version when title changes' do
page = create_page(title: 'wiki_version_recorder_title_before', body: 'body')
page.tag_name.update!(name: 'wiki_version_recorder_title_after')

expect {
described_class.record!(
page:,
event_type: :update,
reason: 'rename',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)

version = page.wiki_versions.order(:version_no).last

expect(version.title).to eq('wiki_version_recorder_title_after')
expect(version.body).to eq('body')
expect(version.reason).to eq('rename')
end

it 'creates update version when body changes' do
page = create_page(title: 'wiki_version_recorder_body', body: 'before')
page.update!(body: 'after')

expect {
described_class.record!(
page:,
event_type: :update,
reason: 'body',
created_by_user: user)
}
.to change { page.reload.wiki_versions.count }.by(1)

version = page.wiki_versions.order(:version_no).last

expect(version.title).to eq('wiki_version_recorder_body')
expect(version.body).to eq('after')
expect(version.reason).to eq('body')
end
end
end

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

@@ -0,0 +1,130 @@
require 'rails_helper'

RSpec.describe Youtube::ApiClient do
let(:api_key) { 'test-api-key' }
let(:client) { described_class.new(api_key:) }

describe '#search_videos' do
it 'calls YouTube search API with expected params' do
published_after = Time.zone.parse('2026-05-01 00:00:00')
published_before = Time.zone.parse('2026-05-02 00:00:00')

expect(client).to receive(:get_json).with(
'/search',
{
part: 'snippet',
type: 'video',
q: 'ぼざろクリーチャー',
order: 'date',
maxResults: 50,
regionCode: 'JP',
relevanceLanguage: 'ja',
publishedAfter: published_after.iso8601,
publishedBefore: published_before.iso8601,
pageToken: 'NEXT'
}
).and_return({ 'items' => [] })

client.search_videos(
q: 'ぼざろクリーチャー',
published_after:,
published_before:,
page_token: 'NEXT'
)
end

it 'omits nil optional params' do
expect(client).to receive(:get_json).with(
'/search',
hash_excluding(:publishedAfter, :publishedBefore, :pageToken)
).and_return({ 'items' => [] })

client.search_videos(q: 'ぼざろクリーチャー')
end
end

describe '#videos' do
it 'returns empty items when ids are empty' do
expect(client).not_to receive(:get_json)

expect(client.videos([])).to eq({ 'items' => [] })
end

it 'calls videos API with comma separated ids' do
expect(client).to receive(:get_json).with(
'/videos',
{
part: 'snippet,status,contentDetails',
id: 'video-1,video-2'
}
).and_return({ 'items' => [] })

client.videos(['video-1', 'video-2'])
end
end

describe '#playlist_items' do
it 'calls playlistItems API with page token' do
expect(client).to receive(:get_json).with(
'/playlistItems',
{
part: 'snippet,contentDetails,status',
playlistId: 'PL123',
maxResults: 50,
pageToken: 'NEXT'
}
).and_return({ 'items' => [] })

client.playlist_items(playlist_id: 'PL123', page_token: 'NEXT')
end

it 'omits page token when nil' do
expect(client).to receive(:get_json).with(
'/playlistItems',
{
part: 'snippet,contentDetails,status',
playlistId: 'PL123',
maxResults: 50
}
).and_return({ 'items' => [] })

client.playlist_items(playlist_id: 'PL123')
end
end

describe '#channel' do
it 'calls channels API by id' do
expect(client).to receive(:get_json).with(
'/channels',
{
part: 'snippet,contentDetails',
id: 'UC123'
}
).and_return({ 'items' => [] })

client.channel(id: 'UC123')
end

it 'calls channels API by handle' do
expect(client).to receive(:get_json).with(
'/channels',
{
part: 'snippet,contentDetails',
forHandle: '@some_handle'
}
).and_return({ 'items' => [] })

client.channel(handle: '@some_handle')
end

it 'raises when neither id nor handle is given' do
expect { client.channel }.to raise_error(ArgumentError, 'id or handle is required')
end

it 'raises when both id and handle are given' do
expect do
client.channel(id: 'UC123', handle: '@some_handle')
end.to raise_error(ArgumentError, 'id or handle is required')
end
end
end

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

@@ -0,0 +1,310 @@
require 'rails_helper'

RSpec.describe Youtube::Sync do
let(:client) { instance_double(Youtube::ApiClient) }
let(:sync) { described_class.new(client:) }

before do
allow(PostVersionRecorder).to receive(:record!)
allow(PostVersionRecorder).to receive(:ensure_snapshot!)
allow(sync).to receive(:attach_thumbnail_if_needed!)
end

describe '#sync!' do
it 'returns without fetching video details when no video ids are discovered' do
allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return([])

expect(client).not_to receive(:videos)

sync.sync!
end

it 'discovers ids from search and all playlist pages' do
allow(sync).to receive(:query_terms).and_return(['ぼざろクリーチャー'])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])
allow(sync).to receive(:sync_since).and_return(Time.zone.parse('2026-05-01 00:00:00'))

allow(client).to receive(:search_videos).with(
q: 'ぼざろクリーチャー',
published_after: Time.zone.parse('2026-05-01 00:00:00')
).and_return({
'items' => [
{
'id' => {
'videoId' => 'search-video-1'
}
}
]
})

allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'playlist-video-1'
}
}
],
'nextPageToken' => 'NEXT'
})

allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: 'NEXT'
).and_return({
'items' => [
{
'snippet' => {
'resourceId' => {
'videoId' => 'playlist-video-2'
}
}
}
]
})

expect(client).to receive(:videos).with(
satisfy do |ids|
ids.sort == ['playlist-video-1', 'playlist-video-2', 'search-video-1']
end
).and_return({ 'items' => [] })

sync.sync!
end

it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do
Tag.tagme
Tag.bot
Tag.youtube
Tag.video
Tag.no_deerjikist

allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])

allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})

allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: 'YouTube テスト動画',
channel_id: 'UC_NO_MAPPING'
)
]
})

expect do
sync.sync!
end.to change(Post, :count).by(1)

post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1')
tag_ids = post.tags.pluck(:id)

expect(post.title).to eq('YouTube テスト動画')
expect(post.uploaded_user_id).to be_nil
expect(post.original_created_from).to eq(Time.zone.parse('2026-05-01 12:34:00'))
expect(post.original_created_before).to eq(Time.zone.parse('2026-05-01 12:35:00'))

expect(tag_ids).to include(Tag.tagme.id)
expect(tag_ids).to include(Tag.bot.id)
expect(tag_ids).to include(Tag.youtube.id)
expect(tag_ids).to include(Tag.video.id)
expect(tag_ids).to include(Tag.no_deerjikist.id)

expect(PostVersionRecorder).to have_received(:record!).with(
post:,
event_type: :create,
created_by_user: nil
)
end

it 'uses deerjikist tag when channel id is mapped' do
Tag.tagme
Tag.bot
Tag.youtube
Tag.video
Tag.no_deerjikist

deerjikist_tag = Tag.find_or_create_by_tag_name!('テスト投稿者', category: :deerjikist)
Deerjikist.create!(
platform: 'youtube',
code: 'UC_MAPPED',
tag: deerjikist_tag
)

allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])

allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})

allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: 'YouTube テスト動画',
channel_id: 'UC_MAPPED'
)
]
})

sync.sync!

post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1')
tag_ids = post.tags.pluck(:id)

expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)
end

it 'removes no_deerjikist when deerjikist mapping is added later' do
Tag.no_deerjikist

post = Post.create!(
title: '旧タイトル',
url: 'https://www.youtube.com/watch?v=video-1',
uploaded_user_id: nil,
original_created_from: Time.zone.parse('2026-05-01 00:00:00'),
original_created_before: Time.zone.parse('2026-05-01 00:01:00')
)
PostTag.create!(post:, tag: Tag.no_deerjikist)

deerjikist_tag = Tag.find_or_create_by_tag_name!('後から判明した投稿者', category: :deerjikist)
Deerjikist.create!(
platform: 'youtube',
code: 'UC_MAPPED_LATER',
tag: deerjikist_tag
)

allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])

allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})

allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: '新タイトル',
channel_id: 'UC_MAPPED_LATER'
)
]
})

sync.sync!

post.reload
tag_ids = post.tags.pluck(:id)

expect(post.title).to eq('新タイトル')
expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)

expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with(
post,
created_by_user: nil
)
expect(PostVersionRecorder).to have_received(:record!).with(
post:,
event_type: :update,
created_by_user: nil
)
end

it 'matches existing youtu.be URL and does not create duplicate post' do
post = Post.create!(
title: '旧タイトル',
url: 'https://youtu.be/video-1',
uploaded_user_id: nil,
original_created_from: Time.zone.parse('2026-05-01 00:00:00'),
original_created_before: Time.zone.parse('2026-05-01 00:01:00')
)

allow(sync).to receive(:query_terms).and_return([])
allow(sync).to receive(:playlist_ids).and_return(['PL123'])

allow(client).to receive(:playlist_items).with(
playlist_id: 'PL123',
page_token: nil
).and_return({
'items' => [
{
'contentDetails' => {
'videoId' => 'video-1'
}
}
]
})

allow(client).to receive(:videos).with(['video-1']).and_return({
'items' => [
youtube_video_item(
id: 'video-1',
title: '新タイトル',
channel_id: 'UC_NO_MAPPING'
)
]
})

expect do
sync.sync!
end.not_to change(Post, :count)

expect(post.reload.title).to eq('新タイトル')
end
end

def youtube_video_item(id:, title:, channel_id:)
{
'id' => id,
'snippet' => {
'title' => title,
'channelId' => channel_id,
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {
'high' => {
'url' => "https://img.youtube.com/#{id}.jpg"
}
},
'tags' => ['tag-a', 'tag-b']
}
}
end
end

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

@@ -0,0 +1,93 @@
require 'rails_helper'

RSpec.describe Youtube::VideoItem do
describe '#initialize' do
it 'extracts fields from YouTube video API item' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'tags' => ['tag-a', 'tag-b'],
'thumbnails' => {
'high' => {
'url' => 'https://img.youtube.com/high.jpg'
},
'medium' => {
'url' => 'https://img.youtube.com/medium.jpg'
}
}
}
}

video = described_class.new(item)

expect(video.id).to eq('video-1')
expect(video.title).to eq('テスト動画')
expect(video.channel_id).to eq('UC123')
expect(video.published_at).to eq(Time.iso8601('2026-05-01T12:34:56Z'))
expect(video.thumbnail_url).to eq('https://img.youtube.com/high.jpg')
expect(video.raw_tags).to eq(['tag-a', 'tag-b'])
expect(video.url).to eq('https://www.youtube.com/watch?v=video-1')
end

it 'uses highest priority thumbnail' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {
'default' => {
'url' => 'https://img.youtube.com/default.jpg'
},
'standard' => {
'url' => 'https://img.youtube.com/standard.jpg'
},
'maxres' => {
'url' => 'https://img.youtube.com/maxres.jpg'
}
}
}
}

video = described_class.new(item)

expect(video.thumbnail_url).to eq('https://img.youtube.com/maxres.jpg')
end

it 'falls back to empty raw tags' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {}
}
}

video = described_class.new(item)

expect(video.raw_tags).to eq([])
end

it 'returns nil thumbnail when no thumbnail exists' do
item = {
'id' => 'video-1',
'snippet' => {
'title' => 'テスト動画',
'channelId' => 'UC123',
'publishedAt' => '2026-05-01T12:34:56Z',
'thumbnails' => {}
}
}

video = described_class.new(item)

expect(video.thumbnail_url).to be_nil
end
end
end

+ 2
- 2
backend/spec/support/test_records.rb View File

@@ -3,13 +3,13 @@ module TestRecords
User.create!(name: 'spec user', User.create!(name: 'spec user',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'member', role: 'member',
banned: false)
banned_at: nil)
end end


def create_admin_user! def create_admin_user!
User.create!(name: 'spec admin', User.create!(name: 'spec admin',
inheritance_code: SecureRandom.hex(16), inheritance_code: SecureRandom.hex(16),
role: 'admin', role: 'admin',
banned: false)
banned_at: nil)
end end
end end

+ 100
- 0
backend/spec/tasks/nico_export_spec.rb View File

@@ -0,0 +1,100 @@
require 'rails_helper'
require 'rake'
require 'open3'

RSpec.describe 'nico:export' do
let(:task) { Rake::Task['nico:export'] }
let(:success_status) { instance_double(Process::Status, success?: true) }
let(:failure_status) { instance_double(Process::Status, success?: false) }

def create_post(url)
Post.create!(url:)
end

before(:all) do
Rails.application.load_tasks unless Rake::Task.task_defined?('nico:export')
end

before do
task.reenable

allow(ENV).to receive(:fetch).with('MYSQL_USER').and_return('mysql-user')
allow(ENV).to receive(:fetch).with('MYSQL_PASS').and_return('mysql-pass')
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico')
end

describe 'export' do
it 'exports nicovideo ids to shared nico DB' do
create_post('https://www.nicovideo.jp/watch/sm12345?ref=foo')
create_post('https://www.nicovideo.jp/watch/so67890#comments')
create_post('https://www.nicovideo.jp/watch/nm24680')
create_post('https://example.com/watch/sm99999')

expect(Open3).to receive(:capture3) do |env, *args, **kwargs|
expect(env).to eq(
{
'MYSQL_USER' => 'mysql-user',
'MYSQL_PASS' => 'mysql-pass',
},
)

expect(args.take(3)).to eq(
[
'python3',
'-m',
'tracked_videos.put_bulk_upsert',
],
)

expect(args.drop(3)).to contain_exactly(
'sm12345',
'so67890',
'nm24680',
)

expect(kwargs).to eq(chdir: '/srv/nizika-nico')

['', '', success_status]
end

task.invoke
end

it 'deduplicates video ids' do
create_post('https://www.nicovideo.jp/watch/sm12345')
create_post('https://www.nicovideo.jp/watch/sm12345?from=1')

expect(Open3).to receive(:capture3) do |_env, *args, **_kwargs|
expect(args.drop(3)).to eq(['sm12345'])

['', '', success_status]
end

task.invoke
end

it 'does not call python when there are no nicovideo posts' do
create_post('https://example.com/watch/sm12345')

expect(Open3).not_to receive(:capture3)

task.invoke
end

it 'raises stderr when python command fails' do
create_post('https://www.nicovideo.jp/watch/sm12345')

allow(Open3).to receive(:capture3).and_return(
[
'',
'bulk upsert failed',
failure_status,
],
)

expect {
task.invoke
}.to raise_error(RuntimeError, 'bulk upsert failed')
end
end
end

+ 227
- 0
backend/spec/tasks/nico_sync_spec.rb View File

@@ -90,4 +90,231 @@ RSpec.describe "nico:sync" do
expect(active_names).to include("nico:NEW") expect(active_names).to include("nico:NEW")
expect(active_names).not_to include("nico:OLD") expect(active_names).not_to include("nico:OLD")
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),
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 end

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

@@ -0,0 +1,25 @@
require 'rails_helper'
require 'rake'

RSpec.describe 'post:sync' do
around do |example|
original_application = Rake.application
Rake.application = Rake::Application.new

Rake::Task.define_task(:environment)
load Rails.root.join('lib/tasks/sync_posts.rake')

example.run
ensure
Rake.application = original_application
end

it 'runs Youtube::Sync' do
sync = instance_double(Youtube::Sync)

expect(Youtube::Sync).to receive(:new).once.and_return(sync)
expect(sync).to receive(:sync!).once

Rake::Task['post:sync'].invoke
end
end

+ 936
- 192
frontend/package-lock.json
File diff suppressed because it is too large
View File


+ 6
- 2
frontend/package.json View File

@@ -15,6 +15,8 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9", "@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-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-toast": "^1.2.14",
@@ -37,13 +39,15 @@
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0", "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": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.19",
"@types/axios": "^0.14.4", "@types/axios": "^0.14.4",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/mdx": "^2.0.13",
"@types/node": "^24.0.13", "@types/node": "^24.0.13",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",


+ 51
- 29
frontend/src/App.tsx 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 { useEffect, useState } from 'react'
import { BrowserRouter, import { BrowserRouter,
Navigate, Navigate,
@@ -10,8 +10,15 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
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 NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostListPage from '@/pages/posts/PostListPage' import PostListPage from '@/pages/posts/PostListPage'
@@ -19,6 +26,8 @@ import PostNewPage from '@/pages/posts/PostNewPage'
import PostSearchPage from '@/pages/posts/PostSearchPage' import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable' import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage' import SettingPage from '@/pages/users/SettingPage'
import TagDetailPage from '@/pages/tags/TagDetailPage'
import TagHistoryPage from '@/pages/tags/TagHistoryPage'
import TagListPage from '@/pages/tags/TagListPage' import TagListPage from '@/pages/tags/TagListPage'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
@@ -39,30 +48,38 @@ const RouteTransitionWrapper = ({ user, setUser }: {
const location = useLocation () const location = useLocation ()


return ( return (
<LayoutGroup id="gallery-shared">
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<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="*" element={<NotFound/>}/>
</Routes>
</AnimatePresence>
</LayoutGroup>)
<AnimatePresence mode="wait">
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<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="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<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>)
} }




@@ -120,10 +137,15 @@ export default (() => {
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
<BrowserRouter> <BrowserRouter>
<div className="flex flex-col h-screen w-screen">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</div>
<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}/>
</motion.div>
</LayoutGroup>
<Toaster/> <Toaster/>
</BrowserRouter> </BrowserRouter>
</>) </>)


+ 3
- 1
frontend/src/components/DraggableDroppableTagRow.tsx View File

@@ -90,7 +90,9 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }:
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
{...attributes} {...attributes}
{...listeners}> {...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}/> <TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div> </motion.div>
</div>) </div>)


+ 97
- 0
frontend/src/components/MaterialSidebar.tsx View File

@@ -0,0 +1,97 @@
import { Fragment, useEffect, useState } from 'react'

import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api'

import type { FC, ReactNode } from 'react'

import type { Tag } from '@/types'

type TagWithDepth = Tag & {
hasChildren: boolean
children: TagWithDepth[] }


const setChildrenById = (
tags: TagWithDepth[],
targetId: number,
children: TagWithDepth[],
): TagWithDepth[] => (
tags.map (tag => {
if (tag.id === targetId)
return { ...tag, children }

if (tag.children.length === 0)
return tag

return { ...tag,
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
}))


export default (() => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })

useEffect (() => {
void (async () => {
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
.filter (t => t.category !== 'meme' || t.hasChildren))
}) ()
}, [])

const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
ts.map (t => (
<Fragment key={t.id}>
<li>
<div className="flex">
<div className="flex-none w-4">
{t.hasChildren && (
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(tagFetchedFlags[t.id]))
{
try
{
const data =
await apiGet<TagWithDepth[]> (
'/tags/with-depth', { params: { parent: String (t.id) } })
setTags (prev => setChildrenById (prev, t.id, data))
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
}
catch
{
;
}
}
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
}}>
{openTags[t.id] ? <>&minus;</> : '+'}
</a>)}
</div>
<div className="flex-1 truncate">
<TagLink
tag={t}
nestLevel={nestLevel}
title={t.name}
withCount={false}
withWiki={false}
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
</div>
</div>
</li>
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
</Fragment>)))

return (
<SidebarComponent>
<ul>
{renderTags (tags)}
</ul>
</SidebarComponent>)
}) satisfies FC

+ 1
- 1
frontend/src/components/PostEditForm.tsx View File

@@ -62,7 +62,7 @@ export default (({ post, onSave }: Props) => {
<Label>タイトル</Label> <Label>タイトル</Label>
<input type="text" <input type="text"
className="w-full border rounded p-2" className="w-full border rounded p-2"
value={title}
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/> onChange={ev => setTitle (ev.target.value)}/>
</div> </div>




+ 1
- 1
frontend/src/components/PostList.tsx View File

@@ -56,7 +56,7 @@ export default (({ posts, onClick }: Props) => {
cardRef.current.style.zIndex = '' cardRef.current.style.zIndex = ''
cardRef.current.style.position = '' 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} <img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url} alt={post.title || post.url}
title={post.title || post.url || undefined} title={post.title || post.url || undefined}


+ 6
- 2
frontend/src/components/TagDetailSidebar.tsx View File

@@ -313,7 +313,9 @@ export default (({ post, sp }: Props) => {
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
<div className="my-3" key={cat}> <div className="my-3" key={cat}>
<SubsectionTitle> <SubsectionTitle>
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}>
<motion.div
layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
{CATEGORY_NAMES[cat]} {CATEGORY_NAMES[cat]}
</motion.div> </motion.div>
</SubsectionTitle> </SubsectionTitle>
@@ -325,7 +327,9 @@ export default (({ post, sp }: Props) => {
</ul> </ul>
</div>))} </div>))}
{post && ( {post && (
<motion.div layoutId={`post-info-${ sp }`}>
<motion.div
layoutId={`post-info-${ sp }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<SectionTitle>情報</SectionTitle> <SectionTitle>情報</SectionTitle>
<ul> <ul>
<li>Id.: {post.id}</li> <li>Id.: {post.id}</li>


+ 38
- 59
frontend/src/components/TagLink.tsx View File

@@ -1,8 +1,5 @@
import { useEffect, useState } from 'react'

import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { apiGet } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'


import type { ComponentProps, FC, HTMLAttributes } from 'react' import type { ComponentProps, FC, HTMLAttributes } from 'react'
@@ -13,8 +10,7 @@ type CommonProps = {
tag: Tag tag: Tag
nestLevel?: number nestLevel?: number
withWiki?: boolean withWiki?: boolean
withCount?: boolean
prefetch?: boolean }
withCount?: boolean }


type PropsWithLink = type PropsWithLink =
& CommonProps & CommonProps
@@ -36,37 +32,7 @@ export default (({ tag,
linkFlg = true, linkFlg = true,
withWiki = true, withWiki = true,
withCount = true, withCount = true,
prefetch = false,
...props }: Props) => { ...props }: Props) => {
const [havingWiki, setHavingWiki] = useState (true)

const wikiExists = async (tag: Tag) => {
if ('hasWiki' in tag)
{
setHavingWiki (tag.hasWiki)
return
}

const tagName = (tag as Tag).name

try
{
await apiGet (`/wiki/title/${ encodeURIComponent (tagName) }/exists`)
setHavingWiki (true)
}
catch
{
setHavingWiki (false)
}
}

useEffect (() => {
if (!(linkFlg) || !(withWiki))
return

wikiExists (tag)
}, [tag.name, linkFlg, withWiki])

const spanClass = cn ( const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -79,19 +45,39 @@ export default (({ tag,
<> <>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className="mr-1"> <span className="mr-1">
{havingWiki
{(tag.materialId != null || tag.hasWiki)
? ( ? (
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
?
</PrefetchLink>)
tag.materialId == null
? (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>))
: ( : (
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>)}
['character', 'material'].includes (tag.category)
? (
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } 素材情報が存在しません.`}>
!
</PrefetchLink>)
: (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>))}
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
@@ -108,19 +94,12 @@ export default (({ tag,
</>)} </>)}
{linkFlg {linkFlg
? ( ? (
prefetch
? <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>
: <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
<PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
: ( : (
<span className={spanClass} <span className={spanClass}
{...props}> {...props}>


+ 4
- 2
frontend/src/components/TagSidebar.tsx View File

@@ -65,8 +65,10 @@ export default (({ posts, onClick }: Props) => {
{CATEGORIES.flatMap (cat => cat in tags ? ( {CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => ( tags[cat].map (tag => (
<li key={tag.id} className="mb-1"> <li key={tag.id} className="mb-1">
<motion.div layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} prefetch onClick={onClick}/>
<motion.div
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div> </motion.div>
</li>))) : [])} </li>))) : [])}
</ul> </ul>


+ 244
- 103
frontend/src/components/TopNav.tsx View File

@@ -8,17 +8,78 @@ import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser' import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys' import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki' import { fetchWikiPage } from '@/lib/wiki'


import type { FC, MouseEvent } from 'react' 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 } type Props = { user: User | null }




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 (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' },
{ name: '追加', to: '/posts/new' },
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: '履歴', to: '/tags/changes' },
{ 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: tagFlg && tag?.category !== 'nico' }] },
{ 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' },
{ name: <>ニジカ放送局第&thinsp;1&thinsp;チャンネル</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
{ name: '全体履歴', to: '/wiki/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ 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: '法規', visible: false, subMenu: [
{ name: '利用規約', to: '/tos' }] }]
}


export default (({ user }: Props) => { export default (({ user }: Props) => {
const location = useLocation () const location = useLocation ()


@@ -26,25 +87,30 @@ export default (({ user }: Props) => {
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
const navRef = useRef<HTMLDivElement | null> (null) const navRef = useRef<HTMLDivElement | null> (null)


const measure = () => {
const measure = (idx: number) => {
const nav = navRef.current const nav = navRef.current
const el = itemsRef.current[activeIdx]
if (!(nav) || !(el) || activeIdx < 0)
return
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 navRect = nav.getBoundingClientRect ()
const elRect = el.getBoundingClientRect () const elRect = el.getBoundingClientRect ()


setHl ({ left: elRect.left - navRect.left,
setHL ({ left: elRect.left - navRect.left,
width: elRect.width, width: elRect.width,
visible: true }) visible: true })
} }


const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
left: 0, left: 0,
width: 0, width: 0,
visible: false }) visible: false })
const [menuOpen, setMenuOpen] = useState (false) const [menuOpen, setMenuOpen] = useState (false)
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1) const [openItemIdx, setOpenItemIdx] = useState (-1)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())


@@ -55,51 +121,19 @@ export default (({ user }: Props) => {
queryKey: wikiKeys.show (wikiIdStr, { }), queryKey: wikiKeys.show (wikiIdStr, { }),
queryFn: () => fetchWikiPage (wikiIdStr, { }) }) queryFn: () => fetchWikiPage (wikiIdStr, { }) })


const effectiveTitle = wikiPage?.title ?? ''
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? ''


const { data: tag } = useQuery ({ const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle), enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })

const postCount = tag?.postCount ?? 0
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })


const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId)
const wikiTitle = location.pathname.split ('/')[2] ?? ''
const menu: Menu = [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '投稿追加', to: '/posts/new' },
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: true },
{ name: '別名タグ', to: '/tags/aliases', visible: false },
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ 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' },
{ name: <>ニジカ放送局第&thinsp;1&thinsp;チャンネル</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
{ name: '全体履歴', to: '/wiki/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
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', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]


const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
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) const prevActiveIdxRef = useRef<number> (activeIdx)


@@ -112,35 +146,31 @@ export default (({ user }: Props) => {
const dir = dirRef.current const dir = dirRef.current


useLayoutEffect (() => { 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) addEventListener ('resize', onResize)
return () => { return () => {
cancelAnimationFrame (raf) cancelAnimationFrame (raf)
removeEventListener ('resize', onResize) removeEventListener ('resize', onResize)
} }
}, [activeIdx])
})


useEffect (() => { useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId) const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe () return () => unsubscribe ()
}, [])
}, [activeIdx])


useEffect (() => { useEffect (() => {
setMenuOpen (false) setMenuOpen (false)
setOpenItemIdx (menu.findIndex (item => (
location.pathname.startsWith (item.base || item.to))))
setOpenItemIdx (activeIdx)
}, [location]) }, [location])


return ( return (
<> <>
<nav className="px-3 flex justify-between items-center w-full min-h-[48px]
<nav className="px-3 flex justify-between items-center w-full
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50"> bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div className="flex items-center gap-2 h-full">
<div className="flex items-center gap-2 h-12">
<PrefetchLink <PrefetchLink
to="/posts" to="/posts"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
@@ -151,26 +181,48 @@ export default (({ user }: Props) => {
ぼざクリ タグ広場 ぼざクリ タグ広場
</PrefetchLink> </PrefetchLink>


<div ref={navRef} className="relative hidden md:flex h-full items-center">
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
<div aria-hidden <div aria-hidden
className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
className={cn ('absolute inset-y-0 h-12',
'bg-yellow-200 dark:bg-red-950', 'bg-yellow-200 dark:bg-red-950',
'transition-[transform,width] duration-200 ease-out')} 'transition-[transform,width] duration-200 ease-out')}
style={{ width: hl.width, style={{ width: hl.width,
transform: `translate(${ hl.left }px, -50%)`,
transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/> opacity: hl.visible ? 1 : 0 }}/>


{menu.map ((item, i) => (
<PrefetchLink
key={i}
to={item.to}
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</PrefetchLink>))}
{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
to={item.to}
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</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>
</div> </div>


@@ -188,36 +240,115 @@ export default (({ user }: Props) => {
</a> </a>
</nav> </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} custom={dir}>
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={{ duration: .2, ease: 'easeOut' }}>
{(menu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
: (
<PrefetchLink
key={`l-${ i }`}
to={item.to}
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
className="h-full flex items-center px-3">
{item.name}
</PrefetchLink>)))}
</motion.div>
</AnimatePresence>
</div>
<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}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={{ duration: .2, ease: 'easeOut' }}>
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<motion.div
key={`c-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component}
</motion.div>)
: (
<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>
</motion.div>)))}
</motion.div>
</AnimatePresence>
</div>))}
</motion.div>
</AnimatePresence>


<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{menuOpen && ( {menuOpen && (
@@ -234,7 +365,7 @@ export default (({ user }: Props) => {
exit="closed" exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}> transition={{ duration: .2, ease: 'easeOut' }}>
<Separator/> <Separator/>
{menu.map ((item, i) => (
{visibleMenu.map ((item, i) => (
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
@@ -287,6 +418,16 @@ export default (({ user }: Props) => {
</motion.div>)} </motion.div>)}
</AnimatePresence> </AnimatePresence>
</Fragment>))} </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/> <TopNavUser user={user} sp/>
<Separator/> <Separator/>
</motion.div>)} </motion.div>)}


+ 0
- 1
frontend/src/components/WikiBody.tsx View File

@@ -5,6 +5,5 @@ import type { FC } from 'react'
type Props = { title: string type Props = { title: string
body?: string } body?: string }



export default (({ title, body }: Props) => export default (({ title, body }: Props) =>
<WikiMarkdown title={title} body={body ?? ''}/>) satisfies FC<Props> <WikiMarkdown title={title} body={body ?? ''}/>) satisfies FC<Props>

+ 7
- 5
frontend/src/components/common/SectionTitle.tsx View File

@@ -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} {children}
</h2>)
</h2>)) satisfies FC<Props>

+ 97
- 0
frontend/src/components/common/TagInput.tsx View File

@@ -0,0 +1,97 @@
import { useState } from 'react'

import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'

import type { FC, ChangeEvent, KeyboardEvent } from 'react'

import type { Tag } from '@/types'


type Props = {
value: string
setValue: (value: string) => void }

export default (({ value, setValue }: Props) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)

// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)

const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}

const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
parts[parts.length - 1] = tag.name
setValue (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}

// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break

case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break

case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break

case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}

return (
<div className="relative">
<input
type="text"
value={value}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}) satisfies FC<Props>

+ 7
- 2
frontend/src/components/layout/MainArea.tsx View File

@@ -1,3 +1,5 @@
import { motion } from 'framer-motion'

import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'


import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
@@ -8,6 +10,9 @@ type Props = {




export default (({ children, className }: Props) => ( export default (({ children, className }: Props) => (
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
<motion.main
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout="position">
{children} {children}
</main>)) satisfies FC<Props>
</motion.main>)) satisfies FC<Props>

+ 26
- 5
frontend/src/components/layout/SidebarComponent.tsx View File

@@ -1,9 +1,30 @@
import React from 'react'
import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async'


type Props = { children: React.ReactNode }
import type { FC, ReactNode } from 'react'


type Props = { children: ReactNode }


export default (({ children }: Props) => (
<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>
{`
.sidebar
{
direction: rtl;
}

.sidebar > *
{
direction: ltr;
}`}
</style>
</Helmet>


export default ({ children }: Props) => (
<div className="p-4 w-full md:w-64 md:h-full">
{children} {children}
</div>)
</motion.div>)) satisfies FC<Props>

+ 5
- 3
frontend/src/index.css View File

@@ -46,10 +46,12 @@ a
body body
{ {
margin: 0; margin: 0;
display: flex;
place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh;
}

#root
{
min-height: 100dvh;
} }


h1 h1


+ 7
- 7
frontend/src/lib/posts.ts View File

@@ -1,6 +1,6 @@
import { apiDelete, apiGet, apiPost } from '@/lib/api' 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 ( export const fetchPosts = async (
@@ -29,17 +29,17 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po




export const fetchPostChanges = async ( export const fetchPostChanges = async (
{ id, tag, page, limit }: {
id?: string
{ post, tag, page, limit }: {
post?: string
tag?: string tag?: string
page: number page: number
limit: number }, limit: number },
): Promise<{ ): Promise<{
changes: PostTagChange[]
versions: PostVersion[]
count: number }> => count: number }> =>
await apiGet ('/posts/changes', { params: { ...(id && { id }),
...(tag && { tag }),
page, limit } })
await apiGet ('/posts/versions', { params: { ...(post && { post }),
...(tag && { tag }),
page, limit } })




export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => {


+ 31
- 2
frontend/src/lib/prefetchers.ts View File

@@ -3,7 +3,7 @@ import { match } from 'path-to-regexp'


import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName, fetchTag, fetchTags } from '@/lib/tags'
import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiPage, import { fetchWikiPage,
fetchWikiPageByTitle, fetchWikiPageByTitle,
fetchWikiPages } from '@/lib/wiki' fetchWikiPages } from '@/lib/wiki'
@@ -14,6 +14,7 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>


const mPost = match<{ id: string }> ('/posts/:id') const mPost = match<{ id: string }> ('/posts/:id')
const mWiki = match<{ title: string }> ('/wiki/:title') const mWiki = match<{ title: string }> ('/wiki/:title')
const mTag = match<{ id: string }> ('/tags/:id')




const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
@@ -169,6 +170,30 @@ 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) })
}


const prefetchTagChanges: Prefetcher = async (qc, url) => {
const id = url.searchParams.get ('id')
const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20)

await qc.prefetchQuery ({
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) })
}


export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
run: prefetchPostsIndex }, run: prefetchPostsIndex },
@@ -180,7 +205,11 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
&& Boolean (mWiki (u.pathname))), && Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow }, run: prefetchWikiPageShow },
{ test: u => u.pathname === '/tags', run: prefetchTagsIndex }]
{ test: u => u.pathname === '/tags', run: prefetchTagsIndex },
{ test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname))
&& Boolean (mTag (u.pathname))),
run: prefetchTagShow },
{ test: u => u.pathname === '/tags/changes', run: prefetchTagChanges }]




export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {


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

@@ -5,13 +5,15 @@ export const postsKeys = {
index: (p: FetchPostsParams) => ['posts', 'index', p] as const, index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const, show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', 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 } ['posts', 'changes', p] as const }


export const tagsKeys = { export const tagsKeys = {
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
show: (name: string) => ['tags', name] as const }
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const }


export const wikiKeys = { export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,


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

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


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




export const fetchTags = async ( export const fetchTags = async (
@@ -45,3 +45,14 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => {
return null return null
} }
} }


export const fetchTagChanges = async (
{ id, page, limit }: {
id?: string
page: number
limit: number },
): Promise<{
versions: TagVersion[]
count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })

+ 4
- 0
frontend/src/mdx-components.tsx View File

@@ -0,0 +1,4 @@
import type { MDXComponents } from 'mdx/types'


export const useMDXComponents = (): MDXComponents => ({ })

+ 46
- 0
frontend/src/pages/MorePage.tsx 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

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save