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/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/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/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 a8926c2..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 @@ -31,6 +34,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 +76,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 @@ -147,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 @@ -155,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) @@ -177,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/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/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/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/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 6a2096b..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_17_015000) 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 @@ -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 @@ -93,6 +132,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) 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 @@ -239,6 +299,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 +327,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 +366,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" @@ -300,6 +383,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) 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" @@ -320,6 +406,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/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/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/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/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 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/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('')) + + 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('')) + + 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('')) + + 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df91625..39de095 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 TOSPage from '@/pages/TOSPage.mdx' @@ -42,7 +47,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { return ( - + }/> }/> }/> @@ -52,6 +57,12 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }> + }/> + }/> + }/> + + {/* }/> */} }/> }/> }/> @@ -122,7 +133,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 e6ceef5..f13b3ec 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -74,20 +74,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', 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' }] }, + to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] }, { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new' }, @@ -139,9 +146,9 @@ export default (({ user }: Props) => { return ( <> -