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/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 2349deb..f0e33a4 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -30,14 +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_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false) - return head :bad_request if linked_tags.any? { |t| t.category == 'nico' } + linked_tag_names = params[:tags].to_s.split + linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false, + with_no_deerjikist: false) + return head :bad_request if linked_tags.any? { |t| t.nico? } - tag.linked_tags = linked_tags - tag.save! + ApplicationRecord.transaction do + TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) + + tag.linked_tags = linked_tags + tag.save! + + NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) + end render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok end 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 74d720c..111052b 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) @@ -127,17 +127,22 @@ 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! + + 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.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 +171,27 @@ 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:) - tags = post.tags.where(category: 'nico').to_a + - Tag.normalise_tags(tag_names, with_tagme: false) + + ApplicationRecord.transaction do + PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) + + post.update!(title:, original_created_from:, original_created_before:) + + normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false) + TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) + + tags = post.tags.nico.to_a + normalised_tags tags = Tag.expand_parent_tags(tags) 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 @@ -198,7 +211,7 @@ class PostsController < ApplicationController pts = pts.where(post_id: id) if id.present? pts = pts.where(tag_id:) if tag_id.present? pts = pts.includes(:post, :created_user, :deleted_user, - tag: { tag_name: :wiki_page }) + tag: [:materials, { tag_name: :wiki_page }]) events = [] pts.each do |pt| 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/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/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9652f18..3560e81 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] @@ -66,7 +66,45 @@ 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 + 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 @@ -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,72 @@ 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_all + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + tag = Tag.find_by(id: params[:id]) + return head :not_found unless tag + + name = params[:name].to_s.strip + category = params[:category].to_s.strip + return head :unprocessable_entity if name.blank? || category.blank? + + if name != tag.name && + tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) + return render json: { error: 'システム・タグの名称は変更できません.' }, + status: :unprocessable_entity + end + + if tag.nico? || category == 'nico' + return render json: { error: 'ニコタグは変更できません.' }, + status: :unprocessable_entity + end + + alias_names = params[:aliases].to_s.split.uniq + parent_names = params[:parent_tags].to_s.split.uniq + + ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + + old_name = tag.name + name_changed = name != old_name + wiki_page = tag.tag_name.wiki_page if name_changed + + tag.update!(category:) + tag.tag_name.update!(name:) + + alias_names << old_name if name_changed + alias_names.delete(name) + + update_aliases!(tag, alias_names) + update_parent_tags!(tag, parent_names) + + tag.reload + + record_tag_version!( + tag, + event_type: :update, + created_by_user: current_user, + name_changed:, + wiki_page:) + end + + render json: TagRepr.base(tag.reload) + end + def update return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? @@ -168,14 +272,123 @@ class TagsController < ApplicationController tag = Tag.find(params[:id]) - if name.present? - tag.tag_name.update!(name:) + if tag.nico? || (category.present? && category == 'nico') + return render json: { error: 'ニコタグは変更できません.' }, + status: :unprocessable_entity end - if category.present? - tag.update!(category:) + ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + + old_name = tag.name + name_changed = name.present? && name != old_name + wiki_page = tag.tag_name.wiki_page if name_changed + + tag.tag_name.update!(name:) if name.present? + tag.update!(category:) if category.present? + + tag.reload + + record_tag_version!( + tag, + event_type: :update, + created_by_user: current_user, + name_changed:, + wiki_page:) + end + + render json: TagRepr.base(tag.reload) + end + + private + + def build_tag_children tag + material = tag.materials.first + file = nil + content_type = nil + if material&.file&.attached? + file = rails_storage_proxy_url(material.file, only_path: false) + content_type = material.file.blob.content_type end - render json: TagRepr.base(tag) + TagRepr.base(tag).merge( + children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, + material: material.as_json&.merge(file:, content_type:)) + end + + def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil + if tag.nico? + NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) + return + end + + TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + + return unless name_changed + + wiki_page ||= tag.tag_name.wiki_page + return unless wiki_page&.wiki_versions&.exists? + + WikiVersionRecorder.record!( + page: wiki_page, + event_type: :update, + created_by_user:) + end + + def update_aliases! tag, alias_names + alias_names = alias_names.uniq + + affected_tags = [tag] + + current_aliases = tag.tag_name.aliases.to_a + + current_aliases.each do |alias_tag_name| + next if alias_names.include?(alias_tag_name.name) + + affected_tags << alias_tag_name.canonical&.tag + end + + alias_names.each do |alias_name| + alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) + affected_tags << alias_tag_name.canonical&.tag + end + + affected_tags.compact.uniq.each do |affected_tag| + TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user) + end + + current_aliases.each do |alias_tag_name| + next if alias_names.include?(alias_tag_name.name) + + alias_tag_name.update!(canonical: nil) + end + + alias_names.each do |alias_name| + alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name) + alias_tag_name.update!(canonical: tag.tag_name) + end + + affected_tags.compact.uniq.each do |affected_tag| + record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user) + end + end + + def update_parent_tags! tag, parent_names + parent_tags = Tag.normalise_tags(parent_names, with_tagme: false, + with_no_deerjikist: false, + deny_nico: true) + + old_parent_tags = tag.parents.to_a + + TagVersioning.record_tag_snapshots!((old_parent_tags + parent_tags).uniq, + created_by_user: current_user) + + tag.reversed_tag_implications.destroy_all + + parent_tags.each do |parent_tag| + next if parent_tag == tag + + TagImplication.create!(tag:, parent_tag:) + end end end 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/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 9e264fb..6bec3c3 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).merge(body:), status: :created - else - render json: { errors: page.errors.full_messages }, - status: :unprocessable_entity - end + page = + Wiki::Commit.create_content!( + tag_name:, + body:, + created_by_user: current_user, + message:) + + render json: WikiPageRepr.base(page), status: :created + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique + head :unprocessable_entity end def update @@ -113,10 +115,32 @@ 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 + ApplicationRecord.transaction do + page.lock! + + old_title = page.title + + tag = Tag.find_by(tag_name_id: page.tag_name_id) + + if tag && title != old_title + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + end + + page.tag_name.update!(name: title) if title != old_title + + message = params[:message].presence + Wiki::Commit.content!(page:, + body:, + created_user: current_user, + message:, + base_revision_id:) + + if tag && title != old_title + tag.reload + TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) + end end message = params[:message].presence diff --git a/backend/app/models/ip_address.rb b/backend/app/models/ip_address.rb index e5e01a3..8297d4c 100644 --- a/backend/app/models/ip_address.rb +++ b/backend/app/models/ip_address.rb @@ -1,6 +1,14 @@ 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 + + def banned? = banned_at? + def banned = banned? + + def banned= value + bool = ActiveModel::Type::Boolean.new.cast(value) + self.banned_at = bool ? banned_at || Time.current : nil + end end 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/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.rb b/backend/app/models/post.rb index c898615..a784607 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -1,7 +1,6 @@ class Post < ApplicationRecord require 'mini_magick' - belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id' belongs_to :uploaded_user, class_name: 'User', optional: true has_many :post_tags, dependent: :destroy, inverse_of: :post @@ -11,6 +10,7 @@ class Post < ApplicationRecord has_many :user_post_views, dependent: :delete_all has_many :post_similarities, dependent: :delete_all + has_many :post_versions has_one_attached :thumbnail @@ -30,6 +30,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..966bfcf --- /dev/null +++ b/backend/app/models/post_version.rb @@ -0,0 +1,21 @@ +class PostVersion < ApplicationRecord + include VersionRecord + + belongs_to :post + + validates :url, presence: true + + validate :validate_original_created_range + + private + + def validate_original_created_range + f = original_created_from + b = original_created_before + return if f.blank? || b.blank? + + if f >= b + errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.' + end + end +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index a8926c2..acdc5c7 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -1,3 +1,6 @@ +require 'set' + + class Tag < ApplicationRecord include MyDiscard @@ -5,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' @@ -31,6 +32,10 @@ class Tag < ApplicationRecord class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all has_many :deerjikists, dependent: :delete_all + has_many :materials + + has_many :tag_versions + has_many :nico_tag_versions belongs_to :tag_name delegate :wiki_page, to: :tag_name @@ -72,27 +77,18 @@ class Tag < ApplicationRecord def has_wiki = wiki_page.present? - def self.tagme - @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) - end - - def self.bot - @bot ||= find_or_create_by_tag_name!('bot操作', category: :meta) - end - - def self.no_deerjikist - @no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) - end - - def self.video - @video ||= find_or_create_by_tag_name!('動画', category: :meta) - end + def material_id = materials.first&.id - def self.niconico - @niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta) - end + def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) + def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) + def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) + def self.video = find_or_create_by_tag_name!('動画', category: :meta) + def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) + def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta) - def self.normalise_tags tag_names, with_tagme: true, deny_nico: true + def self.normalise_tags tag_names, with_tagme: true, + with_no_deerjikist: true, + deny_nico: true if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } raise NicoTagNormalisationError end @@ -106,7 +102,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 @@ -144,18 +140,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 - source_pt.discard_by!(nil) + affected_post_ids << post_id + source_pt.discard_by!(created_by_user) unless PostTag.kept.exists?(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag) end @@ -167,6 +170,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? @@ -175,6 +179,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.ensure_snapshot!(post, created_by_user:) + PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) end # 投稿件数を再集計 @@ -184,6 +195,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/user.rb b/backend/app/models/user.rb index 7e07642..f80f1ad 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -4,7 +4,6 @@ class User < ApplicationRecord validates :name, length: { maximum: 255 } validates :inheritance_code, presence: true, length: { maximum: 64 } validates :role, presence: true, inclusion: { in: roles.keys } - validates :banned, inclusion: { in: [true, false] } has_many :created_posts, class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify @@ -18,6 +17,14 @@ class User < ApplicationRecord has_many :updated_wiki_pages, class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify + def banned? = banned_at? + def banned = banned? + + def banned= value + bool = ActiveModel::Type::Boolean.new.cast(value) + self.banned_at = bool ? (banned_at || Time.current) : nil + end + def viewed?(post) = user_post_views.exists?(post_id: post.id) def gte_member? = member? || admin? 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 b752599..eb850b7 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' @@ -17,8 +15,11 @@ class WikiPage < ApplicationRecord has_many :assets, class_name: 'WikiAsset', dependent: :destroy + has_many :wiki_versions + belongs_to :tag_name validates :tag_name, presence: true + validates :body, presence: true def title = tag_name.name @@ -28,11 +29,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/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..ecbed17 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -3,15 +3,14 @@ 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 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/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 new file mode 100644 index 0000000..021502d --- /dev/null +++ b/backend/app/services/post_version_recorder.rb @@ -0,0 +1,30 @@ +class PostVersionRecorder < VersionRecorder + def self.record! post:, event_type:, created_by_user: + new(post:, event_type:, created_by_user:).record! + end + + def initialize post:, event_type:, created_by_user: + super(record: post, event_type:, created_by_user:) + end + + def self.ensure_snapshot! post, created_by_user: + return if post.post_versions.exists? + + record!(post:, event_type: :create, created_by_user:) + end + + private + + def version_class = PostVersion + def version_association = :post_versions + def record_key = :post + + def snapshot_attributes + { title: @record.title, + url: @record.url, + thumbnail_base: @record.thumbnail_base, + tags: @record.snapshot_tag_names.join(' '), + original_created_from: @record.original_created_from, + original_created_before: @record.original_created_before } + end +end 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/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/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/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 2a2a496..458d617 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -6,17 +6,23 @@ 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 + get :versions, to: 'tag_versions#index' 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 member do + put '', action: :update_all + patch '', action: :update + get :deerjikists end end @@ -49,6 +55,7 @@ Rails.application.routes.draw do collection do get :random get :changes + get :versions, to: 'post_versions#index' end member do @@ -83,4 +90,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/schedule.rb b/backend/config/schedule.rb index b4db72a..1209ab1 100644 --- a/backend/config/schedule.rb +++ b/backend/config/schedule.rb @@ -1,12 +1,27 @@ 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 + +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/config/storage.yml b/backend/config/storage.yml index 4942ab6..ae4ef9b 100644 --- a/backend/config/storage.yml +++ b/backend/config/storage.yml @@ -6,29 +6,11 @@ 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 + request_checksum_calculation: when_required 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..58df885 --- /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 < ActiveRecord::Base + self.table_name = 'posts' + end + + class PostTag < ActiveRecord::Base + self.table_name = 'post_tags' + end + + class PostVersion < ActiveRecord::Base + self.table_name = 'post_versions' + end + + def up + create_table :post_versions do |t| + t.references :post, null: false, foreign_key: true + t.integer :version_no, null: false + t.string :event_type, null: false + t.string :title + t.string :url, limit: 768, null: false + t.string :thumbnail_base, limit: 2000 + t.text :tags, null: false + t.references :parent, foreign_key: { to_table: :posts } + t.datetime :original_created_from + t.datetime :original_created_before + t.datetime :created_at, null: false + t.references :created_by_user, foreign_key: { to_table: :users } + + t.index [:post_id, :version_no], unique: true + t.check_constraint 'version_no > 0', + name: 'post_versions_version_no_positive' + t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')", + name: 'post_versions_event_type_valid' + end + + PostVersion.reset_column_information + + say_with_time 'Backfilling post_versions' do + Post.find_in_batches(batch_size: 500) do |posts| + post_ids = posts.map(&:id) + + post_tag_rows_by_post_id = + PostTag + .joins('INNER JOIN tags ON tags.id = post_tags.tag_id') + .joins('INNER JOIN tag_names ON tag_names.id = tags.tag_name_id') + .where(post_id: post_ids) + .pluck('post_tags.post_id', + 'post_tags.created_at', + 'post_tags.discarded_at', + 'post_tags.created_user_id', + 'post_tags.deleted_user_id', + 'tag_names.name') + .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| + post_id, created_at, discarded_at, created_user_id, deleted_user_id, tag_name = row + h[post_id] << { created_at:, + discarded_at:, + created_user_id:, + deleted_user_id:, + tag_name: } + end + + rows = [] + + posts.each do |post| + post_tag_rows = post_tag_rows_by_post_id[post.id] + + events = post_tag_rows.flat_map do |post_tag_row| + ary = [[post_tag_row[:created_at], + post_tag_row[:created_user_id], + :add, + post_tag_row[:tag_name]]] + + if post_tag_row[:discarded_at] + ary << [post_tag_row[:discarded_at], + post_tag_row[:deleted_user_id], + :remove, + post_tag_row[:tag_name]] + end + + ary + end + + kind_order = { add: 0, remove: 1 } + + events.sort_by! do |event_at, user_id, kind, tag_name| + [event_at, user_id || 0, kind_order.fetch(kind), tag_name] + end + + event_buckets = bucket_events(events) + + active_tags = Set.new + version_no = 0 + + if event_buckets.empty? + version_no += 1 + rows << build_row(post:, + version_no:, + event_type: 'create', + created_at: post.created_at, + created_by_user_id: post.uploaded_user_id, + tags: []) + next + end + + first_bucket = event_buckets.first + merge_first_bucket_into_create = first_bucket[:first_at] <= post.created_at + 1.second + + if merge_first_bucket_into_create + event_buckets.shift + apply_bucket!(active_tags, first_bucket) + + version_no += 1 + rows << build_row( + post:, + version_no:, + event_type: 'create', + created_at: post.created_at, + created_by_user_id: post.uploaded_user_id || first_bucket[:user_ids].compact.first, + tags: active_tags.to_a.sort) + else + version_no += 1 + rows << build_row( + post:, + version_no:, + event_type: 'create', + created_at: post.created_at, + created_by_user_id: post.uploaded_user_id, + tags: []) + end + + event_buckets.each do |bucket| + apply_bucket!(active_tags, bucket) + + version_no += 1 + rows << build_row( + post:, + version_no:, + event_type: 'update', + created_at: bucket[:first_at], + created_by_user_id: bucket[:user_ids].compact.first, + tags: active_tags.to_a.sort) + end + end + + PostVersion.insert_all!(rows) if rows.any? + end + end + end + + def down + drop_table :post_versions + end + + private + + def bucket_events events + buckets = [] + + events.each do |event_at, user_id, kind, tag_name| + if buckets.empty? || event_at - buckets.last[:last_at] > 1.second + buckets << { first_at: event_at, + last_at: event_at, + user_ids: [user_id], + events: [[kind, tag_name]] } + else + bucket = buckets.last + bucket[:last_at] = event_at + bucket[:user_ids] << user_id + bucket[:events] << [kind, tag_name] + end + end + + buckets + end + + def apply_bucket! active_tags, bucket + bucket[:events].each do |kind, tag_name| + if kind == :add + active_tags.add(tag_name) + else + active_tags.delete(tag_name) + end + end + end + + def build_row post:, version_no:, event_type:, created_at:, created_by_user_id:, tags: + { post_id: post.id, + version_no:, + event_type:, + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: tags.join(' '), + parent_id: post.parent_id, + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at:, + created_by_user_id: } + end +end 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/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 82e8fe2..042d227 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_23_192300) do +ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -50,12 +50,52 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.binary "ip_address", limit: 16, null: false - t.boolean "banned", default: false, null: false + t.datetime "banned_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["banned_at"], name: "index_ip_addresses_on_banned_at" t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true 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 @@ -65,6 +105,30 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id" end + create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.string "name", null: false + t.text "linked_tags", null: false + t.datetime "created_at", null: false + t.bigint "created_by_user_id" + t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" + t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true + t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" + end + + create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "post_id", null: false + t.bigint "parent_post_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" + t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self" + end + create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false @@ -93,17 +157,35 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do t.index ["tag_id"], name: "index_post_tags_on_tag_id" end + create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "post_id", null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.string "title" + t.string "url", limit: 768, null: false + t.string "thumbnail_base", limit: 2000 + t.text "tags", null: false + t.text "parent_post_ids", null: false + t.datetime "original_created_from" + t.datetime "original_created_before" + t.datetime "created_at", null: false + t.bigint "created_by_user_id" + t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" + t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true + t.index ["post_id"], name: "index_post_versions_on_post_id" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" + end + create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "title" t.string "url", limit: 768, null: false t.string "thumbnail_base", limit: 2000 - t.bigint "parent_id" t.bigint "uploaded_user_id" t.datetime "created_at", null: false t.datetime "original_created_from" t.datetime "original_created_before" t.datetime "updated_at", null: false - t.index ["parent_id"], name: "index_posts_on_parent_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true end @@ -156,6 +238,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" 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 @@ -234,9 +333,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do t.string "name" t.string "inheritance_code", limit: 64, null: false t.string "role", null: false - t.boolean "banned", default: false, null: false + t.datetime "banned_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["banned_at"], name: "index_users_on_banned_at" end create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -262,6 +362,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| 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 @@ -304,17 +405,47 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" 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" + 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 "nico_tag_versions", "tags" + add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" + add_foreign_key "post_implications", "posts" + add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_tags", "posts" 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 "posts", "posts", column: "parent_id" + add_foreign_key "post_versions", "posts" + add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "settings", "users" add_foreign_key "tag_implications", "tags" @@ -322,6 +453,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do add_foreign_key "tag_names", "tag_names", column: "canonical_id" add_foreign_key "tag_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" @@ -345,4 +478,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do add_foreign_key "wiki_revisions", "wiki_pages" add_foreign_key "wiki_revisions", "wiki_pages", 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/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/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index 09be474..f7c760c 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, @@ -110,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 # 新たに記載される外部タグと連携される内部タグを記載 @@ -140,6 +149,13 @@ 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.ensure_snapshot!(post, created_by_user: nil) + PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) + end end end 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/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/post_version_spec.rb b/backend/spec/models/post_version_spec.rb new file mode 100644 index 0000000..5d2528b --- /dev/null +++ b/backend/spec/models/post_version_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe PostVersion, type: :model do + let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') } + let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } + + let!(:post_record) do + Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post| + PostTag.create!(post: post, tag: tag) + end + end + + let!(:post_version) do + PostVersion.create!( + post: post_record, + version_no: 1, + event_type: 'create', + title: post_record.title, + url: post_record.url, + thumbnail_base: post_record.thumbnail_base, + tags: post_record.snapshot_tag_names.join(' '), + original_created_from: post_record.original_created_from, + original_created_before: post_record.original_created_before, + created_at: Time.current, + created_by_user: nil + ) + end + + it 'is read only after create' do + expect do + post_version.update!(title: 'changed') + end.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'cannot be destroyed' do + expect do + post_version.destroy! + end.to raise_error(ActiveRecord::ReadOnlyRecord) + end +end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index a9fc35e..dbcd69b 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 @@ -145,5 +147,69 @@ 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), + 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/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/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/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 120a221..c24dbef 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 ではスタブしとくのが無難。 @@ -756,6 +756,217 @@ 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), + 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) } @@ -795,4 +1006,175 @@ 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), + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: post.created_at, + created_by_user: post.uploaded_user + ) + end + + it 'creates version 1 on POST /posts' do + sign_in_as(member) + + expect do + post '/posts', params: { + title: 'versioned post', + url: 'https://example.com/versioned-post', + tags: 'spec_tag', + thumbnail: dummy_upload + } + end.to change(PostVersion, :count).by(1) + + expect(response).to have_http_status(:created) + + created_post = Post.find(json.fetch('id')) + version = PostVersion.find_by!(post: created_post, version_no: 1) + + expect(version.event_type).to eq('create') + expect(version.title).to eq('versioned post') + expect(version.url).to eq('https://example.com/versioned-post') + expect(version.created_by_user_id).to eq(member.id) + expect(version.tags).to eq(snapshot_tags(created_post)) + end + + it 'creates next version on PUT /posts/:id when snapshot changes' do + sign_in_as(member) + create_post_version_for!(post_record) + + tag_name2 = TagName.create!(name: 'spec_tag_2') + Tag.create!(tag_name: tag_name2, category: :general) + + expect do + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag_2' + } + end.to change(PostVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + version = post_record.reload.post_versions.order(:version_no).last + expect(version.version_no).to eq(2) + expect(version.event_type).to eq('update') + expect(version.title).to eq('updated title') + expect(version.created_by_user_id).to eq(member.id) + expect(version.tags).to eq(snapshot_tags(post_record.reload)) + end + + it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do + sign_in_as(member) + + PostTag.create!(post: post_record, tag: Tag.no_deerjikist) + create_post_version_for!(post_record.reload) + + expect { + put "/posts/#{post_record.id}", params: { + title: post_record.title, + tags: 'spec_tag' + } + }.not_to change(PostVersion, :count) + expect(response).to have_http_status(:ok) + + version = post_record.reload.post_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.tags).to eq(snapshot_tags(post_record)) + end + + it 'does not create a version when POST /posts is invalid' do + sign_in_as(member) + + expect do + post '/posts', params: { + title: 'invalid post', + url: 'ぼざクリタグ広場', + tags: 'spec_tag', + thumbnail: dummy_upload + } + end.not_to change(PostVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'does not create a version when PUT /posts/:id is invalid' do + sign_in_as(member) + create_post_version_for!(post_record) + + expect do + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag', + original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, + original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601 + } + end.not_to change(PostVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe 'tag versioning from post write actions' do + let(:member) { create(:user, :member) } + + it 'creates tag snapshot for normalised tags on POST /posts' do + sign_in_as(member) + + expect { + post '/posts', params: { + title: 'tag versioned post', + url: 'https://example.com/tag-versioned-post', + tags: 'spec_tag', + thumbnail: dummy_upload + } + }.to change { tag.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:created) + + version = tag.reload.tag_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('spec_tag') + expect(version.category).to eq('general') + expect(version.created_by_user_id).to eq(member.id) + end + + it 'creates tag snapshot for normalised tags on PUT /posts/:id' do + sign_in_as(member) + + tag_name2 = TagName.create!(name: 'spec_tag_2') + tag2 = Tag.create!(tag_name: tag_name2, category: :general) + + expect { + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag_2' + } + }.to change { tag2.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:ok) + + version = tag2.reload.tag_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('spec_tag_2') + expect(version.created_by_user_id).to eq(member.id) + end + end end 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/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb new file mode 100644 index 0000000..aae790c --- /dev/null +++ b/backend/spec/requests/tag_versions_spec.rb @@ -0,0 +1,243 @@ +require 'rails_helper' + +RSpec.describe 'TagVersions API', type: :request do + let(:member) { create(:user, :member, name: 'version member') } + + let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) } + let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) } + + let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) } + let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) } + let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) } + let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) } + + let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } + let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } + let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) } + + def create_tag_version!( + tag:, + version_no:, + event_type:, + name:, + category:, + aliases: [], + parent_tags: [], + created_by_user:, + created_at: + ) + TagVersion.create!( + tag: tag, + version_no: version_no, + event_type: event_type, + name: name, + category: category, + aliases: Array(aliases).join(' '), + parent_tag_ids: Array(parent_tags).map(&:id).join(' '), + created_by_user: created_by_user, + created_at: created_at + ) + end + + let!(:v1) do + create_tag_version!( + tag: tag, + version_no: 1, + event_type: 'create', + name: 'old_tag_name', + category: 'general', + aliases: ['alias_shared', 'alias_old'], + parent_tags: [parent_shared, parent_old], + created_by_user: member, + created_at: t_v1 + ) + end + + let!(:v2) do + create_tag_version!( + tag: tag, + version_no: 2, + event_type: 'update', + name: 'new_tag_name', + category: 'meme', + aliases: ['alias_shared', 'alias_new'], + parent_tags: [parent_shared, parent_new], + created_by_user: member, + created_at: t_v2 + ) + end + + let!(:other_v1) do + create_tag_version!( + tag: other_tag, + version_no: 1, + event_type: 'create', + name: 'other_tag_name', + category: 'general', + aliases: ['other_alias'], + parent_tags: [other_parent], + created_by_user: member, + created_at: t_other + ) + end + + describe 'GET /tags/versions' do + it 'returns all versions in reverse chronological order when id is omitted' do + get '/tags/versions' + + expect(response).to have_http_status(:ok) + expect(json).to include('versions', 'count') + expect(json.fetch('count')).to eq(3) + + versions = json.fetch('versions') + + expect(versions.map { |v| [v['tag_id'], v['version_no']] }).to eq([ + [other_tag.id, 1], + [tag.id, 2], + [tag.id, 1] + ]) + end + + it 'returns versions for the specified tag with diffs' do + get '/tags/versions', params: { id: tag.id } + + expect(response).to have_http_status(:ok) + expect(json).to include('versions', 'count') + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.map { |v| v['tag_id'] }.uniq).to eq([tag.id]) + expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) + + latest = versions.first + expect(latest).to include( + 'tag_id' => tag.id, + 'version_no' => 2, + 'event_type' => 'update', + 'created_by_user' => { + 'id' => member.id, + 'name' => member.name + } + ) + + expect(latest.fetch('name')).to eq( + 'current' => 'new_tag_name', + 'prev' => 'old_tag_name' + ) + expect(latest.fetch('category')).to eq( + 'current' => 'meme', + 'prev' => 'general' + ) + expect(latest.fetch('aliases')).to include( + { 'name' => 'alias_shared', 'type' => 'context' }, + { 'name' => 'alias_new', 'type' => 'added' }, + { 'name' => 'alias_old', 'type' => 'removed' } + ) + expect(latest.fetch('parent_tags')).to include( + a_hash_including( + 'type' => 'context', + 'tag' => a_hash_including( + 'id' => parent_shared.id + ) + ), + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_new.id + ) + ), + a_hash_including( + 'type' => 'removed', + 'tag' => a_hash_including( + 'id' => parent_old.id + ) + ) + ) + expect(latest.fetch('created_at')).to eq(t_v2.iso8601) + + first = versions.second + expect(first).to include( + 'tag_id' => tag.id, + 'version_no' => 1, + 'event_type' => 'create', + 'created_by_user' => { + 'id' => member.id, + 'name' => member.name + } + ) + expect(first.fetch('name')).to eq( + 'current' => 'old_tag_name', + 'prev' => nil + ) + expect(first.fetch('category')).to eq( + 'current' => 'general', + 'prev' => nil + ) + expect(first.fetch('aliases')).to include( + { 'name' => 'alias_shared', 'type' => 'added' }, + { 'name' => 'alias_old', 'type' => 'added' } + ) + expect(first.fetch('parent_tags')).to include( + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_shared.id + ) + ), + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_old.id + ) + ) + ) + expect(first.fetch('created_at')).to eq(t_v1.iso8601) + end + + it 'returns empty when the specified tag has no versions' do + fresh_tag = create(:tag, name: 'no_versions_tag', category: :general) + + get '/tags/versions', params: { id: fresh_tag.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('versions')).to eq([]) + expect(json.fetch('count')).to eq(0) + end + + it 'clamps page and limit to at least 1' do + get '/tags/versions', params: { id: tag.id, page: 0, limit: 0 } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.size).to eq(1) + expect(versions.first['version_no']).to eq(2) + end + + it 'does not create tag versions by wiki updates when tag has no versions yet' do + wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki') + wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general) + + wiki_page = + Wiki::Commit.create_content!( + tag_name: wiki_tag_name, + body: 'before', + created_by_user: member, + message: 'init') + + Wiki::Commit.content!( + page: wiki_page, + body: 'after', + created_user: member, + message: 'edit', + base_revision_id: wiki_page.current_revision.id) + + get '/tags/versions', params: { id: wiki_tag.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('versions')).to eq([]) + expect(json.fetch('count')).to eq(0) + end + end +end 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 8dfa51a..a688598 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) } @@ -19,6 +18,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' @@ -186,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 @@ -209,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 @@ -348,14 +404,653 @@ RSpec.describe 'Tags API', type: :request do expect(tag.category).to eq('meta') end - it '存在しない id だと RecordNotFound になる(通常は 404)' do + it '存在しない id なら 404 を返す' do patch '/tags/999999999', params: { name: 'x' } - expect(response.status).to be_in([404, 500]) + expect(response).to have_http_status(:not_found) end - it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do - patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' } - expect(response.status).to be_in([422, 500]) + it 'nico category への変更は 422 を返す' do + patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.name).to eq('spec_tag') + expect(tag.category).to eq('general') + end + + it 'creates initial and update tag versions when name and category change' do + expect { + patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' } + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + versions = tag.reload.tag_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create', 'update']) + + expect(versions.first.name).to eq('spec_tag') + expect(versions.first.category).to eq('general') + expect(versions.first.aliases.split).to include('unko') + + expect(versions.second.name).to eq('new_tag_name') + expect(versions.second.category).to eq('meme') + expect(versions.second.created_by_user_id).to eq(member_user.id) + end + + it 'returns 422 when changing normal tag category to nico' do + expect { + patch "/tags/#{tag.id}", params: { category: 'nico' } + }.not_to change(TagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.category).to eq('general') + end + + it 'returns 422 when updating nico tag name' do + nico_tag_name = TagName.create!(name: 'nico:tags_spec_source') + nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) + + expect { + patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + + expect(nico_tag.reload.name).to eq('nico:tags_spec_source') + expect(nico_tag.category).to eq('nico') + end + + it 'returns 422 when changing nico tag category to normal category' do + nico_tag_name = TagName.create!(name: 'nico:category_change_ng') + nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) + + expect { + patch "/tags/#{nico_tag.id}", params: { category: 'general' } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(nico_tag.reload.category).to eq('nico') + end + + it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + expect { + patch "/tags/#{ tag.id }", params: { + name: 'patch_wiki_renamed_tag', + } + } + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + version = wiki_page.reload.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + event_type: 'update', + title: 'patch_wiki_renamed_tag', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end + + it 'tag の category だけを変更しても wiki version は作成しない' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + before_wiki_version_count = wiki_page.reload.wiki_versions.count + + expect { + patch "/tags/#{ tag.id }", params: { + category: 'meme', + } + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count) + end + end + end + + describe 'GET /tags/with-depth' do + let!(:root_meme) do + Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme) + end + + let!(:root_material) do + Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material) + end + + let!(:hidden_general_root) do + Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general) + end + + let!(:child_character) do + Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character) + end + + let!(:grandchild_material) do + Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material) + end + + let!(:child_general) do + Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general) + end + + before do + TagImplication.create!(parent_tag: root_meme, tag: child_character) + TagImplication.create!(parent_tag: child_character, tag: grandchild_material) + TagImplication.create!(parent_tag: root_material, tag: child_general) + end + + it 'returns only visible root tags and visible has_children flags' do + get '/tags/with-depth' + + expect(response).to have_http_status(:ok) + expect(json.map { |t| t['name'] }).to eq([ + 'depth_a_root_meme', + 'depth_b_root_material' + ]) + + meme_row = json.find { |t| t['name'] == 'depth_a_root_meme' } + material_row = json.find { |t| t['name'] == 'depth_b_root_material' } + + expect(meme_row['has_children']).to eq(true) + expect(meme_row['children']).to eq([]) + + expect(material_row['has_children']).to eq(false) + expect(material_row['children']).to eq([]) + + expect(json.map { |t| t['name'] }).not_to include('depth_hidden_general_root') + end + + it 'returns children of the specified parent' do + get '/tags/with-depth', params: { parent: root_meme.id } + + expect(response).to have_http_status(:ok) + expect(json.map { |t| t['name'] }).to eq(['depth_child_character']) + + row = json.first + expect(row['category']).to eq('character') + expect(row['has_children']).to eq(true) + expect(row['children']).to eq([]) + end + end + + describe 'GET /tags/name/:name/materials' do + let!(:material_user) { create_member_user! } + + let!(:root_tag) do + Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material) + end + + let!(:child_a_tag) do + Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material) + end + + let!(:child_b_tag) do + Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character) + end + + let!(:grandchild_tag) do + Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material) + end + + let!(:root_material) do + create_material(root_tag, user: material_user, filename: 'root.png') + end + + let!(:child_a_material) do + create_material(child_a_tag, user: material_user, filename: 'child_a.png') + end + + let!(:grandchild_material) do + create_material(grandchild_tag, user: material_user, filename: 'grandchild.png') + end + + before do + TagImplication.create!(parent_tag: root_tag, tag: child_b_tag) + TagImplication.create!(parent_tag: root_tag, tag: child_a_tag) + TagImplication.create!(parent_tag: child_a_tag, tag: grandchild_tag) + end + + it 'returns a tag tree with nested materials sorted by child name' do + get "/tags/name/#{ CGI.escape(root_tag.name) }/materials" + + expect(response).to have_http_status(:ok) + + expect(json).to include( + 'id' => root_tag.id, + 'name' => 'materials_root', + 'category' => 'material' + ) + + expect(json['material']).to be_present + expect(json.dig('material', 'id')).to eq(root_material.id) + expect(json.dig('material', 'file')).to be_present + expect(json.dig('material', 'content_type')).to eq('image/png') + + expect(json['children'].map { |t| t['name'] }).to eq([ + 'materials_child_a', + 'materials_child_b' + ]) + + child_a = json['children'].find { |t| t['name'] == 'materials_child_a' } + child_b = json['children'].find { |t| t['name'] == 'materials_child_b' } + + expect(child_a.dig('material', 'id')).to eq(child_a_material.id) + expect(child_a['children'].map { |t| t['name'] }).to eq(['materials_grandchild']) + expect(child_a.dig('children', 0, 'material', 'id')).to eq(grandchild_material.id) + + expect(child_b['material']).to be_nil + expect(child_b['children']).to eq([]) + end + + it 'returns 404 when the tag does not exist' do + get '/tags/name/no_such_tag_12345/materials' + expect(response).to have_http_status(:not_found) + end + end + + describe 'PUT /tags/:id' do + context '未ログイン' do + before { stub_current_user(nil) } + + it '401 を返す' do + put "/tags/#{ tag.id }", params: { + name: 'new', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'ログインしてゐるが member でない' do + before { stub_current_user(non_member_user) } + + it '403 を返す' do + put "/tags/#{ tag.id }", params: { + name: 'new', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:forbidden) + end + end + + context 'member' do + before { stub_current_user(member_user) } + + it '存在しない id なら 404 を返す' do + put '/tags/999999999', params: { + name: 'new', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:not_found) + end + + it 'name が空なら 422 を返す' do + put "/tags/#{ tag.id }", params: { + name: '', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.name).to eq('spec_tag') + end + + it 'category が空なら 422 を返す' do + put "/tags/#{ tag.id }", params: { + name: 'new', + category: '', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.name).to eq('spec_tag') + expect(tag.category).to eq('general') + end + + it 'name, category, aliases, parent tags をまとめて更新できる' do + old_parent = Tag.create!( + tag_name: TagName.create!(name: 'put_old_parent'), + category: :general + ) + kept_parent = Tag.create!( + tag_name: TagName.create!(name: 'put_kept_parent'), + category: :general + ) + TagImplication.create!(tag:, parent_tag: old_parent) + TagImplication.create!(tag:, parent_tag: kept_parent) + + put "/tags/#{ tag.id }", params: { + name: 'put_renamed_tag', + category: 'meme', + aliases: 'put_alias_a put_alias_b put_alias_a', + parent_tags: 'put_kept_parent put_new_parent', + } + + expect(response).to have_http_status(:ok) + + tag.reload + + expect(tag.name).to eq('put_renamed_tag') + expect(tag.category).to eq('meme') + + expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name) + expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name) + + old_name_alias = TagName.find_by(name: 'spec_tag') + expect(old_name_alias).to be_present + expect(old_name_alias.canonical).to eq(tag.tag_name) + + expect(alias_tn.reload.canonical).to be_nil + + expect(tag.parents.map(&:name)).to contain_exactly( + 'put_kept_parent', + 'put_new_parent' + ) + + expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist + + expect(json['name']).to eq('put_renamed_tag') + expect(json['category']).to eq('meme') + expect(json['aliases']).to contain_exactly( + 'put_alias_a', + 'put_alias_b', + 'spec_tag' + ) + expect(json['parents'].map { |t| t['name'] }).to contain_exactly( + 'put_kept_parent', + 'put_new_parent' + ) + end + + it 'aliases に現在名を指定しても alias には残さない' do + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'spec_tag put_alias_self_test', + parent_tags: '', + } + + expect(response).to have_http_status(:ok) + + tag.reload + + expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name) + expect(json['aliases']).to include('put_alias_self_test') + expect(json['aliases']).not_to include('spec_tag') + end + + it 'parent_tags に自分自身を指定しても自己参照は作らない' do + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko', + parent_tags: 'spec_tag', + } + + expect(response).to have_http_status(:ok) + + expect(TagImplication.where(tag:, parent_tag: tag)).not_to exist + expect(tag.reload.parents).to eq([]) + end + + it 'initial and update tag versions を作成する' do + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_versioned_tag', + category: 'meta', + aliases: '', + parent_tags: '', + } + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + versions = tag.reload.tag_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create', 'update']) + + expect(versions.first.name).to eq('spec_tag') + expect(versions.first.category).to eq('general') + expect(versions.first.aliases.split).to include('unko') + + expect(versions.second.name).to eq('put_versioned_tag') + expect(versions.second.category).to eq('meta') + expect(versions.second.aliases.split).to include('spec_tag') + expect(versions.second.created_by_user_id).to eq(member_user.id) + end + + it 'parent tag の snapshot も作成する' do + old_parent = Tag.create!( + tag_name: TagName.create!(name: 'put_snapshot_old_parent'), + category: :general + ) + new_parent = Tag.create!( + tag_name: TagName.create!(name: 'put_snapshot_new_parent'), + category: :general + ) + TagImplication.create!(tag:, parent_tag: old_parent) + + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko', + parent_tags: new_parent.name, + } + + expect(response).to have_http_status(:ok) + + expect(old_parent.reload.tag_versions.map(&:event_type)).to include('create') + expect(new_parent.reload.tag_versions.map(&:event_type)).to include('create') + end + + it 'normal tag を nico category には変更できない' do + expect { + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'nico', + aliases: '', + parent_tags: '', + } + }.not_to change(TagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + + expect(tag.reload.name).to eq('spec_tag') + expect(tag.category).to eq('general') + end + + it 'nico tag は更新できない' do + nico_tag = Tag.create!( + tag_name: TagName.create!(name: 'nico:put_update_all_ng'), + category: :nico + ) + + expect { + put "/tags/#{ nico_tag.id }", params: { + name: 'nico:put_update_all_renamed', + category: 'nico', + aliases: '', + parent_tags: '', + } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + + expect(nico_tag.reload.name).to eq('nico:put_update_all_ng') + expect(nico_tag.category).to eq('nico') + end + + it 'system tag の name は変更できない' do + system_tag = Tag.tagme + old_name = system_tag.name + old_category = system_tag.category + + expect { + put "/tags/#{ system_tag.id }", params: { + name: 'put_system_tag_renamed', + category: old_category, + aliases: '', + parent_tags: '', + } + }.not_to change(TagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + + expect(system_tag.reload.name).to eq(old_name) + expect(system_tag.category).to eq(old_category) + end + + it 'wiki を持つ tag を更新すると wiki version も作成する' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + Wiki::Commit.content!( + page: wiki_page, + body: 'wiki body before', + created_user: member_user, + message: 'init' + ) + + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_wiki_version_tag', + category: 'meme', + aliases: 'unko', + parent_tags: '', + } + } + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + version = wiki_page.reload.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + event_type: 'update', + title: 'put_wiki_version_tag', + body: 'wiki body before', + created_by_user_id: member_user.id + ) + end + + it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do + old_owner = Tag.create!( + tag_name: TagName.create!(name: 'put_alias_old_owner'), + category: :general + ) + stolen_alias = TagName.create!( + name: 'put_stolen_alias', + canonical: old_owner.tag_name + ) + + expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias') + + expect { + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko put_stolen_alias', + parent_tags: '', + } + } + .to change { tag.reload.tag_versions.count }.by(2) + .and change { old_owner.reload.tag_versions.count }.by(2) + + expect(response).to have_http_status(:ok) + + expect(stolen_alias.reload.canonical).to eq(tag.tag_name) + expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias') + + old_owner_versions = old_owner.tag_versions.order(:version_no) + + expect(old_owner_versions.first.event_type).to eq('create') + expect(old_owner_versions.first.aliases.split).to include('put_stolen_alias') + + expect(old_owner_versions.second.event_type).to eq('update') + expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias') + end + + it 'parent_tags に指定すると循環する tag は 422 にする' do + pending '#332 で対応予定' + + child = Tag.create!( + tag_name: TagName.create!(name: 'put_cycle_child'), + category: :general + ) + + TagImplication.create!(tag: child, parent_tag: tag) + + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko', + parent_tags: child.name, + } + + expect(response).to have_http_status(:unprocessable_entity) + + expect(TagImplication.where(tag:, parent_tag: child)).not_to exist + end + + it 'tag の name を変更すると対応する wiki version を作成する' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_wiki_renamed_tag', + category: 'general', + aliases: 'unko', + parent_tags: '', + } + } + .to change(TagVersion, :count).by(2) + .and change(WikiVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + version = wiki_page.reload.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + event_type: 'update', + title: 'put_wiki_renamed_tag', + body: 'wiki body before', + created_by_user_id: member_user.id + ) end end end 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") 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..98bd6af --- /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 '#336 で対応予定' + + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'wiki_body_search_hit'), + body: 'unique body keyword for wiki search', + created_by_user: user, + message: 'init') + + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'wiki_body_search_miss'), + body: 'ordinary body', + created_by_user: user, + message: 'init') + + get '/wiki/search', params: { body: 'unique body keyword' } + + expect(response).to have_http_status(:ok) + expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit') + expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss') + end +end 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..33b6a63 --- /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 '#337 で対応予定' + + page = + Wiki::Commit.create_content!( + tag_name: TagName.create!(name: 'wiki_restore_page'), + body: 'v1', + created_by_user: user, + message: 'init') + + v1 = page.wiki_versions.order(:version_no).last + + Wiki::Commit.content!( + page:, + body: 'v2', + created_user: user, + message: 'edit', + base_revision_id: page.current_revision.id) + + post "/wiki/#{ page.id }/restore", + params: { version_no: v1.version_no }, + headers: auth_headers(user) + + expect(response).to have_http_status(:ok) + expect(page.reload.body).to eq('v1') + expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore') + end +end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index bc92f1c..5585491 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 @@ -97,8 +105,9 @@ RSpec.describe 'Wiki API', type: :request do post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' }, headers: auth_headers(member) end - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:created) @@ -106,16 +115,78 @@ RSpec.describe 'Wiki API', type: :request do expect(json.fetch('title')).to eq('TestPage') expect(json.fetch('body')).to eq("a\nb\nc") - page = WikiPage.find(page_id) - rev = page.current_revision + created_page = WikiPage.find(page_id) + version = created_page.wiki_versions.order(:version_no).last + + expect(version).to have_attributes( + version_no: 1, + event_type: 'create', + title: 'TestPage', + body: "a\nb\nc", + created_by_user_id: member.id + ) + + rev = created_page.current_revision expect(rev).to be_present expect(rev).to be_content expect(rev.message).to eq('init') - expect(page.body).to eq("a\nb\nc") + expect(created_page.body).to eq("a\nb\nc") + expect(rev.lines_count).to eq(3) expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) - expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c]) + expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c']) + end + + it 'reuses existing WikiLine rows by sha256' do + # 先に同じ行を作っておく + WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current) + + post endpoint, + params: { title: 'Reuse', body: "a\na" }, + headers: auth_headers(member) + + page = WikiPage.find(JSON.parse(response.body).fetch('id')) + rev = page.current_revision + expect(rev.lines_count).to eq(2) + + # "a" の WikiLine が増殖しない(1行のはず) + expect(WikiLine.where(body: 'a').count).to eq(1) + end + + it 'deduplicates duplicated new lines before upsert' do + duplicated = 'duplicated_line_for_wiki_line_upsert_spec' + + post endpoint, + params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, + headers: auth_headers(member) + + expect(response).to have_http_status(:created) + + page = WikiPage.find(json.fetch('id')) + rev = page.current_revision + + expect(rev.lines_count).to eq(2) + expect(WikiLine.where(body: duplicated).count).to eq(1) + expect(rev.wiki_revision_lines.count).to eq(2) + expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) + end + + it 'normalises CRLF and strips trailing newlines' do + post endpoint, + params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' }, + headers: auth_headers(member) + + expect(response).to have_http_status(:created) + + page = WikiPage.find(json.fetch('id')) + rev = page.current_revision + version = page.wiki_versions.order(:version_no).last + + expect(page.body).to eq("a\nb") + expect(version.body).to eq("a\nb") + expect(rev.lines_count).to eq(2) + expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b']) end end end @@ -128,17 +199,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 @@ -164,14 +232,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 @@ -182,7 +242,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) @@ -193,25 +264,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 @@ -243,14 +349,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 @@ -260,7 +369,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 @@ -311,7 +422,12 @@ RSpec.describe 'Wiki API', type: :request do it 'returns empty array when page has no revisions and filtered by id' do # 別ページを作って 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) @@ -380,29 +496,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 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/support/test_records.rb b/backend/spec/support/test_records.rb index 350b8da..0e4796e 100644 --- a/backend/spec/support/test_records.rb +++ b/backend/spec/support/test_records.rb @@ -3,13 +3,13 @@ module TestRecords User.create!(name: 'spec user', inheritance_code: SecureRandom.hex(16), role: 'member', - banned: false) + banned_at: nil) end def create_admin_user! User.create!(name: 'spec admin', inheritance_code: SecureRandom.hex(16), role: 'admin', - banned: false) + banned_at: nil) 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 diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index d4f0e09..4c7e4e7 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -90,4 +90,231 @@ 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), + 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 + + 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 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 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 a8966a4..7f44d7d 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, @@ -10,8 +10,15 @@ 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 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' @@ -19,6 +26,8 @@ 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 TagHistoryPage from '@/pages/tags/TagHistoryPage' import TagListPage from '@/pages/tags/TagListPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage' @@ -39,30 +48,38 @@ const RouteTransitionWrapper = ({ user, setUser }: { const location = useLocation () return ( - - - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - - - ) + + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }> + }/> + }/> + }/> + + {/* }/> */} + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + + ) } @@ -120,10 +137,15 @@ export default (() => { <> -
- - -
+ + + + + +
) 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}> - + ) 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/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) => { setTitle (ev.target.value)}/> 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' } }}> {post.title { {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
    - + {CATEGORY_NAMES[cat]} @@ -325,7 +327,9 @@ export default (({ post, sp }: Props) => {
    ))} {post && ( - + 情報
    • Id.: {post.id}
    • 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..1b52c3d 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -65,8 +65,10 @@ export default (({ posts, onClick }: Props) => { {CATEGORIES.flatMap (cat => cat in tags ? ( tags[cat].map (tag => (
    • - - + +
    • ))) : [])}
    diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 7d1575c..6a8e732 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -8,17 +8,78 @@ import PrefetchLink from '@/components/PrefetchLink' import TopNavUser from '@/components/TopNavUser' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { tagsKeys, wikiKeys } from '@/lib/queryKeys' -import { fetchTagByName } from '@/lib/tags' +import { fetchTag, fetchTagByName } from '@/lib/tags' import { cn } from '@/lib/utils' import { fetchWikiPage } from '@/lib/wiki' import type { FC, MouseEvent } from 'react' -import type { Menu, User } from '@/types' +import type { Menu, MenuVisibleItem, Tag, User } from '@/types' type Props = { user: User | null } +export const menuOutline = ({ tag, wikiId, user, pathName }: { + tag?: Tag | null + wikiId: number | null + user: User | null, + pathName: string }): Menu => { + const postCount = tag?.postCount ?? 0 + + const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) + const wikiTitle = pathName.split ('/')[2] ?? '' + + const tagFlg = /^\/tags\/\d+/.test (pathName) + + return [ + { name: '広場', to: '/posts', subMenu: [ + { name: '一覧', to: '/posts' }, + { name: '検索', to: '/posts/search' }, + { name: '追加', to: '/posts/new' }, + { name: '履歴', to: '/posts/changes' }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, + { name: 'タグ', to: '/tags', subMenu: [ + { name: 'マスタ', to: '/tags' }, + { name: 'ニコニコ連携', to: '/tags/nico' }, + { name: '履歴', to: '/tags/changes' }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, + { component: , visible: tagFlg }, + { name: `広場 (${ postCount || 0 })`, + to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, + visible: tagFlg }, + { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, + visible: tagFlg && tag?.category !== 'nico' }] }, + { name: '素材', to: '/materials', visible: false, subMenu: [ + { name: '一覧', to: '/materials' }, + { name: '検索', to: '/materials/search', visible: false }, + { name: '追加', to: '/materials/new' }, + { name: '履歴', to: '/materials/changes', visible: false }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, + { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ + { name: <>第 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: , 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 +87,30 @@ export default (({ user }: Props) => { const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) const navRef = useRef (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 (WikiIdBus.get ()) @@ -55,51 +121,19 @@ 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) }) - - const postCount = tag?.postCount ?? 0 + queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) }) - const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId) - const wikiTitle = location.pathname.split ('/')[2] ?? '' - const menu: Menu = [ - { name: '広場', to: '/posts', subMenu: [ - { name: '一覧', to: '/posts' }, - { name: '検索', to: '/posts/search' }, - { name: '投稿追加', to: '/posts/new' }, - { name: '履歴', to: '/posts/changes' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, - { name: 'タグ', to: '/tags', subMenu: [ - { name: 'タグ一覧', to: '/tags', visible: true }, - { name: '別名タグ', to: '/tags/aliases', visible: false }, - { name: '上位タグ', to: '/tags/implications', visible: false }, - { name: 'ニコニコ連携', to: '/tags/nico' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, - { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ - { name: <>第 1 会場, to: '/theatres/1' }, - { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, - { name: <>ニジカ放送局第 1 チャンネル, - to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }] }, - { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ - { name: '検索', to: '/wiki' }, - { name: '新規', to: '/wiki/new' }, - { name: '全体履歴', to: '/wiki/changes' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' }, - { component: , 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 (activeIdx) @@ -112,35 +146,31 @@ 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 ( <> - -
    - - ({ 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 - ? {item.component} - : ( - - {item.name} - )))} - - -
    + + { + if (moreVsbl) + setMoreVsbl (false) + }} + transition={{ layout: { duration: .2, ease: 'easeOut' } }} + onAnimationComplete={() => { + measure (moreVsbl ? -1 : activeIdx) + }}> + {moreVsbl + ? ( + menu.map ((item, i) => ( +
    +
    + +

    {item.name}

    +
    + {item.subMenu + .filter (subItem => subItem.visible ?? true) + .map ((subItem, j) => ( + 'component' in subItem + ? ( + + {subItem.component} + ) + : ( + + setMoreVsbl (false)} + className="h-full flex items-center px-3"> + {subItem.name} + + )))} +
    +
    ))) + : ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0 + && ( +
    + + ({ 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 + ? ( + + {item.component} + ) + : ( + + + {item.name} + + )))} + + +
    ))} +
    +
    {menuOpen && ( @@ -234,7 +365,7 @@ export default (({ user }: Props) => { exit="closed" transition={{ duration: .2, ease: 'easeOut' }}> - {menu.map ((item, i) => ( + {visibleMenu.map ((item, i) => ( {
    )} ))} + { + 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'))}> + その他 » +
    )} diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index a071e79..400a10c 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -5,6 +5,5 @@ import type { FC } from 'react' type Props = { title: string body?: string } - export default (({ title, body }: Props) => ) satisfies FC 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) => ( -

    + +export default (({ children, className, ...rest }: Props) => ( +

    {children} -

    ) + )) satisfies FC 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..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,6 +10,9 @@ type Props = { export default (({ children, className }: Props) => ( -
    + {children} -
    )) satisfies FC + )) satisfies FC diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index cfe2c08..4f8d983 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -1,9 +1,30 @@ -import React from 'react' +import { motion } from 'framer-motion' +import { Helmet } from 'react-helmet-async' -type Props = { children: React.ReactNode } +import type { FC, ReactNode } from 'react' +type Props = { children: ReactNode } + + +export default (({ children }: Props) => ( + + + + -export default ({ children }: Props) => ( -
    {children} -
    ) +
    )) satisfies FC 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/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 => 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 => { diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index ad18e1b..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' @@ -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,30 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => { } +const prefetchTagShow: Prefetcher = async (qc, url) => { + const m = mTag (url.pathname) + if (!(m)) + return + + const { id } = m.params + + await qc.prefetchQuery ({ + queryKey: tagsKeys.show (id), + queryFn: () => fetchTag (id) }) +} + + +const prefetchTagChanges: Prefetcher = async (qc, url) => { + const id = url.searchParams.get ('id') + const page = Number (url.searchParams.get ('page') || 1) + const limit = Number (url.searchParams.get ('limit') || 20) + + await qc.prefetchQuery ({ + queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), + queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) +} + + export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), run: prefetchPostsIndex }, @@ -180,7 +205,11 @@ 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 => (!(['/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 65a8be5..97bae56 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -5,13 +5,15 @@ 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 = { - 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/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 ( + + + {`メニュー | ${ 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 new file mode 100644 index 0000000..d641f47 --- /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..50a19d9 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -93,10 +93,10 @@ export default (({ user }: Props) => { : 'bg-gray-500 hover:bg-gray-600') return ( -
    +
    {(post?.thumbnail || post?.thumbnailBase) && ( - )} + )} {post && {`${ 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/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 70e3e68..6d76887 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -69,7 +69,9 @@ 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..419c134 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"> @@ -365,7 +289,7 @@ export default (() => { {results.map (row => ( <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700"> <td className="p-2"> - <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> + <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> <motion.div layoutId={`page-${ row.id }`} transition={{ type: 'spring', @@ -380,7 +304,7 @@ export default (() => { </PrefetchLink> </td> <td className="p-2 truncate"> - <PrefetchLink to={`/posts/${ row.id }`} title={row.title}> + <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> {row.title} </PrefetchLink> </td> 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<Category> ('general') + const [aliases, setAliases] = useState ('') + const [parentTags, setParentTags] = useState ('') + const [disabled, setDisabled] = useState (true) + + const qc = useQueryClient () + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault () + + const formData = new FormData + formData.append ('name', name) + formData.append ('category', category) + formData.append ('aliases', aliases) + formData.append ('parent_tags', parentTags) + + try + { + const data = await apiPut<Tag> (`/tags/${ id }`, formData) + + setName (data.name) + setCategory (data.category as Category) + setAliases (data.aliases.join (' ')) + setParentTags (data.parents.map (t => t.name).join (' ')) + + qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + toast ({ description: '更新しました.' }) + } + catch + { + toast ({ description: '更新に失敗しました.' }) + } + } + + useEffect (() => { + if (!(tag)) + { + setDisabled (true) + return + } + + setName (tag.name) + setCategory (tag.category as Category) + setAliases (tag.aliases.join (' ')) + setParentTags (tag.parents.map (t => t.name).join (' ')) + setDisabled (tag.category === 'nico') + }, [tag]) + + return ( + <MainArea> + {(loading || !(tag)) ? 'Loading...' : ( + <div className="max-w-xl"> + <PageTitle> + <TagLink + tag={tag} + withWiki={false} + withCount={false}/> + </PageTitle> + + <form onSubmit={handleSubmit} className="my-4 space-y-2"> + {/* 名称 */} + <div> + <Label>名称</Label> + {/* TODO: 補完に対応させる */} + <input + type="text" + disabled={disabled} + value={name} + onChange={e => setName (e.target.value)} + className="w-full border p-2 rounded"/> + </div> + + {/* カテゴリ */} + <div> + <Label>カテゴリ</Label> + <select + disabled={disabled} + value={category ?? ''} + onChange={e => setCategory(e.target.value as Category)} + className="w-full border p-2 rounded"> + {CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico') + .map (cat => ( + <option key={cat} value={cat}> + {CATEGORY_NAMES[cat]} + </option>))} + </select> + </div> + + {/* 別名 */} + <div> + <Label>別名</Label> + {/* TODO: 補完に対応させる */} + <input + type="text" + disabled={disabled} + value={aliases} + onChange={e => setAliases (e.target.value)} + className="w-full border p-2 rounded"/> + </div> + + {/* 上位タグ */} + <div> + <Label>上位タグ</Label> + {/* TODO: 補完に対応させる */} + <input + type="text" + disabled={disabled} + value={parentTags} + onChange={e => setParentTags (e.target.value)} + className="w-full border p-2 rounded"/> + </div> + + <div className="py-3"> + <button + type="submit" + disabled={disabled} + className={cn ('px-4 py-2 rounded', + (disabled + ? 'text-gray-300 bg-gray-500' + : 'text-white bg-blue-500'))}> + 更新 + </button> + </div> + </form> + </div>)} + </MainArea>) +}) satisfies FC 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) && ( + <> + <del className="text-red-600 dark:text-red-400"> + {diff.prev} + </del> + {diff.current && <br/>} + </>)} + {diff.current} + </>) + + +export default (() => { + const location = useLocation () + const query = new URLSearchParams (location.search) + 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 ( + <MainArea> + <Helmet> + <title>{`タグ定義変更履歴 | ${ 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/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/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 return ( -
    +
    {theatre && ( diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index fd2c0b5..4ead153 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,21 +106,23 @@ 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> - - {(!(version) && posts.length > 0) && ( - <TabGroup> - <Tab name="広場"> - <PostList posts={posts}/> - </Tab> - </TabGroup>)} + <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}/>} + + {(!(version) && posts.length > 0) && ( + <div className="not-prose"> + <TabGroup> + <Tab name="広場"> + <PostList posts={posts}/> + </Tab> + </TabGroup> + </div>)} + </article> </MainArea>) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index efb7d22..6340b52 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -49,12 +49,34 @@ 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 = { +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 & { @@ -95,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[] @@ -105,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 @@ -114,24 +136,56 @@ export type PostTagChange = { changeType: 'add' | 'remove' timestamp: string } -export type SubMenuItem = - | { component: ReactNode - visible: boolean } - | { name: ReactNode - to: string - visible?: boolean } +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 } + +export type SubMenuItem = SubMenuComponentItem | SubMenuStringItem + +export type SubMenuStringItem = { + name: ReactNode + to: string + visible?: boolean } export type Tag = { id: number name: string category: Category + aliases: string[] + parents: Tag[] postCount: number createdAt: string updatedAt: string hasWiki: boolean + materialId: number 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 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,