From 7b15cb2c5a282bd506a263e52967a1d34e3f3240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Tue, 7 Apr 2026 07:44:50 +0900 Subject: [PATCH 01/17] =?UTF-8?q?=E7=B4=A0=E6=9D=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=EF=BC=88#99=EF=BC=89=20(#303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #99 #99 #99 #99 #99 #99 #99 #99 #99 #99 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/303 --- backend/Gemfile | 4 +- backend/Gemfile.lock | 21 + .../app/controllers/materials_controller.rb | 101 +++++ backend/app/controllers/tags_controller.rb | 76 +++- backend/app/models/material.rb | 39 ++ backend/app/models/tag.rb | 3 + backend/app/representations/material_repr.rb | 24 ++ backend/app/representations/tag_repr.rb | 2 +- backend/config/environments/production.rb | 3 +- backend/config/environments/test.rb | 2 + backend/config/routes.rb | 4 + backend/config/storage.yml | 33 +- .../20260329034700_create_materials.rb | 34 ++ backend/db/schema.rb | 66 ++- backend/spec/requests/materials_spec.rb | 378 ++++++++++++++++++ backend/spec/requests/tags_spec.rb | 151 +++++++ frontend/src/App.tsx | 15 +- frontend/src/components/MaterialSidebar.tsx | 97 +++++ frontend/src/components/TagLink.tsx | 97 ++--- frontend/src/components/TagSidebar.tsx | 2 +- frontend/src/components/TopNav.tsx | 13 +- frontend/src/components/common/TagInput.tsx | 97 +++++ frontend/src/components/layout/MainArea.tsx | 4 +- .../components/layout/SidebarComponent.tsx | 30 +- .../src/pages/materials/MaterialBasePage.tsx | 12 + .../pages/materials/MaterialDetailPage.tsx | 182 +++++++++ .../src/pages/materials/MaterialListPage.tsx | 166 ++++++++ .../src/pages/materials/MaterialNewPage.tsx | 124 ++++++ .../pages/materials/MaterialSearchPage.tsx | 49 +++ frontend/src/pages/posts/PostDetailPage.tsx | 2 +- frontend/src/pages/posts/PostListPage.tsx | 2 +- frontend/src/pages/posts/PostSearchPage.tsx | 88 +--- frontend/src/types.ts | 13 + 33 files changed, 1742 insertions(+), 192 deletions(-) create mode 100644 backend/app/controllers/materials_controller.rb create mode 100644 backend/app/models/material.rb create mode 100644 backend/app/representations/material_repr.rb create mode 100644 backend/db/migrate/20260329034700_create_materials.rb create mode 100644 backend/spec/requests/materials_spec.rb create mode 100644 frontend/src/components/MaterialSidebar.tsx create mode 100644 frontend/src/components/common/TagInput.tsx create mode 100644 frontend/src/pages/materials/MaterialBasePage.tsx create mode 100644 frontend/src/pages/materials/MaterialDetailPage.tsx create mode 100644 frontend/src/pages/materials/MaterialListPage.tsx create mode 100644 frontend/src/pages/materials/MaterialNewPage.tsx create mode 100644 frontend/src/pages/materials/MaterialSearchPage.tsx diff --git a/backend/Gemfile b/backend/Gemfile index 1d48493..2d0a90c 100644 --- a/backend/Gemfile +++ b/backend/Gemfile @@ -50,8 +50,6 @@ group :development, :test do gem 'factory_bot_rails' end - - gem "mysql2", "~> 0.5.6" gem "image_processing", "~> 1.14" @@ -69,3 +67,5 @@ gem 'whenever', require: false gem 'discard' gem "rspec-rails", "~> 8.0", :groups => [:development, :test] + +gem 'aws-sdk-s3', require: false diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock index 42eb862..f9dc02c 100644 --- a/backend/Gemfile.lock +++ b/backend/Gemfile.lock @@ -73,6 +73,25 @@ GEM tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) 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) bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1-arm64-darwin) @@ -157,6 +176,7 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + jmespath (1.6.2) json (2.12.0) jwt (2.10.1) base64 @@ -441,6 +461,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + aws-sdk-s3 bootsnap brakeman diff-lcs diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb new file mode 100644 index 0000000..d61a4b3 --- /dev/null +++ b/backend/app/controllers/materials_controller.rb @@ -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 diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9652f18..3a9e8a6 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -33,11 +33,11 @@ class TagsController < ApplicationController else Tag.joins(:tag_name) 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('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[1]) if post_count_between[1] q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] @@ -69,6 +69,44 @@ class TagsController < ApplicationController render json: { tags: TagRepr.base(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 + def autocomplete q = params[:q].to_s.strip.sub(/\Anot:/i, '') @@ -90,7 +128,7 @@ class TagsController < ApplicationController end 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 canonical_hit = @@ -115,7 +153,7 @@ class TagsController < ApplicationController def show 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]) if tag render json: TagRepr.base(tag) @@ -129,7 +167,7 @@ class TagsController < ApplicationController return head :bad_request if name.blank? 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: }) if tag render json: TagRepr.base(tag) @@ -159,6 +197,18 @@ class TagsController < ApplicationController render json: DeerjikistRepr.many(tag.deerjikists) 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 return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? @@ -178,4 +228,20 @@ class TagsController < ApplicationController render json: TagRepr.base(tag) 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 + + 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 end diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb new file mode 100644 index 0000000..417b292 --- /dev/null +++ b/backend/app/models/material.rb @@ -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 diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index a8926c2..64e0b9b 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -31,6 +31,7 @@ class Tag < ApplicationRecord class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all has_many :deerjikists, dependent: :delete_all + has_many :materials belongs_to :tag_name delegate :wiki_page, to: :tag_name @@ -72,6 +73,8 @@ class Tag < ApplicationRecord def has_wiki = wiki_page.present? + def material_id = materials.first&.id + def self.tagme @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) end diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb new file mode 100644 index 0000000..44edd26 --- /dev/null +++ b/backend/app/representations/material_repr.rb @@ -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 diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index db8b6eb..df6925b 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -3,7 +3,7 @@ module TagRepr BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], - methods: [:name, :has_wiki] }.freeze + methods: [:name, :has_wiki, :material_id] }.freeze module_function diff --git a/backend/config/environments/production.rb b/backend/config/environments/production.rb index 0bd58c3..3038b02 100644 --- a/backend/config/environments/production.rb +++ b/backend/config/environments/production.rb @@ -18,8 +18,7 @@ Rails.application.configure do # Enable serving of images, stylesheets, and JavaScripts from an asset server. # 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. config.assume_ssl = true diff --git a/backend/config/environments/test.rb b/backend/config/environments/test.rb index c2095b1..1914d54 100644 --- a/backend/config/environments/test.rb +++ b/backend/config/environments/test.rb @@ -50,4 +50,6 @@ Rails.application.configure do # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + + Rails.application.routes.default_url_options[:host] = 'www.example.com' end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index b9db110..fc56aa4 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -9,9 +9,11 @@ Rails.application.routes.draw do resources :tags, only: [:index, :show, :update] do collection do get :autocomplete + get :'with-depth', action: :with_depth scope :name do get ':name/deerjikists', action: :deerjikists_by_name + get ':name/materials', action: :materials_by_name get ':name', action: :show_by_name end end @@ -81,4 +83,6 @@ Rails.application.routes.draw do resources :comments, controller: :theatre_comments, only: [:index, :create] end + + resources :materials, only: [:index, :show, :create, :update, :destroy] end diff --git a/backend/config/storage.yml b/backend/config/storage.yml index 4942ab6..c2c2a46 100644 --- a/backend/config/storage.yml +++ b/backend/config/storage.yml @@ -6,29 +6,10 @@ local: service: Disk 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 diff --git a/backend/db/migrate/20260329034700_create_materials.rb b/backend/db/migrate/20260329034700_create_materials.rb new file mode 100644 index 0000000..ecb1c07 --- /dev/null +++ b/backend/db/migrate/20260329034700_create_materials.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 6a2096b..22541e2 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do +ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -56,6 +56,45 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true 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| t.bigint "nico_tag_id", null: false t.bigint "tag_id", null: false @@ -239,6 +278,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do t.datetime "updated_at", null: false end + create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "wiki_page_id", null: false + t.integer "no", null: false + t.string "alt_text" + t.binary "sha256", limit: 32, null: false + t.bigint "created_by_user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" + t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true + t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true + end + create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "sha256", limit: 64, null: false t.text "body", null: false @@ -254,6 +306,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" + t.integer "next_asset_no", default: 1, null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true @@ -292,6 +345,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do 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 "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", column: "nico_tag_id" add_foreign_key "post_similarities", "posts" @@ -320,6 +382,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do add_foreign_key "user_ips", "users" add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" + add_foreign_key "wiki_assets", "users", column: "created_by_user_id" + add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb new file mode 100644 index 0000000..f2cc27e --- /dev/null +++ b/backend/spec/requests/materials_spec.rb @@ -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 diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 8dfa51a..9a140b1 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -19,6 +19,17 @@ RSpec.describe 'Tags API', type: :request do response_tags.map { |t| t.fetch('name') } 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 it 'returns tags with count and metadata' do get '/tags' @@ -359,4 +370,144 @@ RSpec.describe 'Tags API', type: :request do 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 end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8966a4..770f46c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,11 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import { Toaster } from '@/components/ui/toaster' 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 NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' import PostDetailPage from '@/pages/posts/PostDetailPage' @@ -41,7 +46,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { return ( - + }/> }/> }/> @@ -51,6 +56,12 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }> + }/> + }/> + }/> + + {/* }/> */} }/> }/> }/> @@ -120,7 +131,7 @@ export default (() => { <> -
+
diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx new file mode 100644 index 0000000..08bf2b2 --- /dev/null +++ b/frontend/src/components/MaterialSidebar.tsx @@ -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 ([]) + const [openTags, setOpenTags] = useState> ({ }) + const [tagFetchedFlags, setTagFetchedFlags] = useState> ({ }) + + useEffect (() => { + void (async () => { + setTags ((await apiGet ('/tags/with-depth')) + .filter (t => t.category !== 'meme' || t.hasChildren)) + }) () + }, []) + + const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => ( + ts.map (t => ( + +
  • + +
  • + {openTags[t.id] && renderTags (t.children, nestLevel + 1)} +
    ))) + + return ( + +
      + {renderTags (tags)} +
    +
    ) +}) satisfies FC diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index b3a926c..884c851 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -1,8 +1,5 @@ -import { useEffect, useState } from 'react' - import PrefetchLink from '@/components/PrefetchLink' import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' -import { apiGet } from '@/lib/api' import { cn } from '@/lib/utils' import type { ComponentProps, FC, HTMLAttributes } from 'react' @@ -13,8 +10,7 @@ type CommonProps = { tag: Tag nestLevel?: number withWiki?: boolean - withCount?: boolean - prefetch?: boolean } + withCount?: boolean } type PropsWithLink = & CommonProps @@ -36,37 +32,7 @@ export default (({ tag, linkFlg = true, withWiki = true, withCount = true, - prefetch = false, ...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 ( `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) @@ -79,19 +45,39 @@ export default (({ tag, <> {(linkFlg && withWiki) && ( - {havingWiki + {(tag.materialId != null || tag.hasWiki) ? ( - - ? - ) + tag.materialId == null + ? ( + + ? + ) + : ( + + ? + )) : ( - - ! - )} + ['character', 'material'].includes (tag.category) + ? ( + + ! + ) + : ( + + ! + ))} )} {nestLevel > 0 && ( )} {linkFlg ? ( - prefetch - ? - {tag.name} - - : - {tag.name} - ) + + {tag.name} + ) : ( diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index d0bf5cc..7fbdfa3 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -66,7 +66,7 @@ export default (({ posts, onClick }: Props) => { tags[cat].map (tag => (
  • - +
  • ))) : [])} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 7d1575c..b173dac 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -70,20 +70,27 @@ export default (({ user }: Props) => { { name: '広場', to: '/posts', subMenu: [ { name: '一覧', to: '/posts' }, { name: '検索', to: '/posts/search' }, - { name: '投稿追加', to: '/posts/new' }, + { name: '追加', to: '/posts/new' }, { name: '履歴', to: '/posts/changes' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'タグ', to: '/tags', subMenu: [ - { name: 'タグ一覧', to: '/tags', visible: true }, + { name: 'マスタ', to: '/tags' }, { name: '別名タグ', to: '/tags/aliases', visible: false }, { name: '上位タグ', to: '/tags/implications', visible: false }, { name: 'ニコニコ連携', to: '/tags/nico' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, + { name: '素材', to: '/materials', subMenu: [ + { name: '一覧', to: '/materials' }, + // { name: '検索', to: '/materials/search' }, + { name: '追加', to: '/materials/new' }, + // { name: '履歴', to: '/materials/changes' }, + { name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] }, { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ { name: <>第 1 会場, to: '/theatres/1' }, { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, { name: <>ニジカ放送局第 1 チャンネル, - to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }] }, + to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] }, { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new' }, diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx new file mode 100644 index 0000000..87b7238 --- /dev/null +++ b/frontend/src/components/common/TagInput.tsx @@ -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 ([]) + const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) + + // TODO: TagSearch からのコピペのため,共通化を考へる. + const whenChanged = async (ev: ChangeEvent) => { + setValue (ev.target.value) + + const q = ev.target.value.trim ().split (' ').at (-1) + if (!(q)) + { + setSuggestions ([]) + return + } + + const data = await apiGet ('/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) => { + 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 ( +
    + setSuggestionsVsbl (true)} + onBlur={() => setSuggestionsVsbl (false)} + onKeyDown={handleKeyDown} + className="w-full border p-2 rounded"/> + 0 ? suggestions : [] as Tag[]} + activeIndex={activeIndex} + onSelect={handleTagSelect}/> +
    ) +}) satisfies FC diff --git a/frontend/src/components/layout/MainArea.tsx b/frontend/src/components/layout/MainArea.tsx index 1067101..8839ebc 100644 --- a/frontend/src/components/layout/MainArea.tsx +++ b/frontend/src/components/layout/MainArea.tsx @@ -8,6 +8,8 @@ type Props = { export default (({ children, className }: Props) => ( -
    +
    {children}
    )) satisfies FC diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index cfe2c08..d6e8803 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -1,9 +1,29 @@ -import React from 'react' +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) => ( +
    + + + -export default ({ children }: Props) => ( -
    {children} -
    ) +
    )) satisfies FC diff --git a/frontend/src/pages/materials/MaterialBasePage.tsx b/frontend/src/pages/materials/MaterialBasePage.tsx new file mode 100644 index 0000000..69d6a33 --- /dev/null +++ b/frontend/src/pages/materials/MaterialBasePage.tsx @@ -0,0 +1,12 @@ +import { Outlet } from 'react-router-dom' + +import MaterialSidebar from '@/components/MaterialSidebar' + +import type { FC } from 'react' + + +export default (() => ( +
    + + +
    )) satisfies FC diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx new file mode 100644 index 0000000..99c5d46 --- /dev/null +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react' +import { Helmet } from 'react-helmet-async' +import { useParams } from 'react-router-dom' + +import TagLink from '@/components/TagLink' +import WikiBody from '@/components/WikiBody' +import Label from '@/components/common/Label' +import PageTitle from '@/components/common/PageTitle' +import TabGroup, { Tab } from '@/components/common/TabGroup' +import TagInput from '@/components/common/TagInput' +import MainArea from '@/components/layout/MainArea' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import { SITE_TITLE } from '@/config' +import { apiGet, apiPut } from '@/lib/api' + +import type { FC } from 'react' + +import type { Material, Tag } from '@/types' + +type MaterialWithTag = Material & { tag: Tag } + + +export default (() => { + const { id } = useParams () + + const [file, setFile] = useState (null) + const [filePreview, setFilePreview] = useState ('') + const [loading, setLoading] = useState (false) + const [material, setMaterial] = useState (null) + const [sending, setSending] = useState (false) + const [tag, setTag] = useState ('') + const [url, setURL] = useState ('') + + const handleSubmit = async () => { + const formData = new FormData + if (tag.trim ()) + formData.append ('tag', tag) + if (file) + formData.append ('file', file) + if (url.trim ()) + formData.append ('url', url) + + try + { + setSending (true) + const data = await apiPut (`/materials/${ id }`, formData) + setMaterial (data) + toast ({ title: '更新成功!' }) + } + catch + { + toast ({ title: '更新失敗……', description: '入力を見直してください.' }) + } + finally + { + setSending (false) + } + } + + useEffect (() => { + if (!(id)) + return + + void (async () => { + try + { + setLoading (true) + const data = await apiGet (`/materials/${ id }`) + setMaterial (data) + setTag (data.tag.name) + if (data.file && data.contentType) + { + setFilePreview (data.file) + setFile (new File ([await (await fetch (data.file)).blob ()], + data.file, + { type: data.contentType })) + } + setURL (data.url ?? '') + } + finally + { + setLoading (false) + } + }) () + }, [id]) + + return ( + + {material && ( + + {`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`} + )} + + {loading ? 'Loading...' : (material && ( + <> + + + + + {(material.file && material.contentType) && ( + (/image\/.*/.test (material.contentType) && ( + {material.tag.name)) + || (/video\/.*/.test (material.contentType) && ( + ) +}) satisfies FC diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx new file mode 100644 index 0000000..25ad9e7 --- /dev/null +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -0,0 +1,166 @@ +import { Fragment, useEffect, useState } from 'react' +import { Helmet } from 'react-helmet-async' +import { useLocation } from 'react-router-dom' + +import nikumaru from '@/assets/fonts/nikumaru.otf' +import PrefetchLink from '@/components/PrefetchLink' +import TagLink from '@/components/TagLink' +import PageTitle from '@/components/common/PageTitle' +import SectionTitle from '@/components/common/SectionTitle' +import SubsectionTitle from '@/components/common/SubsectionTitle' +import MainArea from '@/components/layout/MainArea' +import { SITE_TITLE } from '@/config' +import { apiGet } from '@/lib/api' + +import type { FC } from 'react' + +import type { Material, Tag } from '@/types' + +type TagWithMaterial = Omit & { + children: TagWithMaterial[] + material: Material | null } + + +const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { + if (!(tag.material)) + return + + return ( + +
    + {(tag.material.contentType && /image\/.*/.test (tag.material.contentType)) + ? + : 照会} +
    +
    ) +} + + +export default (() => { + const [loading, setLoading] = useState (false) + const [tag, setTag] = useState (null) + + const location = useLocation () + const query = new URLSearchParams (location.search) + const tagQuery = query.get ('tag') ?? '' + + useEffect (() => { + if (!(tagQuery)) + { + setTag (null) + return + } + + void (async () => { + try + { + setLoading (true) + setTag ( + await apiGet ( + `/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) + } + finally + { + setLoading (false) + } + }) () + }, [location.search]) + + return ( + + + + {`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`} + + + {loading ? 'Loading...' : ( + tag + ? ( + <> + + + + {(!(tag.material) && tag.category !== 'meme') && ( +
    + + 追加 + +
    )} + + + +
    + {tag.children.map (c2 => ( + + + + + {(!(c2.material) && c2.category !== 'meme') && ( +
    + + 追加 + +
    )} + + + +
    + {c2.children.map (c3 => ( + + + + + {(!(c3.material) && c3.category !== 'meme') && ( +
    + + 追加 + +
    )} + + +
    ))} +
    +
    ))} +
    + ) + : ( + <> +

    左のリストから照会したいタグを選択してください。

    +

    もしくは……

    + + ))} +
    ) +}) satisfies FC diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx new file mode 100644 index 0000000..9260639 --- /dev/null +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { Helmet } from 'react-helmet-async' +import { useLocation, useNavigate } from 'react-router-dom' + +import Form from '@/components/common/Form' +import Label from '@/components/common/Label' +import PageTitle from '@/components/common/PageTitle' +import TagInput from '@/components/common/TagInput' +import MainArea from '@/components/layout/MainArea' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import { SITE_TITLE } from '@/config' +import { apiPost } from '@/lib/api' + +import type { FC } from 'react' + + +export default (() => { + const location = useLocation () + const query = new URLSearchParams (location.search) + const tagQuery = query.get ('tag') ?? '' + + const navigate = useNavigate () + + const [file, setFile] = useState (null) + const [filePreview, setFilePreview] = useState ('') + const [sending, setSending] = useState (false) + const [tag, setTag] = useState (tagQuery) + const [url, setURL] = useState ('') + + const handleSubmit = async () => { + const formData = new FormData + if (tag) + formData.append ('tag', tag) + if (file) + formData.append ('file', file) + if (url) + formData.append ('url', url) + + try + { + setSending (true) + await apiPost ('/materials', formData) + toast ({ title: '送信成功!' }) + navigate (`/materials?tag=${ encodeURIComponent (tag) }`) + } + catch + { + toast ({ title: '送信失敗……', description: '入力を見直してください.' }) + } + finally + { + setSending (false) + } + } + + return ( + + + {`素材追加 | ${ SITE_TITLE }`} + + +
    + 素材追加 + + {/* タグ */} +
    + + +
    + + {/* ファイル */} +
    + + { + const f = e.target.files?.[0] + setFile (f ?? null) + setFilePreview (f ? URL.createObjectURL (f) : '') + }}/> + {(file && filePreview) && ( + (/image\/.*/.test (file.type) && ( + preview)) + || (/video\/.*/.test (file.type) && ( +
    + + {/* 参考 URL */} +
    + + setURL (e.target.value)} + className="w-full border p-2 rounded"/> +
    + + {/* 送信 */} + +
    +
    ) +}) satisfies FC diff --git a/frontend/src/pages/materials/MaterialSearchPage.tsx b/frontend/src/pages/materials/MaterialSearchPage.tsx new file mode 100644 index 0000000..5882b40 --- /dev/null +++ b/frontend/src/pages/materials/MaterialSearchPage.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import { Helmet } from 'react-helmet-async' + +import Label from '@/components/common/Label' +import PageTitle from '@/components/common/PageTitle' +import TagInput from '@/components/common/TagInput' +import MainArea from '@/components/layout/MainArea' +import { SITE_TITLE } from '@/config' + +import type { FC, FormEvent } from 'react' + + +export default (() => { + const [tagName, setTagName] = useState ('') + const [parentTagName, setParentTagName] = useState ('') + + const handleSearch = (e: FormEvent) => { + e.preventDefault () + } + + return ( + + + 素材集 | {SITE_TITLE} + + +
    + 素材集 + +
    + {/* タグ */} +
    + + +
    + + {/* 親タグ */} +
    + + +
    +
    +
    +
    ) +}) satisfies FC diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 11f6100..a15f035 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -93,7 +93,7 @@ export default (({ user }: Props) => { : 'bg-gray-500 hover:bg-gray-600') return ( -
    +
    {(post?.thumbnail || post?.thumbnailBase) && ( )} diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 70e3e68..8fc4ba8 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -69,7 +69,7 @@ export default (() => { }, [location.search]) return ( -
    +
    {tags.length diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index a824953..73071cc 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -7,24 +7,22 @@ import { useLocation, useNavigate } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' import SortHeader from '@/components/SortHeader' import TagLink from '@/components/TagLink' -import TagSearchBox from '@/components/TagSearchBox' import DateTimeField from '@/components/common/DateTimeField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' +import TagInput from '@/components/common/TagInput' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' -import { apiGet } from '@/lib/api' import { fetchPosts } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' import { dateString, originalCreatedAtString } from '@/lib/utils' -import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react' +import type { FC, FormEvent } from 'react' import type { FetchPostsOrder, FetchPostsOrderField, - FetchPostsParams, - Tag } from '@/types' + FetchPostsParams } from '@/types' const setIf = (qs: URLSearchParams, k: string, v: string | null) => { @@ -57,14 +55,11 @@ export default (() => { const qUpdatedTo = query.get ('updated_to') ?? '' const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder - const [activeIndex, setActiveIndex] = useState (-1) const [createdFrom, setCreatedFrom] = useState<string | null> (null) const [createdTo, setCreatedTo] = useState<string | null> (null) const [matchType, setMatchType] = useState<'all' | 'any'> ('all') const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null) - const [suggestions, setSuggestions] = useState<Tag[]> ([]) - const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [tagsStr, setTagsStr] = useState ('') const [title, setTitle] = useState ('') const [updatedFrom, setUpdatedFrom] = useState<string | null> (null) @@ -103,58 +98,6 @@ export default (() => { document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) - // TODO: TagSearch からのコピペのため,共通化を考へる. - const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => { - setTagsStr (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 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) - } - } - const search = async () => { const qs = new URLSearchParams () setIf (qs, 'tags', tagsStr) @@ -172,15 +115,6 @@ export default (() => { navigate (`${ location.pathname }?${ qs.toString () }`) } - // TODO: TagSearch からのコピペのため,共通化を考へる. - const handleTagSelect = (tag: Tag) => { - const parts = tagsStr.split (' ') - parts[parts.length - 1] = tag.name - setTagsStr (parts.join (' ') + ' ') - setSuggestions ([]) - setActiveIndex (-1) - } - const handleSearch = (e: FormEvent) => { e.preventDefault () search () @@ -223,21 +157,11 @@ export default (() => { </div> {/* タグ */} - <div className="relative"> + <div> <Label>タグ</Label> - <input - type="text" + <TagInput value={tagsStr} - 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}/> + setValue={setTagsStr}/> <fieldset className="w-full my-2"> <label>検索区分:</label> <label className="mx-2"> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d788ebd..2f71826 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -49,6 +49,18 @@ export type FetchTagsParams = { limit: number order: FetchTagsOrder } +export type Material = { + id: number + tag: Tag + file: string | null + url: string | null + wikiPageBody?: string | null + contentType: string | null + createdAt: string + createdByUser: { id: number; name: string } + updatedAt: string + updatedByUser: { id: number; name: string } } + export type Menu = MenuItem[] export type MenuItem = { @@ -129,6 +141,7 @@ export type Tag = { createdAt: string updatedAt: string hasWiki: boolean + materialId: number children?: Tag[] matchedAlias?: string | null } From e021423904fd8def84e9d8bb880142c5eed01c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= <miteruzo@naver.com> Date: Sat, 11 Apr 2026 16:58:41 +0900 Subject: [PATCH 02/17] =?UTF-8?q?=E3=82=B9=E3=83=9E=E3=83=9B=E3=83=BB?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=82=A2=E3=82=A6=E3=83=88=E3=83=BB=E3=83=90?= =?UTF-8?q?=E3=82=B0=EF=BC=88#304=EF=BC=89=20(#305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #304 #304 #304 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/305 --- frontend/src/App.tsx | 2 +- frontend/src/components/TopNav.tsx | 22 +++++++++---------- frontend/src/components/layout/MainArea.tsx | 3 +-- frontend/src/index.css | 8 ++++--- .../src/pages/materials/MaterialBasePage.tsx | 3 ++- frontend/src/pages/posts/PostDetailPage.tsx | 3 ++- frontend/src/pages/posts/PostListPage.tsx | 5 ++++- 7 files changed, 26 insertions(+), 20 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 770f46c..b49aa26 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -131,7 +131,7 @@ export default (() => { <> <RouteBlockerOverlay/> <BrowserRouter> - <div className="flex flex-col h-dvh w-screen"> + <div className="flex flex-col h-dvh w-full"> <TopNav user={user}/> <RouteTransitionWrapper user={user} setUser={setUser}/> </div> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index b173dac..27d40b1 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -79,12 +79,12 @@ export default (({ user }: Props) => { { name: '上位タグ', to: '/tags/implications', visible: false }, { name: 'ニコニコ連携', to: '/tags/nico' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, - { name: '素材', to: '/materials', subMenu: [ - { name: '一覧', to: '/materials' }, - // { name: '検索', to: '/materials/search' }, - { name: '追加', to: '/materials/new' }, - // { name: '履歴', to: '/materials/changes' }, - { name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] }, + // { name: '素材', to: '/materials', subMenu: [ + // { name: '一覧', to: '/materials' }, + // { name: '検索', to: '/materials/search', visible: false }, + // { name: '追加', to: '/materials/new' }, + // { name: '履歴', to: '/materials/changes', visible: false }, + // { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ { name: <>第 1 会場</>, to: '/theatres/1' }, { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, @@ -145,9 +145,9 @@ export default (({ user }: Props) => { 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"> - <div className="flex items-center gap-2 h-full"> + <div className="flex items-center gap-2 h-12"> <PrefetchLink to="/posts" className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 @@ -158,13 +158,13 @@ export default (({ user }: Props) => { ぼざクリ タグ広場 </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 - 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', 'transition-[transform,width] duration-200 ease-out')} style={{ width: hl.width, - transform: `translate(${ hl.left }px, -50%)`, + transform: `translateX(${ hl.left }px)`, opacity: hl.visible ? 1 : 0 }}/> {menu.map ((item, i) => ( diff --git a/frontend/src/components/layout/MainArea.tsx b/frontend/src/components/layout/MainArea.tsx index 8839ebc..273793d 100644 --- a/frontend/src/components/layout/MainArea.tsx +++ b/frontend/src/components/layout/MainArea.tsx @@ -8,8 +8,7 @@ type Props = { export default (({ children, className }: Props) => ( - <main className={cn ('flex-1 overflow-y-auto p-4', - 'md:h-[calc(100dvh-88px)] md:overflow-y-auto', + <main className={cn ('flex-1 overflow-y-auto p-4 md:h-[calc(100dvh-88px)]', className)}> {children} </main>)) satisfies FC<Props> diff --git a/frontend/src/index.css b/frontend/src/index.css index 263b75d..440f499 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -46,10 +46,12 @@ a body { margin: 0; - display: flex; - place-items: center; min-width: 320px; - min-height: 100vh; +} + +#root +{ + min-height: 100dvh; } h1 diff --git a/frontend/src/pages/materials/MaterialBasePage.tsx b/frontend/src/pages/materials/MaterialBasePage.tsx index 69d6a33..467ef58 100644 --- a/frontend/src/pages/materials/MaterialBasePage.tsx +++ b/frontend/src/pages/materials/MaterialBasePage.tsx @@ -6,7 +6,8 @@ import type { FC } from 'react' export default (() => ( - <div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]"> + <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden + md:h-[calc(100dvh-88px)]"> <MaterialSidebar/> <Outlet/> </div>)) satisfies FC diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index a15f035..518b914 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -93,7 +93,8 @@ export default (({ user }: Props) => { : 'bg-gray-500 hover:bg-gray-600') return ( - <div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]"> + <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden + md:h-[calc(100dvh-88px)]"> <Helmet> {(post?.thumbnail || post?.thumbnailBase) && ( <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 8fc4ba8..979c75e 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -69,7 +69,10 @@ export default (() => { }, [location.search]) return ( - <div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]" ref={containerRef}> + <div + className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden + md:h-[calc(100dvh-88px)]" + ref={containerRef}> <Helmet> <title> {tags.length From c36b2c8a1b3762ff76e1b71d984893c0dc53f2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= <miteruzo@naver.com> Date: Sat, 11 Apr 2026 17:05:57 +0900 Subject: [PATCH 03/17] =?UTF-8?q?=E6=8A=95=E7=A8=BF=E3=81=AB=E5=AF=BE?= =?UTF-8?q?=E3=81=99=E3=82=8B=E5=B1=A5=E6=AD=B4=EF=BC=88#264=EF=BC=89=20(#?= =?UTF-8?q?307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge branch 'main' into feature/264 #264 #264 #264 #264 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/307 --- backend/app/controllers/posts_controller.rb | 36 ++-- backend/app/models/post.rb | 3 + backend/app/models/post_version.rb | 38 ++++ backend/app/models/tag.rb | 10 + backend/app/services/post_version_recorder.rb | 57 +++++ .../20260409123700_create_post_versions.rb | 203 ++++++++++++++++++ backend/db/schema.rb | 26 ++- backend/lib/tasks/sync_nico.rake | 13 +- backend/spec/models/post_version_spec.rb | 41 ++++ backend/spec/requests/posts_spec.rb | 123 +++++++++++ 10 files changed, 533 insertions(+), 17 deletions(-) create mode 100644 backend/app/models/post_version.rb create mode 100644 backend/app/services/post_version_recorder.rb create mode 100644 backend/db/migrate/20260409123700_create_post_versions.rb create mode 100644 backend/spec/models/post_version_spec.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 74d720c..fda77ed 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -127,17 +127,20 @@ class PostsController < ApplicationController post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, original_created_from:, original_created_before:) post.thumbnail.attach(thumbnail) - if post.save - post.resized_thumbnail! + + ActiveRecord::Base.transaction do + post.save! tags = Tag.normalise_tags(tag_names) tags = Tag.expand_parent_tags(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 + + 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 head :bad_request end @@ -166,19 +169,22 @@ class PostsController < ApplicationController original_created_before = params[:original_created_before] post = Post.find(params[:id].to_i) - if post.update(title:, original_created_from:, original_created_before:) + + ActiveRecord::Base.transaction do + 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) tags = Tag.expand_parent_tags(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 + + 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 head :bad_request end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index c898615..901b1e3 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -11,6 +11,7 @@ class Post < ApplicationRecord has_many :user_post_views, dependent: :delete_all has_many :post_similarities, dependent: :delete_all + has_many :post_versions has_one_attached :thumbnail @@ -30,6 +31,8 @@ class Post < ApplicationRecord super(options).merge(thumbnail: nil) end + def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + def related limit: nil ids = post_similarities.order(cos: :desc) ids = ids.limit(limit) if limit diff --git a/backend/app/models/post_version.rb b/backend/app/models/post_version.rb new file mode 100644 index 0000000..c933813 --- /dev/null +++ b/backend/app/models/post_version.rb @@ -0,0 +1,38 @@ +class PostVersion < ApplicationRecord + before_update do + raise ActiveRecord::ReadOnlyRecord, '版は更新できません.' + end + + before_destroy do + raise ActiveRecord::ReadOnlyRecord, '版は削除できません.' + end + + belongs_to :post + belongs_to :parent, class_name: 'Post', optional: true + belongs_to :created_by_user, class_name: 'User', optional: true + + enum :event_type, { create: 'create', + update: 'update', + discard: 'discard', + restore: 'restore' }, prefix: true, validate: true + + validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :event_type, presence: true, inclusion: { in: event_types.keys } + validates :url, presence: true + + validate :validate_original_created_range + + scope :chronological, -> { order(:version_no, :id) } + + private + + def validate_original_created_range + 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 diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 64e0b9b..d4edd8d 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -1,3 +1,6 @@ +require 'set' + + class Tag < ApplicationRecord include MyDiscard @@ -150,6 +153,8 @@ class Tag < ApplicationRecord def self.merge_tags! target_tag, source_tags target_tag => Tag + affected_post_ids = Set.new + Tag.transaction do Array(source_tags).compact.uniq.each do |source_tag| source_tag => Tag @@ -158,6 +163,7 @@ class Tag < ApplicationRecord source_tag.post_tags.kept.find_each do |source_pt| post_id = source_pt.post_id + affected_post_ids << post_id source_pt.discard_by!(nil) unless PostTag.kept.exists?(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag) @@ -180,6 +186,10 @@ class Tag < ApplicationRecord end end + Post.where(id: affected_post_ids.to_a).find_each do |post| + PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) + end + # 投稿件数を再集計 target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) end diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb new file mode 100644 index 0000000..052bacd --- /dev/null +++ b/backend/app/services/post_version_recorder.rb @@ -0,0 +1,57 @@ +class PostVersionRecorder + def self.record! post:, event_type:, created_by_user: + new(post:, event_type:, created_by_user:).record! + end + + def initialize post:, event_type:, created_by_user: + @post = post + @event_type = event_type + @created_by_user = created_by_user + end + + def record! + @post.with_lock do + latest = @post.post_versions.order(version_no: :desc).first + attrs = snapshot_attributes + + return latest if @event_type == :update && latest && same_snapshot?(latest, attrs) + + PostVersion.create!( + post: @post, + version_no: (latest&.version_no || 0) + 1, + event_type: @event_type, + title: attrs[:title], + url: attrs[:url], + thumbnail_base: attrs[:thumbnail_base], + tags: attrs[:tags], + parent: attrs[:parent], + original_created_from: attrs[:original_created_from], + original_created_before: attrs[:original_created_before], + created_at: Time.current, + created_by_user: @created_by_user) + end + end + + private + + def snapshot_attributes + { title: @post.title, + url: @post.url, + thumbnail_base: @post.thumbnail_base, + tags: @post.snapshot_tag_names.join(' '), + parent: @post.parent, + original_created_from: @post.original_created_from, + original_created_before: @post.original_created_before } + end + + def same_snapshot? version, attrs + true && + version.title == attrs[:title] && + version.url == attrs[:url] && + version.thumbnail_base == attrs[:thumbnail_base] && + version.tags == attrs[:tags] && + version.parent_id == attrs[:parent]&.id && + version.original_created_from == attrs[:original_created_from] && + version.original_created_before == attrs[:original_created_before] + end +end diff --git a/backend/db/migrate/20260409123700_create_post_versions.rb b/backend/db/migrate/20260409123700_create_post_versions.rb new file mode 100644 index 0000000..a2c6da7 --- /dev/null +++ b/backend/db/migrate/20260409123700_create_post_versions.rb @@ -0,0 +1,203 @@ +require 'set' + + +class CreatePostVersions < ActiveRecord::Migration[8.0] + class Post < ApplicationRecord + self.table_name = 'posts' + end + + class PostTag < ApplicationRecord + self.table_name = 'post_tags' + end + + class PostVersion < ApplicationRecord + 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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 22541e2..42c7cd4 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do +ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -132,6 +132,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do t.index ["tag_id"], name: "index_post_tags_on_tag_id" 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.bigint "parent_id" + 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 ["parent_id"], name: "index_post_versions_on_parent_id" + t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true + t.index ["post_id"], 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| t.string "title" t.string "url", limit: 768, null: false @@ -362,6 +383,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "deleted_user_id" + add_foreign_key "post_versions", "posts" + add_foreign_key "post_versions", "posts", column: "parent_id" + add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "posts", "posts", column: "parent_id" add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "settings", "users" diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index 09be474..da396a0 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -61,6 +61,9 @@ namespace :nico do original_created_from = original_created_at&.change(sec: 0) original_created_before = original_created_from&.+(1.minute) + post_created = false + post_changed = false + if post attrs = { title:, original_created_from:, original_created_before: } @@ -76,11 +79,13 @@ namespace :nico do end post.assign_attributes(attrs) - if post.changed? + post_changed = post.changed? + if post_changed post.save! post.resized_thumbnail! if post.thumbnail.attached? end else + post_created = true url = "https://www.nicovideo.jp/watch/#{ code }" thumbnail_base = fetch_thumbnail.(url) rescue nil post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, @@ -140,6 +145,12 @@ namespace :nico do desired_all_tag_ids.uniq! 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.record!(post:, event_type: :update, created_by_user: nil) + end end end end diff --git a/backend/spec/models/post_version_spec.rb b/backend/spec/models/post_version_spec.rb new file mode 100644 index 0000000..d35ab4c --- /dev/null +++ b/backend/spec/models/post_version_spec.rb @@ -0,0 +1,41 @@ +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(' '), + parent: post_record.parent, + 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 diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 120a221..1295165 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -795,4 +795,127 @@ RSpec.describe 'Posts API', type: :request do expect(user.reload.viewed?(post_record)).to be(false) 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), + parent: post.parent, + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: post.created_at, + created_by_user: post.uploaded_user + ) + end + + it 'creates version 1 on POST /posts' do + 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) + create_post_version_for!(post_record) + + expect do + put "/posts/#{post_record.id}", params: { + title: post_record.title, + tags: 'spec_tag' + } + end.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 end From a3914fb22a86f3795744fd8a4324f4d96f99d6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= <miteruzo@naver.com> Date: Sat, 11 Apr 2026 22:13:19 +0900 Subject: [PATCH 04/17] =?UTF-8?q?posts=20=E5=B1=A5=E6=AD=B4=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=88RSpec=20=E4=BF=AE=E6=AD=A3=EF=BC=89=EF=BC=88#?= =?UTF-8?q?264=EF=BC=89=20(#310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge remote-tracking branch 'origin/main' into feature/264 Merge branch 'feature/264' of https://git.miteruzo.com/miteruzo/btrc-hub into feature/264 #264 Merge branch 'main' into feature/264 #264 #264 #264 #264 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/310 --- backend/spec/models/tag_spec.rb | 65 ++++++++++++++ backend/spec/tasks/nico_sync_spec.rb | 124 +++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index a9fc35e..1dee732 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -145,5 +145,70 @@ RSpec.describe Tag, type: :model do expect(target_tag.reload.post_count).to eq(0) end end + + def snapshot_tags(post) + post.snapshot_tag_names.join(' ') + end + + def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil) + PostVersion.create!( + post: post, + version_no: version_no, + event_type: event_type, + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: snapshot_tags(post), + parent: post.parent, + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: Time.current, + created_by_user: created_by_user + ) + end + + context 'when post versions are enabled' do + let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } + let!(:unaffected_post) do + Post.create!(url: 'https://example.com/posts/2', title: 'unaffected post') + end + + before do + create_post_version_for!(post_record) + create_post_version_for!(unaffected_post) + end + + it 'creates an update post_version only for affected posts' do + expect { + described_class.merge_tags!(target_tag, [source_tag]) + }.to change(PostVersion, :count).by(1) + + affected_versions = post_record.reload.post_versions.order(:version_no) + expect(affected_versions.pluck(:version_no)).to eq([1, 2]) + + latest = affected_versions.last + expect(latest.event_type).to eq('update') + expect(latest.created_by_user).to be_nil + expect(latest.tags).to eq(snapshot_tags(post_record.reload)) + + expect(unaffected_post.reload.post_versions.count).to eq(1) + end + end + + context 'when the source tag has no active post_tags' do + let!(:another_post) do + Post.create!(url: 'https://example.com/posts/3', title: 'another post') + end + + before do + create_post_version_for!(another_post) + end + + it 'does not create any post_version' do + expect { + described_class.merge_tags!(target_tag, [source_tag]) + }.not_to change(PostVersion, :count) + end + end end end diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index d4f0e09..ff64490 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -90,4 +90,128 @@ RSpec.describe "nico:sync" do expect(active_names).to include("nico:NEW") expect(active_names).not_to include("nico:OLD") end + + def snapshot_tags(post) + post.snapshot_tag_names.join(' ') + end + + def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil) + PostVersion.create!( + post: post, + version_no: version_no, + event_type: event_type, + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: snapshot_tags(post), + parent: post.parent, + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: Time.current, + created_by_user: created_by_user + ) + end + + it '新規 post 作成時に version 1 を作る' do + Tag.bot + Tag.tagme + Tag.niconico + Tag.video + Tag.no_deerjikist + + stub_python([{ + 'code' => 'sm9', + 'title' => 't', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) + + expect { + run_rake_task('nico:sync') + }.to change(PostVersion, :count).by(1) + + post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9') + version = post.post_versions.order(:version_no).last + + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.created_by_user).to be_nil + expect(version.tags).to eq(snapshot_tags(post.reload)) + end + + it '既存 post の内容または tags が変わったとき update version を作る' do + post = Post.create!( + title: 'old', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil + ) + + kept_general = create_tag!('spec_kept', category: 'general') + PostTag.create!(post: post, tag: kept_general) + create_post_version_for!(post) + + linked = create_tag!('spec_linked', category: 'general') + nico = create_tag!('nico:AAA', category: 'nico') + link_nico_to_tag!(nico, linked) + + Tag.bot + Tag.tagme + Tag.no_deerjikist + + stub_python([{ + 'code' => 'sm9', + 'title' => 't', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) + + expect { + run_rake_task('nico:sync') + }.to change(PostVersion, :count).by(1) + + version = post.reload.post_versions.order(:version_no).last + expect(version.version_no).to eq(2) + expect(version.event_type).to eq('update') + expect(version.created_by_user).to be_nil + expect(version.tags).to eq(snapshot_tags(post.reload)) + end + + it '既存 post に差分が無いときは新しい version を作らない' do + nico = create_tag!('nico:AAA', category: 'nico') + no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta') + + post = Post.create!( + title: 't', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil, + original_created_from: Time.iso8601('2026-01-01T03:34:00Z'), + original_created_before: Time.iso8601('2026-01-01T03:35:00Z') + ) + + PostTag.create!(post: post, tag: nico) + PostTag.create!(post: post, tag: no_deerjikist) + create_post_version_for!(post) + + stub_python([{ + 'code' => 'sm9', + 'title' => 't', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) + + expect { + run_rake_task('nico:sync') + }.not_to change(PostVersion, :count) + + version = post.reload.post_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.tags).to eq(snapshot_tags(post.reload)) + end end From e72ec608f4147e30311a45119ee3acbe49f7c72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= <miteruzo@naver.com> Date: Tue, 14 Apr 2026 12:31:48 +0900 Subject: [PATCH 05/17] =?UTF-8?q?=E5=88=A9=E7=94=A8=E8=A6=8F=E7=B4=84?= =?UTF-8?q?=EF=BC=88#95=EF=BC=89=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #95 #95 #95 #95 #95 Merge remote-tracking branch 'origin/main' into feature/095 #95 #95 #95 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/311 --- frontend/package-lock.json | 1128 ++++++++++++++--- frontend/package.json | 8 +- frontend/src/App.tsx | 77 +- frontend/src/components/PostList.tsx | 2 +- frontend/src/components/TagSidebar.tsx | 4 +- frontend/src/components/TopNav.tsx | 331 +++-- frontend/src/components/WikiBody.tsx | 24 +- .../src/components/common/SectionTitle.tsx | 12 +- frontend/src/components/layout/MainArea.tsx | 10 +- .../components/layout/SidebarComponent.tsx | 11 +- frontend/src/mdx-components.tsx | 4 + frontend/src/pages/MorePage.tsx | 46 + frontend/src/pages/TOSPage.mdx | 134 ++ .../src/pages/materials/MaterialBasePage.tsx | 3 +- frontend/src/pages/posts/PostDetailPage.tsx | 3 +- frontend/src/pages/posts/PostListPage.tsx | 3 +- .../src/pages/theatres/TheatreDetailPage.tsx | 2 +- frontend/src/pages/wiki/WikiDetailPage.tsx | 19 +- frontend/src/types.ts | 28 +- frontend/tailwind.config.js | 4 +- frontend/vite.config.ts | 5 +- 21 files changed, 1468 insertions(+), 390 deletions(-) create mode 100644 frontend/src/mdx-components.tsx create mode 100644 frontend/src/pages/MorePage.tsx create mode 100644 frontend/src/pages/TOSPage.mdx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ad6239..6123d0e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/noto-sans-jp": "^5.2.9", + "@mdx-js/react": "^3.1.1", + "@mdx-js/rollup": "^3.1.1", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-toast": "^1.2.14", @@ -40,8 +42,10 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@tailwindcss/typography": "^0.5.19", "@types/axios": "^0.14.4", "@types/markdown-it": "^14.1.2", + "@types/mdx": "^2.0.13", "@types/node": "^24.0.13", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", @@ -996,19 +1000,32 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@fontsource-variable/noto-sans-jp": { "version": "5.2.9", "resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.9.tgz", @@ -1155,6 +1172,79 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@mdx-js/rollup": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/rollup/-/rollup-3.1.1.tgz", + "integrity": "sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw==", + "license": "MIT", + "dependencies": { + "@mdx-js/mdx": "^3.0.0", + "@rollup/pluginutils": "^5.0.0", + "source-map": "^0.7.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "rollup": ">=2" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1681,18 +1771,58 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, "engines": { "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", - "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -1704,9 +1834,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", - "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -1718,9 +1848,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", - "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -1732,9 +1862,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", - "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -1746,9 +1876,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", - "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -1760,9 +1890,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", - "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -1774,13 +1904,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", - "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1788,13 +1921,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", - "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1802,13 +1938,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", - "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1816,41 +1955,84 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", - "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", - "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", - "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1858,13 +2040,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", - "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1872,13 +2057,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", - "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1886,13 +2074,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", - "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1900,13 +2091,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", - "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1914,23 +2108,54 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", - "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", - "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -1942,9 +2167,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", - "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -1955,10 +2180,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", - "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -1969,6 +2208,33 @@ "win32" ] }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.2", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", @@ -2061,9 +2327,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -2132,6 +2398,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2346,9 +2618,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -2356,13 +2628,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2456,7 +2728,6 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2469,16 +2740,15 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2567,6 +2837,15 @@ "node": ">=10" } }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2612,14 +2891,14 @@ } }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/bail": { @@ -2653,9 +2932,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2925,6 +3204,16 @@ "node": ">=6" } }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3206,6 +3495,38 @@ "node": ">= 0.4" } }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esbuild": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", @@ -3438,6 +3759,35 @@ "node": ">=4.0" } }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -3448,6 +3798,58 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3588,16 +3990,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -3632,14 +4034,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -3768,9 +4171,10 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -3802,9 +4206,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -3812,13 +4216,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3893,19 +4297,47 @@ "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/hast-util-to-jsx-runtime": { @@ -4184,9 +4616,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4375,10 +4807,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -4564,6 +5008,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -4639,9 +5100,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -4899,6 +5360,108 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -4942,6 +5505,33 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, "node_modules/micromark-factory-space": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", @@ -5143,6 +5733,31 @@ ], "license": "MIT" }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, "node_modules/micromark-util-html-tag-name": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", @@ -5308,9 +5923,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5582,9 +6197,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", "license": "MIT", "funding": { "type": "opencollective", @@ -5599,9 +6214,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5826,10 +6441,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -6030,12 +6648,12 @@ } }, "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -6045,13 +6663,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -6123,6 +6741,88 @@ "node": ">=8.10.0" } }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -6141,6 +6841,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -6232,13 +6946,13 @@ } }, "node_modules/rollup": { - "version": "4.40.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", - "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -6248,26 +6962,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.40.2", - "@rollup/rollup-android-arm64": "4.40.2", - "@rollup/rollup-darwin-arm64": "4.40.2", - "@rollup/rollup-darwin-x64": "4.40.2", - "@rollup/rollup-freebsd-arm64": "4.40.2", - "@rollup/rollup-freebsd-x64": "4.40.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", - "@rollup/rollup-linux-arm-musleabihf": "4.40.2", - "@rollup/rollup-linux-arm64-gnu": "4.40.2", - "@rollup/rollup-linux-arm64-musl": "4.40.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", - "@rollup/rollup-linux-riscv64-gnu": "4.40.2", - "@rollup/rollup-linux-riscv64-musl": "4.40.2", - "@rollup/rollup-linux-s390x-gnu": "4.40.2", - "@rollup/rollup-linux-x64-gnu": "4.40.2", - "@rollup/rollup-linux-x64-musl": "4.40.2", - "@rollup/rollup-win32-arm64-msvc": "4.40.2", - "@rollup/rollup-win32-ia32-msvc": "4.40.2", - "@rollup/rollup-win32-x64-msvc": "4.40.2", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, @@ -6359,6 +7078,15 @@ "integrity": "sha512-p19rtTs+NksBRKW9qn0UhZ8/TUI9BPw9lmtHny+Y3TinWlOa9jWh9xB0AtPSdmOy49NJJJSSe0Ey4C7h0TrcYA==", "license": "BSD-3-Clause" }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6681,9 +7409,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6872,6 +7600,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -7043,9 +7784,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7133,9 +7874,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -7274,9 +8015,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -7284,6 +8025,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { diff --git a/frontend/package.json b/frontend/package.json index df73a58..1745d0f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/noto-sans-jp": "^5.2.9", + "@mdx-js/react": "^3.1.1", + "@mdx-js/rollup": "^3.1.1", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-toast": "^1.2.14", @@ -37,13 +39,15 @@ "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0", - "zustand": "^5.0.8", - "unist-util-visit-parents": "^6.0.1" + "unist-util-visit-parents": "^6.0.1", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.25.0", + "@tailwindcss/typography": "^0.5.19", "@types/axios": "^0.14.4", "@types/markdown-it": "^14.1.2", + "@types/mdx": "^2.0.13", "@types/node": "^24.0.13", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b49aa26..7316e8b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { AnimatePresence, LayoutGroup } from 'framer-motion' +import { AnimatePresence, LayoutGroup, motion } from 'framer-motion' import { useEffect, useState } from 'react' import { BrowserRouter, Navigate, @@ -15,8 +15,10 @@ import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage' // import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' +import MorePage from '@/pages/MorePage' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' +import TOSPage from '@/pages/TOSPage.mdx' import PostDetailPage from '@/pages/posts/PostDetailPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostListPage from '@/pages/posts/PostListPage' @@ -44,36 +46,36 @@ const RouteTransitionWrapper = ({ user, setUser }: { const location = useLocation () return ( - <LayoutGroup id="gallery-shared"> - <AnimatePresence mode="wait"> - <Routes location={location}> - <Route path="/" element={<Navigate to="/posts" replace/>}/> - <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="/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="*" 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/nico" element={<NicoTagListPage user={user}/>}/> + <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>) } @@ -131,10 +133,15 @@ export default (() => { <> <RouteBlockerOverlay/> <BrowserRouter> - <div className="flex flex-col h-dvh w-full"> - <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/> </BrowserRouter> </>) diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 39adbb3..c072154 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -56,7 +56,7 @@ export default (({ posts, onClick }: Props) => { cardRef.current.style.zIndex = '' cardRef.current.style.position = '' }} - transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}> + transition={{ layout: { duration: .2, ease: 'easeOut' } }}> <img src={post.thumbnail || post.thumbnailBase || undefined} alt={post.title || post.url} title={post.title || post.url || undefined} diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index 7fbdfa3..1b52c3d 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -65,7 +65,9 @@ export default (({ posts, onClick }: Props) => { {CATEGORIES.flatMap (cat => cat in tags ? ( tags[cat].map (tag => ( <li key={tag.id} className="mb-1"> - <motion.div layoutId={`tag-${ tag.id }`}> + <motion.div + transition={{ layout: { duration: .2, ease: 'easeOut' } }} + layoutId={`tag-${ tag.id }`}> <TagLink tag={tag} onClick={onClick}/> </motion.div> </li>))) : [])} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 27d40b1..2b5eee2 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -14,11 +14,65 @@ import { fetchWikiPage } from '@/lib/wiki' import type { FC, MouseEvent } from 'react' -import type { Menu, User } from '@/types' +import type { Menu, MenuVisibleItem, Tag, User } from '@/types' type Props = { user: User | null } +export 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] ?? '' + + 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/aliases', visible: false }, + { name: '上位タグ', to: '/tags/implications', visible: false }, + { name: 'ニコニコ連携', to: '/tags/nico' }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, + { 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: <>第 1 会場</>, to: '/theatres/1' }, + { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, + { name: <>ニジカ放送局第 1 チャンネル</>, + 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) => { const location = useLocation () @@ -26,25 +80,30 @@ export default (({ user }: Props) => { const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) const navRef = useRef<HTMLDivElement | null> (null) - const measure = () => { + const measure = (idx: number) => { 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 elRect = el.getBoundingClientRect () - setHl ({ left: elRect.left - navRect.left, + setHL ({ left: elRect.left - navRect.left, width: elRect.width, visible: true }) } - const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({ + const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({ left: 0, width: 0, visible: false }) const [menuOpen, setMenuOpen] = useState (false) + const [moreVsbl, setMoreVsbl] = useState (false) const [openItemIdx, setOpenItemIdx] = useState (-1) const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) @@ -62,51 +121,10 @@ export default (({ user }: Props) => { queryKey: tagsKeys.show (effectiveTitle), queryFn: () => fetchTagByName (effectiveTitle) }) - const postCount = tag?.postCount ?? 0 - - 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' }, - { name: '別名タグ', to: '/tags/aliases', visible: false }, - { name: '上位タグ', to: '/tags/implications', visible: false }, - { name: 'ニコニコ連携', to: '/tags/nico' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, - // { name: '素材', to: '/materials', subMenu: [ - // { name: '一覧', to: '/materials' }, - // { name: '検索', to: '/materials/search', visible: false }, - // { name: '追加', to: '/materials/new' }, - // { name: '履歴', to: '/materials/changes', visible: false }, - // { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, - { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ - { name: <>第 1 会場</>, to: '/theatres/1' }, - { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, - { name: <>ニジカ放送局第 1 チャンネル</>, - 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', 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) @@ -119,28 +137,24 @@ export default (({ user }: Props) => { const dir = dirRef.current useLayoutEffect (() => { - if (activeIdx < 0) - return - - const raf = requestAnimationFrame (measure) - const onResize = () => requestAnimationFrame (measure) + const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx)) + const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx)) addEventListener ('resize', onResize) return () => { cancelAnimationFrame (raf) removeEventListener ('resize', onResize) } - }, [activeIdx]) + }) useEffect (() => { const unsubscribe = WikiIdBus.subscribe (setWikiId) return () => unsubscribe () - }, []) + }, [activeIdx]) useEffect (() => { setMenuOpen (false) - setOpenItemIdx (menu.findIndex (item => ( - location.pathname.startsWith (item.base || item.to)))) + setOpenItemIdx (activeIdx) }, [location]) return ( @@ -167,17 +181,39 @@ export default (({ user }: Props) => { transform: `translateX(${ hl.left }px)`, 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')}> + その他 » + </PrefetchLink> </div> </div> @@ -195,36 +231,115 @@ export default (({ user }: Props) => { </a> </nav> - <div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950 - items-center w-full min-h-[40px] overflow-hidden"> - <AnimatePresence initial={false} 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}> {menuOpen && ( @@ -241,7 +356,7 @@ export default (({ user }: Props) => { exit="closed" transition={{ duration: .2, ease: 'easeOut' }}> <Separator/> - {menu.map ((item, i) => ( + {visibleMenu.map ((item, i) => ( <Fragment key={i}> <PrefetchLink to={i === openItemIdx ? item.to : '#'} @@ -294,6 +409,16 @@ export default (({ user }: Props) => { </motion.div>)} </AnimatePresence> </Fragment>))} + <PrefetchLink + to="/more" + ref={(el: (HTMLAnchorElement | null)) => { + itemsRef.current[visibleMenu.length] = el + }} + className={cn ('w-full min-h-[40px] flex items-center pl-8', + ((openItemIdx < 0) + && 'font-bold bg-yellow-50 dark:bg-red-950'))}> + その他 » + </PrefetchLink> <TopNavUser user={user} sp/> <Separator/> </motion.div>)} diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index 50316b2..49ac6e5 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -4,8 +4,6 @@ import ReactMarkdown from 'react-markdown' import remarkGFM from 'remark-gfm' import PrefetchLink from '@/components/PrefetchLink' -import SectionTitle from '@/components/common/SectionTitle' -import SubsectionTitle from '@/components/common/SubsectionTitle' import { wikiKeys } from '@/lib/queryKeys' import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' import { fetchWikiPages } from '@/lib/wiki' @@ -16,19 +14,15 @@ import type { Components } from 'react-markdown' type Props = { title: string body?: string } -const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>, - h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>, - ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>, - ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, - a: (({ href, children }) => ( - ['/', '.'].some (e => href?.startsWith (e)) - ? <PrefetchLink to={href!}>{children}</PrefetchLink> - : ( - <a href={href} - target="_blank" - rel="noopener noreferrer"> - {children} - </a>))) } as const satisfies Components +const mdComponents = { a: (({ href, children }) => ( + ['/', '.'].some (e => href?.startsWith (e)) + ? <PrefetchLink to={href!}>{children}</PrefetchLink> + : ( + <a href={href} + target="_blank" + rel="noopener noreferrer"> + {children} + </a>))) } as const satisfies Components export default (({ title, body }: Props) => { diff --git a/frontend/src/components/common/SectionTitle.tsx b/frontend/src/components/common/SectionTitle.tsx index fb8b6bf..81e7158 100644 --- a/frontend/src/components/common/SectionTitle.tsx +++ b/frontend/src/components/common/SectionTitle.tsx @@ -1,9 +1,11 @@ -import React from 'react' +import { cn } from '@/lib/utils' -type Props = { children: React.ReactNode } +import type { ComponentPropsWithoutRef, FC } from 'react' +type Props = ComponentPropsWithoutRef<'h2'> -export default ({ children }: Props) => ( - <h2 className="text-xl my-4"> + +export default (({ children, className, ...rest }: Props) => ( + <h2 {...rest} className={cn ('text-xl my-4', className)}> {children} - </h2>) + </h2>)) satisfies FC<Props> diff --git a/frontend/src/components/layout/MainArea.tsx b/frontend/src/components/layout/MainArea.tsx index 273793d..e573525 100644 --- a/frontend/src/components/layout/MainArea.tsx +++ b/frontend/src/components/layout/MainArea.tsx @@ -1,3 +1,5 @@ +import { motion } from 'framer-motion' + import { cn } from '@/lib/utils' import type { FC, ReactNode } from 'react' @@ -8,7 +10,9 @@ type Props = { export default (({ children, className }: Props) => ( - <main className={cn ('flex-1 overflow-y-auto p-4 md:h-[calc(100dvh-88px)]', - className)}> + <motion.main + transition={{ layout: { duration: .2, ease: 'easeOut' } }} + className={cn ('flex-1 overflow-y-auto p-4', className)} + layout="position"> {children} - </main>)) satisfies FC<Props> + </motion.main>)) satisfies FC<Props> diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index d6e8803..4f8d983 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -1,3 +1,4 @@ +import { motion } from 'framer-motion' import { Helmet } from 'react-helmet-async' import type { FC, ReactNode } from 'react' @@ -6,10 +7,10 @@ type Props = { children: ReactNode } export default (({ children }: Props) => ( - <div - className="p-4 w-full md:w-64 md:h-full - md:h-[calc(100dvh-88px)] md:overflow-y-auto - sidebar"> + <motion.div + layout="position" + transition={{ layout: { duration: .2, ease: 'easeOut' } }} + className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar"> <Helmet> <style> {` @@ -26,4 +27,4 @@ export default (({ children }: Props) => ( </Helmet> {children} - </div>)) satisfies FC<Props> + </motion.div>)) satisfies FC<Props> diff --git a/frontend/src/mdx-components.tsx b/frontend/src/mdx-components.tsx new file mode 100644 index 0000000..b49be36 --- /dev/null +++ b/frontend/src/mdx-components.tsx @@ -0,0 +1,4 @@ +import type { MDXComponents } from 'mdx/types' + + +export const useMDXComponents = (): MDXComponents => ({ }) diff --git a/frontend/src/pages/MorePage.tsx b/frontend/src/pages/MorePage.tsx new file mode 100644 index 0000000..a798a26 --- /dev/null +++ b/frontend/src/pages/MorePage.tsx @@ -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 }`} + + + {[...Array (Math.ceil (menu.length / 4)).keys ()].map (i => ( +
    + {menu.slice (4 * i, 4 * (i + 1)).map ((item, j) => ( +
    + {item.name} +
      + {item.subMenu + .filter (subItem => (subItem.visible ?? true)) + .map ((subItem, k) => ('name' in subItem && ( +
    • + + {subItem.name} + +
    • )))} +
    +
    ))} +
    ))} + ) +}) satisfies FC diff --git a/frontend/src/pages/TOSPage.mdx b/frontend/src/pages/TOSPage.mdx new file mode 100644 index 0000000..1ea976c --- /dev/null +++ b/frontend/src/pages/TOSPage.mdx @@ -0,0 +1,134 @@ +import { Helmet } from 'react-helmet-async' +import MainArea from '@/components/layout/MainArea' +import { SITE_TITLE } from '@/config' +import { dateString } from '@/lib/utils' + +export const lastUpdatedAt = dateString ('2026-04-12', 'hour') + + + + + {`利用規約 | ${ SITE_TITLE }`} + + +
    + # 利用規約 + + 最終更新日: {lastUpdatedAt} + + この利用規約(以下「本規約」)は、ぼざクリ タグ広場(以下「本サービス」)の利用条件を定めるものです。利用者は、本サービスを利用した時点で、本規約に同意したものとみなされます。 + + ## 第 1 条 本サービスの位置づけ + + 1. 本サービスは、タグ・Wiki・外部リンクの整理を中心とする知識共有基盤です。 + 2. 本サービスの中心価値は、コンテンツそのものの再配布ではなく、タグを軸にした整理、検索、再発見、および周辺知識の蓄積にあります。 + 3. 本サービスは、運営上の必要に応じて、機能、公開範囲、名称、URL、表示内容その他の仕様を変更することがあります。 + + ## 第 2 条 公開方針と利用者区分 + + 1. 本サービスは、初回一般公開時点では、**誰でも閲覧できる一方で、投稿・編輯は申請制** とします。 + 2. 初回一般公開時点では、通常の農奴は閲覧のみを行えます。 + 3. 投稿、タグ編輯、Wiki 編輯その他の耕作行為は、運営が承認した利用者(以下「耕作員」)に限って認めます。 + 4. 独裁者は、耕作員に加えて、差戻、削除、利用制限、その他の管理操作を行えます。 + 5. 運営は、履歴管理、差戻、BAN 運用、監査導線その他の運営装備がじゅうぶんに整ったと判断した場合、農奴に一部の編輯権限を開放することがあります。 + 6. 利用者区分、権限範囲、申請条件、承認基準、承認後の取扱いは、運営が必要に応じて定め、変更できます。 + + ## 第 3 条 利用開始と引継ぎコード + + 1. 本サービスでは、一般的な Id. / パスワード方式ではなく、運営が別途定める認証情報または引継ぎコードを用いる場合があります。 + 2. 利用者は、自身に割り当てられた引継ぎコード、認証情報、端末上の保存情報を自己の責任で管理するものとします。 + 3. 利用者は、自己の引継ぎコードまたは認証情報を第 3 者に譲渡、貸与、共有、漏洩してはなりません。 + 4. 引継ぎコードの漏洩、第 3 者利用、紛失、盗用その他の事故によって利用者または第 3 者に生じた損害について、運営は責任を負いません。 + 5. 運営は、本人確認、濫用対策、監査対応または保守のため、利用情報とアクセス元情報を関聯づけて扱うことがあります。 + + ## 第 4 条 申請制編輯の基本ルール + + 1. 耕作員は、タグ整理基盤の品質維持を最優先し、個人的な所有主張ではなく、検索性、再利用性、可読性、整合性を重視して編輯しなければなりません。 + 2. 耕作員は、主観的な好悪、内輪ネタ、報復、私怨、対立誘導のためにタグや Wiki を操作してはなりません。 + 3. 耕作員は、誤りの修正、体系の整理、リンクの保守、知識の補足を目的として編輯を行うものとします。 + 4. 運営は、申請内容、過去の行動、編輯品質、聯絡可能性、運営負荷その他の事情を考慮して、承認、保留、拒否、取消を行えます。 + 5. 耕作員資格は権利ではなく、運営が本サービスの維持のために付与する可撤回の権限です。 + + ## 第 5 条 禁止事項 + + 利用者は、以下の行為をしてはなりません。 + + 1. 法令または公序良俗に違反する行為。 + 2. 犯罪を助長し、またはこれに結びつく行為。 + 3. 著作権、著作者人格権、商標権、肖像権、パブリシティ権、プライバシー権その他第 3 者の権利を侵害する行為。 + 4. 無断転載、違法アップロード、違法複製物、海賊版、権限のない転載先への誘導、またはそれらを正当化、拡散、補助する行為。 + 5. 実在人物に関する名誉毀損、侮辱、差別、脅迫、晒し、つきまとい、嫌がらせ、私刑の扇動その他の加害行為。 + 6. 個人情報、非公開情報、秘匿されるべき情報を本人の承諾なく掲載、送信、共有、推測可能な形で開示する行為。 + 7. 虚偽の情報、誤解を招く情報、出典を偽装した情報、意図的なミスリード、荒らし目的のタグづけ、関係のないタグの大量付与、分類妨碍、検索妨碍その他の品質破壊行為。 + 8. マルウェア、フィッシング、詐欺、誘導広告、悪質なリダイレクト、危険な外部リンクその他利用者または運営に危害を与える行為。 + 9. 本サービスの趣旨に照らして不相当な政治的扇動、宗教勧誘、商業宣伝、連鎖的勧誘、スパム、同一内容の反復送信。 + 10. 未成年の安全に反する行為、児童性的搾取、違法または著しく不適切な性的表現、過度に露骨な性表現や残虐表現を、一般公開導線に無警告で流し込む行為。 + 11. 運営、他の利用者、外部サービスまたは第 3 者に著しい負担、不利益、混乱を生じさせる行為。 + 12. 前各号のいずれかを試みる行為、教唆する行為、容易にする行為。 + 13. その他、運営が本サービスの目的または安全な運営に照らして不適切と判断する行為。 + + ## 第 6 条 投稿、タグ、Wiki 等の取扱い + + 1. 利用者は、自らが投稿、編輯、登録、送信または変更する情報について、必要な権利を有し、または適法に利用できる状態でなければなりません。 + 2. 利用者は、自らが行った投稿、タグづけ、Wiki 編輯、説明文、コメント、関聯づけその他の行為について責任を負います。 + 3. 利用者は、運営に対し、本サービスの運営、表示、複製、保存、配信、整形、引用、履歴表示、差戻、バックアップ、障碍対応および弘報のために必要な範囲で、当該利用者生成情報を無償で利用する非独占的な権利を許諾するものとします。 + 4. 前項の許諾は、本サービスの運営上必要な範囲に限られ、利用者の権利帰属自体を運営へ移転するものではありません。 + 5. 運営は、分類整合性、表記統一、誤記修正、別名統合、差戻その他の理由により、投稿、タグ、Wiki その他の内容を編輯、非表示化、削除、統合、分割または凍結できます。 + + ## 第 7 条 外部リンクと埋め込み + + 1. 本サービスは、外部サイトへのリンク、外部コンテンツの埋め込みまたはそれらに関するメタデータを表示する場合があります。 + 2. 外部リンク先または埋め込み先の権利、利用条件、公開範囲、削除方針、広告、追跡、Cookie その他の取扱いは、当該外部サービスの定めに従います。 + 3. 運営は、外部リンク先の適法性、安全性、継続性、正確性、品質、可用性、または内容の完全性を保証しません。 + 4. 外部権利者からの申立て、運営判断、法令対応または安全性確保のため、運営は外部リンク、埋め込み、サムネイル、説明文その他の表示を制限、差替え、非表示または削除できます。 + + ## 第 8 条 履歴、差戻、削除 + + 1. 本サービスでは、保守、監査、荒らし対策、説明責任その他の目的で、投稿、タグ、Wiki その他の変更履歴を保持し、表示し、または内部的に参照することがあります。 + 2. 利用者は、一度行った編輯が、後に差戻、修正、非表示化または削除されることがあることをあらかじめ承諾するものとします。 + 3. 利用者が削除を希望した場合でも、法令上、保守上、監査上、紛争対応上またはバックアップ上の必要により、直ちに完全消去できないことがあります。 + 4. 運営は、本サービス全体の健全性を維持するため、説明の有無を問わず、履歴の表示範囲、保存期間、差戻方針、削除方針を定め、変更できます。 + + ## 第 9 条 利用制限、資格取消、BAN + + 1. 運営は、利用者が次のいずれかに該当すると判断した場合、事前の通知なく、または通知後に、投稿・編輯の制限、耕作員資格の取消、コンテンツの非表示または削除、引継ぎコードの失効、ユーザ BAN、IP BAN その他必要な措置を行えます。 + - 本規約に違反した場合 + - 本サービスの趣旨に反する運用妨碍、荒らし、品質破壊行為を行った場合 + - 運営からの確認、修正要請、停止要請に合理的理由なく応じない場合 + - 登録情報、申請内容または説明に虚偽がある場合 + - 安全性、法令順守、運営継続の観点から措置が必要と判断された場合 + 2. 運営は、前項の措置について、その理由、基準、証拠または内部判断過程を常に開示する義務を負いません。 + 3. 利用制限または資格取消後も、運営は、必要に応じて履歴、ログ、申請記録、通報記録その他のデータを保持できます。 + + ## 第 10 条 未成年の利用 + + 1. 運営は、未成年の安全確保の観点から、年齢に応じた表示制限、導線制御、非表示化、削除、申請拒否その他の措置を行えます。 + 2. 利用者は、未成年が閲覧しうる一般公開面において、未成年に不適切な内容を無警告で流し込まないものとします。 + + ## 第 11 条 お問い合わせ、通報、御意見番 + + 1. 利用者は、本サービスが別途案内する問い合わせ、通報または御意見板の導線を通じて、バグ報告、問題報告、削除要請その他の聯絡を行えます。 + 2. 運営は、すべての問い合わせに回答する義務を負わず、回答期限、対応結果または対応方法を保証しません。 + + ## 第 12 条 免責 + + 1. 運営は、本サービスについて、特定目的適合性、完全性、正確性、継続性、安全性、無瑕疵性、または利用者の期待への適合を保証しません。 + 2. 運営は、外部リンク先、外部埋め込み先、第 3 者投稿、利用者同士の紛争、通信障碍、データ消失、誤分類、誤リンク、誤記、差戻、機能停止または仕様変更によって生じた損害について、責任を負いません。 + 3. 本サービスは、予告なく停止、終了、変更または縮小されることがあります。 + + ## 第 13 条 規約の変更 + + 1. 運営は、法令改正、機能追加、運用方針の変更、安全対策、表現調整その他の理由により、本規約を変更できます。 + 2. 変更後の本規約は、本サービス上に掲載された時点または運営が別途定める時点から効力を生じます。 + 3. 変更後に利用を継続した利用者は、変更後の本規約に同意したものとみなされます。 + + ## 第 14 条 準拠法および管轄 + + 1. 本規約および本サービスの利用には、日本法を準拠法とします。 + 2. 本規約または本サービスに関して生じた一切の紛争については、運営の所在地を管轄する裁判所を第 1 審の専属的合意管轄裁判所とします。ただし、法令に別段の定めがある場合はこの限りではありません。 + + ## 附則 + + 本規約は、{lastUpdatedAt} から適用します。 +
    +
    diff --git a/frontend/src/pages/materials/MaterialBasePage.tsx b/frontend/src/pages/materials/MaterialBasePage.tsx index 467ef58..d641f47 100644 --- a/frontend/src/pages/materials/MaterialBasePage.tsx +++ b/frontend/src/pages/materials/MaterialBasePage.tsx @@ -6,8 +6,7 @@ import type { FC } from 'react' export default (() => ( -
    +
    )) satisfies FC diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 518b914..51d9b15 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -93,8 +93,7 @@ export default (({ user }: Props) => { : 'bg-gray-500 hover:bg-gray-600') return ( -
    +
    {(post?.thumbnail || post?.thumbnailBase) && ( )} diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 979c75e..6d76887 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -70,8 +70,7 @@ export default (() => { return (
    diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index ba8ec75..a0617b0 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -232,7 +232,7 @@ export default (() => { return <ErrorScreen status={status}/> return ( - <div className="md:flex md:flex-1"> + <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> <Helmet> {theatre && ( <title> diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index fd2c0b5..7eab461 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -7,7 +7,6 @@ import PostList from '@/components/PostList' import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' import WikiBody from '@/components/WikiBody' -import PageTitle from '@/components/common/PageTitle' import TabGroup, { Tab } from '@/components/common/TabGroup' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' @@ -107,15 +106,15 @@ export default () => { </PrefetchLink>) : '(最新)'} </div>)} - <PageTitle> - <TagLink tag={tag ?? defaultTag} - withWiki={false} - withCount={false} - {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> - </PageTitle> - <div className="prose mx-auto p-4"> - {loading ? 'Loading...' : <WikiBody title={title} body={wikiPage?.body}/>} - </div> + <article className="prose dark:prose-invert mx-auto p-4"> + <h1 className="prose-a:no-underline"> + <TagLink tag={tag ?? defaultTag} + withWiki={false} + withCount={false} + {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> + </h1> + {loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} + </article> {(!(version) && posts.length > 0) && ( <TabGroup> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2f71826..c453a10 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -63,10 +63,20 @@ export type Material = { export type Menu = MenuItem[] -export type MenuItem = { +export type MenuInvisibleItem = { + name: ReactNode + to?: string + base?: string + visible: false + subMenu: SubMenuItem[] } + +export type MenuItem = MenuVisibleItem | MenuInvisibleItem + +export type MenuVisibleItem = { name: ReactNode to: string base?: string + visible?: true subMenu: SubMenuItem[] } export type NicoTag = Tag & { @@ -126,12 +136,16 @@ export type PostTagChange = { changeType: 'add' | 'remove' timestamp: string } -export type SubMenuItem = - | { component: ReactNode - visible: boolean } - | { name: ReactNode - to: string - visible?: boolean } +export type SubMenuComponentItem = { + component: ReactNode + visible: boolean } + +export type SubMenuItem = SubMenuComponentItem | SubMenuStringItem + +export type SubMenuStringItem = { + name: ReactNode + to: string + visible?: boolean } export type Tag = { id: number diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index fddc378..7982b0a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -8,7 +8,7 @@ import { DARK_COLOUR_SHADE, const colours = Object.values (TAG_COLOUR) export default { - content: ['./src/**/*.{html,js,ts,jsx,tsx}'], + content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'], safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`), ...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`), ...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`), @@ -24,4 +24,4 @@ export default { 'rainbow-scroll': { '0%': { backgroundPosition: '0% 50%' }, '100%': { backgroundPosition: '200% 50%' } } } } }, - plugins: [] } satisfies Config + plugins: [require ('@tailwindcss/typography')] } satisfies Config diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9e1bfe2..948e859 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,11 +1,12 @@ -import { defineConfig } from 'vite' +import mdx from '@mdx-js/rollup' import react from '@vitejs/plugin-react' import path from 'path' +import { defineConfig } from 'vite' // https://vite.dev/config/ export default defineConfig ({ - plugins: [react()], + plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()], resolve: { alias: { '@': path.resolve (__dirname, './src') } }, server: { host: true, port: 5173, From bd11e37fd317bb73fad3176caf7f80fc6f8068af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= <miteruzo@naver.com> Date: Wed, 15 Apr 2026 20:21:51 +0900 Subject: [PATCH 06/17] =?UTF-8?q?=E7=B7=8A=E6=80=A5=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=EF=BC=88#312=EF=BC=89=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'backend/config/storage.yml' を更新 Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/313 --- backend/config/storage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/config/storage.yml b/backend/config/storage.yml index c2c2a46..ae4ef9b 100644 --- a/backend/config/storage.yml +++ b/backend/config/storage.yml @@ -13,3 +13,4 @@ r2: secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %> bucket: <%= ENV['R2_BUCKET'] %> region: auto + request_checksum_calculation: when_required From 48f823a7c895d8908c01d89e1721d42c290eb755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= <miteruzo@naver.com> Date: Sat, 18 Apr 2026 05:43:33 +0900 Subject: [PATCH 07/17] =?UTF-8?q?=E5=B1=A5=E6=AD=B4=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=EF=BC=88#308=EF=BC=89=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge branch 'main' into feature/308 #308 #308 #308 #308 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/315 --- .../controllers/post_versions_controller.rb | 119 ++++++++ backend/app/controllers/posts_controller.rb | 8 +- backend/config/routes.rb | 1 + backend/spec/requests/posts_spec.rb | 212 ++++++++++++++ .../components/DraggableDroppableTagRow.tsx | 4 +- frontend/src/components/PostEditForm.tsx | 2 +- frontend/src/components/TagDetailSidebar.tsx | 8 +- frontend/src/lib/posts.ts | 14 +- frontend/src/lib/queryKeys.ts | 2 +- frontend/src/pages/posts/PostDetailPage.tsx | 4 +- frontend/src/pages/posts/PostHistoryPage.tsx | 262 +++++++++++++----- frontend/src/pages/posts/PostSearchPage.tsx | 4 +- frontend/src/types.ts | 22 +- 13 files changed, 563 insertions(+), 99 deletions(-) create mode 100644 backend/app/controllers/post_versions_controller.rb diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb new file mode 100644 index 0000000..04032e3 --- /dev/null +++ b/backend/app/controllers/post_versions_controller.rb @@ -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 diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index fda77ed..26ca581 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -44,7 +44,7 @@ class PostsController < ApplicationController filtered_posts .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) - .preload(tags: { tag_name: :wiki_page }) + .preload(tags: [:materials, { tag_name: :wiki_page }]) .with_attached_thumbnail q = q.where('posts.url LIKE ?', "%#{ url }%") if url @@ -95,7 +95,7 @@ class PostsController < ApplicationController end def random - post = filtered_posts.preload(tags: { tag_name: :wiki_page }) + post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }]) .order('RAND()') .first return head :not_found unless post @@ -104,7 +104,7 @@ class PostsController < ApplicationController end def show - post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) + post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) return head :not_found unless post render json: PostRepr.base(post, current_user) @@ -204,7 +204,7 @@ class PostsController < ApplicationController pts = pts.where(post_id: id) if id.present? pts = pts.where(tag_id:) if tag_id.present? pts = pts.includes(:post, :created_user, :deleted_user, - tag: { tag_name: :wiki_page }) + tag: [:materials, { tag_name: :wiki_page }]) events = [] pts.each do |pt| diff --git a/backend/config/routes.rb b/backend/config/routes.rb index fc56aa4..edd978f 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -49,6 +49,7 @@ Rails.application.routes.draw do collection do get :random get :changes + get :versions, to: 'post_versions#index' end member do diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 1295165..605fd8d 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -756,6 +756,218 @@ RSpec.describe 'Posts API', type: :request do end end + describe 'GET /posts/versions' do + let(:member) { create(:user, :member, name: 'version member') } + + let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } + let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } + let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) } + + let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) } + let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) } + + let!(:tag_name2) { TagName.create!(name: 'spec_tag_2') } + let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) } + + def snapshot_tags(post) + post.snapshot_tag_names.join(' ') + end + + def create_post_version!(post, version_no:, event_type:, created_by_user:, created_at:) + PostVersion.create!( + post: post, + version_no: version_no, + event_type: event_type, + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: snapshot_tags(post), + parent: post.parent, + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: created_at, + created_by_user: created_by_user + ) + end + + let!(:v1) do + travel_to(t_v1) do + create_post_version!( + post_record, + version_no: 1, + event_type: 'create', + created_by_user: member, + created_at: t_v1 + ) + end + end + + let!(:v2) do + post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member) + PostTag.create!(post: post_record, tag: tag2, created_user: member) + post_record.update!( + title: 'updated spec post', + original_created_from: oc_from, + original_created_before: oc_before + ) + + travel_to(t_v2) do + create_post_version!( + post_record.reload, + version_no: 2, + event_type: 'update', + created_by_user: member, + created_at: t_v2 + ) + end + end + + let!(:other_post_version) do + other_post = Post.create!( + title: 'other versioned post', + url: 'https://example.com/other-versioned' + ) + PostTag.create!(post: other_post, tag: tag) + + travel_to(t_other) do + create_post_version!( + other_post, + version_no: 1, + event_type: 'create', + created_by_user: member, + created_at: t_other + ) + end + end + + it 'returns versions for the specified post in reverse chronological order' do + get '/posts/versions', params: { post: post_record.id } + + expect(response).to have_http_status(:ok) + expect(json).to include('versions', 'count') + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.map { |v| v['post_id'] }.uniq).to eq([post_record.id]) + expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) + + latest = versions.first + expect(latest).to include( + 'post_id' => post_record.id, + 'version_no' => 2, + 'event_type' => 'update', + 'created_by_user' => { + 'id' => member.id, + 'name' => member.name + } + ) + + expect(latest.fetch('title')).to eq( + 'current' => 'updated spec post', + 'prev' => 'spec post' + ) + expect(latest.fetch('url')).to eq( + 'current' => 'https://example.com/spec', + 'prev' => 'https://example.com/spec' + ) + expect(latest.fetch('thumbnail')).to eq( + 'current' => nil, + 'prev' => nil + ) + expect(latest.fetch('thumbnail_base')).to eq( + 'current' => nil, + 'prev' => nil + ) + expect(latest.fetch('tags')).to include( + { 'name' => 'spec_tag_2', 'type' => 'added' }, + { 'name' => 'spec_tag', 'type' => 'removed' } + ) + expect(latest.fetch('original_created_from')).to eq( + 'current' => oc_from.iso8601, + 'prev' => nil + ) + expect(latest.fetch('original_created_before')).to eq( + 'current' => oc_before.iso8601, + 'prev' => nil + ) + expect(latest.fetch('created_at')).to eq(t_v2.iso8601) + + first = versions.second + expect(first).to include( + 'post_id' => post_record.id, + 'version_no' => 1, + 'event_type' => 'create', + 'created_by_user' => { + 'id' => member.id, + 'name' => member.name + } + ) + expect(first.fetch('title')).to eq( + 'current' => 'spec post', + 'prev' => nil + ) + expect(first.fetch('tags')).to include( + { 'name' => 'spec_tag', 'type' => 'added' } + ) + expect(first.fetch('created_at')).to eq(t_v1.iso8601) + end + + it 'filters versions by tag when the current snapshot includes the tag' do + get '/posts/versions', params: { post: post_record.id, tag: tag2.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(1) + + versions = json.fetch('versions') + expect(versions.size).to eq(1) + expect(versions[0]['post_id']).to eq(post_record.id) + expect(versions[0]['version_no']).to eq(2) + expect(versions[0]['tags']).to include( + { 'name' => 'spec_tag_2', 'type' => 'added' } + ) + end + + it 'filters versions by tag when the tag exists in either current or previous snapshot' do + get '/posts/versions', params: { post: post_record.id, tag: tag.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.map { |v| v['post_id'] }).to all(eq(post_record.id)) + expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) + + latest = versions[0] + first = versions[1] + + expect(latest['tags']).to include( + { 'name' => 'spec_tag', 'type' => 'removed' } + ) + expect(first['tags']).to include( + { 'name' => 'spec_tag', 'type' => 'added' } + ) + end + + it 'returns empty when tag does not exist' do + get '/posts/versions', params: { tag: 999_999_999 } + + expect(response).to have_http_status(:ok) + expect(json.fetch('versions')).to eq([]) + expect(json.fetch('count')).to eq(0) + end + + it 'clamps page and limit to at least 1' do + get '/posts/versions', params: { post: post_record.id, page: 0, limit: 0 } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.size).to eq(1) + expect(versions[0]['version_no']).to eq(2) + end + end + describe 'POST /posts/:id/viewed' do let(:user) { create(:user) } diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index 6660f7e..55bc4d4 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -90,7 +90,9 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} {...attributes} {...listeners}> - <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> + <motion.div + transition={{ layout: { duration: .2, ease: 'easeOut' } }} + layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> <TagLink tag={tag} nestLevel={nestLevel}/> </motion.div> </div>) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index d4421e8..8c3411b 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -62,7 +62,7 @@ export default (({ post, onSave }: Props) => { <Label>タイトル</Label> <input type="text" className="w-full border rounded p-2" - value={title} + value={title ?? ''} onChange={ev => setTitle (ev.target.value)}/> </div> diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index c02eca9..e195619 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -313,7 +313,9 @@ export default (({ post, sp }: Props) => { {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( <div className="my-3" key={cat}> <SubsectionTitle> - <motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}> + <motion.div + layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`} + transition={{ layout: { duration: .2, ease: 'easeOut' } }}> {CATEGORY_NAMES[cat]} </motion.div> </SubsectionTitle> @@ -325,7 +327,9 @@ export default (({ post, sp }: Props) => { </ul> </div>))} {post && ( - <motion.div layoutId={`post-info-${ sp }`}> + <motion.div + layoutId={`post-info-${ sp }`} + transition={{ layout: { duration: .2, ease: 'easeOut' } }}> <SectionTitle>情報</SectionTitle> <ul> <li>Id.: {post.id}</li> diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 7ee14c0..57907dc 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -1,6 +1,6 @@ import { apiDelete, apiGet, apiPost } from '@/lib/api' -import type { FetchPostsParams, Post, PostTagChange } from '@/types' +import type { FetchPostsParams, Post, PostVersion } from '@/types' export const fetchPosts = async ( @@ -29,17 +29,17 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po export const fetchPostChanges = async ( - { id, tag, page, limit }: { - id?: string + { post, tag, page, limit }: { + post?: string tag?: string page: number limit: number }, ): Promise<{ - changes: PostTagChange[] + versions: PostVersion[] count: number }> => - await apiGet ('/posts/changes', { params: { ...(id && { id }), - ...(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> => { diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 65a8be5..610c847 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -5,7 +5,7 @@ export const postsKeys = { index: (p: FetchPostsParams) => ['posts', 'index', p] as const, show: (id: string) => ['posts', id] as const, related: (id: string) => ['related', id] as const, - changes: (p: { id?: string; tag?: string; page: number; limit: number }) => + changes: (p: { post?: string; tag?: string; page: number; limit: number }) => ['posts', 'changes', p] as const } export const tagsKeys = { diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 51d9b15..50a19d9 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -96,7 +96,7 @@ export default (({ user }: Props) => { <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> <Helmet> {(post?.thumbnail || post?.thumbnailBase) && ( - <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} + <meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)} {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}} @@ -116,7 +116,7 @@ export default (({ user }: Props) => { initial={{ opacity: 1 }} animate={{ opacity: 0 }} transition={{ duration: .2, ease: 'easeOut' }}> - {post.title diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 4652441..fb6b27e 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { useEffect } from 'react' import { Helmet } from 'react-helmet-async' @@ -9,15 +9,30 @@ import PrefetchLink from '@/components/PrefetchLink' import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' +import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' +import { apiPut } from '@/lib/api' import { fetchPostChanges } from '@/lib/posts' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchTag } from '@/lib/tags' -import { cn, dateString } from '@/lib/utils' +import { cn, dateString, originalCreatedAtString } from '@/lib/utils' import type { FC } from 'react' +const renderDiff = (diff: { current: string | null; prev: string | null }) => ( + <> + {(diff.prev && diff.prev !== diff.current) && ( + <> + + {diff.prev} + + {diff.current &&
    } + )} + {diff.current} + ) + + export default (() => { const location = useLocation () const query = new URLSearchParams (location.search) @@ -36,15 +51,17 @@ export default (() => { : { data: null } const { data, isLoading: loading } = useQuery ({ - queryKey: postsKeys.changes ({ ...(id && { id }), + queryKey: postsKeys.changes ({ ...(id && { post: id }), ...(tagId && { tag: tagId }), page, limit }), - queryFn: () => fetchPostChanges ({ ...(id && { id }), + queryFn: () => fetchPostChanges ({ ...(id && { post: id }), ...(tagId && { tag: tagId }), page, limit }) }) - const changes = data?.changes ?? [] + const changes = data?.versions ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 + const qc = useQueryClient () + useEffect (() => { document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) @@ -65,76 +82,171 @@ export default (() => { {loading ? 'Loading...' : ( <> - - - - - - - - - - {changes.map ((change, i) => { - const withPost = i === 0 || change.post.id !== changes[i - 1].post.id - if (withPost) - { - rowsCnt = 1 - for (let j = i + 1; - (j < changes.length - && change.post.id === changes[j].post.id); - ++j) - ++rowsCnt - } - - let layoutId: string | undefined = `page-${ change.post.id }` - if (layoutIds.includes (layoutId)) - layoutId = undefined - else - layoutIds.push (layoutId) - - return ( - - {withPost && ( - )} - - - ) - })} - -
    投稿変更日時
    - - - {change.post.title - - - - {change.tag - ? - : '(マスタ削除済のタグ) '} - {`を${ change.changeType === 'add' ? '記載' : '消除' }`} - - {change.user - ? ( - - {change.user.name} - ) - : 'bot 操作'} -
    - {dateString (change.timestamp)} -
    +
    + + + {/* 投稿 */} + + {/* 版 */} + + {/* タイトル */} + + {/* URL */} + + {/* タグ */} + + {/* オリジナルの投稿日時 */} + + {/* 更新日時 */} + + {/* (差戻ボタン) */} + + + + + + + + + + + + + + + + + {changes.map ((change, i) => { + const withPost = i === 0 || change.postId !== changes[i - 1].postId + if (withPost) + { + rowsCnt = 1 + for (let j = i + 1; + (j < changes.length + && change.postId === changes[j].postId); + ++j) + ++rowsCnt + } + + let layoutId: string | undefined = `page-${ change.postId }` + if (layoutIds.includes (layoutId)) + layoutId = undefined + else + layoutIds.push (layoutId) + + return ( + + {withPost && ( + )} + + + + + + + + ) + })} + +
    投稿タイトルURLタグオリジナルの投稿日時更新日時 +
    + + + {change.title.current + + + {change.postId}.{change.versionNo}{renderDiff (change.title)}{renderDiff (change.url)} + {change.tags.map ((tag, i) => ( + tag.type === 'added' + ? ( + + {tag.name} + ) + : ( + tag.type === 'removed' + ? ( + + {tag.name} + ) + : ( + + {tag.name} + ))))} + + {change.versionNo === 1 + ? originalCreatedAtString (change.originalCreatedFrom.current, + change.originalCreatedBefore.current) + : renderDiff ({ + current: originalCreatedAtString ( + change.originalCreatedFrom.current, + change.originalCreatedBefore.current), + prev: originalCreatedAtString ( + change.originalCreatedFrom.prev, + change.originalCreatedBefore.prev) })} + + {change.createdByUser + ? ( + + {change.createdByUser.name + || `名もなきニジラー(#${ change.createdByUser.id })`} + ) + : 'bot 操作'} +
    + {dateString (change.createdAt)} +
    + { + e.preventDefault () + + if (!(confirm ( + `『${ change.title.current + || change.url.current }』を版 ${ + change.versionNo } に差戻します.\nよろしいですか?`))) + return + + try + { + await apiPut ( + `/posts/${ change.postId }`, + { title: change.title.current, + tags: change.tags + .filter (t => t.type !== 'removed') + .map (t => t.name) + .filter (t => t.slice (0, 5) !== 'nico:') + .join (' '), + original_created_from: + change.originalCreatedFrom.current, + original_created_before: + change.originalCreatedBefore.current }) + + qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + toast ({ description: '差戻しました.' }) + } + catch + { + toast ({ description: '差戻に失敗……' }) + } + }}> + 復元 + +
    +
    )} diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 73071cc..419c134 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -289,7 +289,7 @@ export default (() => { {results.map (row => ( - + { - + {row.title} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c453a10..12f8838 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -117,9 +117,9 @@ export type NiconicoViewerHandle = { export type Post = { id: number url: string - title: string - thumbnail: string - thumbnailBase: string + title: string | null + thumbnail: string | null + thumbnailBase: string | null tags: Tag[] viewed: boolean related: Post[] @@ -127,7 +127,7 @@ export type Post = { originalCreatedBefore: string | null createdAt: string updatedAt: string - uploadedUser: { id: number; name: string } | null } + uploadedUser: { id: number; name: string | null } | null } export type PostTagChange = { post: Post @@ -136,6 +136,20 @@ export type PostTagChange = { changeType: 'add' | 'remove' timestamp: string } +export type PostVersion = { + postId: number + versionNo: number + eventType: 'create' | 'update' | 'discard' | 'restore' + title: { current: string | null; prev: string | null } + url: { current: string; prev: string | null } + thumbnail: { current: string | null; prev: string | null } + thumbnailBase: { current: string | null; prev: string | null } + tags: { name: string; type: 'context' | 'added' | 'removed' }[] + originalCreatedFrom: { current: string | null; prev: string | null } + originalCreatedBefore: { current: string | null; prev: string | null } + createdAt: string + createdByUser: { id: number; name: string | null } | null } + export type SubMenuComponentItem = { component: ReactNode visible: boolean } From 5c7580d57124556226ea209907622007ea4e46d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sun, 19 Apr 2026 16:44:20 +0900 Subject: [PATCH 08/17] =?UTF-8?q?=E3=83=8B=E3=82=B3=E3=82=BF=E3=82=B0?= =?UTF-8?q?=E9=80=A3=E6=90=BA=E3=83=90=E3=82=B0=E4=BF=AE=E6=AD=A3=20(#294)?= =?UTF-8?q?=20(#316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #294 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/316 --- backend/app/controllers/nico_tags_controller.rb | 3 ++- backend/app/models/tag.rb | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 2349deb..8bb582c 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -33,7 +33,8 @@ class NicoTagsController < ApplicationController return head :bad_request if tag.category != 'nico' linked_tag_names = params[:tags].to_s.split(' ') - linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false) + linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false, + with_no_deerjikist: false) return head :bad_request if linked_tags.any? { |t| t.category == 'nico' } tag.linked_tags = linked_tags diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index d4edd8d..dfa83b0 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -98,7 +98,9 @@ class Tag < ApplicationRecord @niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta) end - def self.normalise_tags tag_names, with_tagme: true, deny_nico: true + def self.normalise_tags tag_names, with_tagme: true, + with_no_deerjikist: true, + deny_nico: true if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } raise NicoTagNormalisationError end @@ -112,7 +114,7 @@ class Tag < ApplicationRecord end tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) - tags << Tag.no_deerjikist if tags.all? { |t| !(t.deerjikist?) } + tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) } tags.uniq(&:id) end From bde7d33949c7da355a5603b647e796a24c53f9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sun, 19 Apr 2026 20:21:51 +0900 Subject: [PATCH 09/17] =?UTF-8?q?=E3=82=BF=E3=82=B0=E5=B1=A5=E6=AD=B4=20(#?= =?UTF-8?q?309)=20(#319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #309 #309 #309 #309 #309 Merge remote-tracking branch 'origin/main' into feature/309 #309 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/319 --- .../app/controllers/nico_tags_controller.rb | 16 +- backend/app/controllers/posts_controller.rb | 15 +- .../controllers/tag_children_controller.rb | 22 ++- backend/app/controllers/tags_controller.rb | 24 ++- backend/app/models/my_discard.rb | 6 +- backend/app/models/nico_tag_version.rb | 7 + backend/app/models/post_version.rb | 18 +- backend/app/models/tag.rb | 51 +++--- backend/app/models/tag_name.rb | 2 - backend/app/models/tag_version.rb | 15 ++ backend/app/models/version_record.rb | 19 +++ backend/app/models/wiki_page.rb | 2 - .../app/services/nico_tag_version_recorder.rb | 19 +++ backend/app/services/post_version_recorder.rb | 58 ++----- backend/app/services/tag_version_recorder.rb | 22 +++ backend/app/services/tag_versioning.rb | 38 +++++ backend/app/services/version_recorder.rb | 62 +++++++ .../20260409123700_create_post_versions.rb | 6 +- .../20260419035400_create_tag_versions.rb | 156 ++++++++++++++++++ backend/db/schema.rb | 38 ++++- backend/lib/tasks/sync_nico.rake | 5 + backend/spec/models/version_record_spec.rb | 74 +++++++++ backend/spec/requests/nico_tags_spec.rb | 55 ++++++ backend/spec/requests/posts_spec.rb | 61 ++++++- backend/spec/requests/tag_children_spec.rb | 84 +++++++++- backend/spec/requests/tags_spec.rb | 67 +++++++- backend/spec/tasks/nico_sync_spec.rb | 104 ++++++++++++ 27 files changed, 923 insertions(+), 123 deletions(-) create mode 100644 backend/app/models/nico_tag_version.rb create mode 100644 backend/app/models/tag_version.rb create mode 100644 backend/app/models/version_record.rb create mode 100644 backend/app/services/nico_tag_version_recorder.rb create mode 100644 backend/app/services/tag_version_recorder.rb create mode 100644 backend/app/services/tag_versioning.rb create mode 100644 backend/app/services/version_recorder.rb create mode 100644 backend/db/migrate/20260419035400_create_tag_versions.rb create mode 100644 backend/spec/models/version_record_spec.rb diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 8bb582c..f0e33a4 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -30,15 +30,21 @@ class NicoTagsController < ApplicationController id = params[:id].to_i tag = Tag.find(id) - return head :bad_request if tag.category != 'nico' + return head :bad_request unless tag.nico? - linked_tag_names = params[:tags].to_s.split(' ') + linked_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.category == 'nico' } + 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 end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 26ca581..111052b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -128,9 +128,11 @@ class PostsController < ApplicationController original_created_from:, original_created_before:) post.thumbnail.attach(thumbnail) - ActiveRecord::Base.transaction do + ApplicationRecord.transaction do post.save! tags = Tag.normalise_tags(tag_names) + TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) + tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) post.resized_thumbnail! @@ -170,10 +172,15 @@ class PostsController < ApplicationController post = Post.find(params[:id].to_i) - ActiveRecord::Base.transaction do + ApplicationRecord.transaction do + PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) + post.update!(title:, original_created_from:, original_created_before:) - tags = post.tags.where(category: 'nico').to_a + - Tag.normalise_tags(tag_names, with_tagme: false) + + normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false) + TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) + + tags = post.tags.nico.to_a + normalised_tags tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) diff --git a/backend/app/controllers/tag_children_controller.rb b/backend/app/controllers/tag_children_controller.rb index 4b352b4..8eb972e 100644 --- a/backend/app/controllers/tag_children_controller.rb +++ b/backend/app/controllers/tag_children_controller.rb @@ -7,7 +7,16 @@ class TagChildrenController < ApplicationController child_id = params[:child_id] return head :bad_request if parent_id.blank? || child_id.blank? - Tag.find(parent_id).children << Tag.find(child_id) rescue nil + parent = Tag.find(parent_id) + child = Tag.find(child_id) + return head :bad_request if parent.nico? || child.nico? + + ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(child, created_by_user: current_user) + + TagImplication.find_or_create_by!(parent_tag: parent, tag: child) + TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user) + end head :no_content end @@ -20,7 +29,16 @@ class TagChildrenController < ApplicationController child_id = params[:child_id] return head :bad_request if parent_id.blank? || child_id.blank? - Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil + parent = Tag.find(parent_id) + child = Tag.find(child_id) + return head :bad_request if parent.nico? || child.nico? + + ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(child, created_by_user: current_user) + + TagImplication.find_by(parent_tag: parent, tag: child)&.destroy! + TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user) + end head :no_content end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 3a9e8a6..94a7041 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -218,15 +218,21 @@ class TagsController < ApplicationController tag = Tag.find(params[:id]) - if name.present? - tag.tag_name.update!(name:) + if category.present? && tag.nico? != (category == 'nico') + return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, + status: :unprocessable_entity end - if category.present? - tag.update!(category:) + ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + + tag.tag_name.update!(name:) if name.present? + tag.update!(category:) if category.present? + + record_tag_version!(tag, event_type: :update, created_by_user: current_user) end - render json: TagRepr.base(tag) + render json: TagRepr.base(tag.reload) end private @@ -244,4 +250,12 @@ class TagsController < ApplicationController children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, material: material.as_json&.merge(file:, content_type:)) end + + def record_tag_version!(tag, event_type:, created_by_user:) + if tag.nico? + NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) + else + TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + end + end end diff --git a/backend/app/models/my_discard.rb b/backend/app/models/my_discard.rb index dc4a98d..f51984e 100644 --- a/backend/app/models/my_discard.rb +++ b/backend/app/models/my_discard.rb @@ -1,7 +1,11 @@ module MyDiscard extend ActiveSupport::Concern - included { include Discard::Model } + included do + include Discard::Model + + default_scope -> { kept } + end class_methods do def find_undiscard_or_create_by! attrs, &block diff --git a/backend/app/models/nico_tag_version.rb b/backend/app/models/nico_tag_version.rb new file mode 100644 index 0000000..b57252a --- /dev/null +++ b/backend/app/models/nico_tag_version.rb @@ -0,0 +1,7 @@ +class NicoTagVersion < ApplicationRecord + include VersionRecord + + belongs_to :tag + + validates :name, presence: true +end diff --git a/backend/app/models/post_version.rb b/backend/app/models/post_version.rb index c933813..523d1a0 100644 --- a/backend/app/models/post_version.rb +++ b/backend/app/models/post_version.rb @@ -1,29 +1,13 @@ class PostVersion < ApplicationRecord - before_update do - raise ActiveRecord::ReadOnlyRecord, '版は更新できません.' - end - - before_destroy do - raise ActiveRecord::ReadOnlyRecord, '版は削除できません.' - end + include VersionRecord belongs_to :post belongs_to :parent, class_name: 'Post', optional: true - belongs_to :created_by_user, class_name: 'User', optional: true - enum :event_type, { create: 'create', - update: 'update', - discard: 'discard', - restore: 'restore' }, prefix: true, validate: true - - validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } - validates :event_type, presence: true, inclusion: { in: event_types.keys } validates :url, presence: true validate :validate_original_created_range - scope :chronological, -> { order(:version_no, :id) } - private def validate_original_created_range diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index dfa83b0..54c3d68 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -8,8 +8,6 @@ class Tag < ApplicationRecord ; end - default_scope -> { kept } - has_many :post_tags, inverse_of: :tag has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' @@ -36,6 +34,9 @@ class Tag < ApplicationRecord has_many :deerjikists, dependent: :delete_all has_many :materials + has_many :tag_versions + has_many :nico_tag_versions + belongs_to :tag_name delegate :wiki_page, to: :tag_name @@ -78,25 +79,11 @@ class Tag < ApplicationRecord def material_id = materials.first&.id - 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 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.normalise_tags tag_names, with_tagme: true, with_no_deerjikist: true, @@ -152,21 +139,25 @@ class Tag < ApplicationRecord retry end - def self.merge_tags! target_tag, source_tags + def self.merge_tags! target_tag, source_tags, created_by_user: nil target_tag => Tag affected_post_ids = Set.new Tag.transaction do + TagVersioning.ensure_snapshot!(target_tag, created_by_user:) + Array(source_tags).compact.uniq.each do |source_tag| source_tag => Tag next if source_tag == target_tag + TagVersioning.ensure_snapshot!(source_tag, created_by_user:) + source_tag.post_tags.kept.find_each do |source_pt| post_id = source_pt.post_id affected_post_ids << post_id - source_pt.discard_by!(nil) + source_pt.discard_by!(created_by_user) unless PostTag.kept.exists?(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag) end @@ -178,6 +169,7 @@ class Tag < ApplicationRecord raise ActiveRecord::RecordInvalid.new(source_tag_name) end + TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) source_tag.discard! if source_tag.nico? @@ -186,10 +178,13 @@ class Tag < ApplicationRecord source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, updated_at: Time.current) end + + TagVersioning.record!(target_tag, event_type: :update, created_by_user:) end Post.where(id: affected_post_ids.to_a).find_each do |post| - PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) + PostVersionRecorder.ensure_snapshot!(post, created_by_user:) + PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) end # 投稿件数を再集計 @@ -199,6 +194,14 @@ class Tag < ApplicationRecord target_tag.reload end + def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name) + + def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) + + def snapshot_linked_tag_names + linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + end + private def nico_tag_name_must_start_with_nico diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index b118300..de79d10 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -1,8 +1,6 @@ class TagName < ApplicationRecord include MyDiscard - default_scope -> { kept } - has_one :tag has_one :wiki_page diff --git a/backend/app/models/tag_version.rb b/backend/app/models/tag_version.rb new file mode 100644 index 0000000..6ca5d0a --- /dev/null +++ b/backend/app/models/tag_version.rb @@ -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 diff --git a/backend/app/models/version_record.rb b/backend/app/models/version_record.rb new file mode 100644 index 0000000..7224639 --- /dev/null +++ b/backend/app/models/version_record.rb @@ -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 diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 1573127..8d3feec 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -4,8 +4,6 @@ require 'set' class WikiPage < ApplicationRecord include MyDiscard - default_scope -> { kept } - has_many :wiki_revisions, dependent: :destroy belongs_to :created_user, class_name: 'User' belongs_to :updated_user, class_name: 'User' diff --git a/backend/app/services/nico_tag_version_recorder.rb b/backend/app/services/nico_tag_version_recorder.rb new file mode 100644 index 0000000..8f1be7f --- /dev/null +++ b/backend/app/services/nico_tag_version_recorder.rb @@ -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 diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb index 052bacd..515d1d8 100644 --- a/backend/app/services/post_version_recorder.rb +++ b/backend/app/services/post_version_recorder.rb @@ -1,57 +1,31 @@ -class PostVersionRecorder +class PostVersionRecorder < VersionRecorder def self.record! post:, event_type:, created_by_user: new(post:, event_type:, created_by_user:).record! end def initialize post:, event_type:, created_by_user: - @post = post - @event_type = event_type - @created_by_user = created_by_user + super(record: post, event_type:, created_by_user:) end - def record! - @post.with_lock do - latest = @post.post_versions.order(version_no: :desc).first - attrs = snapshot_attributes + def self.ensure_snapshot! post, created_by_user: + return if post.post_versions.exists? - return latest if @event_type == :update && latest && same_snapshot?(latest, attrs) - - PostVersion.create!( - post: @post, - version_no: (latest&.version_no || 0) + 1, - event_type: @event_type, - title: attrs[:title], - url: attrs[:url], - thumbnail_base: attrs[:thumbnail_base], - tags: attrs[:tags], - parent: attrs[:parent], - original_created_from: attrs[:original_created_from], - original_created_before: attrs[:original_created_before], - created_at: Time.current, - created_by_user: @created_by_user) - end + record!(post:, event_type: :create, created_by_user:) end private - def snapshot_attributes - { title: @post.title, - url: @post.url, - thumbnail_base: @post.thumbnail_base, - tags: @post.snapshot_tag_names.join(' '), - parent: @post.parent, - original_created_from: @post.original_created_from, - original_created_before: @post.original_created_before } - end + def version_class = PostVersion + def version_association = :post_versions + def record_key = :post - def same_snapshot? version, attrs - true && - version.title == attrs[:title] && - version.url == attrs[:url] && - version.thumbnail_base == attrs[:thumbnail_base] && - version.tags == attrs[:tags] && - version.parent_id == attrs[:parent]&.id && - version.original_created_from == attrs[:original_created_from] && - version.original_created_before == attrs[:original_created_before] + def snapshot_attributes + { title: @record.title, + url: @record.url, + thumbnail_base: @record.thumbnail_base, + tags: @record.snapshot_tag_names.join(' '), + parent_id: @record.parent_id, + original_created_from: @record.original_created_from, + original_created_before: @record.original_created_before } end end diff --git a/backend/app/services/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb new file mode 100644 index 0000000..fe2b0c1 --- /dev/null +++ b/backend/app/services/tag_version_recorder.rb @@ -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 diff --git a/backend/app/services/tag_versioning.rb b/backend/app/services/tag_versioning.rb new file mode 100644 index 0000000..ae5b3dd --- /dev/null +++ b/backend/app/services/tag_versioning.rb @@ -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 diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb new file mode 100644 index 0000000..e705ec3 --- /dev/null +++ b/backend/app/services/version_recorder.rb @@ -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 diff --git a/backend/db/migrate/20260409123700_create_post_versions.rb b/backend/db/migrate/20260409123700_create_post_versions.rb index a2c6da7..58df885 100644 --- a/backend/db/migrate/20260409123700_create_post_versions.rb +++ b/backend/db/migrate/20260409123700_create_post_versions.rb @@ -2,15 +2,15 @@ require 'set' class CreatePostVersions < ActiveRecord::Migration[8.0] - class Post < ApplicationRecord + class Post < ActiveRecord::Base self.table_name = 'posts' end - class PostTag < ApplicationRecord + class PostTag < ActiveRecord::Base self.table_name = 'post_tags' end - class PostVersion < ApplicationRecord + class PostVersion < ActiveRecord::Base self.table_name = 'post_versions' end diff --git a/backend/db/migrate/20260419035400_create_tag_versions.rb b/backend/db/migrate/20260419035400_create_tag_versions.rb new file mode 100644 index 0000000..d1f54a6 --- /dev/null +++ b/backend/db/migrate/20260419035400_create_tag_versions.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 42c7cd4..ede919d 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do +ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -104,6 +104,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id" end + create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.string "name", null: false + t.text "linked_tags", null: false + t.datetime "created_at", null: false + t.bigint "created_by_user_id" + t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" + t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true + t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" + end + create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false @@ -216,6 +231,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end + create_table "tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.string "name", null: false + t.string "category", null: false + t.text "aliases", null: false + t.text "parent_tag_ids", null: false + t.datetime "created_at", null: false + t.bigint "created_by_user_id" + t.index ["created_at"], name: "index_tag_versions_on_created_at" + t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true + t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive" + end + create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_name_id", null: false t.string "category", default: "general", null: false @@ -377,6 +409,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" + add_foreign_key "nico_tag_versions", "tags" + add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_tags", "posts" @@ -394,6 +428,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do add_foreign_key "tag_names", "tag_names", column: "canonical_id" add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags", column: "target_tag_id" + add_foreign_key "tag_versions", "tags" + add_foreign_key "tag_versions", "users", column: "created_by_user_id" add_foreign_key "tags", "tag_names" add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "users" diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index da396a0..f7c760c 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -115,6 +115,10 @@ namespace :nico do datum['tags'].each do |raw| name = TagNameSanitisationRule.sanitise("nico:#{ raw }") tag = Tag.find_or_create_by_tag_name!(name, category: :nico) + + event_type = tag.nico_tag_versions.exists? ? :update : :create + NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil) + desired_nico_tag_based_ids << tag.id # 新たに記載される外部タグと連携される内部タグを記載 @@ -149,6 +153,7 @@ namespace :nico do if post_created PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set + PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) end end diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb new file mode 100644 index 0000000..d3acb34 --- /dev/null +++ b/backend/spec/models/version_record_spec.rb @@ -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 diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index 6ee9479..26d5de0 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -14,6 +14,7 @@ RSpec.describe 'NicoTags', type: :request do describe 'PATCH /tags/nico/:id' do let(:member) { create(:user, :member) } + let(:admin) { create(:user, :admin) } let(:nico_tag) { create(:tag, :nico) } it '401 when not logged in' do @@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } expect(response).to have_http_status(:bad_request) end + + it '200 and updates linked tags while recording tag versions' do + sign_in_as(admin) + + nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source') + nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) + + linked_a_name = TagName.create!(name: 'nico_linked_a') + linked_a = Tag.create!(tag_name: linked_a_name, category: :general) + + linked_b_name = TagName.create!(name: 'nico_linked_b') + linked_b = Tag.create!(tag_name: linked_b_name, category: :general) + + TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin) + + expect { + patch "/tags/nico/#{nico_tag.id}", params: { + tags: " #{linked_a.name}\n#{linked_b.name} " + } + }.to change(TagVersion, :count).by(2) + .and change(NicoTagVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + names = json.map { |t| t['name'] } + expect(names).to match_array(['nico_linked_a', 'nico_linked_b']) + + linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id) + expect(linked_versions.map(&:event_type)).to eq(['create', 'create']) + expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id)) + + versions = nico_tag.reload.nico_tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.last.linked_tags.split).to match_array([ + 'nico_linked_a', + 'nico_linked_b' + ]) + expect(versions.last.created_by_user_id).to eq(admin.id) + end + + it '400 when linked tag normalises to nico tag' do + sign_in_as(member) + + other_nico = create(:tag, :nico, name: 'nico:linked_ng') + TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name) + + TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member) + + expect { + patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:bad_request) + end end end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 605fd8d..3c59c9c 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -1,8 +1,8 @@ -include ActiveSupport::Testing::TimeHelpers - require 'rails_helper' require 'set' +include ActiveSupport::Testing::TimeHelpers + RSpec.describe 'Posts API', type: :request do # create / update で thumbnail.attach は走るが、 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 @@ -1082,15 +1082,16 @@ RSpec.describe 'Posts API', type: :request do it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do sign_in_as(member) - create_post_version_for!(post_record) - expect do + PostTag.create!(post: post_record, tag: Tag.no_deerjikist) + create_post_version_for!(post_record.reload) + + expect { put "/posts/#{post_record.id}", params: { title: post_record.title, tags: 'spec_tag' } - end.not_to change(PostVersion, :count) - + }.not_to change(PostVersion, :count) expect(response).to have_http_status(:ok) version = post_record.reload.post_versions.order(:version_no).last @@ -1130,4 +1131,52 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end end + + describe 'tag versioning from post write actions' do + let(:member) { create(:user, :member) } + + it 'creates tag snapshot for normalised tags on POST /posts' do + sign_in_as(member) + + expect { + post '/posts', params: { + title: 'tag versioned post', + url: 'https://example.com/tag-versioned-post', + tags: 'spec_tag', + thumbnail: dummy_upload + } + }.to change { tag.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:created) + + version = tag.reload.tag_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('spec_tag') + expect(version.category).to eq('general') + expect(version.created_by_user_id).to eq(member.id) + end + + it 'creates tag snapshot for normalised tags on PUT /posts/:id' do + sign_in_as(member) + + tag_name2 = TagName.create!(name: 'spec_tag_2') + tag2 = Tag.create!(tag_name: tag_name2, category: :general) + + expect { + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag_2' + } + }.to change { tag2.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:ok) + + version = tag2.reload.tag_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('spec_tag_2') + expect(version.created_by_user_id).to eq(member.id) + end + end end diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb index a5e4f83..8e1b91b 100644 --- a/backend/spec/requests/tag_children_spec.rb +++ b/backend/spec/requests/tag_children_spec.rb @@ -58,15 +58,47 @@ RSpec.describe "TagChildren", type: :request do end end - context "when Tag.find raises (invalid ids) it still returns 204" do + context "when Tag.find raises (invalid ids)" do before { stub_current_user(admin) } let(:parent_id) { -1 } let(:child_id) { -1 } - it "returns 204 (rescue nil)" do + it "returns 404" do do_request - expect(response).to have_http_status(:no_content) + expect(response).to have_http_status(:not_found) + end + end + + context 'when parent is nico' do + before { stub_current_user(admin) } + + let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it 'returns 400 and does not create relation' do + expect { + do_request + }.not_to change(TagImplication, :count) + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when child is nico' do + before { stub_current_user(admin) } + + let!(:child) { create(:tag, :nico, name: 'nico:child_ng') } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it 'returns 400 and does not create relation' do + expect { + do_request + }.not_to change(TagImplication, :count) + + expect(response).to have_http_status(:bad_request) end end end @@ -116,17 +148,57 @@ RSpec.describe "TagChildren", type: :request do expect(response).to have_http_status(:no_content) end + + it 'records create and update versions for child tag' do + expect { + do_request + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:no_content) + + versions = child.reload.tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s) + expect(versions.second.parent_tag_ids).to eq('') + expect(versions.second.created_by_user_id).to eq(admin.id) + end end - context "when Tag.find raises (invalid ids) it still returns 204" do + context "when Tag.find raises (invalid ids)" do before { stub_current_user(admin) } let(:parent_id) { -1 } let(:child_id) { -1 } - it "returns 204 (rescue nil)" do + it "returns 404" do do_request - expect(response).to have_http_status(:no_content) + expect(response).to have_http_status(:not_found) + end + end + + context 'when parent is nico' do + before { stub_current_user(admin) } + + let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it 'returns 400' do + do_request + expect(response).to have_http_status(:bad_request) + end + end + + context 'when child is nico' do + before { stub_current_user(admin) } + + let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it 'returns 400' do + do_request + expect(response).to have_http_status(:bad_request) end end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 9a140b1..42d3728 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -364,9 +364,70 @@ RSpec.describe 'Tags API', type: :request do expect(response.status).to be_in([404, 500]) 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 'creates nico tag versions 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' } + }.to change(NicoTagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + versions = nico_tag.reload.nico_tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.first.name).to eq('nico:tags_spec_source') + expect(versions.second.name).to eq('nico:tags_spec_renamed') + expect(versions.second.created_by_user_id).to eq(member_user.id) + end + + it 'returns 422 when changing nico tag category to normal category' do + nico_tag_name = TagName.create!(name: 'nico:category_change_ng') + nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) + + expect { + patch "/tags/#{nico_tag.id}", params: { category: 'general' } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(nico_tag.reload.category).to eq('nico') end end end diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index ff64490..27e63e5 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -214,4 +214,108 @@ RSpec.describe "nico:sync" do 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('')) + + 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('')) + + 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('')) + + 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 From 8ff1819d5a5b152c89d9c00ebb7cfd5f3e62a849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sun, 19 Apr 2026 23:04:15 +0900 Subject: [PATCH 10/17] =?UTF-8?q?=E5=90=8C=E6=9C=9F=E3=83=90=E3=82=B0?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(#324)=20(#325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #324 Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/325 --- backend/config/schedule.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/config/schedule.rb b/backend/config/schedule.rb index b4db72a..23b6c73 100644 --- a/backend/config/schedule.rb +++ b/backend/config/schedule.rb @@ -1,12 +1,19 @@ env :PATH, '/root/.rbenv/shims:/root/.rbenv/bin:/usr/local/bin:/usr/bin:/bin' +set :path, '/var/www/btrc-hub/backend' +set :environment, 'production' set :output, standard: '/var/log/btrc_hub_nico_sync.log', error: '/var/log/btrc_hub_nico_sync_err.log' -every 1.day, at: '3:00 pm' do +job_type :rake, + 'cd :path && set -a && . /etc/btrc-hub/backend.env && set +a && ' \ + ':environment_variable=:environment bundle exec rake :task --silent :output' + +every 1.day, at: '11:00 am' do rake 'nico:sync', environment: 'production' end every 1.day, at: '0:00 am' do rake 'post_similarity:calc', environment: 'production' + rake 'tag_similarity:calc', environment: 'production' end From 43cd38a216cb4a632998b8337dcba3fcf02f459c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Thu, 23 Apr 2026 00:06:49 +0900 Subject: [PATCH 11/17] =?UTF-8?q?=E3=82=BF=E3=82=B0=E8=A9=B3=E7=B4=B0=20(#?= =?UTF-8?q?318)=20(#328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #318 #318 #318 #318 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/328 --- backend/app/controllers/tags_controller.rb | 90 +++++- backend/app/representations/tag_repr.rb | 7 +- backend/config/routes.rb | 5 +- backend/spec/requests/tags_spec.rb | 331 ++++++++++++++++++++- frontend/src/App.tsx | 2 + frontend/src/components/TopNav.tsx | 19 +- frontend/src/lib/prefetchers.ts | 18 +- frontend/src/pages/tags/TagDetailPage.tsx | 158 ++++++++++ frontend/src/pages/tags/TagListPage.tsx | 37 ++- frontend/src/pages/wiki/WikiDetailPage.tsx | 16 +- frontend/src/types.ts | 2 + 11 files changed, 639 insertions(+), 46 deletions(-) create mode 100644 frontend/src/pages/tags/TagDetailPage.tsx diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 94a7041..3785a9e 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -66,7 +66,7 @@ class TagsController < ApplicationController .offset(offset) .to_a - render json: { tags: TagRepr.base(tags), count: q.size } + render json: { tags: TagRepr.many(tags), count: q.size } end def with_depth @@ -209,6 +209,52 @@ class TagsController < ApplicationController render json: build_tag_children(tag) end + def update_all + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + tag = Tag.find_by(id: params[:id]) + return head :not_found unless tag + + name = params[:name].to_s.strip + category = params[:category].to_s.strip + return head :unprocessable_entity if name.blank? || category.blank? + + if name != tag.name && + tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) + return render json: { error: 'システム・タグの名称は変更できません.' }, + status: :unprocessable_entity + end + + if tag.nico? || category == 'nico' + return render json: { error: 'ニコタグは変更できません.' }, + status: :unprocessable_entity + end + + alias_names = params[:aliases].to_s.split.uniq + parent_names = params[:parent_tags].to_s.split.uniq + + ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + + old_name = tag.name + + tag.update!(category:) + tag.tag_name.update!(name:) + + alias_names << old_name if name != old_name + alias_names.delete(name) + + update_aliases!(tag, alias_names) + update_parent_tags!(tag, parent_names) + + tag.reload + record_tag_version!(tag, event_type: :update, created_by_user: current_user) + end + + render json: TagRepr.base(tag.reload) + end + def update return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? @@ -218,8 +264,8 @@ class TagsController < ApplicationController tag = Tag.find(params[:id]) - if category.present? && tag.nico? != (category == 'nico') - return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, + if tag.nico? || (category.present? && category == 'nico') + return render json: { error: 'ニコタグは変更できません.' }, status: :unprocessable_entity end @@ -237,7 +283,7 @@ class TagsController < ApplicationController private - def build_tag_children(tag) + def build_tag_children tag material = tag.materials.first file = nil content_type = nil @@ -251,11 +297,45 @@ class TagsController < ApplicationController material: material.as_json&.merge(file:, content_type:)) end - def record_tag_version!(tag, event_type:, created_by_user:) + def record_tag_version! tag, event_type:, created_by_user: if tag.nico? NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) else TagVersionRecorder.record!(tag:, event_type:, created_by_user:) end end + + def update_aliases! tag, alias_names + current_aliases = tag.tag_name.aliases.to_a + + current_aliases.each do |alias_tag_name| + next if alias_names.include?(alias_tag_name.name) + + alias_tag_name.update!(canonical: nil) + end + + alias_names.each do |alias_name| + alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) + alias_tag_name.update!(canonical: tag.tag_name) + end + end + + def update_parent_tags! tag, parent_names + parent_tags = Tag.normalise_tags(parent_names, with_tagme: false, + with_no_deerjikist: false, + deny_nico: true) + + old_parent_tags = tag.parents.to_a + + TagVersioning.record_tag_snapshots!((old_parent_tags + parent_tags).uniq, + created_by_user: current_user) + + tag.reversed_tag_implications.destroy_all + + parent_tags.each do |parent_tag| + next if parent_tag == tag + + TagImplication.create!(tag:, parent_tag:) + end + end end diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index df6925b..ecbed17 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -8,10 +8,9 @@ module TagRepr module_function def base tag - tag.as_json(BASE) + tag.as_json(BASE).merge(aliases: tag.snapshot_aliases, + parents: tag.parents.map { _1.as_json(BASE) }) end - def many tags - tags.map { |t| base(t) } - end + def many(tags) = tags.map { |t| base(t) } end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index edd978f..373bb17 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -6,7 +6,7 @@ Rails.application.routes.draw do delete ':child_id', action: :destroy end - resources :tags, only: [:index, :show, :update] do + resources :tags, only: [:index, :show] do collection do get :autocomplete get :'with-depth', action: :with_depth @@ -19,6 +19,9 @@ Rails.application.routes.draw do end member do + put '', action: :update_all + patch '', action: :update + get :deerjikists end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 42d3728..d3bc594 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -1,7 +1,6 @@ require 'cgi' require 'rails_helper' - RSpec.describe 'Tags API', type: :request do let!(:tn) { TagName.create!(name: 'spec_tag') } let!(:tag) { Tag.create!(tag_name: tn, category: :general) } @@ -197,6 +196,30 @@ RSpec.describe 'Tags API', type: :request do expect(response_tags.size).to eq(1) expect(response_names).to eq(['norm_a']) end + + it 'returns aliases and parent tags' do + parent_tag = Tag.create!( + tag_name: TagName.create!(name: 'index_parent_tag'), + category: :meme + ) + TagImplication.create!(tag:, parent_tag:) + + get '/tags', params: { name: 'spec_tag' } + + expect(response).to have_http_status(:ok) + + row = response_tags.find { |t| t['name'] == 'spec_tag' } + + expect(row['aliases']).to include('unko') + expect(row['parents'].map { |t| t['name'] }).to include('index_parent_tag') + + parent = row['parents'].find { |t| t['name'] == 'index_parent_tag' } + expect(parent).to include( + 'id' => parent_tag.id, + 'name' => 'index_parent_tag', + 'category' => 'meme' + ) + end end describe 'GET /tags/:id' do @@ -220,6 +243,28 @@ RSpec.describe 'Tags API', type: :request do expect(json).to have_key('created_at') expect(json).to have_key('updated_at') end + + it 'returns aliases and parent tags' do + parent_tag = Tag.create!( + tag_name: TagName.create!(name: 'show_parent_tag'), + category: :character + ) + TagImplication.create!(tag:, parent_tag:) + + request + + expect(response).to have_http_status(:ok) + + expect(json['aliases']).to include('unko') + expect(json['parents'].map { |t| t['name'] }).to include('show_parent_tag') + + parent = json['parents'].find { |t| t['name'] == 'show_parent_tag' } + expect(parent).to include( + 'id' => parent_tag.id, + 'name' => 'show_parent_tag', + 'category' => 'character' + ) + end end context 'when tag does not exist' do @@ -359,9 +404,9 @@ RSpec.describe 'Tags API', type: :request do expect(tag.category).to eq('meta') end - it '存在しない id だと RecordNotFound になる(通常は 404)' do + it '存在しない id なら 404 を返す' do patch '/tags/999999999', params: { name: 'x' } - expect(response.status).to be_in([404, 500]) + expect(response).to have_http_status(:not_found) end it 'nico category への変更は 422 を返す' do @@ -401,21 +446,18 @@ RSpec.describe 'Tags API', type: :request do expect(tag.reload.category).to eq('general') end - it 'creates nico tag versions when updating nico tag name' do + 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' } - }.to change(NicoTagVersion, :count).by(2) + patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' } + }.not_to change(NicoTagVersion, :count) - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:unprocessable_entity) - versions = nico_tag.reload.nico_tag_versions.order(:version_no) - expect(versions.map(&:event_type)).to eq(['create', 'update']) - expect(versions.first.name).to eq('nico:tags_spec_source') - expect(versions.second.name).to eq('nico:tags_spec_renamed') - expect(versions.second.created_by_user_id).to eq(member_user.id) + 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 @@ -571,4 +613,269 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:not_found) end end + + describe 'PUT /tags/:id' do + context '未ログイン' do + before { stub_current_user(nil) } + + it '401 を返す' do + put "/tags/#{ tag.id }", params: { + name: 'new', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'ログインしてゐるが member でない' do + before { stub_current_user(non_member_user) } + + it '403 を返す' do + put "/tags/#{ tag.id }", params: { + name: 'new', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:forbidden) + end + end + + context 'member' do + before { stub_current_user(member_user) } + + it '存在しない id なら 404 を返す' do + put '/tags/999999999', params: { + name: 'new', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:not_found) + end + + it 'name が空なら 422 を返す' do + put "/tags/#{ tag.id }", params: { + name: '', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.name).to eq('spec_tag') + end + + it 'category が空なら 422 を返す' do + put "/tags/#{ tag.id }", params: { + name: 'new', + category: '', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.name).to eq('spec_tag') + expect(tag.category).to eq('general') + end + + it 'name, category, aliases, parent tags をまとめて更新できる' do + old_parent = Tag.create!( + tag_name: TagName.create!(name: 'put_old_parent'), + category: :general + ) + kept_parent = Tag.create!( + tag_name: TagName.create!(name: 'put_kept_parent'), + category: :general + ) + TagImplication.create!(tag:, parent_tag: old_parent) + TagImplication.create!(tag:, parent_tag: kept_parent) + + put "/tags/#{ tag.id }", params: { + name: 'put_renamed_tag', + category: 'meme', + aliases: 'put_alias_a put_alias_b put_alias_a', + parent_tags: 'put_kept_parent put_new_parent', + } + + expect(response).to have_http_status(:ok) + + tag.reload + + expect(tag.name).to eq('put_renamed_tag') + expect(tag.category).to eq('meme') + + expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name) + expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name) + + old_name_alias = TagName.find_by(name: 'spec_tag') + expect(old_name_alias).to be_present + expect(old_name_alias.canonical).to eq(tag.tag_name) + + expect(alias_tn.reload.canonical).to be_nil + + expect(tag.parents.map(&:name)).to contain_exactly( + 'put_kept_parent', + 'put_new_parent' + ) + + expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist + + expect(json['name']).to eq('put_renamed_tag') + expect(json['category']).to eq('meme') + expect(json['aliases']).to contain_exactly( + 'put_alias_a', + 'put_alias_b', + 'spec_tag' + ) + expect(json['parents'].map { |t| t['name'] }).to contain_exactly( + 'put_kept_parent', + 'put_new_parent' + ) + end + + it 'aliases に現在名を指定しても alias には残さない' do + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'spec_tag put_alias_self_test', + parent_tags: '', + } + + expect(response).to have_http_status(:ok) + + tag.reload + + expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name) + expect(json['aliases']).to include('put_alias_self_test') + expect(json['aliases']).not_to include('spec_tag') + end + + it 'parent_tags に自分自身を指定しても自己参照は作らない' do + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko', + parent_tags: 'spec_tag', + } + + expect(response).to have_http_status(:ok) + + expect(TagImplication.where(tag:, parent_tag: tag)).not_to exist + expect(tag.reload.parents).to eq([]) + end + + it 'initial and update tag versions を作成する' do + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_versioned_tag', + category: 'meta', + aliases: '', + parent_tags: '', + } + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + versions = tag.reload.tag_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create', 'update']) + + expect(versions.first.name).to eq('spec_tag') + expect(versions.first.category).to eq('general') + expect(versions.first.aliases.split).to include('unko') + + expect(versions.second.name).to eq('put_versioned_tag') + expect(versions.second.category).to eq('meta') + expect(versions.second.aliases.split).to include('spec_tag') + expect(versions.second.created_by_user_id).to eq(member_user.id) + end + + it 'parent tag の snapshot も作成する' do + old_parent = Tag.create!( + tag_name: TagName.create!(name: 'put_snapshot_old_parent'), + category: :general + ) + new_parent = Tag.create!( + tag_name: TagName.create!(name: 'put_snapshot_new_parent'), + category: :general + ) + TagImplication.create!(tag:, parent_tag: old_parent) + + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko', + parent_tags: new_parent.name, + } + + expect(response).to have_http_status(:ok) + + expect(old_parent.reload.tag_versions.map(&:event_type)).to include('create') + expect(new_parent.reload.tag_versions.map(&:event_type)).to include('create') + end + + it 'normal tag を nico category には変更できない' do + expect { + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'nico', + aliases: '', + parent_tags: '', + } + }.not_to change(TagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + + expect(tag.reload.name).to eq('spec_tag') + expect(tag.category).to eq('general') + end + + it 'nico tag は更新できない' do + nico_tag = Tag.create!( + tag_name: TagName.create!(name: 'nico:put_update_all_ng'), + category: :nico + ) + + expect { + put "/tags/#{ nico_tag.id }", params: { + name: 'nico:put_update_all_renamed', + category: 'nico', + aliases: '', + parent_tags: '', + } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + + expect(nico_tag.reload.name).to eq('nico:put_update_all_ng') + expect(nico_tag.category).to eq('nico') + end + + it 'system tag の name は変更できない' do + system_tag = Tag.tagme + old_name = system_tag.name + old_category = system_tag.category + + expect { + put "/tags/#{ system_tag.id }", params: { + name: 'put_system_tag_renamed', + category: old_category, + aliases: '', + parent_tags: '', + } + }.not_to change(TagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + + expect(system_tag.reload.name).to eq(old_name) + expect(system_tag.category).to eq(old_category) + end + end + end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7316e8b..f8178f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import PostNewPage from '@/pages/posts/PostNewPage' import PostSearchPage from '@/pages/posts/PostSearchPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' import SettingPage from '@/pages/users/SettingPage' +import TagDetailPage from '@/pages/tags/TagDetailPage' import TagListPage from '@/pages/tags/TagListPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage' @@ -55,6 +56,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> }> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 2b5eee2..c06f1b9 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -8,7 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink' import TopNavUser from '@/components/TopNavUser' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { tagsKeys, wikiKeys } from '@/lib/queryKeys' -import { fetchTagByName } from '@/lib/tags' +import { fetchTag, fetchTagByName } from '@/lib/tags' import { cn } from '@/lib/utils' import { fetchWikiPage } from '@/lib/wiki' @@ -29,6 +29,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { 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' }, @@ -38,10 +40,13 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'タグ', to: '/tags', subMenu: [ { name: 'マスタ', to: '/tags' }, - { name: '別名タグ', to: '/tags/aliases', visible: false }, - { name: '上位タグ', to: '/tags/implications', visible: false }, { name: 'ニコニコ連携', to: '/tags/nico' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, + { component: , visible: tagFlg }, + { name: `広場 (${ postCount || 0 })`, + to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, + visible: tagFlg }, + { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, visible: false }] }, { name: '素材', to: '/materials', visible: false, subMenu: [ { name: '一覧', to: '/materials' }, { name: '検索', to: '/materials/search', visible: false }, @@ -114,12 +119,14 @@ export default (({ user }: Props) => { queryKey: wikiKeys.show (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 ({ enabled: Boolean (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle), - queryFn: () => fetchTagByName (effectiveTitle) }) + queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) }) + const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index ad18e1b..166daa8 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -14,6 +14,7 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise const mPost = match<{ id: string }> ('/posts/:id') const mWiki = match<{ title: string }> ('/wiki/:title') +const mTag = match<{ id: string }> ('/tags/:id') const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { @@ -169,6 +170,19 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => { } +const prefetchTagShow: Prefetcher = async (qc, url) => { + const m = mTag (url.pathname) + if (!(m)) + return + + const { id } = m.params + + await qc.prefetchQuery ({ + queryKey: tagsKeys.show (id), + queryFn: () => fetchTag (id) }) +} + + export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), run: prefetchPostsIndex }, @@ -180,7 +194,9 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) && Boolean (mWiki (u.pathname))), run: prefetchWikiPageShow }, - { test: u => u.pathname === '/tags', run: prefetchTagsIndex }] + { test: u => u.pathname === '/tags', run: prefetchTagsIndex }, + { test: u => u.pathname !== '/tags/nico' && Boolean (mTag (u.pathname)), + run: prefetchTagShow }] export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise => { diff --git a/frontend/src/pages/tags/TagDetailPage.tsx b/frontend/src/pages/tags/TagDetailPage.tsx new file mode 100644 index 0000000..6452508 --- /dev/null +++ b/frontend/src/pages/tags/TagDetailPage.tsx @@ -0,0 +1,158 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' + +import TagLink from '@/components/TagLink' +import Label from '@/components/common/Label' +import PageTitle from '@/components/common/PageTitle' +import MainArea from '@/components/layout/MainArea' +import { toast } from '@/components/ui/use-toast' +import { CATEGORIES, CATEGORY_NAMES } from '@/consts' +import { apiPut } from '@/lib/api' +import { postsKeys, tagsKeys } from '@/lib/queryKeys' +import { fetchTag } from '@/lib/tags' +import { cn } from '@/lib/utils' + +import type { FC, FormEvent } from 'react' + +import type { Category, Tag } from '@/types' + + +export default (() => { + const { id } = useParams () + const tagId = String (id ?? '') + const tagKey = tagsKeys.show (tagId) + + const { data: tag, isLoading: loading } = useQuery ({ + queryKey: tagKey, + queryFn: () => fetchTag (tagId) }) + + const [name, setName] = useState ('') + const [category, setCategory] = useState ('general') + const [aliases, setAliases] = useState ('') + const [parentTags, setParentTags] = useState ('') + const [disabled, setDisabled] = useState (true) + + const qc = useQueryClient () + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault () + + const formData = new FormData + formData.append ('name', name) + formData.append ('category', category) + formData.append ('aliases', aliases) + formData.append ('parent_tags', parentTags) + + try + { + const data = await apiPut (`/tags/${ id }`, formData) + + setName (data.name) + setCategory (data.category as Category) + setAliases (data.aliases.join (' ')) + setParentTags (data.parents.map (t => t.name).join (' ')) + + qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + toast ({ description: '更新しました.' }) + } + catch + { + toast ({ description: '更新に失敗しました.' }) + } + } + + useEffect (() => { + if (!(tag)) + { + setDisabled (true) + return + } + + setName (tag.name) + setCategory (tag.category as Category) + setAliases (tag.aliases.join (' ')) + setParentTags (tag.parents.map (t => t.name).join (' ')) + setDisabled (tag.category === 'nico') + }, [tag]) + + return ( + + {(loading || !(tag)) ? 'Loading...' : ( +
    + + + + +
    + {/* 名称 */} +
    + + {/* TODO: 補完に対応させる */} + setName (e.target.value)} + className="w-full border p-2 rounded"/> +
    + + {/* カテゴリ */} +
    + + +
    + + {/* 別名 */} +
    + + {/* TODO: 補完に対応させる */} + setAliases (e.target.value)} + className="w-full border p-2 rounded"/> +
    + + {/* 上位タグ */} +
    + + {/* TODO: 補完に対応させる */} + setParentTags (e.target.value)} + className="w-full border p-2 rounded"/> +
    + +
    + +
    +
    +
    )} +
    ) +}) satisfies FC diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index e1ce2fc..f183aa7 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -205,13 +205,15 @@ export default (() => { {loading ? 'Loading...' : (results.length > 0 ? (
    - +
    - - - + + + + + @@ -226,18 +228,20 @@ export default (() => { + + - + + +
    - by="category" - label="カテゴリ" + by="post_count" + label="件数" currentOrder={order} defaultDirection={defaultDirection}/> - by="post_count" - label="件数" + by="category" + label="カテゴリ" currentOrder={order} defaultDirection={defaultDirection}/> 別名上位タグ by="created_at" @@ -260,10 +264,23 @@ export default (() => { {results.map (row => (
    - + {CATEGORY_NAMES[row.category]} {row.postCount}{CATEGORY_NAMES[row.category]}{row.aliases.join (' ')} + {row.parents.map (t => ( + + + ))} + {dateString (row.createdAt)} {dateString (row.updatedAt)} diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index 7eab461..4ead153 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -114,13 +114,15 @@ export default () => { {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {loading ?
    Loading...
    : } - - {(!(version) && posts.length > 0) && ( - - - - - )} + {(!(version) && posts.length > 0) && ( +
    + + + + + +
    )} + ) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 12f8838..8adb5ff 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -165,6 +165,8 @@ export type Tag = { id: number name: string category: Category + aliases: string[] + parents: Tag[] postCount: number createdAt: string updatedAt: string From 6235b293f0c70edb8fbb2609c53039eaaf790ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Fri, 24 Apr 2026 02:21:26 +0900 Subject: [PATCH 12/17] =?UTF-8?q?=E3=82=BF=E3=82=B0=E5=B1=A5=E6=AD=B4?= =?UTF-8?q?=E3=83=9A=E3=83=BC=E3=82=B8=20(#321)=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #321 #321 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/330 --- .../controllers/tag_versions_controller.rb | 92 ++++++++ backend/config/routes.rb | 1 + backend/spec/requests/tag_versions_spec.rb | 218 ++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/TopNav.tsx | 4 +- frontend/src/lib/prefetchers.ts | 19 +- frontend/src/lib/queryKeys.ts | 8 +- frontend/src/lib/tags.ts | 13 +- frontend/src/pages/tags/TagHistoryPage.tsx | 212 +++++++++++++++++ frontend/src/types.ts | 11 + 10 files changed, 572 insertions(+), 8 deletions(-) create mode 100644 backend/app/controllers/tag_versions_controller.rb create mode 100644 backend/spec/requests/tag_versions_spec.rb create mode 100644 frontend/src/pages/tags/TagHistoryPage.tsx diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb new file mode 100644 index 0000000..0958c75 --- /dev/null +++ b/backend/app/controllers/tag_versions_controller.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 373bb17..363dbf4 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -10,6 +10,7 @@ Rails.application.routes.draw do collection do get :autocomplete get :'with-depth', action: :with_depth + get :versions, to: 'tag_versions#index' scope :name do get ':name/deerjikists', action: :deerjikists_by_name diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb new file mode 100644 index 0000000..f1d92e7 --- /dev/null +++ b/backend/spec/requests/tag_versions_spec.rb @@ -0,0 +1,218 @@ +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 + end +end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8178f6..7f44d7d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import PostSearchPage from '@/pages/posts/PostSearchPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' 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 TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage' @@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }> }/> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index c06f1b9..6a8e732 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -41,12 +41,14 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: 'タグ', to: '/tags', subMenu: [ { name: 'マスタ', to: '/tags' }, { name: 'ニコニコ連携', to: '/tags/nico' }, + { name: '履歴', to: '/tags/changes' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, { component: , visible: tagFlg }, { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, visible: tagFlg }, - { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, visible: false }] }, + { 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 }, diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 166daa8..5dc9d70 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -3,7 +3,7 @@ import { match } from 'path-to-regexp' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' -import { fetchTagByName, fetchTag, fetchTags } from '@/lib/tags' +import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' @@ -183,6 +183,17 @@ const prefetchTagShow: Prefetcher = async (qc, url) => { } +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 }[] = [ { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), run: prefetchPostsIndex }, @@ -195,8 +206,10 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] && Boolean (mWiki (u.pathname))), run: prefetchWikiPageShow }, { test: u => u.pathname === '/tags', run: prefetchTagsIndex }, - { test: u => u.pathname !== '/tags/nico' && Boolean (mTag (u.pathname)), - run: prefetchTagShow }] + { 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 => { diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 610c847..97bae56 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -9,9 +9,11 @@ export const postsKeys = { ['posts', 'changes', p] as const } 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 = { root: ['wiki'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 8a7829f..e2c95c3 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,6 +1,6 @@ import { apiGet } from '@/lib/api' -import type { FetchTagsParams, Tag } from '@/types' +import type { FetchTagsParams, Tag, TagVersion } from '@/types' export const fetchTags = async ( @@ -45,3 +45,14 @@ export const fetchTagByName = async (name: string): Promise => { 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 } }) diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx new file mode 100644 index 0000000..30e96c9 --- /dev/null +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -0,0 +1,212 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { Helmet } from 'react-helmet-async' +import { useLocation } from 'react-router-dom' + +import PrefetchLink from '@/components/PrefetchLink' +import PageTitle from '@/components/common/PageTitle' +import Pagination from '@/components/common/Pagination' +import MainArea from '@/components/layout/MainArea' +import { toast } from '@/components/ui/use-toast' +import { SITE_TITLE } from '@/config' +import { CATEGORY_NAMES } from '@/consts' +import { apiPut } from '@/lib/api' +import { postsKeys, tagsKeys } from '@/lib/queryKeys' +import { fetchTagChanges } from '@/lib/tags' +import { cn, dateString } from '@/lib/utils' + +import type { FC } from 'react' + + +const renderDiff = (diff: { current: string | null; prev: string | null }) => ( + <> + {(diff.prev && diff.prev !== diff.current) && ( + <> + + {diff.prev} + + {diff.current &&
    } + )} + {diff.current} + ) + + +export default (() => { + const location = useLocation () + const query = new URLSearchParams (location.search) + const id = query.get ('id') + const page = Number (query.get ('page') ?? 1) + const limit = Number (query.get ('limit') ?? 20) + + const { data, isLoading: loading } = useQuery ({ + queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), + queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) + const changes = data?.versions ?? [] + const totalPages = data ? Math.ceil (data.count / limit) : 0 + + const qc = useQueryClient () + + useEffect (() => { + document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) + }, [location.search]) + + return ( + + + {`タグ定義変更履歴 | ${ SITE_TITLE }`} + + + + タグ定義変更履歴 + {id && <>: タグ {#{id}}} + + + {loading ? 'Loading...' : ( + <> +
    + + + {/* 版 */} + + {/* 名称 */} + + {/* カテゴリ */} + + {/* 別名 */} + + {/* 上位タグ */} + + {/* 更新日時 */} + + {/* (差戻ボタン) */} + + + + + + + + + + + + + + + + {changes.map (change => ( + + + + + + + + + ))} + +
    名称カテゴリ別名上位タグ更新日時 +
    {change.tagId}.{change.versionNo}{renderDiff (change.name)} + {renderDiff ({ + current: CATEGORY_NAMES[change.category.current], + prev: (change.category.prev + && CATEGORY_NAMES[change.category.prev]) })} + + {change.aliases.map ((tag, i) => ( + tag.type === 'added' + ? ( + + {tag.name} + ) + : ( + tag.type === 'removed' + ? ( + + {tag.name} + ) + : ( + + {tag.name} + ))))} + + {change.parentTags.map ((tag, i) => ( + tag.type === 'added' + ? ( + + {tag.tag.name} + ) + : ( + tag.type === 'removed' + ? ( + + {tag.tag.name} + ) + : ( + + {tag.tag.name} + ))))} + + {change.createdByUser + ? ( + + {change.createdByUser.name + || `名もなきニジラー(#${ change.createdByUser.id })`} + ) + : 'bot 操作'} +
    + {dateString (change.createdAt)} +
    + { + e.preventDefault () + + if (!(confirm ( + `タグ『${ change.name.current }』を版 ${ + change.versionNo } に差戻します.\nよろしいですか?`))) + return + + try + { + await apiPut ( + `/tags/${ change.tagId }`, + { name: change.name.current, + category: change.category.current, + aliases: + change.aliases + .filter (t => t.type !== 'removed') + .map (t => t.name) + .join (' '), + parent_tags: + change.parentTags + .filter (t => t.type !== 'removed') + .map (t => t.tag.name) + .join (' ') }) + + qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + toast ({ description: '差戻しました.' }) + } + catch + { + toast ({ description: '差戻に失敗……' }) + } + }}> + 復元 + +
    +
    + + + )} +
    ) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8adb5ff..d5eb53e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -175,6 +175,17 @@ export type Tag = { children?: Tag[] matchedAlias?: string | null } +export type TagVersion = { + tagId: number + versionNo: number + eventType: 'create' | 'update' | 'discard' | 'restore' + name: { current: string; prev: string | null } + category: { current: Category; prev: Category | null } + aliases: { name: string; type: 'context' | 'added' | 'removed' }[] + parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[] + createdAt: string + createdByUser: { id: number; name: string | null } | null } + export type Theatre = { id: number name: string | null From c112576b11d9e1075d33d9a236633b7ad13f57c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sat, 25 Apr 2026 20:46:49 +0900 Subject: [PATCH 13/17] =?UTF-8?q?=E3=83=8B=E3=82=B3=E3=83=8B=E3=82=B3=20DB?= =?UTF-8?q?=20=E9=80=86=E9=80=A3=E6=90=BA=20(#200)=20(#331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #200 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/331 --- backend/lib/tasks/export_nico.rake | 23 ++++++ backend/spec/tasks/nico_export_spec.rb | 100 +++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 backend/lib/tasks/export_nico.rake create mode 100644 backend/spec/tasks/nico_export_spec.rb diff --git a/backend/lib/tasks/export_nico.rake b/backend/lib/tasks/export_nico.rake new file mode 100644 index 0000000..c0c6c9f --- /dev/null +++ b/backend/lib/tasks/export_nico.rake @@ -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 diff --git a/backend/spec/tasks/nico_export_spec.rb b/backend/spec/tasks/nico_export_spec.rb new file mode 100644 index 0000000..e6ed87c --- /dev/null +++ b/backend/spec/tasks/nico_export_spec.rb @@ -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 From b2c3e02cccd96c94af33a7629df696a57d8ac296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sat, 25 Apr 2026 21:00:22 +0900 Subject: [PATCH 14/17] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E4=BD=9C?= =?UTF-8?q?=E6=88=90=E6=99=82=E3=81=AB=20IP=20=E3=82=A2=E3=83=89=E3=83=AC?= =?UTF-8?q?=E3=82=B9=E9=80=A3=E6=90=BA=E3=81=99=E3=82=8B=E3=82=84=E3=81=85?= =?UTF-8?q?=E3=81=AB=20(#323)=20(#326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge branch 'main' into feature/323 Merge branch 'main' into feature/323 Merge branch 'main' into feature/323 #323 #323 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/326 --- backend/app/controllers/users_controller.rb | 31 ++++++++++++++++----- backend/spec/requests/users_spec.rb | 5 ++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/backend/app/controllers/users_controller.rb b/backend/app/controllers/users_controller.rb index 4d8e57e..64aa43c 100644 --- a/backend/app/controllers/users_controller.rb +++ b/backend/app/controllers/users_controller.rb @@ -1,18 +1,26 @@ class UsersController < ApplicationController 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, - user: user.slice(:id, :name, :inheritance_code, :role) } + user: user.slice(:id, :name, :inheritance_code, :role) }, + status: :created end 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]) 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) } end @@ -41,9 +49,18 @@ class UsersController < ApplicationController return head :bad_request if name.blank? 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 render json: user.errors, status: :unprocessable_entity 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 diff --git a/backend/spec/requests/users_spec.rb b/backend/spec/requests/users_spec.rb index 89003a4..1f28e95 100644 --- a/backend/spec/requests/users_spec.rb +++ b/backend/spec/requests/users_spec.rb @@ -1,11 +1,10 @@ require "rails_helper" - RSpec.describe "Users", type: :request do describe "POST /users" do it "creates guest user and returns code" do post "/users" - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:created) expect(json["code"]).to be_present expect(json["user"]["role"]).to eq("guest") end @@ -38,7 +37,7 @@ RSpec.describe "Users", type: :request do sign_in_as(user) 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["name"]).to eq("new-name") From 0ff7fdf78a692367918181a55a0330458543481b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sun, 26 Apr 2026 22:17:25 +0900 Subject: [PATCH 15/17] =?UTF-8?q?Wiki=20=E3=81=AE=E3=83=90=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=A7=E3=83=B3=E7=AE=A1=E7=90=86=20(#317)=20(#333)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #317 #317 #317 #317 #317 #317 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/333 --- backend/app/controllers/tags_controller.rb | 42 ++- .../app/controllers/wiki_pages_controller.rb | 61 ++-- backend/app/models/ip_address.rb | 3 +- backend/app/models/wiki_page.rb | 8 +- backend/app/models/wiki_version.rb | 8 + backend/app/services/wiki/commit.rb | 99 +++--- backend/app/services/wiki_version_recorder.rb | 21 ++ .../20260426120600_create_wiki_versions.rb | 91 ++++++ backend/db/schema.rb | 21 +- backend/spec/factories/wiki_pages.rb | 2 + backend/spec/models/tag_spec.rb | 12 +- backend/spec/requests/tag_versions_spec.rb | 25 ++ .../tag_wiki_history_integrity_spec.rb | 160 ++++++++++ backend/spec/requests/tags_spec.rb | 178 +++++++++++ .../requests/wiki_body_search_pending_spec.rb | 27 ++ backend/spec/requests/wiki_conflict_spec.rb | 42 +++ .../requests/wiki_history_integrity_spec.rb | 196 ++++++++++++ .../requests/wiki_restore_pending_spec.rb | 37 +++ backend/spec/requests/wiki_spec.rb | 286 +++++++++++++----- .../requests/wiki_title_collision_spec.rb | 62 ++++ .../services/wiki/commit_integrity_spec.rb | 173 +++++++++++ backend/spec/services/wiki/commit_spec.rb | 150 +++++++++ .../services/wiki_version_recorder_spec.rb | 99 ++++++ 23 files changed, 1648 insertions(+), 155 deletions(-) create mode 100644 backend/app/models/wiki_version.rb create mode 100644 backend/app/services/wiki_version_recorder.rb create mode 100644 backend/db/migrate/20260426120600_create_wiki_versions.rb create mode 100644 backend/spec/requests/tag_wiki_history_integrity_spec.rb create mode 100644 backend/spec/requests/wiki_body_search_pending_spec.rb create mode 100644 backend/spec/requests/wiki_conflict_spec.rb create mode 100644 backend/spec/requests/wiki_history_integrity_spec.rb create mode 100644 backend/spec/requests/wiki_restore_pending_spec.rb create mode 100644 backend/spec/requests/wiki_title_collision_spec.rb create mode 100644 backend/spec/services/wiki/commit_integrity_spec.rb create mode 100644 backend/spec/services/wiki/commit_spec.rb create mode 100644 backend/spec/services/wiki_version_recorder_spec.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 3785a9e..6a81dd4 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -238,18 +238,26 @@ class TagsController < ApplicationController 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 != old_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) + + record_tag_version!( + tag, + event_type: :update, + created_by_user: current_user, + name_changed:, + wiki_page:) end render json: TagRepr.base(tag.reload) @@ -272,10 +280,21 @@ class TagsController < ApplicationController 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? - record_tag_version!(tag, event_type: :update, created_by_user: current_user) + 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) @@ -297,12 +316,23 @@ class TagsController < ApplicationController material: material.as_json&.merge(file:, content_type:)) end - def record_tag_version! tag, event_type:, created_by_user: + 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:) - else - TagVersionRecorder.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 diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index dc6c47f..de42c52 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -85,22 +85,24 @@ class WikiPagesController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? - name = params[:title]&.strip + title = params[:title].to_s.strip 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), 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 def update @@ -113,18 +115,33 @@ class WikiPagesController < ApplicationController return head :unprocessable_entity if title.blank? || body.blank? 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 - end + ApplicationRecord.transaction do + page.lock! - message = params[:message].presence - Wiki::Commit.content!(page:, - body:, - created_user: current_user, - message:, - base_revision_id:) + 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 head :ok end diff --git a/backend/app/models/ip_address.rb b/backend/app/models/ip_address.rb index e5e01a3..bf73658 100644 --- a/backend/app/models/ip_address.rb +++ b/backend/app/models/ip_address.rb @@ -2,5 +2,6 @@ class IpAddress < ApplicationRecord 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 end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 8d3feec..b725706 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -13,8 +13,11 @@ class WikiPage < ApplicationRecord foreign_key: :redirect_page_id, dependent: :nullify + has_many :wiki_versions + belongs_to :tag_name validates :tag_name, presence: true + validates :body, presence: true def title = tag_name.name @@ -24,11 +27,6 @@ class WikiPage < ApplicationRecord 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 page = self visited = Set.new diff --git a/backend/app/models/wiki_version.rb b/backend/app/models/wiki_version.rb new file mode 100644 index 0000000..4f55804 --- /dev/null +++ b/backend/app/models/wiki_version.rb @@ -0,0 +1,8 @@ +class WikiVersion < ApplicationRecord + include VersionRecord + + belongs_to :wiki_page + + validates :title, presence: true + validates :body, presence: true +end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index c0be98a..bd6fb7b 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -7,6 +7,31 @@ module Wiki ; 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 new(page:, created_user:).content!(body:, message:, base_revision_id:) end @@ -21,7 +46,12 @@ module Wiki end 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) line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } @@ -37,10 +67,19 @@ module Wiki 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 })." + "競合が発生してゐます" + + "(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." 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!( wiki_page: @page, base_revision_id:, @@ -54,65 +93,45 @@ module Wiki rows = line_ids.each_with_index.map do |line_id, pos| { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } end - WikiRevisionLine.insert_all!(rows) + WikiRevisionLine.insert_all!(rows) if rows.any? rev 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.gsub!("\r\n", "\n") + s.gsub!(/\r\n?/, "\n") s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') + s.gsub(/\n+$/, '') end - def split_lines body - body.split("\n") - end + private + + def split_lines(body) = body.split("\n") def upsert_lines! lines, line_shas now = Time.current 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| 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 - 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 end diff --git a/backend/app/services/wiki_version_recorder.rb b/backend/app/services/wiki_version_recorder.rb new file mode 100644 index 0000000..41e1db9 --- /dev/null +++ b/backend/app/services/wiki_version_recorder.rb @@ -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 diff --git a/backend/db/migrate/20260426120600_create_wiki_versions.rb b/backend/db/migrate/20260426120600_create_wiki_versions.rb new file mode 100644 index 0000000..756597f --- /dev/null +++ b/backend/db/migrate/20260426120600_create_wiki_versions.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index ede919d..6e1a67f 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do +ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -354,6 +354,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_name_id", null: false + t.text "body", null: false t.bigint "created_user_id", null: false t.bigint "updated_user_id", null: false t.datetime "created_at", null: false @@ -396,6 +397,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" 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_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "material_versions", "materials" @@ -453,4 +470,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do add_foreign_key "wiki_revisions", "wiki_pages" 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_versions", "users", column: "created_by_user_id" + add_foreign_key "wiki_versions", "wiki_pages" end diff --git a/backend/spec/factories/wiki_pages.rb b/backend/spec/factories/wiki_pages.rb index b4f1496..7f5f41a 100644 --- a/backend/spec/factories/wiki_pages.rb +++ b/backend/spec/factories/wiki_pages.rb @@ -3,5 +3,7 @@ FactoryBot.define do title { "TestPage" } association :created_user, factory: :user association :updated_user, factory: :user + + body { ' ' } end end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 1dee732..1a1714b 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -107,11 +107,13 @@ RSpec.describe Tag, type: :model 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!(: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 it 'rolls back the transaction' do diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index f1d92e7..aae790c 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -214,5 +214,30 @@ RSpec.describe 'TagVersions API', type: :request do 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 diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb new file mode 100644 index 0000000..909ebe3 --- /dev/null +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -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 diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index d3bc594..864cdae 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -471,6 +471,54 @@ RSpec.describe 'Tags API', type: :request do 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 @@ -876,6 +924,136 @@ RSpec.describe 'Tags API', type: :request do 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 + pending '#329 で対応予定' + + 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 diff --git a/backend/spec/requests/wiki_body_search_pending_spec.rb b/backend/spec/requests/wiki_body_search_pending_spec.rb new file mode 100644 index 0000000..ebfc874 --- /dev/null +++ b/backend/spec/requests/wiki_body_search_pending_spec.rb @@ -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 'Wiki 本文検索実装時に有効化する' + + 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 diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb new file mode 100644 index 0000000..9ae9052 --- /dev/null +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -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 diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb new file mode 100644 index 0000000..7f2ccce --- /dev/null +++ b/backend/spec/requests/wiki_history_integrity_spec.rb @@ -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 diff --git a/backend/spec/requests/wiki_restore_pending_spec.rb b/backend/spec/requests/wiki_restore_pending_spec.rb new file mode 100644 index 0000000..0ceeca1 --- /dev/null +++ b/backend/spec/requests/wiki_restore_pending_spec.rb @@ -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 'Wiki 版巻き戻し API 実装時に有効化する' + + 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 diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 4541501..9b6e8c1 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -4,13 +4,19 @@ require 'securerandom' RSpec.describe 'Wiki API', type: :request do + def auth_headers(user) + { 'X-Transfer-Code' => user.inheritance_code } + end + let!(:user) { create_member_user! } let!(:tn) { TagName.create!(name: 'spec_wiki_title') } 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 describe 'GET /wiki' do @@ -37,11 +43,12 @@ RSpec.describe 'Wiki API', type: :request do context 'when wiki page exists' do it 'returns wiki page with title' do request + expect(response).to have_http_status(:ok) expect(json).to include( - 'id' => page.id, - 'title' => 'spec_wiki_title') + 'id' => page.id, + 'title' => 'spec_wiki_title') end end @@ -50,6 +57,7 @@ RSpec.describe 'Wiki API', type: :request do it 'returns 404' do request + expect(response).to have_http_status(:not_found) end end @@ -99,25 +107,34 @@ RSpec.describe 'Wiki API', type: :request do end .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_id = json.fetch('id') expect(json.fetch('title')).to eq('TestPage') - 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_content expect(rev.message).to eq('init') - # body が復元できること - 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.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 @@ -135,6 +152,41 @@ RSpec.describe 'Wiki API', type: :request do # "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 @@ -146,17 +198,14 @@ RSpec.describe 'Wiki API', type: :request do { 'X-Transfer-Code' => user.inheritance_code } 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 context 'when not logged in' do @@ -182,14 +231,6 @@ RSpec.describe 'Wiki API', type: :request do headers: auth_headers(member) expect(response).to have_http_status(:unprocessable_entity) 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 context 'when success' do @@ -200,7 +241,18 @@ RSpec.describe 'Wiki API', type: :request do put "/wiki/#{page.id}", params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, 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) @@ -211,25 +263,60 @@ RSpec.describe 'Wiki API', type: :request do expect(page.body).to eq("x\ny") expect(rev.base_revision_id).to eq(current_id) 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 - # 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 it 'returns 404' do @@ -261,14 +348,17 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/search' 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 it 'returns up to 20 pages filtered by title like' do @@ -278,7 +368,9 @@ RSpec.describe 'Wiki API', type: :request do expect(json).to be_an(Array) 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') end @@ -329,7 +421,12 @@ RSpec.describe 'Wiki API', type: :request do it 'returns empty array when page has no revisions and filtered by id' do # 別ページを作って revision 無し 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}" expect(response).to have_http_status(:ok) @@ -398,29 +495,68 @@ RSpec.describe 'Wiki API', type: :request do expect(json['older_revision_id']).to eq(rev_a.id) expect(json['newer_revision_id']).to eq(page.current_revision.id) 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 - 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 diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb new file mode 100644 index 0000000..ad02dfa --- /dev/null +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -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 diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb new file mode 100644 index 0000000..4a08de5 --- /dev/null +++ b/backend/spec/services/wiki/commit_integrity_spec.rb @@ -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 diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb new file mode 100644 index 0000000..2e564cf --- /dev/null +++ b/backend/spec/services/wiki/commit_spec.rb @@ -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 diff --git a/backend/spec/services/wiki_version_recorder_spec.rb b/backend/spec/services/wiki_version_recorder_spec.rb new file mode 100644 index 0000000..9f1c6dd --- /dev/null +++ b/backend/spec/services/wiki_version_recorder_spec.rb @@ -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 From fcd3b87b2a1755c65b41b3d8b0ed7c212994e78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Mon, 27 Apr 2026 12:45:06 +0900 Subject: [PATCH 16/17] =?UTF-8?q?=E5=A5=AA=E3=81=AF=E3=82=8C=E3=81=9F?= =?UTF-8?q?=E5=88=A5=E5=90=8D=E3=81=AE=E5=B1=A5=E6=AD=B4=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20(#329)=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #329 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/338 --- backend/app/controllers/tags_controller.rb | 23 +++++++++++++++++++ backend/spec/requests/tags_spec.rb | 2 -- .../requests/wiki_body_search_pending_spec.rb | 2 +- .../requests/wiki_restore_pending_spec.rb | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 6a81dd4..3560e81 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -336,8 +336,27 @@ class TagsController < ApplicationController 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) @@ -348,6 +367,10 @@ class TagsController < ApplicationController 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 diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 864cdae..a688598 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -964,8 +964,6 @@ RSpec.describe 'Tags API', type: :request do end it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do - pending '#329 で対応予定' - old_owner = Tag.create!( tag_name: TagName.create!(name: 'put_alias_old_owner'), category: :general diff --git a/backend/spec/requests/wiki_body_search_pending_spec.rb b/backend/spec/requests/wiki_body_search_pending_spec.rb index ebfc874..98bd6af 100644 --- a/backend/spec/requests/wiki_body_search_pending_spec.rb +++ b/backend/spec/requests/wiki_body_search_pending_spec.rb @@ -4,7 +4,7 @@ RSpec.describe 'Wiki body search', type: :request do let!(:user) { create_member_user! } it 'searches wiki pages by body text' do - pending 'Wiki 本文検索実装時に有効化する' + pending '#336 で対応予定' Wiki::Commit.create_content!( tag_name: TagName.create!(name: 'wiki_body_search_hit'), diff --git a/backend/spec/requests/wiki_restore_pending_spec.rb b/backend/spec/requests/wiki_restore_pending_spec.rb index 0ceeca1..33b6a63 100644 --- a/backend/spec/requests/wiki_restore_pending_spec.rb +++ b/backend/spec/requests/wiki_restore_pending_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'Wiki restore', type: :request do end it 'restores wiki page to previous version' do - pending 'Wiki 版巻き戻し API 実装時に有効化する' + pending '#337 で対応予定' page = Wiki::Commit.create_content!( From 5002859fc82cb05be4ea7c6796ca48d3f1b1da5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sat, 2 May 2026 17:56:14 +0900 Subject: [PATCH 17/17] =?UTF-8?q?YouTube=20=E3=81=AE=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E5=90=8C=E6=9C=9F=20(#314)=20(#340)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #314 #314 #314 #314 #314 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/340 --- backend/app/models/tag.rb | 1 + backend/app/services/youtube/api_client.rb | 73 +++++ backend/app/services/youtube/sync.rb | 168 ++++++++++ backend/app/services/youtube/video_item.rb | 32 ++ backend/config/schedule.rb | 8 + backend/lib/tasks/sync_posts.rake | 6 + .../spec/services/youtube/api_client_spec.rb | 130 ++++++++ backend/spec/services/youtube/sync_spec.rb | 310 ++++++++++++++++++ .../spec/services/youtube/video_item_spec.rb | 93 ++++++ backend/spec/tasks/post_sync_spec.rb | 25 ++ 10 files changed, 846 insertions(+) create mode 100644 backend/app/services/youtube/api_client.rb create mode 100644 backend/app/services/youtube/sync.rb create mode 100644 backend/app/services/youtube/video_item.rb create mode 100644 backend/lib/tasks/sync_posts.rake create mode 100644 backend/spec/services/youtube/api_client_spec.rb create mode 100644 backend/spec/services/youtube/sync_spec.rb create mode 100644 backend/spec/services/youtube/video_item_spec.rb create mode 100644 backend/spec/tasks/post_sync_spec.rb diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 54c3d68..acdc5c7 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -84,6 +84,7 @@ class Tag < ApplicationRecord 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, with_no_deerjikist: true, diff --git a/backend/app/services/youtube/api_client.rb b/backend/app/services/youtube/api_client.rb new file mode 100644 index 0000000..e38ca57 --- /dev/null +++ b/backend/app/services/youtube/api_client.rb @@ -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 diff --git a/backend/app/services/youtube/sync.rb b/backend/app/services/youtube/sync.rb new file mode 100644 index 0000000..2056dc2 --- /dev/null +++ b/backend/app/services/youtube/sync.rb @@ -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 diff --git a/backend/app/services/youtube/video_item.rb b/backend/app/services/youtube/video_item.rb new file mode 100644 index 0000000..fea2b15 --- /dev/null +++ b/backend/app/services/youtube/video_item.rb @@ -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 diff --git a/backend/config/schedule.rb b/backend/config/schedule.rb index 23b6c73..1209ab1 100644 --- a/backend/config/schedule.rb +++ b/backend/config/schedule.rb @@ -17,3 +17,11 @@ every 1.day, at: '0:00 am' do 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 diff --git a/backend/lib/tasks/sync_posts.rake b/backend/lib/tasks/sync_posts.rake new file mode 100644 index 0000000..267d474 --- /dev/null +++ b/backend/lib/tasks/sync_posts.rake @@ -0,0 +1,6 @@ +namespace :post do + desc '投稿同期(ニコニコ以外)' + task sync: :environment do + Youtube::Sync.new.sync! + end +end diff --git a/backend/spec/services/youtube/api_client_spec.rb b/backend/spec/services/youtube/api_client_spec.rb new file mode 100644 index 0000000..5fb9298 --- /dev/null +++ b/backend/spec/services/youtube/api_client_spec.rb @@ -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 diff --git a/backend/spec/services/youtube/sync_spec.rb b/backend/spec/services/youtube/sync_spec.rb new file mode 100644 index 0000000..df8009a --- /dev/null +++ b/backend/spec/services/youtube/sync_spec.rb @@ -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 diff --git a/backend/spec/services/youtube/video_item_spec.rb b/backend/spec/services/youtube/video_item_spec.rb new file mode 100644 index 0000000..4db52da --- /dev/null +++ b/backend/spec/services/youtube/video_item_spec.rb @@ -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 diff --git a/backend/spec/tasks/post_sync_spec.rb b/backend/spec/tasks/post_sync_spec.rb new file mode 100644 index 0000000..c9ce486 --- /dev/null +++ b/backend/spec/tasks/post_sync_spec.rb @@ -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