From 507ce1680e7539b019b9bf73caeb90cb81c1685f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 23 Jun 2026 22:05:11 +0900 Subject: [PATCH 01/23] #306 --- .../app/controllers/materials_controller.rb | 174 ++++++++-- backend/app/controllers/tags_controller.rb | 8 +- backend/app/models/material.rb | 11 + backend/app/models/material_export_item.rb | 48 +++ backend/app/models/material_import_block.rb | 29 ++ backend/app/models/material_version.rb | 18 + backend/app/models/version_record.rb | 16 +- backend/app/representations/material_repr.rb | 24 +- backend/app/services/material_file_sha256.rb | 25 ++ .../services/material_import_block_matcher.rb | 7 + .../app/services/material_version_recorder.rb | 73 +++++ backend/app/services/material_zip_exporter.rb | 118 +++++++ backend/app/services/version_recorder.rb | 3 +- backend/config/routes.rb | 7 +- ...60623000000_enhance_material_management.rb | 98 ++++++ backend/db/schema.rb | 51 ++- .../spec/models/material_export_item_spec.rb | 57 ++++ backend/spec/requests/materials_spec.rb | 307 ++++++++++++++++-- frontend/src/App.tsx | 2 +- .../materials/MaterialDetailPage.test.tsx | 5 +- .../pages/materials/MaterialDetailPage.tsx | 83 ++++- .../src/pages/materials/MaterialListPage.tsx | 21 +- .../src/pages/materials/MaterialNewPage.tsx | 18 +- frontend/src/test/factories.ts | 25 +- frontend/src/types.ts | 31 +- 25 files changed, 1148 insertions(+), 111 deletions(-) create mode 100644 backend/app/models/material_export_item.rb create mode 100644 backend/app/models/material_import_block.rb create mode 100644 backend/app/models/material_version.rb create mode 100644 backend/app/services/material_file_sha256.rb create mode 100644 backend/app/services/material_import_block_matcher.rb create mode 100644 backend/app/services/material_version_recorder.rb create mode 100644 backend/app/services/material_zip_exporter.rb create mode 100644 backend/db/migrate/20260623000000_enhance_material_management.rb create mode 100644 backend/spec/models/material_export_item_spec.rb diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 2a86b77..751b874 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -1,4 +1,7 @@ class MaterialsController < ApplicationController + rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty + rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path + def index page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i @@ -11,7 +14,7 @@ class MaterialsController < ApplicationController tag_id = params[:tag_id].presence parent_id = params[:parent_id].presence - q = Material.includes(:tag, :created_by_user).with_attached_file + q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file q = q.where(tag_id:) if tag_id q = q.where(parent_id:) if parent_id @@ -24,7 +27,7 @@ class MaterialsController < ApplicationController def show material = Material - .includes(:tag) + .includes(:tag, :material_export_items) .with_attached_file .find_by(id: params[:id]) return head :not_found unless material @@ -36,26 +39,38 @@ class MaterialsController < ApplicationController def create return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? tag_name_raw = params[:tag].to_s.strip file = params[:file] + file_sha256 = MaterialFileSha256.from_upload(file) url = params[:url].to_s.strip.presence return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? if file.blank? && url.blank? return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'], url: ['ファイルまたは URL は必須です.'] } end + block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) + return render_material_import_block(block) if block - 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 = nil + Material.transaction do + 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) + material = Material.new(tag:, url:, + created_by_user: current_user, + updated_by_user: current_user) + material.file.attach(file) if file + apply_file_sha256!(material, file_sha256) + material.save! + upsert_export_paths!(material) + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: current_user) + end - if material.save + if material render json: MaterialRepr.base(material, host: request.base_url), status: :created else render_validation_error material @@ -71,29 +86,32 @@ class MaterialsController < ApplicationController tag_name_raw = params[:tag].to_s.strip file = params[:file] - url = params[:url].to_s.strip.presence + file_sha256 = MaterialFileSha256.from_upload(file) + url = params.key?(:url) ? params[:url].to_s.strip.presence : material.url return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? - if file.blank? && url.blank? + if file.blank? && url.blank? && !material.file.attached? return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'], url: ['ファイルまたは URL は必須です.'] } end + block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) + return render_material_import_block(block) if block - 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.transaction do + MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) + 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 + material.update!(tag:, url:, updated_by_user: current_user) + material.file.attach(file) if file + apply_file_sha256!(material, file_sha256) + material.file.detach if params.key?(:url) && url.present? && file.blank? + upsert_export_paths!(material) + MaterialVersionRecorder.record!(material:, event_type: :update, + created_by_user: current_user) end - if material.save - render json: MaterialRepr.base(material, host: request.base_url) - else - render_validation_error material - end + render json: MaterialRepr.base(material, host: request.base_url) end def destroy @@ -103,7 +121,111 @@ class MaterialsController < ApplicationController material = Material.find_by(id: params[:id]) return head :not_found unless material - material.discard + Material.transaction do + MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) + material.discard! + MaterialVersionRecorder.record!(material:, event_type: :discard, + created_by_user: current_user) + end head :no_content end + + def download + zip = MaterialZipExporter.new(profile: params[:profile], + tag_id: params[:tag_id]).export + profile = params[:profile].presence || 'legacy_drive' + send_data zip, + type: 'application/zip', + disposition: 'attachment', + filename: "btrc-materials-#{ profile }.zip" + end + + def suppress_file + return head :unauthorized unless current_user + return head :forbidden unless current_user.admin? + + material = Material.with_attached_file.find_by(id: params[:id]) + return head :not_found unless material + + reason = params[:reason].to_s.strip.presence + return render_unprocessable_entity('理由は必須です.', field: :reason) unless reason + purge = bool?(:purge) + file_snapshot = purge_material_file_snapshot(material) if purge + attachment = purge && material.file.attached? ? material.file.attachment : nil + + Material.transaction do + MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) + material.update!(file_suppressed_at: Time.current, + file_suppressed_by_user: current_user, + file_suppression_reason: reason, + updated_by_user: current_user) + MaterialVersionRecorder.record!(material:, event_type: :suppress, + created_by_user: current_user, + file_snapshot:) + end + # Enqueue failure raises here after the suppress metadata has been committed. + # In that case the file remains suppressed in UI/ZIP and purge can be retried. + attachment&.purge_later + material.reload if purge + + render json: MaterialRepr.base(material, host: request.base_url) + end + + private + + def upsert_export_paths! material + raw = params[:export_paths] + return if raw.blank? + + export_paths = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h + export_paths.each do |profile, export_path| + profile = profile.to_s + export_path = export_path.to_s.strip + item = material.material_export_items.find_or_initialize_by(profile:) + + if export_path.blank? + item.destroy! if item.persisted? + next + end + + item.export_path = export_path + item.enabled = true + item.created_by_user ||= current_user + item.save! + end + end + + def render_zip_empty + render_unprocessable_entity('ZIP export 対象の素材がありません.') + end + + def render_zip_duplicate_path error + render_unprocessable_entity("ZIP export path が重複してゐます: #{ error.message }") + end + + def apply_file_sha256! material, file_sha256 + return if file_sha256.blank? + return unless material.file.attached? + + blob = material.file.blob + blob.metadata['sha256'] = file_sha256 + blob.save! if blob.changed? + end + + def purge_material_file_snapshot material + return nil unless material.file.attached? + + blob = material.file.blob + + { file_blob_id: blob.id, + file_filename: blob.filename.to_s, + file_content_type: blob.content_type, + file_byte_size: blob.byte_size, + file_checksum: blob.checksum, + file_sha256: blob.metadata['sha256'] || MaterialFileSha256.from_blob(blob) } + end + + def render_material_import_block block + render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] } + end end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index c9e585e..f847530 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -437,16 +437,10 @@ class TagsController < ApplicationController def build_tag_children tag material = tag.materials.first - file = nil - content_type = nil - if material&.file&.attached? - file = rails_storage_proxy_url(material.file, only_path: false) - content_type = material.file.blob.content_type - end TagRepr.base(tag).merge( children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, - material: material.as_json&.merge(file:, content_type:)) + material: material && MaterialRepr.base(material, host: request.base_url)) end def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb index 417b292..719e221 100644 --- a/backend/app/models/material.rb +++ b/backend/app/models/material.rb @@ -9,6 +9,10 @@ class Material < ApplicationRecord 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 + belongs_to :file_suppressed_by_user, class_name: 'User', optional: true + + has_many :material_versions, dependent: :destroy + has_many :material_export_items, dependent: :destroy has_one_attached :file, dependent: :purge @@ -18,11 +22,18 @@ class Material < ApplicationRecord validate :tag_must_be_material_category def content_type + return nil if file_suppressed? return nil unless file&.attached? file.blob.content_type end + def file_suppressed? = file_suppressed_at.present? + + def snapshot_export_paths + material_export_items.order(:profile).pluck(:profile, :export_path).to_h + end + private def file_must_be_attached diff --git a/backend/app/models/material_export_item.rb b/backend/app/models/material_export_item.rb new file mode 100644 index 0000000..5d13115 --- /dev/null +++ b/backend/app/models/material_export_item.rb @@ -0,0 +1,48 @@ +class MaterialExportItem < ApplicationRecord + VALID_PROFILES = ['legacy_drive'].freeze + + belongs_to :material + belongs_to :created_by_user, class_name: 'User', optional: true + + validates :profile, presence: true, inclusion: { in: VALID_PROFILES } + validates :export_path, presence: true, uniqueness: { scope: :profile } + validates :material_id, uniqueness: { scope: :profile } + validate :export_path_must_be_relative_safe_path + + scope :enabled, -> { where(enabled: true) } + + private + + def export_path_must_be_relative_safe_path + return if export_path.blank? + + if export_path.start_with?('/') + errors.add(:export_path, '絶対パスは使へません.') + end + + if export_path.match?(/\A[A-Za-z]:\//) + errors.add(:export_path, '絶対パスは使へません.') + end + + if export_path.include?('\\') + errors.add(:export_path, '/ 区切りで指定してください.') + end + + if export_path.include?("\0") + errors.add(:export_path, 'NUL は使へません.') + end + + parts = export_path.split('/') + if export_path.include?('//') + errors.add(:export_path, '空の path segment は使へません.') + end + + if parts.any? { |part| part.in?(['.', '..']) } + errors.add(:export_path, '.. は使へません.') + end + + if export_path.end_with?('/') + errors.add(:export_path, 'directory path は使へません.') + end + end +end diff --git a/backend/app/models/material_import_block.rb b/backend/app/models/material_import_block.rb new file mode 100644 index 0000000..b159c6c --- /dev/null +++ b/backend/app/models/material_import_block.rb @@ -0,0 +1,29 @@ +class MaterialImportBlock < ApplicationRecord + MATCH_KINDS = ['sha256', 'exact_path', 'path_prefix', 'manual'].freeze + REASONS = [ + 'copyright_high_risk', + 'copyright_takedown', + 'adult_or_sensitive', + 'personal_information', + 'malware_or_dangerous_file', + 'duplicate_or_low_quality', + 'source_owner_request', + 'other' + ].freeze + + belongs_to :created_by_user, class_name: 'User', optional: true + + validates :match_kind, presence: true, inclusion: { in: MATCH_KINDS } + validates :reason, presence: true, inclusion: { in: REASONS } + validates :sha256, length: { is: 64 }, allow_blank: true + validate :match_value_must_be_present + + private + + def match_value_must_be_present + return if match_kind == 'manual' + return if sha256.present? || external_path_pattern.present? + + errors.add(:base, 'sha256 または external_path_pattern は必須です.') + end +end diff --git a/backend/app/models/material_version.rb b/backend/app/models/material_version.rb new file mode 100644 index 0000000..9227314 --- /dev/null +++ b/backend/app/models/material_version.rb @@ -0,0 +1,18 @@ +class MaterialVersion < ApplicationRecord + EVENT_TYPE_MAP = { create: 'create', + update: 'update', + discard: 'discard', + restore: 'restore', + suppress: 'suppress' }.freeze + + include VersionRecord + + belongs_to :material + belongs_to :tag, optional: true + belongs_to :parent, class_name: 'Material', optional: true + belongs_to :updated_by_user, class_name: 'User', optional: true + + def export_paths_hash + export_paths_json || {} + end +end diff --git a/backend/app/models/version_record.rb b/backend/app/models/version_record.rb index 7224639..ccee900 100644 --- a/backend/app/models/version_record.rb +++ b/backend/app/models/version_record.rb @@ -1,15 +1,23 @@ module VersionRecord extend ActiveSupport::Concern + DEFAULT_EVENT_TYPE_MAP = { create: 'create', + update: 'update', + discard: 'discard', + restore: 'restore' }.freeze + def readonly? = persisted? included do + event_type_map = if const_defined?(:EVENT_TYPE_MAP, false) + const_get(:EVENT_TYPE_MAP) + else + DEFAULT_EVENT_TYPE_MAP + end + 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 + enum :event_type, event_type_map, prefix: true, validate: true validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :event_type, presence: true diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb index 44edd26..20e4834 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -2,7 +2,8 @@ module MaterialRepr - BASE = { only: [:id, :url, :created_at, :updated_at], + BASE = { only: [:id, :url, :version_no, :file_suppressed_at, + :file_suppression_reason, :created_at, :updated_at], methods: [:content_type], include: { tag: TagRepr::BASE, created_by_user: UserRepr::BASE, @@ -12,13 +13,30 @@ module MaterialRepr def base material, host: material.as_json(BASE).merge( - file: if material.file.attached? + file: if material.file.attached? && !material.file_suppressed? Rails.application.routes.url_helpers.rails_storage_proxy_url( material.file, host:) - end) + end, + export_paths: export_paths(material), + export_items: export_items(material)) end def many materials, host: materials.map { |m| base(m, host:) } end + + def export_paths material + material.material_export_items.each_with_object({ }) do |item, hash| + hash[item.profile] = item.enabled ? item.export_path : '' + end + end + + def export_items material + material.material_export_items.map do |item| + { id: item.id, + profile: item.profile, + export_path: item.export_path, + enabled: item.enabled } + end + end end diff --git a/backend/app/services/material_file_sha256.rb b/backend/app/services/material_file_sha256.rb new file mode 100644 index 0000000..b4243df --- /dev/null +++ b/backend/app/services/material_file_sha256.rb @@ -0,0 +1,25 @@ +require 'digest' + +class MaterialFileSha256 + def self.from_blob blob + sha256 = blob.metadata['sha256'] + return sha256 if sha256.present? + + blob.open do |file| + sha256 = Digest::SHA256.file(file.path).hexdigest + blob.metadata['sha256'] = sha256 + blob.save! if blob.changed? + sha256 + end + end + + def self.from_upload upload + tempfile = upload&.tempfile + return nil unless tempfile + + tempfile.rewind + Digest::SHA256.file(tempfile.path).hexdigest.tap do + tempfile.rewind + end + end +end diff --git a/backend/app/services/material_import_block_matcher.rb b/backend/app/services/material_import_block_matcher.rb new file mode 100644 index 0000000..cb0a92c --- /dev/null +++ b/backend/app/services/material_import_block_matcher.rb @@ -0,0 +1,7 @@ +class MaterialImportBlockMatcher + def self.match_for_sha256 sha256 + return nil if sha256.blank? + + MaterialImportBlock.find_by(match_kind: 'sha256', sha256:) + end +end diff --git a/backend/app/services/material_version_recorder.rb b/backend/app/services/material_version_recorder.rb new file mode 100644 index 0000000..d5d997f --- /dev/null +++ b/backend/app/services/material_version_recorder.rb @@ -0,0 +1,73 @@ +class MaterialVersionRecorder < VersionRecorder + EVENT_TYPES = ['create', 'update', 'discard', 'restore', 'suppress'].freeze + + def self.record! material:, event_type:, created_by_user:, file_snapshot: nil + new(material:, event_type:, created_by_user:, file_snapshot:).record! + end + + def initialize material:, event_type:, created_by_user:, file_snapshot: nil + @file_snapshot = file_snapshot + + super(record: material, event_type:, created_by_user:) + end + + def self.ensure_snapshot! material, created_by_user: + return if material.material_versions.exists? + + record!(material:, event_type: :create, + created_by_user: material.created_by_user || created_by_user) + end + + private + + def version_class = MaterialVersion + def version_association = :material_versions + def record_key = :material + + def snapshot_attributes + blob = @record.file.attached? ? @record.file.blob : nil + file_snapshot = build_file_snapshot(blob) + + { url: @record.url, + parent: @record.parent, + tag: @record.tag, + tag_name: @record.tag&.name, + tag_category: @record.tag&.category, + export_paths_json: @record.snapshot_export_paths, + discarded_at: @record.discarded_at, + file_blob_id: file_snapshot[:file_blob_id], + file_filename: file_snapshot[:file_filename], + file_content_type: file_snapshot[:file_content_type], + file_byte_size: file_snapshot[:file_byte_size], + file_checksum: file_snapshot[:file_checksum], + file_sha256: file_snapshot[:file_sha256], + file_suppressed_at: @record.file_suppressed_at, + file_suppression_reason: @record.file_suppression_reason } + end + + def build_file_snapshot blob + return @file_snapshot if @file_snapshot + return empty_file_snapshot unless blob + + blob.metadata['sha256'] ||= MaterialFileSha256.from_blob(blob) + blob.save! if blob.changed? + + { file_blob_id: blob.id, + file_filename: blob.filename.to_s, + file_content_type: blob.content_type, + file_byte_size: blob.byte_size, + file_checksum: blob.checksum, + file_sha256: blob.metadata['sha256'] } + end + + def empty_file_snapshot + { file_blob_id: nil, + file_filename: nil, + file_content_type: nil, + file_byte_size: nil, + file_checksum: nil, + file_sha256: nil } + end + + def event_types = self.class::EVENT_TYPES +end diff --git a/backend/app/services/material_zip_exporter.rb b/backend/app/services/material_zip_exporter.rb new file mode 100644 index 0000000..4976e54 --- /dev/null +++ b/backend/app/services/material_zip_exporter.rb @@ -0,0 +1,118 @@ +require 'stringio' +require 'zlib' + +# Initial implementation keeps every file payload and the final ZIP in memory. +# Keep this service boundary stable so job/cached export paths can replace it later. +class MaterialZipExporter + Entry = Struct.new(:path, :data, :mtime, keyword_init: true) + + class EmptyExportError < StandardError; end + class DuplicatePathError < StandardError; end + + def initialize profile: 'legacy_drive', tag_id: nil + @profile = profile.presence || 'legacy_drive' + @tag_id = tag_id.presence + end + + def export + entries = build_entries + raise EmptyExportError if entries.empty? + + ZipWriter.write(entries) + end + + private + + def build_entries + rows = MaterialExportItem + .enabled + .includes(material: { file_attachment: :blob }) + .joins(:material) + .merge(Material.kept) + .where(profile: @profile) + .where(materials: { file_suppressed_at: nil }) + .order(:export_path) + + rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id + + entries = rows.filter_map do |item| + material = item.material + next unless material.file.attached? + + Entry.new(path: item.export_path, + data: material.file.blob.download, + mtime: material.updated_at || Time.current) + end + + paths = entries.map(&:path) + duplicated = paths.find { |path| paths.count(path) > 1 } + raise DuplicatePathError, duplicated if duplicated + + entries + end + + class ZipWriter + VERSION_NEEDED = 20 + GP_FLAG = 0x0800 + COMPRESSION_STORE = 0 + + def self.write entries + new(entries).write + end + + def initialize entries + @entries = entries + @central_directory = [] + end + + def write + io = StringIO.new(''.b) + + @entries.each do |entry| + write_entry(io, entry) + end + + central_start = io.pos + @central_directory.each { |header| io.write(header) } + central_size = io.pos - central_start + + io.write([0x06054b50, 0, 0, @entries.size, @entries.size, + central_size, central_start, 0].pack('VvvvvVVv')) + io.string + end + + private + + def write_entry io, entry + path = entry.path.b + data = entry.data.b + crc32 = Zlib.crc32(data) + dos_time, dos_date = dos_timestamp(entry.mtime) + offset = io.pos + + local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE, + dos_time, dos_date, crc32, data.bytesize, data.bytesize, + path.bytesize, 0].pack('VvvvvvVVVvv') + + io.write(local_header) + io.write(path) + io.write(data) + + @central_directory << central_header(path:, crc32:, size: data.bytesize, + dos_time:, dos_date:, offset:) + end + + def central_header path:, crc32:, size:, dos_time:, dos_date:, offset: + [0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE, + dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0, + offset].pack('VvvvvvvVVVvvvvvVV') + path + end + + def dos_timestamp time + local = time.to_time + dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2) + dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day + [dos_time, dos_date] + end + end +end diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb index 8175dba..e289b61 100644 --- a/backend/app/services/version_recorder.rb +++ b/backend/app/services/version_recorder.rb @@ -73,7 +73,7 @@ class VersionRecorder end def validate_event_type! - return if EVENT_TYPES.include?(@event_type) + return if event_types.include?(@event_type) raise ArgumentError, "Invalid event_type: #{ @event_type }" end @@ -84,4 +84,5 @@ class VersionRecorder def snapshot_attributes = raise NotImplementedError def record_class = @record.class + def event_types = self.class::EVENT_TYPES end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index f30959b..dfbfb83 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -113,5 +113,10 @@ Rails.application.routes.draw do resources :skip_events, controller: :theatre_skip_events, only: [:index] end - resources :materials, only: [:index, :show, :create, :update, :destroy] + get 'materials/download.zip', to: 'materials#download' + resources :materials, only: [:index, :show, :create, :update, :destroy] do + member do + patch :suppress_file + end + end end diff --git a/backend/db/migrate/20260623000000_enhance_material_management.rb b/backend/db/migrate/20260623000000_enhance_material_management.rb new file mode 100644 index 0000000..27f6338 --- /dev/null +++ b/backend/db/migrate/20260623000000_enhance_material_management.rb @@ -0,0 +1,98 @@ +class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] + def up + change_table :materials, bulk: true do |t| + t.integer :version_no, null: false, default: 1 + t.datetime :file_suppressed_at + t.references :file_suppressed_by_user, foreign_key: { to_table: :users } + t.string :file_suppression_reason + end + + change_table :material_versions, bulk: true do |t| + t.string :event_type + t.string :tag_name + t.string :tag_category + t.json :export_paths_json + t.bigint :file_blob_id + t.string :file_filename + t.string :file_content_type + t.bigint :file_byte_size + t.string :file_checksum + t.string :file_sha256 + t.datetime :file_suppressed_at + t.string :file_suppression_reason + end + + execute <<~SQL.squish + UPDATE material_versions + SET event_type = CASE + WHEN version_no = 1 THEN 'create' + ELSE 'update' + END + WHERE event_type IS NULL + SQL + + change_column_null :material_versions, :event_type, false + add_index :material_versions, :file_blob_id + + add_check_constraint :material_versions, + "event_type IN ('create', 'update', 'discard', 'restore', 'suppress')", + name: 'material_versions_event_type_valid' + + create_table :material_export_items do |t| + t.references :material, null: false, foreign_key: true + t.string :profile, null: false, default: 'legacy_drive' + t.string :export_path, null: false + t.boolean :enabled, null: false, default: true + t.references :created_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.index [:profile, :export_path], unique: true + t.index [:material_id, :profile], unique: true + end + + create_table :material_import_blocks do |t| + t.string :match_kind, null: false + t.string :sha256 + t.string :external_path_pattern + t.string :reason, null: false + t.text :note + t.references :created_by_user, foreign_key: { to_table: :users } + t.timestamps + end + + execute <<~SQL.squish + UPDATE materials + SET version_no = COALESCE( + (SELECT MAX(material_versions.version_no) + FROM material_versions + WHERE material_versions.material_id = materials.id), + 1) + SQL + end + + def down + drop_table :material_import_blocks + drop_table :material_export_items + + remove_check_constraint :material_versions, name: 'material_versions_event_type_valid' + + remove_index :material_versions, :file_blob_id + remove_column :material_versions, :event_type + remove_column :material_versions, :tag_name + remove_column :material_versions, :tag_category + remove_column :material_versions, :export_paths_json + remove_column :material_versions, :file_blob_id + remove_column :material_versions, :file_filename + remove_column :material_versions, :file_content_type + remove_column :material_versions, :file_byte_size + remove_column :material_versions, :file_checksum + remove_column :material_versions, :file_sha256 + remove_column :material_versions, :file_suppressed_at + remove_column :material_versions, :file_suppression_reason + + remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users } + remove_column :materials, :version_no + remove_column :materials, :file_suppressed_at + remove_column :materials, :file_suppression_reason + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 8c988e5..7bc7d8e 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_06_21_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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 @@ -130,6 +130,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true end + create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "material_id", null: false + t.string "profile", default: "legacy_drive", null: false + t.string "export_path", null: false + t.boolean "enabled", default: true, null: false + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_material_export_items_on_created_by_user_id" + t.index ["material_id", "profile"], name: "index_material_export_items_on_material_id_and_profile", unique: true + t.index ["material_id"], name: "index_material_export_items_on_material_id" + t.index ["profile", "export_path"], name: "index_material_export_items_on_profile_and_export_path", unique: true + end + + create_table "material_import_blocks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "match_kind", null: false + t.string "sha256" + t.string "external_path_pattern" + t.string "reason", null: false + t.text "note" + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id" + 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 @@ -141,14 +167,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" + t.string "event_type", null: false + t.string "tag_name" + t.string "tag_category" + t.json "export_paths_json" + t.bigint "file_blob_id" + t.string "file_filename" + t.string "file_content_type" + t.bigint "file_byte_size" + t.string "file_checksum" + t.string "file_sha256" + t.datetime "file_suppressed_at" + t.string "file_suppression_reason" 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 ["file_blob_id"], name: "index_material_versions_on_file_blob_id" 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" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore',_utf8mb4'suppress')", name: "material_versions_event_type_valid" end create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -161,9 +201,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do 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.integer "version_no", default: 1, null: false + t.datetime "file_suppressed_at" + t.bigint "file_suppressed_by_user_id" + t.string "file_suppression_reason" 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 ["file_suppressed_by_user_id"], name: "index_materials_on_file_suppressed_by_user_id" 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" @@ -567,6 +612,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "users", column: "created_by_id" + add_foreign_key "material_export_items", "materials" + add_foreign_key "material_export_items", "users", column: "created_by_user_id" + add_foreign_key "material_import_blocks", "users", column: "created_by_user_id" add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "tags" @@ -575,6 +623,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do 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: "file_suppressed_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" diff --git a/backend/spec/models/material_export_item_spec.rb b/backend/spec/models/material_export_item_spec.rb new file mode 100644 index 0000000..d98e225 --- /dev/null +++ b/backend/spec/models/material_export_item_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +RSpec.describe MaterialExportItem, type: :model do + let(:user) { create(:user, :member) } + let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) } + let(:material) do + Material.create!(tag:, url: 'https://example.com/material', + created_by_user: user, updated_by_user: user) + end + + it 'rejects blank export_path' do + item = described_class.new(material:, profile: 'legacy_drive', export_path: '') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects absolute export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: '/素材/a.png') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects parent traversal export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: '素材/../a.png') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects double slash export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: '素材//a.png') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects dot segment export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: './素材/a.png') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end + + it 'rejects trailing slash export_path' do + item = described_class.new(material:, profile: 'legacy_drive', + export_path: '素材/a/') + + expect(item).not_to be_valid + expect(item.errors[:export_path]).to be_present + end +end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index d4085ec..1193ab7 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -1,7 +1,10 @@ require 'rails_helper' RSpec.describe 'Materials API', type: :request do + include ActiveJob::TestHelper + let!(:member_user) { create(:user, :member) } + let!(:admin_user) { create(:user, :admin) } let!(:guest_user) { create(:user) } def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy') @@ -13,22 +16,29 @@ RSpec.describe 'Materials API', type: :request do 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.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!(:tag_a) do + Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) + end + let!(:tag_b) do + Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) + end 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')) + build_material(tag: tag_b, user: member_user, parent: material_a, + file: dummy_upload(filename: 'b.png')) end before do @@ -97,7 +107,9 @@ RSpec.describe 'Materials API', type: :request do end describe 'GET /materials/:id' do - let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) } + let!(:tag) do + Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) + end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) end @@ -138,9 +150,22 @@ RSpec.describe 'Materials API', type: :request do end end - context 'when logged in' do + context 'when logged in but not member' do before { sign_in_as(guest_user) } + it 'returns 403' do + post '/materials', params: { + tag: 'material_create_guest_forbidden', + file: dummy_upload + } + + expect(response).to have_http_status(:forbidden) + end + end + + context 'when member' do + before { sign_in_as(member_user) } + it 'returns 422 when tag is blank' do post '/materials', params: { tag: ' ', file: dummy_upload } @@ -162,24 +187,49 @@ RSpec.describe 'Materials API', type: :request do expect do post '/materials', params: { tag: 'material_create_new', - file: dummy_upload(filename: 'created.png') + file: dummy_upload(filename: 'created.png'), + export_paths: { legacy_drive: '伊地知ニジカ/created.png' } } end.to change(Material, :count).by(1) .and change(Tag, :count).by(1) .and change(TagName, :count).by(1) + .and change(MaterialVersion, :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.created_by_user).to eq(member_user) + expect(material.updated_by_user).to eq(member_user) expect(material.file.attached?).to be(true) + expect(material.version_no).to eq(1) + expect(material.material_versions.first.event_type).to eq('create') + expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png') + expect(material.material_versions.first.export_paths_json).to eq( + 'legacy_drive' => '伊地知ニジカ/created.png' + ) 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') + expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png') + end + + it 'snapshots attached file metadata and sha256' do + post '/materials', params: { + tag: 'material_create_file_version', + file: dummy_upload(filename: 'created.png', body: 'sha-body') + } + + expect(response).to have_http_status(:created) + + version = Material.order(:id).last.material_versions.first + expect(version.file_blob_id).to be_present + expect(version.file_filename).to eq('created.png') + expect(version.file_content_type).to eq('image/png') + expect(version.file_byte_size).to eq('sha-body'.bytesize) + expect(version.file_sha256).to eq(Digest::SHA256.hexdigest('sha-body')) end it 'returns 422 when the existing tag is not material/character' do @@ -219,11 +269,33 @@ RSpec.describe 'Materials API', type: :request do expect(response).to have_http_status(:created) expect(json['url']).to eq('https://example.com/material-source') end + + it 'rejects sha256-blocked file upload' do + sha256 = Digest::SHA256.hexdigest('blocked-body') + MaterialImportBlock.create!(match_kind: 'sha256', + sha256:, + reason: 'copyright_high_risk', + created_by_user: admin_user) + + expect do + post '/materials', params: { + tag: 'material_blocked_create', + file: dummy_upload(filename: 'blocked.png', body: 'blocked-body') + } + end.not_to change(Material, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'file' => ['抑止された素材です: copyright_high_risk'] + ) + end end end describe 'PUT /materials/:id' do - let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) } + let!(:tag) do + Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) + end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) end @@ -277,25 +349,26 @@ RSpec.describe 'Materials API', type: :request do 'tag' => ['タグは必須です.']) end - it 'returns 422 when both file and url are blank' do + it 'keeps the existing file when file and url are omitted' do put "/materials/#{ material.id }", params: { tag: 'material_update_no_payload' } - expect(response).to have_http_status(:unprocessable_entity) - expect(json.fetch('errors')).to include( - 'file' => ['ファイルまたは URL は必須です.'], - 'url' => ['ファイルまたは URL は必須です.']) + expect(response).to have_http_status(:ok) + expect(material.reload.file.attached?).to be(true) 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 do + put "/materials/#{ material.id }", params: { + tag: 'material_update_new', + url: 'https://example.com/updated-source', + file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg'), + export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' } + } + end.to change(MaterialVersion, :count).by(2) expect(response).to have_http_status(:ok) @@ -306,8 +379,15 @@ RSpec.describe 'Materials API', type: :request do 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(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true) expect(material.file.blob.filename.to_s).to eq('updated.jpg') expect(material.file.blob.content_type).to eq('image/jpeg') + expect(material.version_no).to eq(2) + expect(material.material_versions.order(:version_no).last.event_type).to eq('update') + expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/updated.jpg') + expect(material.material_versions.order(:version_no).last.export_paths_json).to eq( + 'legacy_drive' => '伊地知ニジカ/updated.jpg' + ) expect(json['id']).to eq(material.id) expect(json['file']).to be_present @@ -315,7 +395,7 @@ RSpec.describe 'Materials API', type: :request do 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 + it 'detaches the existing file without purging blob when url replaces file' do old_blob_id = material.file.blob.id put "/materials/#{ material.id }", params: { @@ -331,9 +411,7 @@ RSpec.describe 'Materials API', type: :request do 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(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true) expect(json['id']).to eq(material.id) expect(json['file']).to be_nil @@ -341,11 +419,190 @@ RSpec.describe 'Materials API', type: :request do expect(json.dig('tag', 'name')).to eq('material_update_remove_file') expect(json['url']).to eq('https://example.com/updated-source') end + + it 'does not increase version for the same snapshot update' do + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + + expect do + put "/materials/#{ material.id }", params: { + tag: 'material_update_old' + } + end.not_to change(MaterialVersion, :count) + + expect(response).to have_http_status(:ok) + expect(material.reload.version_no).to eq(1) + end + + it 'records update version when only export_path changes' do + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + + expect do + put "/materials/#{ material.id }", params: { + tag: 'material_update_old', + export_paths: { legacy_drive: '素材/only-path.png' } + } + end.to change(MaterialVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(material.reload.material_export_items.first.export_path).to eq('素材/only-path.png') + expect(material.material_versions.order(:version_no).last.export_paths_json).to eq( + 'legacy_drive' => '素材/only-path.png' + ) + end + + it 'removes export_path item when blank is submitted' do + MaterialExportItem.create!(material:, profile: 'legacy_drive', + export_path: '素材/remove.png', + created_by_user: member_user) + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + + expect do + put "/materials/#{ material.id }", params: { + tag: 'material_update_old', + export_paths: { legacy_drive: '' } + } + end.to change(MaterialExportItem, :count).by(-1) + .and change(MaterialVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(material.reload.material_export_items).to be_empty + expect(material.material_versions.order(:version_no).last.export_paths_json).to eq({}) + end + + it 'rejects sha256-blocked replacement file' do + sha256 = Digest::SHA256.hexdigest('blocked-update') + MaterialImportBlock.create!(match_kind: 'sha256', + sha256:, + reason: 'source_owner_request', + created_by_user: admin_user) + + put "/materials/#{ material.id }", params: { + tag: 'material_update_old', + file: dummy_upload(filename: 'blocked.png', body: 'blocked-update') + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(material.reload.file.blob.filename.to_s).to eq('old.png') + end + end + end + + describe 'GET /materials/download.zip' do + let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) } + let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) } + let!(:material_a) do + build_material(tag: tag_a, user: member_user, + file: dummy_upload(filename: 'a.png', body: 'zip-a')) + end + let!(:material_b) do + build_material(tag: tag_b, user: member_user, + file: dummy_upload(filename: 'b.png', body: 'zip-b')) + end + + before do + MaterialExportItem.create!(material: material_a, profile: 'legacy_drive', + export_path: '素材/a.png', + created_by_user: member_user) + MaterialExportItem.create!(material: material_b, profile: 'legacy_drive', + export_path: '素材/b.png', + created_by_user: member_user) + end + + it 'uses material_export_items.export_path as ZIP entry paths' do + get '/materials/download.zip', params: { profile: 'legacy_drive' } + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/zip') + expect(response.body.b).to include('素材/a.png'.b) + expect(response.body.b).to include('素材/b.png'.b) + end + + it 'filters by tag_id' do + get '/materials/download.zip', params: { profile: 'legacy_drive', tag_id: tag_a.id } + + expect(response).to have_http_status(:ok) + expect(response.body.b).to include('素材/a.png'.b) + expect(response.body.b).not_to include('素材/b.png'.b) + end + + it 'does not include suppressed materials' do + material_b.update!(file_suppressed_at: Time.current, + file_suppression_reason: 'copyright_high_risk') + + get '/materials/download.zip', params: { profile: 'legacy_drive' } + + expect(response).to have_http_status(:ok) + expect(response.body.b).to include('素材/a.png'.b) + expect(response.body.b).not_to include('素材/b.png'.b) + end + end + + describe 'PATCH /materials/:id/suppress_file' do + let!(:tag) do + Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material) + end + let!(:material) do + build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png')) + end + + it 'allows admin to suppress a file and records a suppress version' do + sign_in_as(admin_user) + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + + expect do + patch "/materials/#{ material.id }/suppress_file", + params: { reason: 'copyright_high_risk' } + end.to change(MaterialVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + material.reload + expect(material.file_suppressed_at).to be_present + expect(material.file_suppressed_by_user).to eq(admin_user) + expect(material.file_suppression_reason).to eq('copyright_high_risk') + expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress') + expect(json['file']).to be_nil + expect(json['file_suppressed_at']).to be_present + end + + it 'purges blob when purge=true is requested' do + sign_in_as(admin_user) + old_blob_id = material.file.blob.id + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + + expect do + patch "/materials/#{ material.id }/suppress_file", + params: { reason: 'copyright_takedown', purge: '1' } + end.to have_enqueued_job(ActiveStorage::PurgeJob) + + expect(response).to have_http_status(:ok) + + version = material.material_versions.order(:version_no).last + expect(version.event_type).to eq('suppress') + expect(version.file_blob_id).to eq(old_blob_id) + expect(version.file_filename).to eq('suppress.png') + expect(version.file_sha256).to be_present + end + + it 'rejects member suppression' do + sign_in_as(member_user) + + patch "/materials/#{ material.id }/suppress_file", + params: { reason: 'copyright_high_risk' } + + expect(response).to have_http_status(:forbidden) end end describe 'DELETE /materials/:id' do - let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) } + let!(:tag) do + Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) + end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 78e7676..7796d7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -69,7 +69,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }> }/> }/> - }/> + }/> {/* }/> */} }/> diff --git a/frontend/src/pages/materials/MaterialDetailPage.test.tsx b/frontend/src/pages/materials/MaterialDetailPage.test.tsx index 2b697a9..bb6fc6b 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.test.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.test.tsx @@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render' const api = vi.hoisted (() => ({ apiGet: vi.fn (), + apiPatch: vi.fn (), apiPut: vi.fn (), })) @@ -26,7 +27,7 @@ vi.mock ('@/components/ui/use-toast', () => toastApi) const renderPage = () => renderWithProviders ( - }/> + }/> , { route: '/materials/8' }, ) @@ -73,6 +74,7 @@ describe ('MaterialDetailPage', () => { const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'new' } }) fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } }) + fireEvent.change (textboxes[2], { target: { value: '素材/new.png' } }) fireEvent.click (screen.getByRole ('button', { name: '更新' })) await waitFor (() => { @@ -81,6 +83,7 @@ describe ('MaterialDetailPage', () => { const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('tag')).toBe ('new') expect (formData.get ('url')).toBe ('https://example.com/ref') + expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png') expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) }) }) diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 09f537e..782505b 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -13,22 +13,23 @@ 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 { apiGet, apiPatch, apiPut } from '@/lib/api' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC } from 'react' -import type { Material, Tag } from '@/types' +import type { Material, Tag, User } from '@/types' type MaterialWithTag = Material & { tag: Tag } -type MaterialFormField = 'tag' | 'file' | 'url' +type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' -const MaterialDetailPage: FC = () => { +const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { const { id } = useParams () + const [exportPath, setExportPath] = useState ('') const [file, setFile] = useState (null) const [filePreview, setFilePreview] = useState ('') const [loading, setLoading] = useState (false) @@ -49,6 +50,7 @@ const MaterialDetailPage: FC = () => { formData.append ('file', file) if (url.trim ()) formData.append ('url', url) + formData.append ('export_paths[legacy_drive]', exportPath) try { @@ -68,6 +70,30 @@ const MaterialDetailPage: FC = () => { } } + const handleSuppress = async () => { + const reason = window.prompt ('抑止理由を入力してください。') + if (reason == null || reason.trim () === '') + return + if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。')) + return + + try + { + const data = await apiPatch ( + `/materials/${ id }/suppress_file`, + { reason }, + ) + setMaterial (data) + setFile (null) + setFilePreview ('') + toast ({ title: '抑止しました' }) + } + catch + { + toast ({ title: '抑止に失敗しました' }) + } + } + useEffect (() => { if (!(id)) return @@ -82,11 +108,10 @@ const MaterialDetailPage: FC = () => { if (data.file && data.contentType) { setFilePreview (data.file) - setFile (new File ([await (await fetch (data.file)).blob ()], - data.file, - { type: data.contentType })) + setFile (null) } setURL (data.url ?? '') + setExportPath (data.exportPaths.legacyDrive ?? '') } finally { @@ -111,7 +136,14 @@ const MaterialDetailPage: FC = () => { withCount={false}/> - {(material.file && material.contentType) && ( + {material.fileSuppressedAt && ( +
+ 素材ファイルは抑止済みです。 + {material.fileSuppressionReason && ( + 理由: {material.fileSuppressionReason})} +
)} + + {(!material.fileSuppressedAt && material.file && material.contentType) && ( (/image\/.*/.test (material.contentType) && ( {material.tag.name)) || (/video\/.*/.test (material.contentType) && ( @@ -189,13 +221,36 @@ const MaterialDetailPage: FC = () => { className={inputClass (invalid)}/>)} + + {({ describedBy, invalid }) => ( + setExportPath (e.target.value)} + placeholder="伊地知ニジカ/表情/泣き.png" + aria-describedby={describedBy} + aria-invalid={invalid} + className={inputClass (invalid)}/>)} + + {/* 送信 */} - +
+ + {user?.role === 'admin' && !material.fileSuppressedAt && ( + )} +
diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index aa14ec8..413427a 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -9,7 +9,7 @@ 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 { API_BASE_URL, SITE_TITLE } from '@/config' import { apiGet } from '@/lib/api' import type { FC } from 'react' @@ -30,10 +30,15 @@ const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { to={`/materials/${ tag.material.id }`} className="block w-40 h-40">
- {(tag.material.contentType && /image\/.*/.test (tag.material.contentType)) + {tag.material.fileSuppressedAt + ? 抑止済み + : (tag.material.contentType && /image\/.*/.test (tag.material.contentType)) ? : 照会}
@@ -108,7 +113,7 @@ const MaterialListPage: FC = () => { -
+
{tag.children.map (c2 => ( @@ -159,7 +164,11 @@ const MaterialListPage: FC = () => {

もしくは……

))} ) diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx index bfbb357..0571580 100644 --- a/frontend/src/pages/materials/MaterialNewPage.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -17,7 +17,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC } from 'react' -type MaterialFormField = 'tag' | 'file' | 'url' +type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' const MaterialNewPage: FC = () => { @@ -32,6 +32,7 @@ const MaterialNewPage: FC = () => { const [sending, setSending] = useState (false) const [tag, setTag] = useState (tagQuery) const [url, setURL] = useState ('') + const [exportPath, setExportPath] = useState ('') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () @@ -45,6 +46,7 @@ const MaterialNewPage: FC = () => { formData.append ('file', file) if (url) formData.append ('url', url) + formData.append ('export_paths[legacy_drive]', exportPath) try { @@ -133,6 +135,20 @@ const MaterialNewPage: FC = () => { className={inputClass (invalid)}/>)} + + {({ describedBy, invalid }) => ( + setExportPath (e.target.value)} + placeholder="伊地知ニジカ/表情/泣き.png" + aria-describedby={describedBy} + aria-invalid={invalid} + className={inputClass (invalid)}/>)} + + {/* 送信 */} ))} +
) + + +const MaterialTreeNode: FC<{ + materialFilter: MaterialFilter + nestLevel?: number + onChildren: (tagId: number, children: MaterialSidebarTag[]) => void + openTags: Record + setOpenTags: Dispatch>> + tag: MaterialSidebarTag +}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, setOpenTags, tag }) => { + const open = Boolean (openTags[tag.id]) + const { data } = useQuery ({ + queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), + queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), + enabled: open && tag.hasChildren && tag.children.length === 0, + }) + + useEffect (() => { + if (open && data && tag.children.length === 0) + onChildren (tag.id, data) + }, [data, onChildren, open, tag.children.length, tag.id]) + + return ( + +
  • +
    +
    + {tag.hasChildren && ( + )} +
    +
    + +
    +
    +
  • + {open && tag.children.length > 0 && ( +
      + {tag.children.map (child => ( + ))} +
    )} +
    ) +} + + +const MobileMaterialTreeNode: FC<{ + depth?: number + materialFilter: MaterialFilter + onChildren: (tagId: number, children: MaterialSidebarTag[]) => void + openTags: Record + setOpenTags: Dispatch>> + tag: MaterialSidebarTag +}> = ({ depth = 0, materialFilter, onChildren, openTags, setOpenTags, tag }) => { + const open = Boolean (openTags[tag.id]) + const { data } = useQuery ({ + queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), + queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), + enabled: open && tag.hasChildren && tag.children.length === 0, + }) + + useEffect (() => { + if (open && data && tag.children.length === 0) + onChildren (tag.id, data) + }, [data, onChildren, open, tag.children.length, tag.id]) + + return ( +
    +
    +
    + +
    + {tag.hasChildren && ( + )} +
    + {open && tag.children.length > 0 && ( +
    +
    )} +
    ) +} + + +const MaterialSidebar: FC = () => { + const location = useLocation () + const navigate = useNavigate () + const qs = new URLSearchParams (location.search) + const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present') + + const [desktopTags, setDesktopTags] = useState ([]) + const [openTags, setOpenTags] = useState> ({ }) + const mobileRailRef = useRef (null) + + const { data: rootTags = [], isLoading, isError } = useQuery ({ + queryKey: materialsKeys.tree ({ parentId: null, materialFilter }), + queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }), + }) + + useEffect (() => { + setDesktopTags (rootTags) + }, [rootTags]) + + useEffect (() => { + const el = mobileRailRef.current + if (!(el)) + return + + requestAnimationFrame (() => { + el.scrollLeft = el.scrollWidth + }) + }, [rootTags, materialFilter]) + + const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags + + const setChildren = (tagId: number, children: MaterialSidebarTag[]) => { + setDesktopTags (prev => { + const base = prev.length > 0 ? prev : rootTags + return setChildrenById (base, tagId, children) + }) + } + + const handleFilterChange = (value: MaterialFilter) => { + setDesktopTags ([]) + setOpenTags ({ }) + updateMaterialFilterQuery (location.pathname, location.search, navigate, value) + } + + const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => ( + tags.map (tag => ( + ))) + + return ( + <> +
    +
    + タグ一覧 + + 未分類素材 + +
    + +
    +
    + {visibleRootTags.map (tag => ( + ))} +
    +
    +
    + +
    + +
    + +
    + + 未分類素材 + +
    + {isLoading && ( +

    読込中……

    )} + {isError && ( +

    + タグ一覧の取得に失敗しました. +

    )} + {(!isLoading && !isError) && ( +
      + {renderDesktopTree (visibleRootTags)} +
    )} +
    +
    +
    + ) +} + + +export default MaterialSidebar diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts new file mode 100644 index 0000000..900c9c4 --- /dev/null +++ b/frontend/src/lib/materials.ts @@ -0,0 +1,114 @@ +import { + apiGet, + isApiError, + apiPatch, + apiPost, + apiPut, +} from '@/lib/api' + +import type { + Material, + MaterialFilter, + MaterialSidebarTag, + MaterialTagTree, +} from '@/types' + +export type FetchMaterialsParams = { + page?: number + limit?: number + tagId?: number | null + parentId?: number | null + unclassified?: boolean +} + +export type FetchMaterialTreeParams = { + parentId?: number | null + materialFilter: MaterialFilter +} + +export type MaterialIndexResponse = { + materials: Material[] + count: number +} + +const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] + + +export const parseMaterialFilter = ( + value: unknown, + fallback: MaterialFilter = 'present', +): MaterialFilter => + typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter) + ? value as MaterialFilter + : fallback + + +export const fetchMaterials = async ( + { page, limit, tagId, parentId, unclassified }: FetchMaterialsParams, +): Promise => + await apiGet ('/materials', { params: { + ...(page != null && { page }), + ...(limit != null && { limit }), + ...(tagId != null && { tag_id: tagId }), + ...(parentId != null && { parent_id: parentId }), + ...(unclassified && { unclassified: '1' }), + } }) + + +export const fetchMaterial = async (id: string): Promise => { + try + { + return await apiGet (`/materials/${ id }`) + } + catch (error) + { + if (isApiError (error) && error.response?.status === 404) + return null + throw error + } +} + + +export const fetchMaterialTagTree = async ( + { parentId, materialFilter }: FetchMaterialTreeParams, +): Promise => + await apiGet ('/tags/with-depth', { params: { + ...(parentId != null && { parent: String (parentId) }), + material_filter: materialFilter, + } }) + + +export const fetchMaterialTagByName = async ( + name: string, + materialFilter: MaterialFilter, +): Promise => { + try + { + return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`, + { params: { material_filter: materialFilter } }) + } + catch (error) + { + if (isApiError (error) && error.response?.status === 404) + return null + throw error + } +} + + +export const createMaterial = async (formData: FormData): Promise => + await apiPost ('/materials', formData) + + +export const updateMaterial = async ( + id: string, + formData: FormData, +): Promise => + await apiPut (`/materials/${ id }`, formData) + + +export const suppressMaterialFile = async ( + id: string, + payload: { reason: string; purge?: boolean }, +): Promise => + await apiPatch (`/materials/${ id }/suppress_file`, payload) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 7e10ab1..3e5f017 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,4 +1,9 @@ -import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types' +import type { + FetchNicoTagsParams, + FetchPostsParams, + FetchTagsParams, + MaterialFilter, +} from '@/types' export const postsKeys = { root: ['posts'] as const, @@ -25,6 +30,26 @@ export const tagsKeys = { ['tags', 'changes', p] as const, deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } +export const materialsKeys = { + root: ['materials'] as const, + index: (p: { + page?: number + limit?: number + tagId?: number | null + parentId?: number | null + unclassified?: boolean + }) => ['materials', 'index', p] as const, + byTagName: (name: string, materialFilter: MaterialFilter) => + ['materials', 'tag', name, materialFilter] as const, + show: (id: string) => ['materials', id] as const, + tree: (p: { + parentId?: number | null + materialFilter: MaterialFilter + }) => ['materials', 'tree', p] as const, + unclassified: (p: { page?: number; limit?: number } = { }) => + ['materials', 'unclassified', p] as const, +} + export const wikiKeys = { root: ['wiki'] as const, index: (p: { title?: string }) => ['wiki', 'index', p] as const, diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 782505b..a8acf66 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -1,3 +1,4 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' @@ -13,248 +14,254 @@ 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, apiPatch, apiPut } from '@/lib/api' +import { + suppressMaterialFile, + fetchMaterial, + updateMaterial, +} from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC } from 'react' -import type { Material, Tag, User } from '@/types' - -type MaterialWithTag = Material & { tag: Tag } +import type { User } from '@/types' type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { const { id } = useParams () + const qc = useQueryClient () const [exportPath, setExportPath] = useState ('') 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 { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () - const handleSubmit = async () => { - clearValidationErrors () + const { data: material, isError, isLoading } = useQuery ({ + queryKey: materialsKeys.show (id ?? ''), + queryFn: () => fetchMaterial (id ?? ''), + enabled: id != null, + }) - const formData = new FormData - if (tag.trim ()) - formData.append ('tag', tag) - if (file) - formData.append ('file', file) - if (url.trim ()) - formData.append ('url', url) - formData.append ('export_paths[legacy_drive]', exportPath) + useEffect (() => { + if (!(material)) + return - try - { - setSending (true) - const data = await apiPut (`/materials/${ id }`, formData) - setMaterial (data) - toast ({ title: '更新成功!' }) - } - catch (e) - { - applyValidationError (e) - toast ({ title: '更新失敗……', description: '入力を見直してください.' }) - } - finally - { - setSending (false) - } + setTag (material.tag.name) + setURL (material.url ?? '') + setExportPath (material.exportPaths.legacyDrive ?? '') + if (material.file && material.contentType) + { + setFilePreview (material.file) + setFile (null) + } + }, [material]) + + const invalidateMaterialQueries = async () => { + await qc.invalidateQueries ({ queryKey: materialsKeys.root }) } - const handleSuppress = async () => { + const updateMutation = useMutation ({ + mutationFn: 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) + formData.append ('export_paths[legacy_drive]', exportPath) + + return await updateMaterial (id ?? '', formData) + }, + onSuccess: async data => { + qc.setQueryData (materialsKeys.show (id ?? ''), data) + await invalidateMaterialQueries () + toast ({ title: '更新成功!' }) + }, + onError: error => { + applyValidationError (error) + toast ({ title: '更新失敗……', description: '入力を見直してください.' }) + }, + }) + + const suppressMutation = useMutation ({ + mutationFn: async (reason: string) => + await suppressMaterialFile (id ?? '', { reason }), + onSuccess: async data => { + qc.setQueryData (materialsKeys.show (id ?? ''), data) + setFile (null) + setFilePreview ('') + await invalidateMaterialQueries () + toast ({ title: '抑止しました' }) + }, + onError: () => { + toast ({ title: '抑止に失敗しました' }) + }, + }) + + const handleSubmit = () => { + clearValidationErrors () + updateMutation.mutate () + } + + const handleSuppress = () => { const reason = window.prompt ('抑止理由を入力してください。') if (reason == null || reason.trim () === '') return if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。')) return - try - { - const data = await apiPatch ( - `/materials/${ id }/suppress_file`, - { reason }, - ) - setMaterial (data) - setFile (null) - setFilePreview ('') - toast ({ title: '抑止しました' }) - } - catch - { - toast ({ title: '抑止に失敗しました' }) - } + suppressMutation.mutate (reason) } - 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 (null) - } - setURL (data.url ?? '') - setExportPath (data.exportPaths.legacyDrive ?? '') - } - finally - { - setLoading (false) - } - }) () - }, [id]) - return ( - {material && ( - - {`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`} - )} + {material && ( + + {`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`} + )} - {loading ? 'Loading...' : (material && ( - <> - - - + {isLoading ? 'Loading...' : isError ? ( +

    + 素材の取得に失敗しました. +

    + ) : material == null ? ( +

    + 素材が見つかりませんでした. +

    + ) : ( + <> + + + - {material.fileSuppressedAt && ( -
    - 素材ファイルは抑止済みです。 - {material.fileSuppressionReason && ( - 理由: {material.fileSuppressionReason})} -
    )} + {material.fileSuppressedAt && ( +
    + 素材ファイルは抑止済みです。 + {material.fileSuppressionReason && ( + 理由: {material.fileSuppressionReason})} +
    )} - {(!material.fileSuppressedAt && material.file && material.contentType) && ( - (/image\/.*/.test (material.contentType) && ( - {material.tag.name)) - || (/video\/.*/.test (material.contentType) && ( -
    ) } diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index 413427a..4e1cc57 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -1,176 +1,328 @@ -import { Fragment, useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Fragment, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import nikumaru from '@/assets/fonts/nikumaru.otf' import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' +import FormField from '@/components/common/FormField' import PageTitle from '@/components/common/PageTitle' import SectionTitle from '@/components/common/SectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle' +import TagInput from '@/components/common/TagInput' import MainArea from '@/components/layout/MainArea' import { API_BASE_URL, SITE_TITLE } from '@/config' -import { apiGet } from '@/lib/api' +import { + fetchMaterials, + fetchMaterialTagByName, + parseMaterialFilter, +} from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' +import { inputClass } from '@/lib/utils' import type { FC } from 'react' -import type { Material, Tag } from '@/types' +import type { Material, MaterialFilter, MaterialTagTree } from '@/types' -type TagWithMaterial = Omit & { - children: TagWithMaterial[] - material: Material | null } +const MATERIAL_FILTER_LABELS: Record = { + present: '素材あり', + missing: '素材なし', + any: 'すべて', +} -const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { +const MaterialCard = ({ tag }: { tag: MaterialTagTree }) => { if (!(tag.material)) - return + return null return ( -
    - {tag.material.fileSuppressedAt - ? 抑止済み - : (tag.material.contentType && /image\/.*/.test (tag.material.contentType)) - ? - : 照会} -
    + to={`/materials/${ tag.material.id }`} + className="block h-40 w-40"> +
    + {tag.material.fileSuppressedAt + ? 抑止済み + : (tag.material.contentType && /image\/.*/.test (tag.material.contentType)) + ? + : 照会} +
    ) } -const MaterialListPage: FC = () => { - const [loading, setLoading] = useState (false) - const [tag, setTag] = useState (null) +const MaterialList = ({ materials }: { materials: Material[] }) => ( +
    + {materials.map (material => ( +
    +
    +
    +

    + {material.tag?.name ?? '未分類素材'} +

    + {material.fileSuppressedAt && ( +

    抑止済み

    )} +
    + + 照会 + +
    + {material.exportPaths.legacyDrive && ( +

    + {material.exportPaths.legacyDrive} +

    )} +
    ))} +
    ) + +const MaterialTagTreeView = ({ tag }: { tag: MaterialTagTree }) => ( + <> + + + + {(!tag.material && tag.hasMaterial !== true && tag.category !== 'meme') && ( +
    + + 追加 + +
    )} + + + +
    + {tag.children.map (c2 => ( + + + + + {(!c2.material && c2.hasMaterial !== true && c2.category !== 'meme') && ( +
    + + 追加 + +
    )} + + + +
    + {c2.children.map (c3 => ( + + + + + {(!c3.material && c3.hasMaterial !== true && c3.category !== 'meme') && ( +
    + + 追加 + +
    )} + + +
    ))} +
    +
    ))} +
    + ) + + +const MaterialSearchTop: FC<{ + materialFilter: MaterialFilter + setMaterialFilter: (value: MaterialFilter) => void + tagName: string + setTagName: (value: string) => void +}> = ({ materialFilter, setMaterialFilter, tagName, setTagName }) => { + const navigate = useNavigate () const location = useLocation () - const query = new URLSearchParams (location.search) + + const handleSearch = () => { + const qs = new URLSearchParams (location.search) + if (tagName.trim ()) + qs.set ('tag', tagName.trim ()) + else + qs.delete ('tag') + qs.delete ('unclassified') + qs.set ('material_filter', materialFilter) + navigate (`/materials?${ qs.toString () }`) + } + + return ( +
    + 素材集 + +
    +
    + + {() => ( + )} + + + + {({ invalid }) => ( + )} + + +
    + + + 未分類素材を見る + + + 新規素材を追加 + + + ZIP をダウンロード + +
    +
    +
    +
    ) +} + + +const MaterialListPage: FC = () => { + const location = useLocation () + const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const tagQuery = query.get ('tag') ?? '' + const unclassified = query.get ('unclassified') === '1' + const initialFilter = parseMaterialFilter (query.get ('material_filter'), 'present') + + const [tagName, setTagName] = useState (tagQuery) + const [materialFilter, setMaterialFilter] = useState (initialFilter) + + const { + data: tag, + isLoading: tagLoading, + isError: tagError, + } = useQuery ({ + queryKey: materialsKeys.byTagName (tagQuery, initialFilter), + queryFn: () => fetchMaterialTagByName (tagQuery, initialFilter), + enabled: tagQuery !== '' && !unclassified, + }) + + const { + data: unclassifiedData, + isLoading: unclassifiedLoading, + isError: unclassifiedError, + } = useQuery ({ + queryKey: materialsKeys.unclassified ({ page: 1, limit: 50 }), + queryFn: () => fetchMaterials ({ page: 1, limit: 50, unclassified: true }), + enabled: unclassified, + }) useEffect (() => { - if (!(tagQuery)) - { - setTag (null) - return - } - - void (async () => { - try - { - setLoading (true) - setTag ( - await apiGet ( - `/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) - } - finally - { - setLoading (false) - } - }) () - }, [location.search, tagQuery]) + setTagName (tagQuery) + setMaterialFilter (initialFilter) + }, [initialFilter, tagQuery]) return ( - - - {`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`} - + + + {`${ 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') && ( -
    - - 追加 - -
    )} - - -
    ))} -
    -
    ))} -
    - ) - : ( - <> -

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

    -

    もしくは……

    - - ))} + {unclassified ? ( + <> + 未分類素材 +
    + + 素材検索トップへ戻る + +
    + {unclassifiedLoading &&

    Loading...

    } + {unclassifiedError && ( +

    未分類素材の取得に失敗しました.

    )} + {unclassifiedData && unclassifiedData.materials.length === 0 && ( +

    未分類素材はありません.

    )} + {unclassifiedData && unclassifiedData.materials.length > 0 && ( + )} + ) : tagQuery ? ( + <> + {tagLoading &&

    Loading...

    } + {tagError && ( +

    素材一覧の取得に失敗しました.

    )} + {(!tagLoading && !tagError && !tag) && ( +

    該当する素材タグが見つかりませんでした.

    )} + {tag && } + ) : ( + )}
    ) } diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx index 0571580..c65326b 100644 --- a/frontend/src/pages/materials/MaterialNewPage.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -1,3 +1,4 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' @@ -11,7 +12,8 @@ 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 { createMaterial } from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' @@ -21,6 +23,7 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' const MaterialNewPage: FC = () => { + const qc = useQueryClient () const location = useLocation () const query = new URLSearchParams (location.search) const tagQuery = query.get ('tag') ?? '' @@ -29,41 +32,39 @@ const MaterialNewPage: FC = () => { const [file, setFile] = useState (null) const [filePreview, setFilePreview] = useState ('') - const [sending, setSending] = useState (false) const [tag, setTag] = useState (tagQuery) const [url, setURL] = useState ('') const [exportPath, setExportPath] = useState ('') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () - const handleSubmit = async () => { + const createMutation = useMutation ({ + mutationFn: async () => { + const formData = new FormData + if (tag) + formData.append ('tag', tag) + if (file) + formData.append ('file', file) + if (url) + formData.append ('url', url) + formData.append ('export_paths[legacy_drive]', exportPath) + + return await createMaterial (formData) + }, + onSuccess: async () => { + await qc.invalidateQueries ({ queryKey: materialsKeys.root }) + toast ({ title: '送信成功!' }) + navigate (`/materials?tag=${ encodeURIComponent (tag) }`) + }, + onError: error => { + applyValidationError (error) + toast ({ title: '送信失敗……', description: '入力を見直してください.' }) + }, + }) + + const handleSubmit = () => { clearValidationErrors () - - const formData = new FormData - if (tag) - formData.append ('tag', tag) - if (file) - formData.append ('file', file) - if (url) - formData.append ('url', url) - formData.append ('export_paths[legacy_drive]', exportPath) - - try - { - setSending (true) - await apiPost ('/materials', formData) - toast ({ title: '送信成功!' }) - navigate (`/materials?tag=${ encodeURIComponent (tag) }`) - } - catch (e) - { - applyValidationError (e) - toast ({ title: '送信失敗……', description: '入力を見直してください.' }) - } - finally - { - setSending (false) - } + createMutation.mutate () } return ( @@ -153,7 +154,7 @@ const MaterialNewPage: FC = () => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9ce4054..41d3839 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -65,6 +65,8 @@ export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc' export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at' +export type MaterialFilter = 'present' | 'missing' | 'any' + export type Material = { id: number versionNo: number @@ -82,6 +84,21 @@ export type Material = { updatedAt: string updatedByUser: { id: number; name: string } } +export type MaterialSidebarTag = { + id: number + name: string + category: Category + deprecated: boolean + hasChildren: boolean + hasMaterial: boolean + children: MaterialSidebarTag[] } + +export type MaterialTagTree = Omit & { + hasChildren: boolean + hasMaterial: boolean + children: MaterialTagTree[] + material?: Material | null } + export type MaterialExportItem = { id: number profile: string -- 2.34.1 From fa6c547cc97907fcef2717c15377d1d6f0224d0f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 25 Jun 2026 07:44:11 +0900 Subject: [PATCH 06/23] #306 --- .../app/controllers/materials_controller.rb | 175 ++++- backend/app/models/material.rb | 13 + backend/app/representations/material_repr.rb | 64 ++ .../services/material_thumbnail_generator.rb | 86 +++ frontend/src/components/MaterialSidebar.tsx | 2 +- frontend/src/lib/materials.ts | 29 +- frontend/src/lib/queryKeys.ts | 9 +- .../pages/materials/MaterialDetailPage.tsx | 32 +- .../src/pages/materials/MaterialListPage.tsx | 726 +++++++++++------- frontend/src/test/factories.ts | 5 + frontend/src/types.ts | 47 +- 11 files changed, 865 insertions(+), 323 deletions(-) create mode 100644 backend/app/services/material_thumbnail_generator.rb diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index bf383ac..3274186 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -12,33 +12,47 @@ class MaterialsController < ApplicationController offset = (page - 1) * limit - tag_id = params[:tag_id].presence - parent_id = params[:parent_id].presence - unclassified = bool?(:unclassified) + filters = material_index_filters + q = Material.includes(:material_export_items, + thumbnail_attachment: :blob, + file_attachment: :blob, + tag: :tag_name) + q = q.where(file_suppressed_at: nil) if filters[:suppression] == 'active' + q = q.where.not(file_suppressed_at: nil) if filters[:suppression] == 'suppressed' + q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged' + q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged' + q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from] + q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to] + q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from] + q = q.where('materials.updated_at <= ?', filters[:updated_to]) if filters[:updated_to] - q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file - if unclassified - q = q.where(tag_id: nil) - else - q = q.where(tag_id:) if tag_id - q = q.where(parent_id:) if parent_id - end + q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters) + q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters) + q = apply_material_query(q, filters[:q]) if filters[:q].present? + q = apply_material_media_kind(q, filters[:media_kind]) - count = q.count - materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) + count = q.distinct.count(:id) + materials = + q + .order(Arel.sql(material_index_order_sql(filters))) + .limit(limit) + .offset(offset) + .to_a - render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count } + render json: { materials: MaterialRepr.list_many(materials, host: request.base_url), + count: } end def show material = Material .includes(:tag, :material_export_items) + .with_attached_thumbnail .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 + 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 @@ -83,6 +97,8 @@ class MaterialsController < ApplicationController end if material + MaterialThumbnailGenerator.generate!(material) + material.reload render json: MaterialRepr.base(material, host: request.base_url), status: :created else render_validation_error material @@ -134,6 +150,9 @@ class MaterialsController < ApplicationController raise end + MaterialThumbnailGenerator.generate!(material) + material.reload + render json: MaterialRepr.base(material, host: request.base_url) end @@ -196,6 +215,134 @@ class MaterialsController < ApplicationController private + def material_index_filters + tag_state = params[:tag_state].to_s.presence + tag_state = 'untagged' if bool?(:unclassified) + tag_state = 'all' unless ['all', 'tagged', 'untagged'].include?(tag_state) + + media_kind = params[:media_kind].to_s.presence + unless ['all', 'image', 'video', 'audio', 'file_other', 'url_only'].include?(media_kind) + media_kind = 'all' + end + + suppression = params[:suppression].to_s.presence + suppression = 'active' unless ['active', 'suppressed', 'all'].include?(suppression) + + sort = params[:sort].to_s.presence + unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size', + 'version_no', 'id'].include?(sort) + sort = 'created_at' + end + + direction = params[:direction].to_s.downcase + direction = 'desc' unless ['asc', 'desc'].include?(direction) + + { q: params[:q].to_s.strip.presence, + tag_state:, + media_kind:, + suppression:, + created_from: parse_time_param(:created_from), + created_to: parse_time_param(:created_to), + updated_from: parse_time_param(:updated_from), + updated_to: parse_time_param(:updated_to), + sort:, + direction: } + end + + def parse_time_param name + value = params[name].to_s.strip + return nil if value.blank? + + Time.zone.parse(value) + rescue ArgumentError + nil + end + + def material_index_needs_tag_name? filters + filters[:q].present? || filters[:sort] == 'tag_name' + end + + def material_index_needs_file_blob? filters + filters[:q].present? || + filters[:media_kind] != 'all' || + ['media_kind', 'file_byte_size'].include?(filters[:sort]) + end + + def material_index_join_tag_name q + q.left_joins(tag: :tag_name) + end + + def material_index_join_file_blob q + q.joins(<<~SQL.squish) + LEFT JOIN active_storage_attachments material_file_attachments + ON material_file_attachments.record_type = 'Material' + AND material_file_attachments.record_id = materials.id + AND material_file_attachments.name = 'file' + LEFT JOIN active_storage_blobs material_file_blobs + ON material_file_blobs.id = material_file_attachments.blob_id + SQL + end + + def apply_material_query q, term + like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%" + q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \ + 'material_file_blobs.filename LIKE :q', + q: like) + end + + def apply_material_media_kind q, media_kind + case media_kind + when 'image' + q.where('material_file_blobs.content_type LIKE ?', 'image/%') + when 'video' + q.where('material_file_blobs.content_type LIKE ?', 'video/%') + when 'audio' + q.where('material_file_blobs.content_type LIKE ?', 'audio/%') + when 'file_other' + q.where('material_file_attachments.id IS NOT NULL') + .where.not('material_file_blobs.content_type LIKE ?', 'image/%') + .where.not('material_file_blobs.content_type LIKE ?', 'video/%') + .where.not('material_file_blobs.content_type LIKE ?', 'audio/%') + when 'url_only' + q.where('material_file_attachments.id IS NULL').where.not(url: [nil, '']) + else + q + end + end + + def material_index_order_sql filters + direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC' + sort_sql = + case filters[:sort] + when 'tag_name' + 'tag_names.name' + when 'media_kind' + material_media_kind_sql + when 'file_byte_size' + 'material_file_blobs.byte_size' + when 'updated_at' + 'materials.updated_at' + when 'version_no' + 'materials.version_no' + when 'id' + 'materials.id' + else + 'materials.created_at' + end + + "#{ sort_sql } #{ direction }, materials.id #{ direction }" + end + + def material_media_kind_sql + "CASE " \ + "WHEN material_file_attachments.id IS NULL AND materials.url IS NOT NULL THEN 5 " \ + "WHEN material_file_blobs.content_type LIKE 'image/%' THEN 1 " \ + "WHEN material_file_blobs.content_type LIKE 'video/%' THEN 2 " \ + "WHEN material_file_blobs.content_type LIKE 'audio/%' THEN 3 " \ + "WHEN material_file_attachments.id IS NOT NULL THEN 4 " \ + "ELSE 6 END" + end + def upsert_export_paths! material raw = params[:export_paths] return if raw.blank? diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb index 719e221..b480ed0 100644 --- a/backend/app/models/material.rb +++ b/backend/app/models/material.rb @@ -15,6 +15,7 @@ class Material < ApplicationRecord has_many :material_export_items, dependent: :destroy has_one_attached :file, dependent: :purge + has_one_attached :thumbnail, dependent: :purge validates :tag_id, presence: true, uniqueness: true @@ -28,6 +29,18 @@ class Material < ApplicationRecord file.blob.content_type end + def file_byte_size + return nil unless file&.attached? + + file.blob.byte_size + end + + def file_filename + return nil unless file&.attached? + + file.blob.filename.to_s + end + def file_suppressed? = file_suppressed_at.present? def snapshot_export_paths diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb index 20e4834..de4ad05 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -17,6 +17,11 @@ module MaterialRepr Rails.application.routes.url_helpers.rails_storage_proxy_url( material.file, host:) end, + thumbnail: thumbnail_url(material, host:), + thumbnail_fallback_text: thumbnail_fallback_text(material), + thumbnail_fallback_kind: thumbnail_fallback_kind(material), + media_kind: media_kind(material), + file_byte_size: material.file_byte_size, export_paths: export_paths(material), export_items: export_items(material)) end @@ -25,6 +30,28 @@ module MaterialRepr materials.map { |m| base(m, host:) } end + def list material, host: + { id: material.id, + version_no: material.version_no, + url: material.url, + tag: compact_tag(material.tag), + thumbnail: thumbnail_url(material, host:), + thumbnail_fallback_text: thumbnail_fallback_text(material), + thumbnail_fallback_kind: thumbnail_fallback_kind(material), + media_kind: media_kind(material), + content_type: material.content_type, + file_byte_size: material.file_byte_size, + file_suppressed_at: material.file_suppressed_at, + created_at: material.created_at, + updated_at: material.updated_at, + export_paths: export_paths(material), + export_items: export_items(material) } + end + + def list_many materials, host: + materials.map { |m| list(m, host:) } + end + def export_paths material material.material_export_items.each_with_object({ }) do |item, hash| hash[item.profile] = item.enabled ? item.export_path : '' @@ -39,4 +66,41 @@ module MaterialRepr enabled: item.enabled } end end + + def thumbnail_url material, host: + return nil if material.file_suppressed? + return nil unless material.thumbnail.attached? + + Rails.application.routes.url_helpers.rails_storage_proxy_url( + material.thumbnail, host:) + end + + def thumbnail_fallback_text material + material.tag&.name || material.created_at&.strftime('%Y-%m-%d') + end + + def thumbnail_fallback_kind material + material.tag.present? ? 'tag_name' : 'created_at' + end + + def media_kind material + return 'suppressed' if material.file_suppressed? + return 'url_only' unless material.file.attached? + + content_type = material.file.blob.content_type.to_s + return 'image' if content_type.start_with?('image/') + return 'video' if content_type.start_with?('video/') + return 'audio' if content_type.start_with?('audio/') + + 'file_other' + end + + def compact_tag tag + return nil unless tag + + { id: tag.id, + name: tag.name, + category: tag.category, + deprecated_at: tag.deprecated_at } + end end diff --git a/backend/app/services/material_thumbnail_generator.rb b/backend/app/services/material_thumbnail_generator.rb new file mode 100644 index 0000000..0f809c5 --- /dev/null +++ b/backend/app/services/material_thumbnail_generator.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'mini_magick' +require 'open3' +require 'tempfile' + + +class MaterialThumbnailGenerator + SIZE = '180x180' + + class << self + def generate! material + new(material).generate! + rescue ActiveStorage::FileNotFoundError, ArgumentError, MiniMagick::Error => e + Rails.logger.warn("Material thumbnail generation skipped: #{ e.class }: #{ e.message }") + nil + end + end + + def initialize material + @material = material + end + + def generate! + @material.thumbnail.purge if @material.thumbnail.attached? + return unless @material.file.attached? + return unless image? || video? + + @material.file.blob.open do |file| + thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path) + attach_thumbnail(thumbnail) if thumbnail + end + end + + private + + def image? = content_type.start_with?('image/') + + def video? = content_type.start_with?('video/') + + def content_type = @material.file.blob.content_type.to_s + + def image_thumbnail path + image = MiniMagick::Image.open(path) + image.resize(SIZE) + image.format('jpg') + image + end + + def video_thumbnail path + [1, 0].each do |seconds| + tempfile = Tempfile.new(['material-thumbnail', '.jpg']) + tempfile.close + ok = extract_video_frame(path, tempfile.path, seconds) + next unless ok && File.size?(tempfile.path) + + return image_thumbnail(tempfile.path) + ensure + tempfile&.unlink + end + + nil + end + + def extract_video_frame input_path, output_path, seconds + _stdout, stderr, status = + Open3.capture3('ffmpeg', + '-y', + '-ss', seconds.to_s, + '-i', input_path, + '-frames:v', '1', + '-f', 'image2', + output_path) + Rails.logger.warn("ffmpeg thumbnail failed: #{ stderr }") unless status.success? + status.success? + rescue Errno::ENOENT => e + Rails.logger.warn("ffmpeg unavailable for material thumbnail: #{ e.message }") + false + end + + def attach_thumbnail image + @material.thumbnail.attach(io: File.open(image.path), + filename: 'material-thumbnail.jpg', + content_type: 'image/jpeg') + end +end diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 4122acb..39b066d 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -40,7 +40,7 @@ const setChildrenById = ( const materialPath = ( tagName: string, materialFilter: MaterialFilter, -): string => `/materials?tag=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }` +): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }` const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index 900c9c4..5e2b570 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -8,19 +8,12 @@ import { import type { Material, + FetchMaterialsParams, MaterialFilter, MaterialSidebarTag, MaterialTagTree, } from '@/types' -export type FetchMaterialsParams = { - page?: number - limit?: number - tagId?: number | null - parentId?: number | null - unclassified?: boolean -} - export type FetchMaterialTreeParams = { parentId?: number | null materialFilter: MaterialFilter @@ -44,14 +37,22 @@ export const parseMaterialFilter = ( export const fetchMaterials = async ( - { page, limit, tagId, parentId, unclassified }: FetchMaterialsParams, + { q, tagState, mediaKind, suppression, createdFrom, createdTo, + updatedFrom, updatedTo, sort, direction, page, limit }: FetchMaterialsParams, ): Promise => await apiGet ('/materials', { params: { - ...(page != null && { page }), - ...(limit != null && { limit }), - ...(tagId != null && { tag_id: tagId }), - ...(parentId != null && { parent_id: parentId }), - ...(unclassified && { unclassified: '1' }), + ...(q && { q }), + tag_state: tagState, + media_kind: mediaKind, + suppression, + ...(createdFrom && { created_from: createdFrom }), + ...(createdTo && { created_to: createdTo }), + ...(updatedFrom && { updated_from: updatedFrom }), + ...(updatedTo && { updated_to: updatedTo }), + sort, + direction, + page, + limit, } }) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 3e5f017..3649838 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,5 +1,6 @@ import type { FetchNicoTagsParams, + FetchMaterialsParams, FetchPostsParams, FetchTagsParams, MaterialFilter, @@ -32,13 +33,7 @@ export const tagsKeys = { export const materialsKeys = { root: ['materials'] as const, - index: (p: { - page?: number - limit?: number - tagId?: number | null - parentId?: number | null - unclassified?: boolean - }) => ['materials', 'index', p] as const, + index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const, byTagName: (name: string, materialFilter: MaterialFilter) => ['materials', 'tag', name, materialFilter] as const, show: (id: string) => ['materials', id] as const, diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index a8acf66..523962a 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -47,12 +47,15 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { queryFn: () => fetchMaterial (id ?? ''), enabled: id != null, }) + const materialTitle = material + ? material.tag?.name ?? `素材 #${ material.id }` + : '' useEffect (() => { if (!(material)) return - setTag (material.tag.name) + setTag (material.tag?.name ?? '') setURL (material.url ?? '') setExportPath (material.exportPaths.legacyDrive ?? '') if (material.file && material.contentType) @@ -124,7 +127,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { {material && ( - {`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`} + {`${ materialTitle } 素材照会 | ${ SITE_TITLE }`} )} {isLoading ? 'Loading...' : isError ? ( @@ -138,10 +141,13 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { ) : ( <> - + {material.tag + ? ( + ) + : materialTitle} {material.fileSuppressedAt && ( @@ -155,7 +161,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { {(!material.fileSuppressedAt && material.file && material.contentType) && ( (/image\/.*/.test (material.contentType) && ( - {material.tag.name)) + {material.tag?.name)) || (/video\/.*/.test (material.contentType) && (
    diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index 0c00a3e..4e65c6e 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -23,7 +23,6 @@ import type { MaterialIndexDirection, MaterialIndexMediaKind, MaterialIndexSort, - MaterialIndexSuppression, MaterialIndexTagState, MaterialIndexView } from '@/types' @@ -32,8 +31,7 @@ const MEDIA_KIND_LABELS: Record = { video: '動画', audio: '音声', file_other: 'その他ファイル', - url_only: 'URL のみ', - suppressed: '抑止済み'} + url_only: 'URL のみ'} const MEDIA_FILTER_LABELS: Record = { all: 'すべて', @@ -84,14 +82,9 @@ const materialTitle = (material: Material): string => const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
    + overflow-hidden rounded-lg border border-stone-200 bg-white text-center + text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900 + dark:text-stone-100`}> {material.thumbnail ? : ( @@ -123,14 +116,8 @@ const MaterialCard: FC<{ material: Material }> = ({ material }) => ( const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
    + className="rounded-lg border border-stone-200 bg-white p-3 text-stone-900 + shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
    @@ -141,8 +128,6 @@ const MaterialListItem: FC<{ material: Material }> = ({ material }) => ( dark:text-sky-300"> {materialTitle (material)} - {material.fileSuppressedAt && ( -

    抑止済み

    )}
    @@ -188,10 +173,6 @@ const MaterialListPage: FC = () => { query.get ('media_kind'), ['all', 'image', 'video', 'audio', 'file_other', 'url_only'], 'all') - const suppression = parseOption ( - query.get ('suppression'), - ['active', 'suppressed', 'all'], - 'active') const sort = parseOption ( query.get ('sort'), ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size', @@ -210,8 +191,6 @@ const MaterialListPage: FC = () => { const [q, setQ] = useState ('') const [tagStateInput, setTagStateInput] = useState ('all') const [mediaKindInput, setMediaKindInput] = useState ('all') - const [suppressionInput, setSuppressionInput] = - useState ('active') const [createdFrom, setCreatedFrom] = useState (null) const [createdTo, setCreatedTo] = useState (null) const [updatedFrom, setUpdatedFrom] = useState (null) @@ -221,7 +200,6 @@ const MaterialListPage: FC = () => { q: qQuery, tagState, mediaKind, - suppression, createdFrom: createdFromQuery, createdTo: createdToQuery, updatedFrom: updatedFromQuery, @@ -241,12 +219,11 @@ const MaterialListPage: FC = () => { setQ (qQuery) setTagStateInput (tagState) setMediaKindInput (mediaKind) - setSuppressionInput (suppression) setCreatedFrom (createdFromQuery) setCreatedTo (createdToQuery) setUpdatedFrom (updatedFromQuery) setUpdatedTo (updatedToQuery) - }, [createdFromQuery, createdToQuery, mediaKind, qQuery, suppression, tagState, + }, [createdFromQuery, createdToQuery, mediaKind, qQuery, tagState, updatedFromQuery, updatedToQuery]) const search = (e: FormEvent) => { @@ -256,7 +233,6 @@ const MaterialListPage: FC = () => { setIf (qs, 'q', q) qs.set ('tag_state', tagStateInput) qs.set ('media_kind', mediaKindInput) - qs.set ('suppression', suppressionInput) setIf (qs, 'created_from', createdFrom) setIf (qs, 'created_to', createdTo) setIf (qs, 'updated_from', updatedFrom) @@ -301,6 +277,13 @@ const MaterialListPage: FC = () => { dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800"> 新規素材を追加 + + 同期元抑止 + { )} - - {({ invalid }) => ( - )} - - {() => (
    diff --git a/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx b/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx new file mode 100644 index 0000000..c79bb4d --- /dev/null +++ b/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx @@ -0,0 +1,194 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { Helmet } from 'react-helmet-async' + +import PrefetchLink from '@/components/PrefetchLink' +import FormField from '@/components/common/FormField' +import PageTitle from '@/components/common/PageTitle' +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 { + createMaterialSyncSuppression, + fetchMaterialSyncSuppressions, +} from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' +import { dateString, inputClass } from '@/lib/utils' + +import type { FC, FormEvent } from 'react' + +import type { MaterialSyncSuppressionSourceKind } from '@/types' + +const SOURCE_KIND_LABELS: Record = { + uri: 'URI', + google_drive_path: 'Google Drive path', + google_drive_file: 'Google Drive file ID', + legacy_drive_path: 'Legacy Drive path'} + +const REASONS = [ + 'copyright_high_risk', + 'copyright_takedown', + 'adult_or_sensitive', + 'personal_information', + 'malware_or_dangerous_file', + 'duplicate_or_low_quality', + 'source_owner_request', + 'other'] + + +const MaterialSyncSuppressionsPage: FC = () => { + const qc = useQueryClient () + const [sourceKind, setSourceKind] = + useState ('uri') + const [sourceUri, setSourceUri] = useState ('') + const [drivePath, setDrivePath] = useState ('') + const [driveFileId, setDriveFileId] = useState ('') + const [reason, setReason] = useState (REASONS[0]) + + const { data, isError, isLoading } = useQuery ({ + queryKey: materialsKeys.suppressions (), + queryFn: fetchMaterialSyncSuppressions}) + + const createMutation = useMutation ({ + mutationFn: async () => + await createMaterialSyncSuppression ({ + sourceKind, + sourceUri, + drivePath, + driveFileId, + reason}), + onSuccess: async () => { + await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions () }) + await qc.invalidateQueries ({ queryKey: materialsKeys.root }) + setSourceUri ('') + setDrivePath ('') + setDriveFileId ('') + toast ({ title: '同期元抑止を登録しました' }) + }, + onError: () => { + toast ({ title: '同期元抑止の登録に失敗しました' }) + }}) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault () + createMutation.mutate () + } + + const suppressions = data?.suppressions ?? [] + + return ( + + + {`同期元抑止 | ${ SITE_TITLE }`} + + +
    +
    + 同期元抑止 + + 素材一覧へ戻る + +
    + +
    +
    + + {({ invalid }) => ( + )} + + + + {({ invalid }) => ( + setSourceUri (e.target.value)} + className={inputClass (invalid)}/>)} + + + + {({ invalid }) => ( + setDrivePath (e.target.value)} + className={inputClass (invalid)}/>)} + + + + {({ invalid }) => ( + setDriveFileId (e.target.value)} + className={inputClass (invalid)}/>)} + + + + {({ invalid }) => ( + )} + +
    + + +
    + + {isLoading &&

    Loading...

    } + {isError && ( +

    + 同期元抑止一覧の取得に失敗しました. +

    )} + +
    + {suppressions.map (suppression => ( +
    +
    + {SOURCE_KIND_LABELS[suppression.sourceKind]} +
    +
    + {suppression.normalizedSourceKey} +
    +
    + 理由: {suppression.reason} / 登録: {dateString (suppression.createdAt)} +
    +
    ))} +
    +
    +
    ) +} + +export default MaterialSyncSuppressionsPage diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts index 2e74bfb..49204c6 100644 --- a/frontend/src/test/factories.ts +++ b/frontend/src/test/factories.ts @@ -76,6 +76,11 @@ export const buildMaterial = (overrides: Partial = {}): Material => ({ tag: buildTag (), file: null, url: null, + sourceKind: null, + sourceUri: null, + sourcePath: null, + sourceFileId: null, + normalizedSourceKey: null, wikiPageBody: null, thumbnail: null, thumbnailFallbackText: 'テストタグ', @@ -83,8 +88,6 @@ export const buildMaterial = (overrides: Partial = {}): Material => ({ mediaKind: 'url_only', contentType: null, fileByteSize: null, - fileSuppressedAt: null, - fileSuppressionReason: null, exportPaths: {}, exportItems: [], createdAt: '2026-01-02T03:04:05.000Z', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9a65013..8189b41 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -86,8 +86,6 @@ export type MaterialIndexSort = | 'version_no' | 'id' -export type MaterialIndexSuppression = 'active' | 'suppressed' | 'all' - export type MaterialIndexTagState = 'all' | 'tagged' | 'untagged' export type MaterialIndexView = 'card' | 'list' @@ -96,7 +94,6 @@ export type FetchMaterialsParams = { q: string tagState: MaterialIndexTagState mediaKind: MaterialIndexMediaKind - suppression: MaterialIndexSuppression createdFrom: string createdTo: string updatedFrom: string @@ -113,15 +110,18 @@ export type Material = { tag: Tag | null file: string | null url: string | null + sourceKind: string | null + sourceUri: string | null + sourcePath: string | null + sourceFileId: string | null + normalizedSourceKey: string | null wikiPageBody?: string | null thumbnail: string | null thumbnailFallbackText: string | null thumbnailFallbackKind: 'tag_name' | 'created_at' - mediaKind: 'image' | 'video' | 'audio' | 'file_other' | 'url_only' | 'suppressed' + mediaKind: 'image' | 'video' | 'audio' | 'file_other' | 'url_only' contentType: string | null fileByteSize: number | null - fileSuppressedAt: string | null - fileSuppressionReason: string | null exportPaths: Record exportItems: MaterialExportItem[] createdAt: string @@ -129,6 +129,24 @@ export type Material = { updatedAt: string updatedByUser: { id: number; name: string } } +export type MaterialSyncSuppressionSourceKind = + | 'uri' + | 'google_drive_path' + | 'google_drive_file' + | 'legacy_drive_path' + +export type MaterialSyncSuppression = { + id: number + sourceKind: MaterialSyncSuppressionSourceKind + sourceUri: string | null + drivePath: string | null + driveFileId: string | null + normalizedSourceKey: string + reason: string + createdByUser: { id: number; name: string } | null + createdAt: string + updatedAt: string } + export type MaterialSidebarTag = { id: number name: string -- 2.34.1 From 8304909c8c857b08aa847c251ecf44eb7ff8e67a Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 26 Jun 2026 00:21:33 +0900 Subject: [PATCH 09/23] #306 --- .../material_versions_controller.rb | 49 ++++ .../app/controllers/materials_controller.rb | 15 +- backend/app/models/material.rb | 2 +- backend/app/models/material_sync_source.rb | 43 ++++ .../app/models/material_sync_suppression.rb | 41 +++- .../app/services/google_drive/api_client.rb | 209 +++++++++++++++++ .../app/services/material_sync_export_path.rb | 56 +++++ .../app/services/material_sync_importer.rb | 115 +++++++++- backend/app/services/material_sync_runner.rb | 212 +++++++++++++++++ .../material_sync_suppression_matcher.rb | 46 ++++ .../material_sync_suppression_registrar.rb | 22 +- .../services/material_thumbnail_generator.rb | 66 ++++-- backend/config/routes.rb | 1 + ...0625010000_create_material_sync_sources.rb | 22 ++ backend/db/schema.rb | 25 +- backend/db/seeds.rb | 18 ++ backend/lib/tasks/sync_materials.rake | 50 ++++ docs/commands.md | 9 + frontend/src/App.tsx | 2 + frontend/src/lib/materials.ts | 22 ++ frontend/src/lib/queryKeys.ts | 7 + .../pages/materials/MaterialDetailPage.tsx | 7 + .../pages/materials/MaterialHistoryPage.tsx | 213 ++++++++++++++++++ .../src/pages/materials/MaterialListPage.tsx | 7 + .../MaterialSyncSuppressionsPage.tsx | 4 +- frontend/src/types.ts | 21 ++ 26 files changed, 1251 insertions(+), 33 deletions(-) create mode 100644 backend/app/controllers/material_versions_controller.rb create mode 100644 backend/app/models/material_sync_source.rb create mode 100644 backend/app/services/google_drive/api_client.rb create mode 100644 backend/app/services/material_sync_export_path.rb create mode 100644 backend/app/services/material_sync_runner.rb create mode 100644 backend/db/migrate/20260625010000_create_material_sync_sources.rb create mode 100644 backend/lib/tasks/sync_materials.rake create mode 100644 frontend/src/pages/materials/MaterialHistoryPage.tsx diff --git a/backend/app/controllers/material_versions_controller.rb b/backend/app/controllers/material_versions_controller.rb new file mode 100644 index 0000000..c697ad7 --- /dev/null +++ b/backend/app/controllers/material_versions_controller.rb @@ -0,0 +1,49 @@ +class MaterialVersionsController < 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 + + q = MaterialVersion.includes(:created_by_user) + q = q.where(material_id: params[:material_id]) if params[:material_id].present? + q = q.where(tag_name: params[:tag].to_s.strip) if params[:tag].present? + q = q.where(event_type: params[:event_type]) if params[:event_type].present? + + count = q.except(:order, :limit, :offset).count + versions = + q + .order(Arel.sql('material_versions.created_at DESC, material_versions.id DESC')) + .limit(limit) + .offset(offset) + + render json: { versions: serialise_versions(versions), count: } + end + + private + + def serialise_versions rows + rows.map do |row| + { id: row.id, + material_id: row.material_id, + version_no: row.version_no, + event_type: row.event_type, + tag_id: row.tag_id, + tag_name: row.tag_name, + tag_category: row.tag_category, + url: row.url, + file_blob_id: row.file_blob_id, + file_filename: row.file_filename, + file_content_type: row.file_content_type, + file_byte_size: row.file_byte_size, + file_sha256: row.file_sha256, + export_paths_json: row.export_paths_hash, + discarded_at: row.discarded_at, + created_by_user: row.created_by_user&.as_json(UserRepr::BASE), + created_at: row.created_at } + end + end +end diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 8747964..ad0e025 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -95,8 +95,7 @@ class MaterialsController < ApplicationController end if material - MaterialThumbnailGenerator.generate!(material) - material.reload + log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material)) render json: MaterialRepr.base(material, host: request.base_url), status: :created else render_validation_error material @@ -147,8 +146,7 @@ class MaterialsController < ApplicationController raise end - MaterialThumbnailGenerator.generate!(material) - material.reload + log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material)) render json: MaterialRepr.base(material, host: request.base_url) end @@ -375,4 +373,13 @@ class MaterialsController < ApplicationController def render_material_import_block block render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] } end + + def log_thumbnail_generation material, result + material.reload + Rails.logger.info( + "Material thumbnail generation: material_id=#{ material.id } " \ + "result=#{ result } " \ + "thumbnail_attached=#{ material.thumbnail.attached? } " \ + "content_type=#{ material.content_type }") + end end diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb index d275738..f8536e1 100644 --- a/backend/app/models/material.rb +++ b/backend/app/models/material.rb @@ -18,7 +18,7 @@ class Material < ApplicationRecord before_validation :assign_normalized_source_key - validates :tag_id, presence: true, uniqueness: true + validates :tag_id, uniqueness: true, allow_nil: true validates :source_kind, inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS }, allow_blank: true diff --git a/backend/app/models/material_sync_source.rb b/backend/app/models/material_sync_source.rb new file mode 100644 index 0000000..fe45ebf --- /dev/null +++ b/backend/app/models/material_sync_source.rb @@ -0,0 +1,43 @@ +class MaterialSyncSource < ApplicationRecord + belongs_to :created_by_user, class_name: 'User', optional: true + belongs_to :updated_by_user, class_name: 'User', optional: true + + validates :name, presence: true + validates :source_kind, + presence: true, + inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS } + validates :profile, presence: true, inclusion: { in: MaterialExportItem::VALID_PROFILES } + validate :source_value_must_be_present + + scope :enabled, -> { where(enabled: true) } + + def normalized_source_key + MaterialSyncSuppression.normalize_source_key(source_kind:, + source_uri:, + source_path:, + source_file_id:) + end + + private + + def source_value_must_be_present + return if source_value_present? + + errors.add(:base, '同期元を指定してください.') + end + + def source_value_present? + case source_kind + when 'uri' + source_uri.present? + when 'google_drive_file' + source_file_id.present? || source_uri.present? + when 'google_drive_path' + source_file_id.present? || source_uri.present? || source_path.present? + when 'legacy_drive_path' + source_path.present? + else + normalized_source_key.present? + end + end +end diff --git a/backend/app/models/material_sync_suppression.rb b/backend/app/models/material_sync_suppression.rb index cb6a6ba..d744807 100644 --- a/backend/app/models/material_sync_suppression.rb +++ b/backend/app/models/material_sync_suppression.rb @@ -2,8 +2,10 @@ class MaterialSyncSuppression < ApplicationRecord SOURCE_KINDS = [ 'uri', 'google_drive_path', + 'google_drive_path_prefix', 'google_drive_file', - 'legacy_drive_path' + 'legacy_drive_path', + 'legacy_drive_path_prefix' ].freeze REASONS = [ @@ -25,6 +27,7 @@ class MaterialSyncSuppression < ApplicationRecord validates :reason, presence: true, inclusion: { in: REASONS } validates :normalized_source_key, presence: true, uniqueness: true validate :source_value_must_be_present + validate :drive_path_must_be_safe_relative_path def self.normalize_source_key source_kind:, source_uri: nil, @@ -37,7 +40,8 @@ class MaterialSyncSuppression < ApplicationRecord case kind when 'uri' source_uri.to_s.strip - when 'google_drive_path', 'legacy_drive_path' + when 'google_drive_path', 'google_drive_path_prefix', + 'legacy_drive_path', 'legacy_drive_path_prefix' (source_path || drive_path).to_s.strip.gsub(%r{/+}, '/') when 'google_drive_file' (source_file_id || drive_file_id).to_s.strip @@ -53,6 +57,7 @@ class MaterialSyncSuppression < ApplicationRecord private def assign_normalized_source_key + normalize_path_fields! self.normalized_source_key = self.class.normalize_source_key(source_kind:, source_uri:, @@ -65,4 +70,36 @@ class MaterialSyncSuppression < ApplicationRecord errors.add(:base, '同期元を指定してください.') end + + def drive_path_must_be_safe_relative_path + return unless source_kind.in?( + ['google_drive_path', 'google_drive_path_prefix', + 'legacy_drive_path', 'legacy_drive_path_prefix']) + + value = drive_path.to_s + return if value.blank? + + if value.start_with?('/') || value.match?(/\A[A-Za-z]:\//) + errors.add(:drive_path, '相対 path を指定してください.') + end + if value.start_with?('My Drive/', 'マイドライブ/') + errors.add(:drive_path, '同期元フォルダからの相対 path を指定してください.') + end + errors.add(:drive_path, 'NUL は使へません.') if value.include?("\0") + errors.add(:drive_path, '連続スラッシュは使へません.') if value.include?('//') + errors.add(:drive_path, '末尾スラッシュは使へません.') if value.end_with?('/') + + parts = value.split('/') + if parts.any? { |part| part.in?(['.', '..']) } + errors.add(:drive_path, '. や .. は使へません.') + end + end + + def normalize_path_fields! + return unless source_kind.in?( + ['google_drive_path', 'google_drive_path_prefix', + 'legacy_drive_path', 'legacy_drive_path_prefix']) + + self.drive_path = drive_path.to_s.tr('\\', '/').strip.presence + end end diff --git a/backend/app/services/google_drive/api_client.rb b/backend/app/services/google_drive/api_client.rb new file mode 100644 index 0000000..8a2fa16 --- /dev/null +++ b/backend/app/services/google_drive/api_client.rb @@ -0,0 +1,209 @@ +require 'json' +require 'jwt' +require 'net/http' +require 'openssl' +require 'tempfile' +require 'uri' + + +module GoogleDrive + class ApiClient + DRIVE_ENDPOINT = 'https://www.googleapis.com/drive/v3' + TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token' + FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder' + DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.readonly' + + def initialize service_account_email: ENV['GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL'], + private_key: ENV['GOOGLE_DRIVE_PRIVATE_KEY'], + private_key_path: ENV['GOOGLE_DRIVE_PRIVATE_KEY_PATH'], + subject: ENV['GOOGLE_DRIVE_SUBJECT'] + @service_account_email = service_account_email.to_s + @private_key = load_private_key(private_key, private_key_path) + @subject = subject.to_s.presence + @access_token = nil + end + + def list_material_files_under_folder folder_id + files = [] + walk_folder(folder_id, nil) do |entry, relative_path| + next if entry['mimeType'] == FOLDER_MIME_TYPE + + files << build_file_entry(entry, relative_path) + end + files + end + + def fetch_material_file file_id + metadata = get_file(file_id) + build_file_entry(metadata, metadata['name']) + end + + def download_to_tempfile file_id, filename: + tempfile = Tempfile.new(['material-sync', File.extname(filename.to_s)]) + request_binary("/files/#{ file_id }", + { alt: 'media', supportsAllDrives: true }) do |chunk| + tempfile.write(chunk) + end + tempfile.rewind + tempfile + rescue StandardError + tempfile&.close! + raise + end + + def extract_file_id value + raw = value.to_s.strip + return nil if raw.blank? + return raw unless raw.include?('/') + + uri = URI.parse(raw) + return uri.query.to_s[%r{(?:^|&)id=([^&]+)}, 1] if uri.query.present? + return uri.path[%r{/folders/([^/]+)}, 1] if uri.path.include?('/folders/') + + uri.path[%r{/d/([^/]+)}, 1] + rescue URI::InvalidURIError + nil + end + + private + + def walk_folder folder_id, prefix, &block + list_children(folder_id).each do |entry| + relative_path = MaterialSyncExportPath.build(prefix:, + relative_path: entry['name']) + if entry['mimeType'] == FOLDER_MIME_TYPE + walk_folder(entry['id'], relative_path, &block) + next + end + + block.call(entry, relative_path) + end + end + + def build_file_entry entry, relative_path + { id: entry['id'], + name: entry['name'], + mime_type: entry['mimeType'], + relative_path: MaterialSyncExportPath.normalize_path(relative_path), + web_view_link: entry['webViewLink'], + web_content_link: entry['webContentLink'] } + end + + def list_children folder_id + files = [] + page_token = nil + + loop do + response = + request_json('/files', { + q: "'#{ folder_id }' in parents and trashed = false", + fields: 'nextPageToken,files(id,name,mimeType,webViewLink,webContentLink)', + orderBy: 'folder,name', + pageSize: 1000, + supportsAllDrives: true, + includeItemsFromAllDrives: true, + pageToken: page_token }.compact) + files.concat(response.fetch('files')) + page_token = response['nextPageToken'] + break if page_token.blank? + end + + files + end + + def get_file file_id + request_json("/files/#{ file_id }", + fields: 'id,name,mimeType,webViewLink,webContentLink', + supportsAllDrives: true) + end + + def request_json path, params = {} + response = request(:get, path, params:) + unless response.is_a?(Net::HTTPSuccess) + raise "Google Drive API error: #{ response.code } #{ response.body }" + end + + JSON.parse(response.body) + end + + def request_binary path, params = {} + response = request(:get, path, params:) + unless response.is_a?(Net::HTTPSuccess) + raise "Google Drive download error: #{ response.code } #{ response.body }" + end + + response.read_body do |chunk| + yield chunk + end + end + + def request method, path, params: nil + uri = URI(DRIVE_ENDPOINT + path) + uri.query = URI.encode_www_form(params) if params.present? + + klass = + case method + when :get + Net::HTTP::Get + else + raise ArgumentError, "Unsupported Google Drive request method: #{ method }" + end + request = klass.new(uri) + request['Authorization'] = "Bearer #{ access_token }" + + Net::HTTP.start(uri.host, + uri.port, + use_ssl: true, + open_timeout: 10, + read_timeout: 60) do |http| + http.request(request) + end + end + + def access_token + @access_token ||= fetch_access_token + end + + def fetch_access_token + raise 'GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL is required' if @service_account_email.blank? + raise 'Google Drive private key is required' if @private_key.blank? + + payload = { iss: @service_account_email, + scope: DRIVE_SCOPE, + aud: TOKEN_ENDPOINT, + exp: 1.hour.from_now.to_i, + iat: Time.current.to_i } + payload[:sub] = @subject if @subject.present? + + assertion = JWT.encode(payload, @private_key, 'RS256') + uri = URI(TOKEN_ENDPOINT) + request = Net::HTTP::Post.new(uri) + request.set_form_data( + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion:) + + response = + Net::HTTP.start(uri.host, + uri.port, + use_ssl: true, + open_timeout: 10, + read_timeout: 30) do |http| + http.request(request) + end + unless response.is_a?(Net::HTTPSuccess) + raise "Google OAuth error: #{ response.code } #{ response.body }" + end + + JSON.parse(response.body).fetch('access_token') + end + + def load_private_key private_key, private_key_path + raw = private_key.to_s + raw = raw.gsub('\n', "\n") if raw.present? + raw = File.read(private_key_path) if raw.blank? && private_key_path.present? + return nil if raw.blank? + + OpenSSL::PKey::RSA.new(raw) + end + end +end diff --git a/backend/app/services/material_sync_export_path.rb b/backend/app/services/material_sync_export_path.rb new file mode 100644 index 0000000..bc0601e --- /dev/null +++ b/backend/app/services/material_sync_export_path.rb @@ -0,0 +1,56 @@ +class MaterialSyncExportPath + class << self + def build prefix: nil, relative_path: nil, filename: nil, source_file_id: nil + path = normalize_path(relative_path) + path = fallback_filename(filename, source_file_id) if path.blank? + + normalized_prefix = normalize_path(prefix) + return path if normalized_prefix.blank? + return normalized_prefix if path.blank? + + [normalized_prefix, path].join('/') + end + + def normalize_path path + raw = path.to_s.delete("\0").tr('\\', '/').strip + raw = raw.sub(%r{\A[A-Za-z]:/+}, '') + raw = raw.sub(%r{\A/+}, '') + return nil if raw.blank? + + segments = raw.split('/').filter_map do |segment| + normalized = normalize_segment(segment) + normalized.presence + end + return nil if segments.empty? + + segments.join('/') + end + + def uniquify path, source_file_id + normalized = normalize_path(path) + return normalized if normalized.blank? || source_file_id.blank? + + ext = File.extname(normalized) + base = ext.present? ? normalized.delete_suffix(ext) : normalized + "#{ base }--#{ source_file_id }#{ ext }" + end + + private + + def fallback_filename filename, source_file_id + name = normalize_path(filename) + return name if name.present? + return nil if source_file_id.blank? + + source_file_id.to_s + end + + def normalize_segment segment + cleaned = segment.to_s.delete("\0").tr('\\/', '_').strip + return nil if cleaned.blank? || cleaned == '.' + return '__' if cleaned == '..' + + cleaned + end + end +end diff --git a/backend/app/services/material_sync_importer.rb b/backend/app/services/material_sync_importer.rb index 4c35894..06c2e51 100644 --- a/backend/app/services/material_sync_importer.rb +++ b/backend/app/services/material_sync_importer.rb @@ -1,5 +1,7 @@ +require 'digest' + class MaterialSyncImporter - Result = Struct.new(:material, :suppressed, :suppression, keyword_init: true) + Result = Struct.new(:material, :action, :suppressed, :suppression, keyword_init: true) def self.import! attributes new(attributes).import! @@ -14,17 +16,53 @@ class MaterialSyncImporter key = normalized_source_key(source) raise ArgumentError, 'normalized_source_key is required for material sync' if key.blank? - suppression = MaterialSyncSuppressionMatcher.match(**source) - return Result.new(material: nil, suppressed: true, suppression:) if suppression + suppression = suppression_for(source) + if suppression + return Result.new(material: nil, + action: :suppressed, + suppressed: true, + suppression:) + end material = Material .unscoped .find_or_initialize_by(normalized_source_key: key) - material.assign_attributes(material_attributes.merge(source)) - material.save! + event_type = + if material.new_record? + :create + elsif material.discarded? + :restore + else + :update + end + uploaded_blob = uploaded_blob! - Result.new(material:, suppressed: false, suppression: nil) + Material.transaction do + if material.persisted? + MaterialVersionRecorder.ensure_snapshot!(material, + created_by_user: @attributes[:updated_by_user]) + end + material.assign_attributes(material_attributes.merge(source)) + material.discarded_at = nil if material.respond_to?(:discarded_at=) + material.file.attach(uploaded_blob) if uploaded_blob + material.save! + upsert_export_item!(material) + MaterialVersionRecorder.record!(material:, + event_type:, + created_by_user: @attributes[:updated_by_user]) + end + MaterialThumbnailGenerator.generate!(material) + + Result.new(material:, + action: event_type == :create ? :imported : :updated, + suppressed: false, + suppression: nil) + rescue StandardError + uploaded_blob&.purge_later + raise + ensure + close_tempfile! end private @@ -44,6 +82,69 @@ class MaterialSyncImporter end def material_attributes - @attributes.except(:source_kind, :source_uri, :source_path, :source_file_id) + @attributes.except(:source_kind, :source_uri, :source_path, :source_file_id, + :file_blob, :file_tempfile, :filename, :content_type, + :file_sha256, :export_path, :profile) + end + + def suppression_for source + if source[:source_kind] == 'google_drive_file' && source[:source_path].present? + return MaterialSyncSuppressionMatcher.match_google_drive_candidate( + drive_file_id: source[:source_file_id], + relative_path: source[:source_path]) + end + + MaterialSyncSuppressionMatcher.match(**source) + end + + def uploaded_blob! + return @attributes[:file_blob] if @attributes[:file_blob] + + tempfile = @attributes[:file_tempfile] + return nil unless tempfile + + tempfile.rewind + blob = ActiveStorage::Blob.create_and_upload!( + io: tempfile, + filename: @attributes[:filename], + content_type: @attributes[:content_type]) + file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest + blob.metadata['sha256'] = file_sha256 if file_sha256.present? + blob.save! if blob.changed? + tempfile.rewind + blob + end + + def upsert_export_item! material + export_path = @attributes[:export_path].to_s.strip + return if export_path.blank? + + profile = @attributes[:profile].presence || 'legacy_drive' + path = export_path + if export_path_taken_by_other?(material, profile, path) + path = MaterialSyncExportPath.uniquify(path, @attributes[:source_file_id]) + end + + item = material.material_export_items.find_or_initialize_by(profile:) + item.export_path = path + item.enabled = true + item.created_by_user ||= @attributes[:created_by_user] + item.save! + end + + def export_path_taken_by_other? material, profile, export_path + MaterialExportItem + .where(profile:, export_path:) + .where.not(material_id: material.id) + .exists? + end + + def close_tempfile! + tempfile = @attributes[:file_tempfile] + return unless tempfile + + tempfile.close! unless tempfile.closed? + rescue StandardError + nil end end diff --git a/backend/app/services/material_sync_runner.rb b/backend/app/services/material_sync_runner.rb new file mode 100644 index 0000000..b475bc6 --- /dev/null +++ b/backend/app/services/material_sync_runner.rb @@ -0,0 +1,212 @@ +require 'digest' + +class MaterialSyncRunner + Result = Struct.new(:imported, :updated, :suppressed, :failed, :errors, keyword_init: true) + + def self.sync_enabled! + results = MaterialSyncSource.enabled.order(:id).map { |source| new(source).sync! } + + Result.new(imported: results.sum(&:imported), + updated: results.sum(&:updated), + suppressed: results.sum(&:suppressed), + failed: results.sum(&:failed), + errors: results.flat_map(&:errors)) + end + + def initialize source + @source = source + end + + def sync! + result = Result.new(imported: 0, updated: 0, suppressed: 0, failed: 0, errors: []) + + candidates.each do |candidate| + sync_candidate!(candidate, result) + end + + @source.update!(last_synced_at: Time.current) + log_result(result) + result + rescue NotImplementedError, StandardError => e + result.failed += 1 + result.errors << { source_id: @source.id, error: e.message } + log_result(result) + result + end + + private + + def candidates + case @source.source_kind + when 'uri' + [uri_candidate] + when 'google_drive_path' + google_drive_path_candidates + when 'google_drive_file' + [google_drive_file_candidate] + when 'legacy_drive_path' + raise NotImplementedError, 'legacy_drive_path material sync is not implemented' + else + raise NotImplementedError, "Unsupported material sync source_kind: #{ @source.source_kind }" + end + end + + def uri_candidate + { source_kind: @source.source_kind, + source_uri: @source.source_uri, + source_path: @source.source_path, + source_file_id: @source.source_file_id, + url: @source.source_uri, + tag: nil, + created_by_user: @source.created_by_user, + updated_by_user: @source.updated_by_user || @source.created_by_user } + end + + def sync_candidate! candidate, result + block = MaterialImportBlockMatcher.match_for_sha256(candidate[:file_sha256]) + if block + result.suppressed += 1 + Rails.logger.info( + material_sync_log(action: 'suppressed', + reason: block.reason, + normalized_source_key: candidate_normalized_source_key(candidate))) + return + end + + suppression = suppression_for(candidate) + + if suppression + result.suppressed += 1 + Rails.logger.info( + material_sync_log(action: 'suppressed', + normalized_source_key: suppression.normalized_source_key)) + return + end + + import = MaterialSyncImporter.import!(candidate) + result.public_send("#{ import.action }=", result.public_send(import.action) + 1) + rescue StandardError => e + result.failed += 1 + result.errors << { source_id: @source.id, + normalized_source_key: candidate_normalized_source_key(candidate), + error: e.message } + Rails.logger.warn( + material_sync_log(action: 'failed', + error: e.message, + normalized_source_key: candidate_normalized_source_key(candidate))) + ensure + close_candidate_tempfile(candidate) + end + + def google_drive_path_candidates + folder_id = google_drive_folder_id + Enumerator.new do |entries| + drive_client.list_material_files_under_folder(folder_id).each do |entry| + entries << build_google_drive_candidate(entry) + end + end + end + + def google_drive_file_candidate + build_google_drive_candidate(drive_client.fetch_material_file(google_drive_file_id), + single_file: true) + end + + def build_google_drive_candidate entry, single_file: false + relative_path = + if single_file + nil + else + entry[:relative_path] + end + export_path = + MaterialSyncExportPath.build(prefix: @source.export_path_prefix, + relative_path:, + filename: entry[:name], + source_file_id: entry[:id]) + tempfile = + drive_client.download_to_tempfile(entry[:id], filename: entry[:name]) + file_sha256 = Digest::SHA256.file(tempfile.path).hexdigest + tempfile.rewind + + { source_kind: 'google_drive_file', + source_uri: entry[:web_view_link] || google_drive_file_url(entry[:id]), + source_path: relative_path.presence || MaterialSyncExportPath.normalize_path(entry[:name]), + source_file_id: entry[:id], + filename: entry[:name], + content_type: entry[:mime_type], + file_tempfile: tempfile, + file_sha256:, + export_path:, + profile: @source.profile, + tag: nil, + url: nil, + created_by_user: @source.created_by_user, + updated_by_user: @source.updated_by_user || @source.created_by_user } + end + + def candidate_normalized_source_key candidate + MaterialSyncSuppression.normalize_source_key(source_kind: candidate[:source_kind], + source_uri: candidate[:source_uri], + source_path: candidate[:source_path], + source_file_id: candidate[:source_file_id]) + end + + def suppression_for candidate + if candidate[:source_kind] == 'google_drive_file' && candidate[:source_path].present? + return MaterialSyncSuppressionMatcher.match_google_drive_candidate( + drive_file_id: candidate[:source_file_id], + relative_path: candidate[:source_path]) + end + + MaterialSyncSuppressionMatcher.match(source_kind: candidate[:source_kind], + source_uri: candidate[:source_uri], + source_path: candidate[:source_path], + source_file_id: candidate[:source_file_id]) + end + + def drive_client + @drive_client ||= GoogleDrive::ApiClient.new + end + + def google_drive_folder_id + google_drive_target_id.tap do |id| + raise ArgumentError, 'google_drive_path source_file_id or source_uri is required' if id.blank? + end + end + + def google_drive_file_id + google_drive_target_id.tap do |id| + raise ArgumentError, 'google_drive_file source_file_id or source_uri is required' if id.blank? + end + end + + def google_drive_target_id + @source.source_file_id.presence || drive_client.extract_file_id(@source.source_uri) + end + + def google_drive_file_url file_id + "https://drive.google.com/file/d/#{ file_id }/view" + end + + def close_candidate_tempfile candidate + tempfile = candidate[:file_tempfile] + return unless tempfile + + tempfile.close! unless tempfile.closed? + rescue StandardError + nil + end + + def log_result result + Rails.logger.info(material_sync_log(imported: result.imported, + updated: result.updated, + suppressed: result.suppressed, + failed: result.failed)) + end + + def material_sync_log fields + { material_sync_source_id: @source.id, + material_sync_source_name: @source.name }.merge(fields).to_json + end +end diff --git a/backend/app/services/material_sync_suppression_matcher.rb b/backend/app/services/material_sync_suppression_matcher.rb index 58f8ce3..74eb760 100644 --- a/backend/app/services/material_sync_suppression_matcher.rb +++ b/backend/app/services/material_sync_suppression_matcher.rb @@ -16,7 +16,53 @@ class MaterialSyncSuppressionMatcher MaterialSyncSuppression.find_by(normalized_source_key: key) end + def self.match_google_drive_candidate drive_file_id:, relative_path: + normalized_path = validate_relative_path!(relative_path) + + exact = [ + normalize_key('google_drive_file', drive_file_id), + normalize_key('google_drive_path', normalized_path), + normalize_key('legacy_drive_path', normalized_path), + ].compact + + suppression = + MaterialSyncSuppression.find_by(normalized_source_key: exact) + return suppression if suppression + + MaterialSyncSuppression + .where(source_kind: ['google_drive_path_prefix', 'legacy_drive_path_prefix']) + .where('drive_path = :path OR :path LIKE CONCAT(drive_path, "/%")', path: normalized_path) + .order(:id) + .first + end + def self.suppressed?(...) match(...).present? end + + def self.validate_relative_path! relative_path + raw = relative_path.to_s.delete("\0").tr('\\', '/').strip + raise ArgumentError, 'relative_path is required' if raw.blank? + raise ArgumentError, 'relative_path must be relative' if raw.start_with?('/') + raise ArgumentError, 'relative_path must be relative' if raw.match?(/\A[A-Za-z]:\//) + if raw.start_with?('My Drive/', 'マイドライブ/') + raise ArgumentError, 'relative_path must be relative to source folder' + end + raise ArgumentError, 'relative_path must not contain //' if raw.include?('//') + raise ArgumentError, 'relative_path must not end with /' if raw.end_with?('/') + + parts = raw.split('/') + if parts.any? { |part| part.in?(['.', '..']) } + raise ArgumentError, 'relative_path must not contain dot segments' + end + + raw + end + + def self.normalize_key source_kind, value + return nil if value.blank? + + MaterialSyncSuppression.normalize_source_key(source_kind:, source_path: value, + source_file_id: value) + end end diff --git a/backend/app/services/material_sync_suppression_registrar.rb b/backend/app/services/material_sync_suppression_registrar.rb index 5afcb8f..ec9169a 100644 --- a/backend/app/services/material_sync_suppression_registrar.rb +++ b/backend/app/services/material_sync_suppression_registrar.rb @@ -23,10 +23,7 @@ class MaterialSyncSuppressionRegistrar private def discard_existing_materials! suppression - Material.unscoped - .kept - .where(normalized_source_key: suppression.normalized_source_key) - .find_each do |material| + matching_materials(suppression).find_each do |material| MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: @created_by_user) material.discard! MaterialVersionRecorder.record!(material:, @@ -34,4 +31,21 @@ class MaterialSyncSuppressionRegistrar created_by_user: @created_by_user) end end + + def matching_materials suppression + materials = Material.unscoped.kept + + case suppression.source_kind + when 'google_drive_path', 'legacy_drive_path' + materials.where(source_path: suppression.drive_path) + when 'google_drive_path_prefix', 'legacy_drive_path_prefix' + path = suppression.drive_path.to_s + materials.where( + 'source_path = :path OR source_path LIKE :prefix', + path:, + prefix: "#{ path }/%") + else + materials.where(normalized_source_key: suppression.normalized_source_key) + end + end end diff --git a/backend/app/services/material_thumbnail_generator.rb b/backend/app/services/material_thumbnail_generator.rb index 0f809c5..176eb58 100644 --- a/backend/app/services/material_thumbnail_generator.rb +++ b/backend/app/services/material_thumbnail_generator.rb @@ -11,25 +11,30 @@ class MaterialThumbnailGenerator class << self def generate! material new(material).generate! - rescue ActiveStorage::FileNotFoundError, ArgumentError, MiniMagick::Error => e - Rails.logger.warn("Material thumbnail generation skipped: #{ e.class }: #{ e.message }") - nil end end def initialize material @material = material + @ffmpeg_stderr = [] end def generate! - @material.thumbnail.purge if @material.thumbnail.attached? - return unless @material.file.attached? - return unless image? || video? + return log_result(:no_file) unless @material.file.attached? + return log_result(:unsupported_content_type) unless image? || video? @material.file.blob.open do |file| thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path) - attach_thumbnail(thumbnail) if thumbnail + return log_result(:generation_failed) unless thumbnail + + return attach_thumbnail(thumbnail) end + rescue ActiveStorage::FileNotFoundError => e + log_result(:file_not_found, error: e) + rescue MiniMagick::Error => e + log_result(:mini_magick_error, error: e) + rescue ArgumentError, StandardError => e + log_result(:generation_failed, error: e) end private @@ -38,7 +43,11 @@ class MaterialThumbnailGenerator def video? = content_type.start_with?('video/') - def content_type = @material.file.blob.content_type.to_s + def content_type + return nil unless @material.file.attached? + + @material.file.blob.content_type.to_s + end def image_thumbnail path image = MiniMagick::Image.open(path) @@ -71,16 +80,47 @@ class MaterialThumbnailGenerator '-frames:v', '1', '-f', 'image2', output_path) - Rails.logger.warn("ffmpeg thumbnail failed: #{ stderr }") unless status.success? + @ffmpeg_stderr << stderr if stderr.present? status.success? rescue Errno::ENOENT => e - Rails.logger.warn("ffmpeg unavailable for material thumbnail: #{ e.message }") + @ffmpeg_stderr << "ffmpeg unavailable: #{ e.message }" false end def attach_thumbnail image - @material.thumbnail.attach(io: File.open(image.path), - filename: 'material-thumbnail.jpg', - content_type: 'image/jpeg') + blob = nil + File.open(image.path) do |io| + blob = ActiveStorage::Blob.create_and_upload!( + io:, + filename: 'material-thumbnail.jpg', + content_type: 'image/jpeg') + end + @material.thumbnail.attach(blob) + log_result(:attached) + rescue StandardError => e + blob&.purge_later + log_result(:attach_failed, error: e) + end + + def log_result result, error: nil + log_payload = { material_id: @material.id, + file_blob_id: file_blob_id, + content_type:, + result:, + error_class: error&.class&.name, + error_message: error&.message, + ffmpeg_stderr: @ffmpeg_stderr.join("\n").presence } + if [:attached, :no_file, :unsupported_content_type].include?(result) + Rails.logger.info("Material thumbnail generation: #{ log_payload.to_json }") + else + Rails.logger.warn("Material thumbnail generation: #{ log_payload.to_json }") + end + result + end + + def file_blob_id + return nil unless @material.file.attached? + + @material.file.blob.id end end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 46b51c5..7888289 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -114,6 +114,7 @@ Rails.application.routes.draw do end get 'materials/download.zip', to: 'materials#download' + get 'materials/versions', to: 'material_versions#index' resources :material_sync_suppressions, path: 'materials/suppressions', only: [:index, :create] resources :materials, only: [:index, :show, :create, :update, :destroy] end diff --git a/backend/db/migrate/20260625010000_create_material_sync_sources.rb b/backend/db/migrate/20260625010000_create_material_sync_sources.rb new file mode 100644 index 0000000..722e81b --- /dev/null +++ b/backend/db/migrate/20260625010000_create_material_sync_sources.rb @@ -0,0 +1,22 @@ +class CreateMaterialSyncSources < ActiveRecord::Migration[8.0] + def change + create_table :material_sync_sources do |t| + t.string :name, null: false + t.string :source_kind, null: false + t.string :source_uri + t.string :source_path + t.string :source_file_id + t.string :profile, null: false, default: 'legacy_drive' + t.boolean :enabled, null: false, default: true + t.string :default_tag_name + t.string :export_path_prefix + t.datetime :last_synced_at + t.references :created_by_user, foreign_key: { to_table: :users } + t.references :updated_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.index :enabled + t.index :source_kind + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 5daf890..0159902 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_06_25_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_25_010000) 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 @@ -155,6 +155,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_25_000000) do t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_by_user_id" end + create_table "material_sync_sources", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.string "source_kind", null: false + t.string "source_uri" + t.string "source_path" + t.string "source_file_id" + t.string "profile", default: "legacy_drive", null: false + t.boolean "enabled", default: true, null: false + t.string "default_tag_name" + t.string "export_path_prefix" + t.datetime "last_synced_at" + 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.index ["created_by_user_id"], name: "index_material_sync_sources_on_created_by_user_id" + t.index ["enabled"], name: "index_material_sync_sources_on_enabled" + t.index ["source_kind"], name: "index_material_sync_sources_on_source_kind" + t.index ["updated_by_user_id"], name: "index_material_sync_sources_on_updated_by_user_id" + end + create_table "material_sync_suppressions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "source_kind", null: false t.string "source_uri" @@ -607,6 +628,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_25_000000) do add_foreign_key "material_export_items", "materials" add_foreign_key "material_export_items", "users", column: "created_by_user_id" add_foreign_key "material_import_blocks", "users", column: "created_by_user_id" + add_foreign_key "material_sync_sources", "users", column: "created_by_user_id" + add_foreign_key "material_sync_sources", "users", column: "updated_by_user_id" add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id" add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials", column: "parent_id" diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index 4fbd6ed..8e481c4 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -7,3 +7,21 @@ # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| # MovieGenre.find_or_create_by!(name: genre_name) # end + +material_sync_source_uri = ENV['MATERIAL_SYNC_SOURCE_URI'] +material_sync_source_tag_name = ENV['MATERIAL_SYNC_SOURCE_TAG_NAME'] + +if material_sync_source_uri.present? && material_sync_source_tag_name.present? + source = MaterialSyncSource.find_or_initialize_by( + name: ENV.fetch('MATERIAL_SYNC_SOURCE_NAME', 'Legacy URI material sync')) + source.assign_attributes( + source_kind: ENV.fetch('MATERIAL_SYNC_SOURCE_KIND', 'uri'), + source_uri: material_sync_source_uri, + source_path: ENV['MATERIAL_SYNC_SOURCE_PATH'], + source_file_id: ENV['MATERIAL_SYNC_SOURCE_FILE_ID'], + profile: ENV.fetch('MATERIAL_SYNC_SOURCE_PROFILE', 'legacy_drive'), + enabled: ENV.fetch('MATERIAL_SYNC_SOURCE_ENABLED', 'true') != 'false', + default_tag_name: material_sync_source_tag_name, + export_path_prefix: ENV['MATERIAL_SYNC_EXPORT_PATH_PREFIX']) + source.save! +end diff --git a/backend/lib/tasks/sync_materials.rake b/backend/lib/tasks/sync_materials.rake new file mode 100644 index 0000000..02256bf --- /dev/null +++ b/backend/lib/tasks/sync_materials.rake @@ -0,0 +1,50 @@ +namespace :materials do + desc '素材同期' + task sync: :environment do + result = MaterialSyncRunner.sync_enabled! + message = [ + "materials:sync imported=#{ result.imported }", + "updated=#{ result.updated }", + "suppressed=#{ result.suppressed }", + "failed=#{ result.failed }" + ].join(' ') + + Rails.logger.info(message) + puts message + + result.errors.each do |error| + Rails.logger.warn(error.to_json) + warn error.to_json + end + end + + namespace :thumbnails do + desc '素材サムネールを補完' + task backfill: :environment do + counts = Hash.new(0) + + Material + .with_attached_file + .with_attached_thumbnail + .find_each do |material| + next unless material.file.attached? + next if material.thumbnail.attached? + + result = MaterialThumbnailGenerator.generate!(material) + counts[result] += 1 + end + + message = + "materials:thumbnails:backfill " \ + "attached=#{ counts[:attached] } " \ + "no_file=#{ counts[:no_file] } " \ + "unsupported_content_type=#{ counts[:unsupported_content_type] } " \ + "generation_failed=#{ counts[:generation_failed] } " \ + "attach_failed=#{ counts[:attach_failed] } " \ + "file_not_found=#{ counts[:file_not_found] } " \ + "mini_magick_error=#{ counts[:mini_magick_error] }" + Rails.logger.info(message) + puts message + end + end +end diff --git a/docs/commands.md b/docs/commands.md index dc91712..b267600 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,6 +10,15 @@ bundle exec rspec bundle exec rails routes ``` +Material video thumbnail generation requires `ffmpeg` on the host. If `ffmpeg` +is missing or frame extraction fails, `MaterialThumbnailGenerator` logs the +result and stderr, then leaves the existing thumbnail unchanged. + +```sh +cd backend +bundle exec rails materials:thumbnails:backfill +``` + ## Frontend ```sh diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b07f4bf..ac69d75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { apiPost, isApiError } from '@/lib/api' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' +import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage' import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage' @@ -69,6 +70,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }> }/> + }/> }/> }/> }/> diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index 12b2320..3930180 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -7,6 +7,7 @@ import { import type { Material, + MaterialVersion, FetchMaterialsParams, MaterialFilter, MaterialSyncSuppression, @@ -28,6 +29,11 @@ export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] } +export type MaterialChangesResponse = { + versions: MaterialVersion[] + count: number +} + const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] @@ -71,6 +77,22 @@ export const fetchMaterial = async (id: string): Promise => { } +export const fetchMaterialChanges = async ( + { materialId, tag, eventType, page, limit }: { + materialId?: string + tag?: string + eventType?: string + page: number + limit: number + }): Promise => + await apiGet ('/materials/versions', { params: { + ...(materialId && { material_id: materialId }), + ...(tag && { tag }), + ...(eventType && { event_type: eventType }), + page, + limit} }) + + export const fetchMaterialTagTree = async ( { parentId, materialFilter }: FetchMaterialTreeParams): Promise => await apiGet ('/tags/with-depth', { params: { diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 33ea46f..293f849 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -34,6 +34,13 @@ export const tagsKeys = { export const materialsKeys = { root: ['materials'] as const, index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const, + changes: (p: { + materialId?: string + tag?: string + eventType?: string + page: number + limit: number + }) => ['materials', 'changes', p] as const, byTagName: (name: string, materialFilter: MaterialFilter) => ['materials', 'tag', name, materialFilter] as const, show: (id: string) => ['materials', id] as const, diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index b6be85f..18b9664 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -8,6 +8,7 @@ import WikiBody from '@/components/WikiBody' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import PageTitle from '@/components/common/PageTitle' +import PrefetchLink from '@/components/PrefetchLink' import TabGroup, { Tab } from '@/components/common/TabGroup' import TagInput from '@/components/common/TagInput' import MainArea from '@/components/layout/MainArea' @@ -118,6 +119,12 @@ const MaterialDetailPage: FC = () => { : materialTitle} + + この素材の履歴 + + {(material.file && material.contentType) && ( (/image\/.*/.test (material.contentType) && ( {material.tag?.name)) diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx new file mode 100644 index 0000000..5bc490a --- /dev/null +++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx @@ -0,0 +1,213 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { Helmet } from 'react-helmet-async' +import { useLocation, useNavigate } from 'react-router-dom' + +import PrefetchLink from '@/components/PrefetchLink' +import FormField from '@/components/common/FormField' +import PageTitle from '@/components/common/PageTitle' +import Pagination from '@/components/common/Pagination' +import MainArea from '@/components/layout/MainArea' +import { SITE_TITLE } from '@/config' +import { fetchMaterialChanges } from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' +import { dateString, inputClass } from '@/lib/utils' + +import type { FC, FormEvent } from 'react' + + +const MaterialHistoryPage: FC = () => { + const location = useLocation () + const navigate = useNavigate () + const query = new URLSearchParams (location.search) + + const materialId = query.get ('material_id') ?? '' + const tag = query.get ('tag') ?? '' + const eventType = query.get ('event_type') ?? '' + const page = Number (query.get ('page') ?? 1) + const limit = Number (query.get ('limit') ?? 20) + + const [materialIdInput, setMaterialIdInput] = useState (materialId) + const [tagInput, setTagInput] = useState (tag) + const [eventTypeInput, setEventTypeInput] = useState (eventType) + + useEffect (() => { + setMaterialIdInput (materialId) + setTagInput (tag) + setEventTypeInput (eventType) + }, [eventType, materialId, tag]) + + const { data, isLoading, isError } = useQuery ({ + queryKey: materialsKeys.changes ({ + ...(materialId && { materialId }), + ...(tag && { tag }), + ...(eventType && { eventType }), + page, + limit }), + queryFn: () => fetchMaterialChanges ({ + ...(materialId && { materialId }), + ...(tag && { tag }), + ...(eventType && { eventType }), + page, + limit }) }) + + const versions = data?.versions ?? [] + const totalPages = data ? Math.ceil (data.count / limit) : 0 + + useEffect (() => { + document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) + }, [location.search]) + + const handleSearch = (event: FormEvent) => { + event.preventDefault () + + const qs = new URLSearchParams () + if (materialIdInput.trim ()) + qs.set ('material_id', materialIdInput.trim ()) + if (tagInput.trim ()) + qs.set ('tag', tagInput.trim ()) + if (eventTypeInput.trim ()) + qs.set ('event_type', eventTypeInput.trim ()) + qs.set ('page', '1') + qs.set ('limit', String (limit)) + navigate (`/materials/changes?${ qs.toString () }`) + } + + return ( + + + {`素材履歴 | ${ SITE_TITLE }`} + + +
    +
    + 素材履歴 + + 素材一覧へ戻る + +
    + +
    +
    + + {({ invalid }) => ( + setMaterialIdInput (e.target.value)} + className={inputClass (invalid)}/>)} + + + + {({ invalid }) => ( + setTagInput (e.target.value)} + className={inputClass (invalid)}/>)} + + + + {({ invalid }) => ( + )} + +
    + + +
    + + {isLoading + ? 'Loading...' + : isError + ? ( +

    + 素材履歴の取得に失敗しました. +

    ) + : ( + <> +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {versions.map (version => ( + + + + + + + + + + + ))} + +
    日時event_type素材 IDタグ名ファイル名URLexport_path操作者
    {dateString (version.createdAt)}{version.eventType} + + #{version.materialId} + + {version.versionNo}{version.tagName}{version.fileFilename}{version.url} + {version.exportPathsJson.legacy_drive} + + {version.createdByUser + ? version.createdByUser.name || `#${ version.createdByUser.id }` + : 'bot 操作'} +
    +
    + + + )} +
    +
    ) +} + + +export default MaterialHistoryPage diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index 4e65c6e..e9c0aa2 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -284,6 +284,13 @@ const MaterialListPage: FC = () => { dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800"> 同期元抑止 + + 履歴 +
    = { uri: 'URI', google_drive_path: 'Google Drive path', + google_drive_path_prefix: 'Google Drive path prefix', google_drive_file: 'Google Drive file ID', - legacy_drive_path: 'Legacy Drive path'} + legacy_drive_path: 'Legacy Drive path', + legacy_drive_path_prefix: 'Legacy Drive path prefix'} const REASONS = [ 'copyright_high_risk', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8189b41..2c3c6a5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -132,8 +132,10 @@ export type Material = { export type MaterialSyncSuppressionSourceKind = | 'uri' | 'google_drive_path' + | 'google_drive_path_prefix' | 'google_drive_file' | 'legacy_drive_path' + | 'legacy_drive_path_prefix' export type MaterialSyncSuppression = { id: number @@ -168,6 +170,25 @@ export type MaterialExportItem = { exportPath: string enabled: boolean } +export type MaterialVersion = { + id: number + materialId: number + versionNo: number + eventType: 'create' | 'update' | 'discard' | 'restore' + tagId: number | null + tagName: string | null + tagCategory: Category | null + url: string | null + fileBlobId: number | null + fileFilename: string | null + fileContentType: string | null + fileByteSize: number | null + fileSha256: string | null + exportPathsJson: Record + discardedAt: string | null + createdByUser: { id: number; name: string | null } | null + createdAt: string } + export type Menu = MenuItem[] export type MenuInvisibleItem = { -- 2.34.1 From daf9e7e6fa721e4965136ee8775418ced4b1ad41 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 26 Jun 2026 00:49:18 +0900 Subject: [PATCH 10/23] #306 --- .../material_versions_controller.rb | 5 + backend/app/models/material_sync_source.rb | 6 +- .../app/services/google_drive/api_client.rb | 35 +++- .../app/services/material_sync_importer.rb | 128 ++++++++++++-- backend/app/services/material_sync_runner.rb | 36 ++-- .../app/services/material_version_recorder.rb | 5 + ...dd_source_snapshot_to_material_versions.rb | 19 +++ ...0000_backfill_initial_material_versions.rb | 159 ++++++++++++++++++ backend/db/schema.rb | 7 +- backend/db/seeds.rb | 8 +- backend/lib/tasks/sync_materials.rake | 1 + frontend/src/types.ts | 5 + 12 files changed, 368 insertions(+), 46 deletions(-) create mode 100644 backend/db/migrate/20260626000000_add_source_snapshot_to_material_versions.rb create mode 100644 backend/db/migrate/20260626010000_backfill_initial_material_versions.rb diff --git a/backend/app/controllers/material_versions_controller.rb b/backend/app/controllers/material_versions_controller.rb index c697ad7..50f59fa 100644 --- a/backend/app/controllers/material_versions_controller.rb +++ b/backend/app/controllers/material_versions_controller.rb @@ -35,6 +35,11 @@ class MaterialVersionsController < ApplicationController tag_name: row.tag_name, tag_category: row.tag_category, url: row.url, + source_kind: row.source_kind, + source_uri: row.source_uri, + source_path: row.source_path, + source_file_id: row.source_file_id, + normalized_source_key: row.normalized_source_key, file_blob_id: row.file_blob_id, file_filename: row.file_filename, file_content_type: row.file_content_type, diff --git a/backend/app/models/material_sync_source.rb b/backend/app/models/material_sync_source.rb index fe45ebf..d45a977 100644 --- a/backend/app/models/material_sync_source.rb +++ b/backend/app/models/material_sync_source.rb @@ -1,11 +1,13 @@ class MaterialSyncSource < ApplicationRecord + SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file', 'legacy_drive_path'].freeze + belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true validates :name, presence: true validates :source_kind, presence: true, - inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS } + inclusion: { in: SOURCE_KINDS } validates :profile, presence: true, inclusion: { in: MaterialExportItem::VALID_PROFILES } validate :source_value_must_be_present @@ -33,7 +35,7 @@ class MaterialSyncSource < ApplicationRecord when 'google_drive_file' source_file_id.present? || source_uri.present? when 'google_drive_path' - source_file_id.present? || source_uri.present? || source_path.present? + source_file_id.present? || source_uri.present? when 'legacy_drive_path' source_path.present? else diff --git a/backend/app/services/google_drive/api_client.rb b/backend/app/services/google_drive/api_client.rb index 8a2fa16..9467333 100644 --- a/backend/app/services/google_drive/api_client.rb +++ b/backend/app/services/google_drive/api_client.rb @@ -11,6 +11,7 @@ module GoogleDrive DRIVE_ENDPOINT = 'https://www.googleapis.com/drive/v3' TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token' FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder' + NATIVE_FILE_MIME_TYPE_PREFIX = 'application/vnd.google-apps.' DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.readonly' def initialize service_account_email: ENV['GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL'], @@ -27,6 +28,7 @@ module GoogleDrive files = [] walk_folder(folder_id, nil) do |entry, relative_path| next if entry['mimeType'] == FOLDER_MIME_TYPE + next if native_file?(entry['mimeType']) files << build_file_entry(entry, relative_path) end @@ -35,6 +37,8 @@ module GoogleDrive def fetch_material_file file_id metadata = get_file(file_id) + return nil if native_file?(metadata['mimeType']) + build_file_entry(metadata, metadata['name']) end @@ -85,6 +89,7 @@ module GoogleDrive name: entry['name'], mime_type: entry['mimeType'], relative_path: MaterialSyncExportPath.normalize_path(relative_path), + sha256_checksum: entry['sha256Checksum'], web_view_link: entry['webViewLink'], web_content_link: entry['webContentLink'] } end @@ -97,7 +102,8 @@ module GoogleDrive response = request_json('/files', { q: "'#{ folder_id }' in parents and trashed = false", - fields: 'nextPageToken,files(id,name,mimeType,webViewLink,webContentLink)', + fields: 'nextPageToken,files(id,name,mimeType,sha256Checksum,' \ + 'webViewLink,webContentLink)', orderBy: 'folder,name', pageSize: 1000, supportsAllDrives: true, @@ -113,7 +119,7 @@ module GoogleDrive def get_file file_id request_json("/files/#{ file_id }", - fields: 'id,name,mimeType,webViewLink,webContentLink', + fields: 'id,name,mimeType,sha256Checksum,webViewLink,webContentLink', supportsAllDrives: true) end @@ -127,13 +133,14 @@ module GoogleDrive end def request_binary path, params = {} - response = request(:get, path, params:) - unless response.is_a?(Net::HTTPSuccess) - raise "Google Drive download error: #{ response.code } #{ response.body }" - end + request(:get, path, params:) do |response| + unless response.is_a?(Net::HTTPSuccess) + raise "Google Drive download error: #{ response.code } #{ response.body }" + end - response.read_body do |chunk| - yield chunk + response.read_body do |chunk| + yield chunk + end end end @@ -156,7 +163,13 @@ module GoogleDrive use_ssl: true, open_timeout: 10, read_timeout: 60) do |http| - http.request(request) + if block_given? + http.request(request) do |response| + return yield response + end + else + http.request(request) + end end end @@ -205,5 +218,9 @@ module GoogleDrive OpenSSL::PKey::RSA.new(raw) end + + def native_file? mime_type + mime_type.to_s.start_with?(NATIVE_FILE_MIME_TYPE_PREFIX) + end end end diff --git a/backend/app/services/material_sync_importer.rb b/backend/app/services/material_sync_importer.rb index 06c2e51..ed292ed 100644 --- a/backend/app/services/material_sync_importer.rb +++ b/backend/app/services/material_sync_importer.rb @@ -28,6 +28,13 @@ class MaterialSyncImporter Material .unscoped .find_or_initialize_by(normalized_source_key: key) + if unchanged?(material, source) + return Result.new(material:, + action: :unchanged, + suppressed: false, + suppression: nil) + end + event_type = if material.new_record? :create @@ -43,7 +50,7 @@ class MaterialSyncImporter MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: @attributes[:updated_by_user]) end - material.assign_attributes(material_attributes.merge(source)) + material.assign_attributes(material_attributes_for(material).merge(source)) material.discarded_at = nil if material.respond_to?(:discarded_at=) material.file.attach(uploaded_blob) if uploaded_blob material.save! @@ -81,10 +88,28 @@ class MaterialSyncImporter source_file_id: source[:source_file_id]) end - def material_attributes - @attributes.except(:source_kind, :source_uri, :source_path, :source_file_id, - :file_blob, :file_tempfile, :filename, :content_type, - :file_sha256, :export_path, :profile) + def material_attributes_for material + attrs = + @attributes + .except(:source_kind, :source_uri, :source_path, :source_file_id, + :file_blob, :file_tempfile, :file_downloader, + :filename, :content_type, + :file_sha256, :export_path, :profile, :tag, :url) + + if material.new_record? + attrs[:tag] = @attributes[:tag] if @attributes.key?(:tag) + attrs[:url] = @attributes[:url] if @attributes.key?(:url) + return attrs + end + + if @attributes.key?(:tag) && @attributes[:tag].present? + attrs[:tag] = @attributes[:tag] + end + if @attributes.key?(:url) && @attributes[:url].present? + attrs[:url] = @attributes[:url] + end + + attrs end def suppression_for source @@ -100,7 +125,7 @@ class MaterialSyncImporter def uploaded_blob! return @attributes[:file_blob] if @attributes[:file_blob] - tempfile = @attributes[:file_tempfile] + tempfile = upload_tempfile return nil unless tempfile tempfile.rewind @@ -115,18 +140,22 @@ class MaterialSyncImporter blob end + def upload_tempfile + return @upload_tempfile if defined?(@upload_tempfile) + + @upload_tempfile = @attributes[:file_tempfile] + if @upload_tempfile.blank? && @attributes[:file_downloader] + @upload_tempfile = @attributes[:file_downloader].call + end + @upload_tempfile + end + def upsert_export_item! material - export_path = @attributes[:export_path].to_s.strip + export_path = resolved_export_path(material) return if export_path.blank? - profile = @attributes[:profile].presence || 'legacy_drive' - path = export_path - if export_path_taken_by_other?(material, profile, path) - path = MaterialSyncExportPath.uniquify(path, @attributes[:source_file_id]) - end - - item = material.material_export_items.find_or_initialize_by(profile:) - item.export_path = path + item = material.material_export_items.find_or_initialize_by(profile: effective_profile) + item.export_path = export_path item.enabled = true item.created_by_user ||= @attributes[:created_by_user] item.save! @@ -140,11 +169,78 @@ class MaterialSyncImporter end def close_tempfile! - tempfile = @attributes[:file_tempfile] + tempfile = upload_tempfile return unless tempfile tempfile.close! unless tempfile.closed? rescue StandardError nil end + + def unchanged? material, source + return false if material.new_record? || material.discarded? + return false unless same_source_attributes?(material, source) + return false unless same_export_path?(material) + return false unless same_material_attributes?(material) + + same_file_snapshot?(material) + end + + def same_source_attributes? material, source + material.source_kind == source[:source_kind] \ + && material.source_uri == source[:source_uri] \ + && material.source_path == source[:source_path] \ + && material.source_file_id == source[:source_file_id] + end + + def same_export_path? material + expected = resolved_export_path(material) + current = material.material_export_items.find { |item| item.profile == effective_profile } + + return current.blank? if expected.blank? + + current&.enabled && current.export_path == expected + end + + def same_material_attributes? material + same_tag_attribute?(material) && same_url_attribute?(material) + end + + def same_file_snapshot? material + expected_sha256 = @attributes[:file_sha256].to_s.presence + + if expected_sha256.present? + return false unless material.file.attached? + + return material.file.blob.metadata['sha256'] == expected_sha256 + end + + !material.file.attached? + end + + def effective_profile + @attributes[:profile].presence || 'legacy_drive' + end + + def resolved_export_path material + export_path = @attributes[:export_path].to_s.strip + return export_path if export_path.blank? + return export_path unless export_path_taken_by_other?(material, effective_profile, export_path) + + MaterialSyncExportPath.uniquify(export_path, @attributes[:source_file_id]) + end + + def same_tag_attribute? material + return true unless @attributes.key?(:tag) + return true if @attributes[:tag].blank? + + material.tag_id == @attributes[:tag].id + end + + def same_url_attribute? material + return true unless @attributes.key?(:url) + return true if @attributes[:url].blank? + + material.url == @attributes[:url] + end end diff --git a/backend/app/services/material_sync_runner.rb b/backend/app/services/material_sync_runner.rb index b475bc6..00baac0 100644 --- a/backend/app/services/material_sync_runner.rb +++ b/backend/app/services/material_sync_runner.rb @@ -1,13 +1,13 @@ -require 'digest' - class MaterialSyncRunner - Result = Struct.new(:imported, :updated, :suppressed, :failed, :errors, keyword_init: true) + Result = Struct.new(:imported, :updated, :unchanged, :suppressed, :failed, + :errors, keyword_init: true) def self.sync_enabled! results = MaterialSyncSource.enabled.order(:id).map { |source| new(source).sync! } Result.new(imported: results.sum(&:imported), updated: results.sum(&:updated), + unchanged: results.sum(&:unchanged), suppressed: results.sum(&:suppressed), failed: results.sum(&:failed), errors: results.flat_map(&:errors)) @@ -18,9 +18,12 @@ class MaterialSyncRunner end def sync! - result = Result.new(imported: 0, updated: 0, suppressed: 0, failed: 0, errors: []) + result = Result.new(imported: 0, updated: 0, unchanged: 0, + suppressed: 0, failed: 0, errors: []) candidates.each do |candidate| + next if candidate.blank? + sync_candidate!(candidate, result) end @@ -108,8 +111,10 @@ class MaterialSyncRunner end def google_drive_file_candidate - build_google_drive_candidate(drive_client.fetch_material_file(google_drive_file_id), - single_file: true) + entry = drive_client.fetch_material_file(google_drive_file_id) + return nil unless entry + + build_google_drive_candidate(entry, single_file: true) end def build_google_drive_candidate entry, single_file: false @@ -124,10 +129,6 @@ class MaterialSyncRunner relative_path:, filename: entry[:name], source_file_id: entry[:id]) - tempfile = - drive_client.download_to_tempfile(entry[:id], filename: entry[:name]) - file_sha256 = Digest::SHA256.file(tempfile.path).hexdigest - tempfile.rewind { source_kind: 'google_drive_file', source_uri: entry[:web_view_link] || google_drive_file_url(entry[:id]), @@ -135,8 +136,10 @@ class MaterialSyncRunner source_file_id: entry[:id], filename: entry[:name], content_type: entry[:mime_type], - file_tempfile: tempfile, - file_sha256:, + file_downloader: lambda { + drive_client.download_to_tempfile(entry[:id], filename: entry[:name]) + }, + file_sha256: entry[:sha256_checksum], export_path:, profile: @source.profile, tag: nil, @@ -171,13 +174,17 @@ class MaterialSyncRunner def google_drive_folder_id google_drive_target_id.tap do |id| - raise ArgumentError, 'google_drive_path source_file_id or source_uri is required' if id.blank? + if id.blank? + raise ArgumentError, 'google_drive_path source_file_id or source_uri is required' + end end end def google_drive_file_id google_drive_target_id.tap do |id| - raise ArgumentError, 'google_drive_file source_file_id or source_uri is required' if id.blank? + if id.blank? + raise ArgumentError, 'google_drive_file source_file_id or source_uri is required' + end end end @@ -201,6 +208,7 @@ class MaterialSyncRunner def log_result result Rails.logger.info(material_sync_log(imported: result.imported, updated: result.updated, + unchanged: result.unchanged, suppressed: result.suppressed, failed: result.failed)) end diff --git a/backend/app/services/material_version_recorder.rb b/backend/app/services/material_version_recorder.rb index 6049d44..6a91a31 100644 --- a/backend/app/services/material_version_recorder.rb +++ b/backend/app/services/material_version_recorder.rb @@ -33,6 +33,11 @@ class MaterialVersionRecorder < VersionRecorder tag: @record.tag, tag_name: @record.tag&.name, tag_category: @record.tag&.category, + source_kind: @record.source_kind, + source_uri: @record.source_uri, + source_path: @record.source_path, + source_file_id: @record.source_file_id, + normalized_source_key: @record.normalized_source_key, export_paths_json: @record.snapshot_export_paths, discarded_at: @record.discarded_at, file_blob_id: file_snapshot[:file_blob_id], diff --git a/backend/db/migrate/20260626000000_add_source_snapshot_to_material_versions.rb b/backend/db/migrate/20260626000000_add_source_snapshot_to_material_versions.rb new file mode 100644 index 0000000..a6d7111 --- /dev/null +++ b/backend/db/migrate/20260626000000_add_source_snapshot_to_material_versions.rb @@ -0,0 +1,19 @@ +class AddSourceSnapshotToMaterialVersions < ActiveRecord::Migration[8.0] + def change + unless column_exists?(:material_versions, :source_kind) + add_column :material_versions, :source_kind, :string + end + unless column_exists?(:material_versions, :source_uri) + add_column :material_versions, :source_uri, :string + end + unless column_exists?(:material_versions, :source_path) + add_column :material_versions, :source_path, :string + end + unless column_exists?(:material_versions, :source_file_id) + add_column :material_versions, :source_file_id, :string + end + unless column_exists?(:material_versions, :normalized_source_key) + add_column :material_versions, :normalized_source_key, :string + end + end +end diff --git a/backend/db/migrate/20260626010000_backfill_initial_material_versions.rb b/backend/db/migrate/20260626010000_backfill_initial_material_versions.rb new file mode 100644 index 0000000..7ffe9f7 --- /dev/null +++ b/backend/db/migrate/20260626010000_backfill_initial_material_versions.rb @@ -0,0 +1,159 @@ +class BackfillInitialMaterialVersions < ActiveRecord::Migration[8.0] + class MigrationMaterial < ActiveRecord::Base + self.table_name = 'materials' + end + + class MigrationMaterialExportItem < ActiveRecord::Base + self.table_name = 'material_export_items' + end + + class MigrationMaterialVersion < ActiveRecord::Base + self.table_name = 'material_versions' + end + + class MigrationStorageAttachment < ActiveRecord::Base + self.table_name = 'active_storage_attachments' + end + + class MigrationStorageBlob < ActiveRecord::Base + self.table_name = 'active_storage_blobs' + end + + class MigrationTag < ActiveRecord::Base + self.table_name = 'tags' + end + + class MigrationTagName < ActiveRecord::Base + self.table_name = 'tag_names' + end + + def up + say_with_time 'Backfilling initial material versions' do + materials_without_versions.find_in_batches(batch_size: 500) do |materials| + material_ids = materials.map(&:id) + tags_by_id = load_tags(materials.map(&:tag_id).compact.uniq) + export_paths_by_material_id = load_export_paths(material_ids) + file_snapshots_by_material_id = load_file_snapshots(material_ids) + + rows = materials.map do |material| + tag = tags_by_id[material.tag_id] + file_snapshot = file_snapshots_by_material_id[material.id] || empty_file_snapshot + + { material_id: material.id, + version_no: 1, + event_type: 'create', + url: material.url, + parent_id: material.parent_id, + tag_id: material.tag_id, + tag_name: tag&.fetch(:name, nil), + tag_category: tag&.fetch(:category, nil), + source_kind: material.source_kind, + source_uri: material.source_uri, + source_path: material.source_path, + source_file_id: material.source_file_id, + normalized_source_key: material.normalized_source_key, + export_paths_json: export_paths_by_material_id[material.id] || { }, + discarded_at: material.discarded_at, + file_blob_id: file_snapshot[:file_blob_id], + file_filename: file_snapshot[:file_filename], + file_content_type: file_snapshot[:file_content_type], + file_byte_size: file_snapshot[:file_byte_size], + file_checksum: file_snapshot[:file_checksum], + file_sha256: file_snapshot[:file_sha256], + created_by_user_id: material.created_by_user_id, + updated_by_user_id: material.updated_by_user_id, + created_at: material.created_at, + updated_at: material.created_at } + end + + MigrationMaterialVersion.insert_all!(rows) if rows.present? + end + end + + execute <<~SQL.squish + UPDATE materials + SET version_no = COALESCE( + ( + SELECT MAX(material_versions.version_no) + FROM material_versions + WHERE material_versions.material_id = materials.id + ), + 1 + ) + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end + + private + + def materials_without_versions + MigrationMaterial.where.not(id: MigrationMaterialVersion.select(:material_id)) + end + + def load_tags tag_ids + return { } if tag_ids.empty? + + tag_name_ids_by_tag_id = MigrationTag.where(id: tag_ids).pluck(:id, :tag_name_id).to_h + tag_names_by_id = + MigrationTagName + .where(id: tag_name_ids_by_tag_id.values.uniq) + .pluck(:id, :name) + .to_h + categories_by_tag_id = MigrationTag.where(id: tag_ids).pluck(:id, :category).to_h + + tag_ids.each_with_object({ }) do |tag_id, hash| + hash[tag_id] = { name: tag_names_by_id[tag_name_ids_by_tag_id[tag_id]], + category: categories_by_tag_id[tag_id] } + end + end + + def load_export_paths material_ids + MigrationMaterialExportItem + .where(material_id: material_ids) + .order(:material_id, :profile) + .pluck(:material_id, :profile, :export_path) + .each_with_object({ }) do |(material_id, profile, export_path), hash| + hash[material_id] ||= { } + hash[material_id][profile] = export_path + end + end + + def load_file_snapshots material_ids + attachments_by_material_id = + MigrationStorageAttachment + .where(record_type: 'Material', name: 'file', record_id: material_ids) + .pluck(:record_id, :blob_id) + .to_h + + blobs_by_id = + MigrationStorageBlob + .where(id: attachments_by_material_id.values.uniq) + .index_by(&:id) + + attachments_by_material_id.each_with_object({ }) do |(material_id, blob_id), hash| + blob = blobs_by_id[blob_id] + next unless blob + + hash[material_id] = { + file_blob_id: blob.id, + file_filename: blob.filename.to_s, + file_content_type: blob.content_type, + file_byte_size: blob.byte_size, + file_checksum: blob.checksum, + file_sha256: blob.metadata['sha256'] + } + end + end + + def empty_file_snapshot + { file_blob_id: nil, + file_filename: nil, + file_content_type: nil, + file_byte_size: nil, + file_checksum: nil, + file_sha256: nil } + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 0159902..ccce7ee 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_06_25_010000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) 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 @@ -213,6 +213,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_25_010000) do t.bigint "file_byte_size" t.string "file_checksum" t.string "file_sha256" + t.string "source_kind" + t.string "source_uri" + t.string "source_path" + t.string "source_file_id" + t.string "normalized_source_key" 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 ["file_blob_id"], name: "index_material_versions_on_file_blob_id" diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index 8e481c4..067d073 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -9,19 +9,19 @@ # end material_sync_source_uri = ENV['MATERIAL_SYNC_SOURCE_URI'] -material_sync_source_tag_name = ENV['MATERIAL_SYNC_SOURCE_TAG_NAME'] +material_sync_source_file_id = ENV['MATERIAL_SYNC_SOURCE_FILE_ID'] -if material_sync_source_uri.present? && material_sync_source_tag_name.present? +if material_sync_source_uri.present? || material_sync_source_file_id.present? source = MaterialSyncSource.find_or_initialize_by( name: ENV.fetch('MATERIAL_SYNC_SOURCE_NAME', 'Legacy URI material sync')) source.assign_attributes( source_kind: ENV.fetch('MATERIAL_SYNC_SOURCE_KIND', 'uri'), source_uri: material_sync_source_uri, source_path: ENV['MATERIAL_SYNC_SOURCE_PATH'], - source_file_id: ENV['MATERIAL_SYNC_SOURCE_FILE_ID'], + source_file_id: material_sync_source_file_id, profile: ENV.fetch('MATERIAL_SYNC_SOURCE_PROFILE', 'legacy_drive'), enabled: ENV.fetch('MATERIAL_SYNC_SOURCE_ENABLED', 'true') != 'false', - default_tag_name: material_sync_source_tag_name, + default_tag_name: ENV['MATERIAL_SYNC_SOURCE_TAG_NAME'], export_path_prefix: ENV['MATERIAL_SYNC_EXPORT_PATH_PREFIX']) source.save! end diff --git a/backend/lib/tasks/sync_materials.rake b/backend/lib/tasks/sync_materials.rake index 02256bf..81d74b7 100644 --- a/backend/lib/tasks/sync_materials.rake +++ b/backend/lib/tasks/sync_materials.rake @@ -5,6 +5,7 @@ namespace :materials do message = [ "materials:sync imported=#{ result.imported }", "updated=#{ result.updated }", + "unchanged=#{ result.unchanged }", "suppressed=#{ result.suppressed }", "failed=#{ result.failed }" ].join(' ') diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2c3c6a5..553fafd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -179,6 +179,11 @@ export type MaterialVersion = { tagName: string | null tagCategory: Category | null url: string | null + sourceKind: string | null + sourceUri: string | null + sourcePath: string | null + sourceFileId: string | null + normalizedSourceKey: string | null fileBlobId: number | null fileFilename: string | null fileContentType: string | null -- 2.34.1 From 4da3f0afba8773cc2484e0165796f05925d0cb90 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 26 Jun 2026 00:51:40 +0900 Subject: [PATCH 11/23] #306 --- ...626010000_backfill_initial_material_versions.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/db/migrate/20260626010000_backfill_initial_material_versions.rb b/backend/db/migrate/20260626010000_backfill_initial_material_versions.rb index 7ffe9f7..45b95a5 100644 --- a/backend/db/migrate/20260626010000_backfill_initial_material_versions.rb +++ b/backend/db/migrate/20260626010000_backfill_initial_material_versions.rb @@ -1,3 +1,5 @@ +require 'json' + class BackfillInitialMaterialVersions < ActiveRecord::Migration[8.0] class MigrationMaterial < ActiveRecord::Base self.table_name = 'materials' @@ -143,7 +145,7 @@ class BackfillInitialMaterialVersions < ActiveRecord::Migration[8.0] file_content_type: blob.content_type, file_byte_size: blob.byte_size, file_checksum: blob.checksum, - file_sha256: blob.metadata['sha256'] + file_sha256: blob_metadata(blob)['sha256'] } end end @@ -156,4 +158,14 @@ class BackfillInitialMaterialVersions < ActiveRecord::Migration[8.0] file_checksum: nil, file_sha256: nil } end + + def blob_metadata blob + metadata = blob.metadata + return metadata if metadata.is_a?(Hash) + return { } if metadata.blank? + + JSON.parse(metadata) + rescue JSON::ParserError + { } + end end -- 2.34.1 From d7b136c1984931c0e5ab5f68a02a36d3920aaafc Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 26 Jun 2026 01:20:30 +0900 Subject: [PATCH 12/23] #306 --- backend/app/controllers/tags_controller.rb | 12 +- .../app/services/material_sync_importer.rb | 16 +- ...60623000000_enhance_material_management.rb | 12 +- backend/spec/requests/materials_spec.rb | 128 +++++++++------- .../services/material_sync_importer_spec.rb | 128 ++++++++++++++++ frontend/src/components/ui/input.tsx | 38 ++--- frontend/src/components/ui/switch.tsx | 52 +++---- .../materials/MaterialDetailPage.test.tsx | 30 +++- .../pages/materials/MaterialListPage.test.tsx | 145 +++++++++++++----- 9 files changed, 407 insertions(+), 154 deletions(-) create mode 100644 backend/spec/services/material_sync_importer_spec.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 43f22f2..f345a02 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -379,12 +379,20 @@ class TagsController < ApplicationController end def visible_root_tag_ids graph - graph[:tags_by_id].filter_map do |tag_id, _attrs| + root_ids = Set.new + + graph[:tags_by_id].each do |tag_id, attrs| next unless visible_root_tag?(tag_id, graph) next unless visible_subtree?(tag_id, graph) - tag_id + if attrs[:deprecated] + collect_visible_child_tag_ids(tag_id, graph, root_ids, Set.new) + else + root_ids << tag_id + end end + + root_ids.to_a end def visible_root_tag? tag_id, graph diff --git a/backend/app/services/material_sync_importer.rb b/backend/app/services/material_sync_importer.rb index ed292ed..34c0357 100644 --- a/backend/app/services/material_sync_importer.rb +++ b/backend/app/services/material_sync_importer.rb @@ -44,6 +44,12 @@ class MaterialSyncImporter :update end uploaded_blob = uploaded_blob! + if @import_block + return Result.new(material: nil, + action: :suppressed, + suppressed: true, + suppression: @import_block) + end Material.transaction do if material.persisted? @@ -129,11 +135,19 @@ class MaterialSyncImporter return nil unless tempfile tempfile.rewind + file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest + @attributes[:file_sha256] = file_sha256 + + @import_block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) + if @import_block + tempfile.rewind + return nil + end + blob = ActiveStorage::Blob.create_and_upload!( io: tempfile, filename: @attributes[:filename], content_type: @attributes[:content_type]) - file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest blob.metadata['sha256'] = file_sha256 if file_sha256.present? blob.save! if blob.changed? tempfile.rewind diff --git a/backend/db/migrate/20260623000000_enhance_material_management.rb b/backend/db/migrate/20260623000000_enhance_material_management.rb index 27f6338..9c0321b 100644 --- a/backend/db/migrate/20260623000000_enhance_material_management.rb +++ b/backend/db/migrate/20260623000000_enhance_material_management.rb @@ -2,9 +2,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] def up change_table :materials, bulk: true do |t| t.integer :version_no, null: false, default: 1 - t.datetime :file_suppressed_at - t.references :file_suppressed_by_user, foreign_key: { to_table: :users } - t.string :file_suppression_reason end change_table :material_versions, bulk: true do |t| @@ -18,8 +15,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] t.bigint :file_byte_size t.string :file_checksum t.string :file_sha256 - t.datetime :file_suppressed_at - t.string :file_suppression_reason end execute <<~SQL.squish @@ -35,7 +30,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] add_index :material_versions, :file_blob_id add_check_constraint :material_versions, - "event_type IN ('create', 'update', 'discard', 'restore', 'suppress')", + "event_type IN ('create', 'update', 'discard', 'restore')", name: 'material_versions_event_type_valid' create_table :material_export_items do |t| @@ -87,12 +82,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] remove_column :material_versions, :file_byte_size remove_column :material_versions, :file_checksum remove_column :material_versions, :file_sha256 - remove_column :material_versions, :file_suppressed_at - remove_column :material_versions, :file_suppression_reason - remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users } remove_column :materials, :version_no - remove_column :materials, :file_suppressed_at - remove_column :materials, :file_suppression_reason end end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index 1193ab7..f2a7581 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -64,27 +64,36 @@ RSpec.describe 'Materials API', type: :request do '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') + expect(row['media_kind']).to eq('image') end - it 'filters materials by tag_id' do - get '/materials', params: { tag_id: material_a.tag_id } + it 'filters materials by q' do + get '/materials', params: { q: 'material_index_a' } 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 } + it 'filters materials by tag_state' do + untagged = + build_material(tag: nil, user: member_user, + file: dummy_upload(filename: 'untagged.png')) + + get '/materials', params: { tag_state: 'untagged' } 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]) + expect(response_materials.map { |m| m['id'] }).to eq([untagged.id]) + end + + it 'filters materials by media_kind' do + get '/materials', params: { media_kind: 'image' } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(2) + expect(response_materials.map { |m| m['media_kind'] }).to all(eq('image')) end it 'paginates and keeps total count' do @@ -528,9 +537,8 @@ RSpec.describe 'Materials API', type: :request do expect(response.body.b).not_to include('素材/b.png'.b) end - it 'does not include suppressed materials' do - material_b.update!(file_suppressed_at: Time.current, - file_suppression_reason: 'copyright_high_risk') + it 'does not include disabled export items' do + material_b.material_export_items.first.update!(enabled: false) get '/materials/download.zip', params: { profile: 'legacy_drive' } @@ -540,62 +548,78 @@ RSpec.describe 'Materials API', type: :request do end end - describe 'PATCH /materials/:id/suppress_file' do + describe 'GET /materials/versions' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material) + Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material) end let!(:material) do - build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png')) + build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png')) end - it 'allows admin to suppress a file and records a suppress version' do - sign_in_as(admin_user) + it 'returns material versions in reverse chronological order' do + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + material.update!(url: 'https://example.com/history') + MaterialVersionRecorder.record!(material:, event_type: :update, + created_by_user: admin_user) + + get '/materials/versions', params: { material_id: material.id } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(2) + versions = json.fetch('versions') + expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) + expect(versions.first).to include( + 'material_id' => material.id, + 'event_type' => 'update', + 'tag_name' => 'material_history', + 'file_filename' => 'history.png', + 'url' => 'https://example.com/history' + ) + expect(versions.first['created_by_user']).to include('id' => admin_user.id) + end + + it 'filters material versions by tag and event_type' do MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: member_user) - expect do - patch "/materials/#{ material.id }/suppress_file", - params: { reason: 'copyright_high_risk' } - end.to change(MaterialVersion, :count).by(1) + get '/materials/versions', params: { + tag: 'material_history', + event_type: 'create' + } expect(response).to have_http_status(:ok) + expect(json.fetch('versions').map { |v| v['material_id'] }).to include(material.id) + expect(json.fetch('versions').map { |v| v['event_type'] }).to all(eq('create')) + end + end - material.reload - expect(material.file_suppressed_at).to be_present - expect(material.file_suppressed_by_user).to eq(admin_user) - expect(material.file_suppression_reason).to eq('copyright_high_risk') - expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress') - expect(json['file']).to be_nil - expect(json['file_suppressed_at']).to be_present + describe 'material sync suppressions' do + it 'requires member permission for index' do + sign_out + + get '/materials/suppressions' + + expect(response).to have_http_status(:unauthorized) end - it 'purges blob when purge=true is requested' do - sign_in_as(admin_user) - old_blob_id = material.file.blob.id - MaterialVersionRecorder.record!(material:, event_type: :create, - created_by_user: member_user) - - expect do - patch "/materials/#{ material.id }/suppress_file", - params: { reason: 'copyright_takedown', purge: '1' } - end.to have_enqueued_job(ActiveStorage::PurgeJob) - - expect(response).to have_http_status(:ok) - - version = material.material_versions.order(:version_no).last - expect(version.event_type).to eq('suppress') - expect(version.file_blob_id).to eq(old_blob_id) - expect(version.file_filename).to eq('suppress.png') - expect(version.file_sha256).to be_present - end - - it 'rejects member suppression' do + it 'creates source suppression as member' do sign_in_as(member_user) - patch "/materials/#{ material.id }/suppress_file", - params: { reason: 'copyright_high_risk' } + expect do + post '/materials/suppressions', params: { + source_kind: 'google_drive_path_prefix', + drive_path: '伊地知ニジカ/危険素材', + reason: 'copyright_high_risk' + } + end.to change(MaterialSyncSuppression, :count).by(1) - expect(response).to have_http_status(:forbidden) + expect(response).to have_http_status(:created) + expect(json).to include( + 'source_kind' => 'google_drive_path_prefix', + 'drive_path' => '伊地知ニジカ/危険素材', + 'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材' + ) end end diff --git a/backend/spec/services/material_sync_importer_spec.rb b/backend/spec/services/material_sync_importer_spec.rb new file mode 100644 index 0000000..20c69fd --- /dev/null +++ b/backend/spec/services/material_sync_importer_spec.rb @@ -0,0 +1,128 @@ +require 'rails_helper' + +RSpec.describe MaterialSyncImporter do + let(:user) { create(:user, :member) } + let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) } + + def tempfile_for body + Tempfile.new(['material-sync-importer', '.png']).tap do |file| + file.binmode + file.write(body) + file.rewind + end + end + + it 'creates an untagged material from a sync candidate with file_downloader' do + result = + described_class.import!( + source_kind: 'google_drive_file', + source_path: '素材/a.png', + source_file_id: 'drive-file-a', + normalized_source_key: 'google_drive_file:drive-file-a', + filename: 'a.png', + content_type: 'image/png', + file_sha256: Digest::SHA256.hexdigest('sync-body'), + file_downloader: -> { tempfile_for('sync-body') }, + export_path: '素材/a.png', + tag: nil, + url: nil, + created_by_user: user, + updated_by_user: user) + + material = result.material.reload + expect(result.action).to eq(:imported) + expect(material.tag).to be_nil + expect(material.url).to be_nil + expect(material.file).to be_attached + expect(material.file.blob.filename.to_s).to eq('a.png') + expect(material.material_export_items.first.export_path).to eq('素材/a.png') + end + + it 'does not clear a human-assigned tag or URL on existing Drive sync' do + material = + Material.create!(tag:, url: 'https://example.com/human', + source_kind: 'google_drive_file', + source_path: '素材/a.png', + source_file_id: 'drive-file-a', + created_by_user: user, + updated_by_user: user) + material.file.attach( + io: StringIO.new('same-body'), + filename: 'a.png', + content_type: 'image/png') + material.file.blob.metadata['sha256'] = Digest::SHA256.hexdigest('same-body') + material.file.blob.save! + MaterialExportItem.create!(material:, + profile: 'legacy_drive', + export_path: '素材/a.png', + created_by_user: user) + + result = + described_class.import!( + source_kind: 'google_drive_file', + source_path: '素材/a.png', + source_file_id: 'drive-file-a', + filename: 'a.png', + content_type: 'image/png', + file_sha256: Digest::SHA256.hexdigest('same-body'), + export_path: '素材/a.png', + tag: nil, + url: nil, + updated_by_user: user) + + expect(result.action).to eq(:unchanged) + expect(material.reload.tag).to eq(tag) + expect(material.url).to eq('https://example.com/human') + end + + it 'suppresses a Google Drive candidate by relative path prefix' do + MaterialSyncSuppression.create!(source_kind: 'google_drive_path_prefix', + drive_path: '素材/危険', + reason: 'copyright_high_risk', + created_by_user: user) + + result = + described_class.import!( + source_kind: 'google_drive_file', + source_path: '素材/危険/a.png', + source_file_id: 'drive-file-b', + filename: 'a.png', + content_type: 'image/png', + file_sha256: Digest::SHA256.hexdigest('blocked-body'), + file_downloader: -> { tempfile_for('blocked-body') }, + export_path: '素材/危険/a.png', + updated_by_user: user) + + expect(result.action).to eq(:suppressed) + expect(result.suppression.normalized_source_key) + .to eq('google_drive_path_prefix:素材/危険') + expect(Material.find_by(source_file_id: 'drive-file-b')).to be_nil + end + + it 'rechecks import blocks after downloaded file sha256 is known' do + MaterialImportBlock.create!(match_kind: 'sha256', + sha256: Digest::SHA256.hexdigest('blocked-body'), + reason: 'copyright_high_risk', + created_by_user: user) + blob_count = ActiveStorage::Blob.count + + expect do + result = + described_class.import!( + source_kind: 'google_drive_file', + source_path: '素材/blocked.png', + source_file_id: 'drive-file-blocked', + filename: 'blocked.png', + content_type: 'image/png', + file_downloader: -> { tempfile_for('blocked-body') }, + export_path: '素材/blocked.png', + updated_by_user: user) + + expect(result.action).to eq(:suppressed) + expect(result.suppression.reason).to eq('copyright_high_risk') + end.not_to change(Material, :count) + + expect(ActiveStorage::Blob.count).to eq(blob_count) + expect(Material.find_by(source_file_id: 'drive-file-blocked')).to be_nil + end +end diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 1ba33a5..7fa6a01 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,19 +1,19 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - ) - } ) -Input.displayName = "Input" - -export { Input } +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + ) + }) +Input.displayName = 'Input' + +export { Input } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx index 49127ff..1b591d8 100644 --- a/frontend/src/components/ui/switch.tsx +++ b/frontend/src/components/ui/switch.tsx @@ -1,26 +1,26 @@ -"use client" - -import * as React from "react" -import * as SwitchPrimitives from "@radix-ui/react-switch" - -import { cn } from "@/lib/utils" - -const Switch = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - )) -Switch.displayName = SwitchPrimitives.Root.displayName - -export { Switch } +'use client' + +import * as React from 'react' +import * as SwitchPrimitives from '@radix-ui/react-switch' + +import { cn } from '@/lib/utils' + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + )) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/frontend/src/pages/materials/MaterialDetailPage.test.tsx b/frontend/src/pages/materials/MaterialDetailPage.test.tsx index bb6fc6b..8a5819a 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.test.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.test.tsx @@ -7,9 +7,9 @@ import { buildMaterial, buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' const api = vi.hoisted (() => ({ - apiGet: vi.fn (), - apiPatch: vi.fn (), - apiPut: vi.fn (), + apiGet: vi.fn (), + apiPut: vi.fn (), + isApiError: vi.fn (), })) const wikiApi = vi.hoisted (() => ({ @@ -35,7 +35,11 @@ const renderPage = () => describe ('MaterialDetailPage', () => { beforeEach (() => { vi.clearAllMocks () - api.apiGet.mockResolvedValue ([]) + api.apiGet.mockImplementation (async (path: string) => + path === '/tags/autocomplete' + ? [] + : buildMaterial ({ id: 8 })) + api.isApiError.mockReturnValue (false) wikiApi.fetchWikiPages.mockResolvedValue ([]) vi.stubGlobal ('fetch', vi.fn (async () => ({ blob: async () => new Blob (['image'], { type: 'image/png' }), @@ -86,4 +90,22 @@ describe ('MaterialDetailPage', () => { expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png') expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) }) + + it ('shows not found when material API returns 404', async () => { + api.isApiError.mockReturnValue (true) + api.apiGet.mockRejectedValueOnce ({ response: { status: 404 } }) + + renderPage () + + expect (await screen.findByText ('素材が見つかりませんでした.')).toBeInTheDocument () + }) + + it ('shows an error for non-404 material API failures', async () => { + api.isApiError.mockReturnValue (false) + api.apiGet.mockRejectedValueOnce (new Error ('network error')) + + renderPage () + + expect (await screen.findByText ('素材の取得に失敗しました.')).toBeInTheDocument () + }) }) diff --git a/frontend/src/pages/materials/MaterialListPage.test.tsx b/frontend/src/pages/materials/MaterialListPage.test.tsx index cc10674..4c0992b 100644 --- a/frontend/src/pages/materials/MaterialListPage.test.tsx +++ b/frontend/src/pages/materials/MaterialListPage.test.tsx @@ -1,5 +1,5 @@ -import { screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import MaterialListPage from '@/pages/materials/MaterialListPage' import { buildMaterial, buildTag } from '@/test/factories' @@ -12,51 +12,118 @@ const api = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) describe ('MaterialListPage', () => { - it ('shows the empty selection guide without a tag query', () => { - renderWithProviders (, { route: '/materials' }) - - expect (screen.getByText ('左のリストから照会したいタグを選択してください。')).toBeInTheDocument () - expect (screen.getByRole ('link', { name: '素材を新規追加する' })).toHaveAttribute ( - 'href', - '/materials/new', - ) + beforeEach (() => { + vi.clearAllMocks () + api.apiGet.mockResolvedValue ({ materials: [], count: 0 }) }) - it ('loads materials for a tag query', async () => { - const tag = { - ...buildTag ({ - id: 4, - name: '素材タグ', - category: 'material', - }), - material: buildMaterial ({ id: 8, contentType: 'image/png', file: 'image.png' }), - children: [], - } - api.apiGet.mockResolvedValueOnce (tag) - - renderWithProviders (, { route: '/materials?tag=%E7%B4%A0%E6%9D%90' }) + it ('loads the material index on the initial page', async () => { + renderWithProviders (, { route: '/materials' }) await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( - '/tags/name/%E7%B4%A0%E6%9D%90/materials', + '/materials', + { params: { + tag_state: 'all', + media_kind: 'all', + sort: 'created_at', + direction: 'desc', + page: 1, + limit: 20, + } }, ) }) - expect (await screen.findByRole ('link', { name: '素材タグ' })).toBeInTheDocument () - expect (screen.getByRole ('link', { name: '' })).toHaveAttribute ('href', '/materials/8') - }) - - it ('offers adding a missing non-meme material', async () => { - api.apiGet.mockResolvedValueOnce ({ - ...buildTag ({ name: '未登録', category: 'material' }), - material: null, - children: [], - }) - - renderWithProviders (, { route: '/materials?tag=x' }) - - expect (await screen.findByRole ('link', { name: '追加' })).toHaveAttribute ( + expect (await screen.findByText ('素材はありません.')).toBeInTheDocument () + expect (screen.getByRole ('link', { name: '新規素材を追加' })).toHaveAttribute ( 'href', - '/materials/new?tag=%E6%9C%AA%E7%99%BB%E9%8C%B2', + '/materials/new', + ) + expect (screen.getByRole ('link', { name: '履歴' })).toHaveAttribute ( + 'href', + '/materials/changes', ) }) + + it ('shows materials in the default card view', async () => { + api.apiGet.mockResolvedValueOnce ({ + materials: [ + buildMaterial ({ + id: 8, + tag: buildTag ({ name: '素材タグ', category: 'material' }), + thumbnail: 'thumb.png', + mediaKind: 'image', + contentType: 'image/png', + }), + ], + count: 1, + }) + + renderWithProviders (, { route: '/materials' }) + + expect ((await screen.findByText ('素材タグ')).closest ('a')) + .toHaveAttribute ('href', '/materials/8') + expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument () + }) + + it ('submits list filters as material index params', async () => { + renderWithProviders (, { route: '/materials' }) + + fireEvent.change (screen.getByPlaceholderText ('タグ名 / URL / ファイル名'), { + target: { value: '虹夏' }, + }) + const selects = screen.getAllByRole ('combobox') + fireEvent.change (selects[0], { + target: { value: 'untagged' }, + }) + fireEvent.change (selects[1], { + target: { value: 'image' }, + }) + fireEvent.click (screen.getByRole ('button', { name: '検索' })) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenCalledWith ( + '/materials', + expect.objectContaining ({ params: expect.objectContaining ({ + q: '虹夏', + tag_state: 'untagged', + media_kind: 'image', + }) }), + ) + }) + }) + + it ('keeps a route back to the material search top for untagged materials', async () => { + renderWithProviders ( + , + { route: '/materials?tag_state=untagged&material_filter=any' }, + ) + + expect (await screen.findByRole ( + 'link', + { name: '素材検索トップへ戻る' }, + )).toHaveAttribute ('href', '/materials?material_filter=any') + }) + + it ('uses list view for detailed material rows', async () => { + api.apiGet.mockResolvedValueOnce ({ + materials: [ + buildMaterial ({ + id: 9, + tag: buildTag ({ name: '詳細素材', category: 'material' }), + mediaKind: 'file_other', + fileByteSize: 2048, + }), + ], + count: 1, + }) + + renderWithProviders (, { route: '/materials?view=list' }) + + expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute ( + 'href', + '/materials/9', + ) + expect (screen.getByText ('サイズ:')).toBeInTheDocument () + expect (screen.getByText ('2.0 KB')).toBeInTheDocument () + }) }) -- 2.34.1 From ce28661271eda84449dd22375577f2f742c1b6bf Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 26 Jun 2026 01:56:01 +0900 Subject: [PATCH 13/23] #306 --- .../app/controllers/materials_controller.rb | 167 +++++++++++++- frontend/src/components/MaterialSidebar.tsx | 107 ++++++--- frontend/src/lib/materials.ts | 10 +- .../src/pages/materials/MaterialListPage.tsx | 205 ++++++++++++++++-- .../src/pages/materials/MaterialNewPage.tsx | 3 +- frontend/src/types.ts | 31 +++ 6 files changed, 471 insertions(+), 52 deletions(-) diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index ad0e025..ffc4e6d 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -1,3 +1,6 @@ +require 'set' + + class MaterialsController < ApplicationController rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path @@ -13,12 +16,14 @@ class MaterialsController < ApplicationController offset = (page - 1) * limit filters = material_index_filters + tag_graph = material_index_tag_graph(filters) q = Material.includes(:material_export_items, thumbnail_attachment: :blob, file_attachment: :blob, tag: :tag_name) q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged' q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged' + q = apply_material_tag_filter(q, filters, tag_graph) q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from] q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to] q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from] @@ -37,8 +42,16 @@ class MaterialsController < ApplicationController .offset(offset) .to_a - render json: { materials: MaterialRepr.list_many(materials, host: request.base_url), - count: } + response = { materials: MaterialRepr.list_many(materials, host: request.base_url), + count: } + if filters[:tag_id] + response[:tag_scope] = material_index_tag_scope(filters, tag_graph) + end + if filters[:group_by] == 'parent_tag' + response[:groups] = material_index_groups(materials, tag_graph) + end + + render json: response end def show @@ -198,9 +211,18 @@ class MaterialsController < ApplicationController direction = params[:direction].to_s.downcase direction = 'desc' unless ['asc', 'desc'].include?(direction) + group_by = params[:group_by].to_s.presence + group_by = 'none' unless ['none', 'parent_tag'].include?(group_by) + + tag_id = params[:tag_id].to_i + tag_id = nil if tag_id <= 0 + { q: params[:q].to_s.strip.presence, tag_state:, media_kind:, + tag_id:, + include_descendants: bool?(:include_descendants), + group_by:, created_from: parse_time_param(:created_from), created_to: parse_time_param(:created_to), updated_from: parse_time_param(:updated_from), @@ -270,6 +292,147 @@ class MaterialsController < ApplicationController end end + def apply_material_tag_filter q, filters, tag_graph + return q unless filters[:tag_id] + + tag_ids = + if tag_graph + tag_graph[:scope_tag_ids] + else + [filters[:tag_id]] + end + + q.where(tag_id: tag_ids) + end + + def material_index_tag_graph filters + return nil unless filters[:tag_id] + + children_by_parent_id = Hash.new { |h, k| h[k] = [] } + TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id| + children_by_parent_id[parent_id] << child_id + end + + scope_tag_ids = + if filters[:include_descendants] + collect_material_descendant_ids(filters[:tag_id], children_by_parent_id) + else + Set.new([filters[:tag_id]]) + end + tags_by_id = + Tag + .joins(:tag_name) + .where(id: scope_tag_ids) + .pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at') + .each_with_object({ }) do |(id, name, category, deprecated_at), hash| + hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? } + end + + selected_tag = tags_by_id[filters[:tag_id]] + return nil unless selected_tag + + group_tag_id_by_tag_id = { } + children_by_parent_id[filters[:tag_id]].each do |child_tag_id| + assign_material_group_branch(child_tag_id, + children_by_parent_id:, + tags_by_id:, + group_tag_id_by_tag_id:, + current_group_tag_id: nil, + seen: Set.new([filters[:tag_id]])) + end + + { selected_tag_id: filters[:tag_id], + selected_tag:, + scope_tag_ids: scope_tag_ids.to_a, + tags_by_id:, + group_tag_id_by_tag_id: } + end + + def collect_material_descendant_ids selected_tag_id, children_by_parent_id + ids = Set.new([selected_tag_id]) + stack = [selected_tag_id] + + until stack.empty? + tag_id = stack.pop + children_by_parent_id[tag_id].each do |child_tag_id| + next if ids.include?(child_tag_id) + + ids << child_tag_id + stack << child_tag_id + end + end + + ids + end + + def assign_material_group_branch tag_id, children_by_parent_id:, tags_by_id:, + group_tag_id_by_tag_id:, current_group_tag_id:, seen: + return if seen.include?(tag_id) + + tag = tags_by_id[tag_id] + return unless tag + + seen = seen.dup << tag_id + next_group_tag_id = current_group_tag_id + next_group_tag_id = tag_id if next_group_tag_id.nil? && !tag[:deprecated] + group_tag_id_by_tag_id[tag_id] = next_group_tag_id if next_group_tag_id + + children_by_parent_id[tag_id].each do |child_tag_id| + assign_material_group_branch(child_tag_id, + children_by_parent_id:, + tags_by_id:, + group_tag_id_by_tag_id:, + current_group_tag_id: next_group_tag_id, + seen:) + end + end + + def material_index_groups materials, tag_graph + return [] unless tag_graph + + groups_by_key = { } + + materials.each do |material| + next unless material.tag_id + + group_tag_id = + if material.tag_id == tag_graph[:selected_tag_id] + tag_graph[:selected_tag_id] + else + tag_graph[:group_tag_id_by_tag_id][material.tag_id] || tag_graph[:selected_tag_id] + end + + group_tag = tag_graph[:tags_by_id][group_tag_id] + next unless group_tag + + key = "tag:#{ group_tag_id }" + group = + groups_by_key[key] ||= { key:, + tag: group_tag_repr(group_tag), + material_ids: [], + count: 0 } + group[:material_ids] << material.id + group[:count] += 1 + end + + groups_by_key.values.sort_by { |group| group[:tag][:name] } + end + + def material_index_tag_scope filters, tag_graph + return nil unless tag_graph + + { + tag: group_tag_repr(tag_graph[:selected_tag]), + include_descendants: filters[:include_descendants] + } + end + + def group_tag_repr group_tag + { id: group_tag[:id], + name: group_tag[:name], + category: group_tag[:category] } + end + def material_index_order_sql filters direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC' sort_sql = diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 13c6562..fa2943e 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -2,6 +2,7 @@ import { Fragment, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useLocation, useNavigate } from 'react-router-dom' +import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' import SidebarComponent from '@/components/layout/SidebarComponent' import { materialsKeys } from '@/lib/queryKeys' @@ -35,8 +36,23 @@ const setChildrenById = ( const materialPath = ( - tagName: string, - materialFilter: MaterialFilter): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }` + tagId: number, + materialFilter: MaterialFilter): string => + `/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag` + + `&material_filter=${ materialFilter }` + + +const clearTagSelectionPath = ( + locationSearch: string, + materialFilter: MaterialFilter): string => { + const qs = new URLSearchParams (locationSearch) + qs.delete ('tag_id') + qs.delete ('include_descendants') + qs.delete ('group_by') + qs.delete ('page') + qs.set ('material_filter', materialFilter) + return `/materials?${ qs.toString () }` +} const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ @@ -55,6 +71,13 @@ const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ matchedAlias: null }) +const tagSelectionShellClass = (selected: boolean): string => + selected + ? 'rounded-md border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700 ' + + 'dark:border-sky-400 dark:bg-sky-950 dark:text-sky-100' + : 'px-2 py-1' + + const updateMaterialFilterQuery = ( pathname: string, locationSearch: string, @@ -72,16 +95,16 @@ const MaterialFilterButtons: FC<{ }> = ({ materialFilter, onChange }) => (
    {FILTERS.map (value => ( - ))}
    ) @@ -89,12 +112,14 @@ const MaterialFilterButtons: FC<{ const MaterialTreeNode: FC<{ materialFilter: MaterialFilter + selectedTagId: number | null nestLevel?: number onChildren: (tagId: number, children: MaterialSidebarTag[]) => void openTags: Record setOpenTags: Dispatch>> tag: MaterialSidebarTag -}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, setOpenTags, tag }) => { +}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId, + setOpenTags, tag }) => { const open = Boolean (openTags[tag.id]) const { data } = useQuery ({ queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), @@ -120,24 +145,27 @@ const MaterialTreeNode: FC<{ )}
    - +
    + +
    {open && tag.children.length > 0 && (
      {tag.children.map (child => ( - ))} @@ -149,11 +177,13 @@ const MaterialTreeNode: FC<{ const MobileMaterialTreeNode: FC<{ depth?: number materialFilter: MaterialFilter + selectedTagId: number | null onChildren: (tagId: number, children: MaterialSidebarTag[]) => void openTags: Record setOpenTags: Dispatch>> tag: MaterialSidebarTag -}> = ({ depth = 0, materialFilter, onChildren, openTags, setOpenTags, tag }) => { +}> = ({ depth = 0, materialFilter, onChildren, openTags, selectedTagId, + setOpenTags, tag }) => { const open = Boolean (openTags[tag.id]) const { data } = useQuery ({ queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), @@ -167,18 +197,17 @@ const MobileMaterialTreeNode: FC<{ return (
      -
      +
      + to={materialPath (tag.id, materialFilter)}/>
      {tag.hasChildren && (
      )}
      ) @@ -219,6 +249,7 @@ const MaterialSidebar: FC = () => { const navigate = useNavigate () const qs = new URLSearchParams (location.search) const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present') + const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null const [desktopTags, setDesktopTags] = useState ([]) const [openTags, setOpenTags] = useState> ({ }) @@ -257,12 +288,15 @@ const MaterialSidebar: FC = () => { updateMaterialFilterQuery (location.pathname, location.search, navigate, value) } + const clearTagSelection = clearTagSelectionPath (location.search, materialFilter) + const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => ( tags.map (tag => ( ))) @@ -271,6 +305,14 @@ const MaterialSidebar: FC = () => { <>
      +
      + + {selectedTagId != null ? '選択解除' : '全素材'} + +
      @@ -281,6 +323,7 @@ const MaterialSidebar: FC = () => { key={tag.id} tag={tag} materialFilter={materialFilter} + selectedTagId={selectedTagId} openTags={openTags} setOpenTags={setOpenTags} onChildren={setChildren}/>))} @@ -291,6 +334,12 @@ const MaterialSidebar: FC = () => {
      + + {selectedTagId != null ? '選択解除' : '全素材'} + diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index 3930180..2acfd84 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -7,6 +7,7 @@ import { import type { Material, + MaterialIndexResponse, MaterialVersion, FetchMaterialsParams, MaterialFilter, @@ -20,11 +21,6 @@ export type FetchMaterialTreeParams = { materialFilter: MaterialFilter } -export type MaterialIndexResponse = { - materials: Material[] - count: number -} - export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] } @@ -48,11 +44,15 @@ export const parseMaterialFilter = ( export const fetchMaterials = async ( { q, tagState, mediaKind, createdFrom, createdTo, updatedFrom, updatedTo, sort, direction, page, + tagId, includeDescendants, groupBy, limit }: FetchMaterialsParams): Promise => await apiGet ('/materials', { params: { ...(q && { q }), tag_state: tagState, media_kind: mediaKind, + ...(tagId != null && { tag_id: tagId }), + ...(includeDescendants && { include_descendants: '1' }), + ...(groupBy !== 'none' && { group_by: groupBy }), ...(createdFrom && { created_from: createdFrom }), ...(createdTo && { created_to: createdTo }), ...(updatedFrom && { updated_from: updatedFrom }), diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index e9c0aa2..333a869 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -20,6 +20,9 @@ import type { FC, FormEvent } from 'react' import type { FetchMaterialsParams, Material, + MaterialFilter, + MaterialIndexGroup, + MaterialIndexGroupBy, MaterialIndexDirection, MaterialIndexMediaKind, MaterialIndexSort, @@ -50,6 +53,10 @@ const SORT_LABELS: Record = { version_no: 'バージョン', id: 'ID'} +const GROUP_BY_LABELS: Record = { + none: 'オフ', + parent_tag: '親タグ'} + const setIf = (qs: URLSearchParams, key: string, value: string | null) => { const next = value?.trim () @@ -79,6 +86,33 @@ const materialTitle = (material: Material): string => material.tag?.name ?? `素材 #${ material.id }` +const groupedTagPath = ( + tagId: number, + materialFilter: MaterialFilter): string => + `/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag` + + `&material_filter=${ materialFilter }` + + +const materialNewPath = ( + tagName: string, + returnTo: string): string => + `/materials/new?tag=${ encodeURIComponent (tagName) }` + + `&return_to=${ encodeURIComponent (returnTo) }` + + +const clearedTagSelectionPath = ( + locationSearch: string, + materialFilter: MaterialFilter): string => { + const qs = new URLSearchParams (locationSearch) + qs.delete ('tag_id') + qs.delete ('include_descendants') + qs.set ('group_by', 'none') + qs.delete ('page') + qs.set ('material_filter', materialFilter) + return `/materials?${ qs.toString () }` +} + + const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
      = ({ material }) => (
    ) +const GroupHeading: FC<{ + count: number + materialFilter: MaterialFilter + returnTo: string + title: string + tagId: number +}> = ({ count, materialFilter, returnTo, title, tagId }) => ( +
    + + {title} + + + {count} 件 + + + このタグに素材を追加 + +
    ) + + const MaterialListPage: FC = () => { const location = useLocation () const navigate = useNavigate () @@ -162,7 +223,10 @@ const MaterialListPage: FC = () => { const page = Number (query.get ('page') ?? 1) const limit = Number (query.get ('limit') ?? 20) - const qQuery = query.get ('q') ?? query.get ('tag') ?? '' + const qQuery = query.get ('q') ?? '' + const tagIdQuery = Number (query.get ('tag_id') ?? 0) + const tagId = tagIdQuery > 0 ? tagIdQuery : null + const includeDescendants = query.get ('include_descendants') === '1' const tagState = query.get ('unclassified') === '1' ? 'untagged' : parseOption ( @@ -182,6 +246,11 @@ const MaterialListPage: FC = () => { query.get ('direction'), ['asc', 'desc'], 'desc') + const groupByQuery = parseOption ( + query.get ('group_by'), + ['none', 'parent_tag'], + 'none') + const groupBy = tagId == null ? 'none' : groupByQuery const view = parseOption (query.get ('view'), ['card', 'list'], 'card') const createdFromQuery = query.get ('created_from') ?? '' const createdToQuery = query.get ('created_to') ?? '' @@ -191,6 +260,7 @@ const MaterialListPage: FC = () => { const [q, setQ] = useState ('') const [tagStateInput, setTagStateInput] = useState ('all') const [mediaKindInput, setMediaKindInput] = useState ('all') + const [groupByInput, setGroupByInput] = useState ('none') const [createdFrom, setCreatedFrom] = useState (null) const [createdTo, setCreatedTo] = useState (null) const [updatedFrom, setUpdatedFrom] = useState (null) @@ -200,6 +270,9 @@ const MaterialListPage: FC = () => { q: qQuery, tagState, mediaKind, + tagId, + includeDescendants, + groupBy, createdFrom: createdFromQuery, createdTo: createdToQuery, updatedFrom: updatedFromQuery, @@ -212,18 +285,33 @@ const MaterialListPage: FC = () => { const { data, isLoading, isError } = useQuery ({ queryKey: materialsKeys.index (keys), queryFn: () => fetchMaterials (keys)}) + const tagScope = data?.tagScope ?? null const materials = data?.materials ?? [] + const groups = data?.groups ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 + const materialsById = useMemo ( + () => new Map (materials.map (material => [material.id, material])), + [materials], + ) + const groupedMaterialIds = useMemo ( + () => new Set (groups.flatMap (group => group.materialIds)), + [groups], + ) + const ungroupedMaterials = useMemo ( + () => materials.filter (material => !groupedMaterialIds.has (material.id)), + [groupedMaterialIds, materials], + ) useEffect (() => { setQ (qQuery) setTagStateInput (tagState) setMediaKindInput (mediaKind) + setGroupByInput (groupBy) setCreatedFrom (createdFromQuery) setCreatedTo (createdToQuery) setUpdatedFrom (updatedFromQuery) setUpdatedTo (updatedToQuery) - }, [createdFromQuery, createdToQuery, mediaKind, qQuery, tagState, + }, [createdFromQuery, createdToQuery, groupBy, mediaKind, qQuery, tagState, updatedFromQuery, updatedToQuery]) const search = (e: FormEvent) => { @@ -239,19 +327,78 @@ const MaterialListPage: FC = () => { setIf (qs, 'updated_to', updatedTo) qs.set ('sort', sort) qs.set ('direction', direction) + qs.set ('group_by', tagId == null ? 'none' : groupByInput) qs.set ('view', view) qs.set ('page', '1') qs.set ('limit', String (limit)) qs.set ('material_filter', materialFilter) + if (tagId != null) + qs.set ('tag_id', String (tagId)) + + if (includeDescendants) + qs.set ('include_descendants', '1') + navigate (`/materials?${ qs.toString () }`) } const updateQuery = (changes: Record) => { const qs = new URLSearchParams (location.search) - Object.entries (changes).forEach (([key, value]) => qs.set (key, value)) + Object.entries (changes).forEach (([key, value]) => { + if (value === '') + qs.delete (key) + else + qs.set (key, value) + }) navigate (`/materials?${ qs.toString () }`) } + const renderMaterialCollection = (rows: Material[]) => + view === 'card' + ? ( +
    + {rows.map (material => ( + ))} +
    ) + : ( +
    + {rows.map (material => ( + ))} +
    ) + + const renderGroupedMaterials = (materialGroups: MaterialIndexGroup[]) => ( +
    + {materialGroups.map (group => { + const groupMaterials = + group.materialIds + .map (materialId => materialsById.get (materialId)) + .filter ((material): material is Material => material != null) + + if (groupMaterials.length === 0) + return null + + return ( +
    + + {renderMaterialCollection (groupMaterials)} +
    ) + })} + {ungroupedMaterials.length > 0 && ( +
    +
    + + その他 + +
    + {renderMaterialCollection (ungroupedMaterials)} +
    )} +
    ) + return ( @@ -303,6 +450,28 @@ const MaterialListPage: FC = () => {
    + {tagScope && ( +
    + + {tagScope.tag.name} + {tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'} + + + このタグに素材を追加 + + + タグ選択を解除 + +
    )} + {tagState === 'untagged' && ( { )} + + {({ invalid }) => ( + )} + + {() => (
    @@ -438,18 +622,9 @@ const MaterialListPage: FC = () => { {(!isLoading && !isError && materials.length === 0) && (

    素材はありません.

    )} {materials.length > 0 && ( - view === 'card' - ? ( -
    - {materials.map (material => ( - ))} -
    ) - : ( -
    - {materials.map (material => ( - ))} -
    ))} + groupBy === 'parent_tag' && groups.length > 0 + ? renderGroupedMaterials (groups) + : renderMaterialCollection (materials))}
    ) diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx index 18421d3..d320ba7 100644 --- a/frontend/src/pages/materials/MaterialNewPage.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -27,6 +27,7 @@ const MaterialNewPage: FC = () => { const location = useLocation () const query = new URLSearchParams (location.search) const tagQuery = query.get ('tag') ?? '' + const returnToQuery = query.get ('return_to') ?? '' const navigate = useNavigate () @@ -54,7 +55,7 @@ const MaterialNewPage: FC = () => { onSuccess: async () => { await qc.invalidateQueries ({ queryKey: materialsKeys.root }) toast ({ title: '送信成功!' }) - navigate (`/materials?tag=${ encodeURIComponent (tag) }`) + navigate (returnToQuery || `/materials?tag=${ encodeURIComponent (tag) }`) }, onError: error => { applyValidationError (error) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 553fafd..53ea0b8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -88,12 +88,26 @@ export type MaterialIndexSort = export type MaterialIndexTagState = 'all' | 'tagged' | 'untagged' +export type MaterialIndexGroupBy = 'none' | 'parent_tag' + export type MaterialIndexView = 'card' | 'list' +export type MaterialTagScope = { + tag: { + id: number + name: string + category: Category + } + includeDescendants: boolean +} + export type FetchMaterialsParams = { q: string tagState: MaterialIndexTagState mediaKind: MaterialIndexMediaKind + tagId: number | null + includeDescendants: boolean + groupBy: MaterialIndexGroupBy createdFrom: string createdTo: string updatedFrom: string @@ -170,6 +184,23 @@ export type MaterialExportItem = { exportPath: string enabled: boolean } +export type MaterialIndexGroup = { + key: string + tag: { + id: number + name: string + category: Category + } + materialIds: number[] + count: number } + +export type MaterialIndexResponse = { + materials: Material[] + count: number + groups?: MaterialIndexGroup[] + tagScope?: MaterialTagScope | null +} + export type MaterialVersion = { id: number materialId: number -- 2.34.1 From 363146c219ba2eaadc557b16be1c4b4084c0c073 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 27 Jun 2026 05:20:25 +0900 Subject: [PATCH 14/23] #306 --- backend/db/schema.rb | 43 +++++++++++++++---- .../src/pages/materials/MaterialListPage.tsx | 14 +++++- .../src/pages/materials/MaterialNewPage.tsx | 3 +- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/backend/db/schema.rb b/backend/db/schema.rb index ccce7ee..dbff31a 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -72,6 +72,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id" t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id" t.index ["user_id"], name: "index_gekanator_games_on_user_id" + t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" end create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -274,9 +275,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do 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" - t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_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| @@ -285,13 +287,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do 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 t.float "cos", null: false - t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos" + t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc } t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end @@ -331,6 +334,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do 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| @@ -345,6 +350,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.integer "version_no", null: false t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true + t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -391,7 +397,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.bigint "tag_id", null: false t.bigint "target_tag_id", null: false t.float "cos", null: false - t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos" + t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc } t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end @@ -401,30 +407,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.string "event_type", null: false t.string "name", null: false t.string "category", null: false + t.datetime "deprecated_at" t.text "aliases", null: false t.text "parent_tag_ids", null: false - t.datetime "deprecated_at" 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" - t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_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 - t.datetime "deprecated_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "post_count", default: 0, null: false + t.datetime "deprecated_at" t.datetime "discarded_at" t.integer "version_no", null: false t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" + t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -549,6 +557,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do 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| + t.bigint "wiki_page_id", null: false + t.integer "no", null: false + t.string "alt_text" + t.binary "sha256", limit: 32, null: false + t.bigint "created_by_user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" + t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true + t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true + end + create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "sha256", limit: 64, null: false t.text "body", null: false @@ -565,11 +586,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" + t.integer "next_asset_no", default: 1, null: false t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" + t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" end create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -614,6 +637,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do 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" @@ -692,6 +717,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do add_foreign_key "user_ips", "users" add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" + add_foreign_key "wiki_assets", "users", column: "created_by_user_id" + add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index 333a869..6017ba7 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -106,7 +106,7 @@ const clearedTagSelectionPath = ( const qs = new URLSearchParams (locationSearch) qs.delete ('tag_id') qs.delete ('include_descendants') - qs.set ('group_by', 'none') + qs.delete ('group_by') qs.delete ('page') qs.set ('material_filter', materialFilter) return `/materials?${ qs.toString () }` @@ -471,6 +471,18 @@ const MaterialListPage: FC = () => { タグ選択を解除
    )} + {(tagId != null && tagScope == null) && ( +
    + 選択中タグが見つかりません. + + タグ選択を解除 + +
    )} {tagState === 'untagged' && ( { const query = new URLSearchParams (location.search) const tagQuery = query.get ('tag') ?? '' const returnToQuery = query.get ('return_to') ?? '' + const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : '' const navigate = useNavigate () @@ -55,7 +56,7 @@ const MaterialNewPage: FC = () => { onSuccess: async () => { await qc.invalidateQueries ({ queryKey: materialsKeys.root }) toast ({ title: '送信成功!' }) - navigate (returnToQuery || `/materials?tag=${ encodeURIComponent (tag) }`) + navigate (safeReturnTo || '/materials') }, onError: error => { applyValidationError (error) -- 2.34.1 From c836369dfcc3cc80328f2143407b65482afc7362 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 27 Jun 2026 05:30:23 +0900 Subject: [PATCH 15/23] #306 --- .../app/controllers/materials_controller.rb | 5 +--- backend/app/models/material.rb | 5 +++- backend/app/services/material_file_sha256.rb | 29 +++++++++++++++++-- .../app/services/material_sync_importer.rb | 5 ++-- .../app/services/material_version_recorder.rb | 2 +- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index ffc4e6d..d438a73 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -523,10 +523,7 @@ class MaterialsController < ApplicationController content_type: file.content_type, ) - if file_sha256.present? - blob.metadata['sha256'] = file_sha256 - blob.save! if blob.changed? - end + MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256) blob ensure diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb index f8536e1..dd0aa22 100644 --- a/backend/app/models/material.rb +++ b/backend/app/models/material.rb @@ -1,6 +1,9 @@ class Material < ApplicationRecord include MyDiscard + SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file', + 'legacy_drive_path'].freeze + default_scope -> { kept } belongs_to :parent, class_name: 'Material', optional: true @@ -20,7 +23,7 @@ class Material < ApplicationRecord validates :tag_id, uniqueness: true, allow_nil: true validates :source_kind, - inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS }, + inclusion: { in: SOURCE_KINDS }, allow_blank: true validate :file_must_be_attached diff --git a/backend/app/services/material_file_sha256.rb b/backend/app/services/material_file_sha256.rb index f9a19b0..31ad4d0 100644 --- a/backend/app/services/material_file_sha256.rb +++ b/backend/app/services/material_file_sha256.rb @@ -1,16 +1,39 @@ require 'digest' +require 'json' class MaterialFileSha256 + def self.blob_metadata blob + metadata = blob.metadata + return metadata if metadata.is_a?(Hash) + return { } if metadata.blank? + + JSON.parse(metadata) + rescue JSON::ParserError + { } + end + + def self.metadata_sha256 blob + blob_metadata(blob)['sha256'].presence + end + + def self.assign_metadata_sha256! blob, sha256 + return if sha256.blank? + + metadata = blob_metadata(blob) + metadata['sha256'] = sha256 + blob.metadata = metadata + blob.save! if blob.changed? + end + def self.from_blob blob, allow_download: false - sha256 = blob.metadata['sha256'] + sha256 = metadata_sha256(blob) return sha256 if sha256.present? return nil unless allow_download begin blob.open do |file| sha256 = Digest::SHA256.file(file.path).hexdigest - blob.metadata['sha256'] = sha256 - blob.save! if blob.changed? + assign_metadata_sha256!(blob, sha256) sha256 end rescue ActiveStorage::FileNotFoundError, ArgumentError => error diff --git a/backend/app/services/material_sync_importer.rb b/backend/app/services/material_sync_importer.rb index 34c0357..1912446 100644 --- a/backend/app/services/material_sync_importer.rb +++ b/backend/app/services/material_sync_importer.rb @@ -148,8 +148,7 @@ class MaterialSyncImporter io: tempfile, filename: @attributes[:filename], content_type: @attributes[:content_type]) - blob.metadata['sha256'] = file_sha256 if file_sha256.present? - blob.save! if blob.changed? + MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256) tempfile.rewind blob end @@ -226,7 +225,7 @@ class MaterialSyncImporter if expected_sha256.present? return false unless material.file.attached? - return material.file.blob.metadata['sha256'] == expected_sha256 + return MaterialFileSha256.metadata_sha256(material.file.blob) == expected_sha256 end !material.file.attached? diff --git a/backend/app/services/material_version_recorder.rb b/backend/app/services/material_version_recorder.rb index 6a91a31..8f268f7 100644 --- a/backend/app/services/material_version_recorder.rb +++ b/backend/app/services/material_version_recorder.rb @@ -57,7 +57,7 @@ class MaterialVersionRecorder < VersionRecorder file_content_type: blob.content_type, file_byte_size: blob.byte_size, file_checksum: blob.checksum, - file_sha256: blob.metadata['sha256'] } + file_sha256: MaterialFileSha256.metadata_sha256(blob) } end def empty_file_snapshot -- 2.34.1 From 41a98ff72525b0467b0a8e4e6d0e4c7fc377b10b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 27 Jun 2026 05:37:58 +0900 Subject: [PATCH 16/23] #306 --- backend/spec/models/material_spec.rb | 19 ++++++ backend/spec/requests/materials_spec.rb | 62 +++++++++++++++++ .../services/material_file_sha256_spec.rb | 23 +++++++ .../pages/materials/MaterialListPage.test.tsx | 66 +++++++++++++++++++ .../pages/materials/MaterialNewPage.test.tsx | 52 +++++++++++++++ 5 files changed, 222 insertions(+) create mode 100644 backend/spec/models/material_spec.rb create mode 100644 backend/spec/services/material_file_sha256_spec.rb diff --git a/backend/spec/models/material_spec.rb b/backend/spec/models/material_spec.rb new file mode 100644 index 0000000..f1f2d15 --- /dev/null +++ b/backend/spec/models/material_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe Material, type: :model do + let(:user) { create(:user, :member) } + + it 'allows only material source kinds, not suppression prefix kinds' do + material = described_class.new(url: 'https://example.com/material', + source_kind: 'google_drive_path_prefix', + source_path: '素材/危険', + created_by_user: user, + updated_by_user: user) + + expect(material).not_to be_valid + expect(material.errors[:source_kind]).to be_present + + material.source_kind = 'google_drive_path' + expect(material).to be_valid + end +end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index f2a7581..fcabde4 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -113,6 +113,68 @@ RSpec.describe 'Materials API', type: :request do expect(response_materials.size).to eq(1) expect(response_materials.first['id']).to eq(material_b.id) end + + it 'filters by descendant tags and returns stable parent tag groups' do + root = + Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'), + category: :material) + child_b = + Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'), + category: :material) + child_a = + Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'), + category: :material) + deprecated = + Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'), + category: :material, + deprecated_at: Time.current) + grandchild = + Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'), + category: :material) + root_material = + build_material(tag: root, user: member_user, + file: dummy_upload(filename: 'root.png')) + child_a_material = + build_material(tag: child_a, user: member_user, + file: dummy_upload(filename: 'child_a.png')) + grandchild_material = + build_material(tag: grandchild, user: member_user, + file: dummy_upload(filename: 'grandchild.png')) + + TagImplication.create!(parent_tag: root, tag: child_b) + TagImplication.create!(parent_tag: root, tag: child_a) + TagImplication.create!(parent_tag: child_b, tag: deprecated) + TagImplication.create!(parent_tag: deprecated, tag: grandchild) + + get '/materials', + params: { tag_id: root.id, include_descendants: '1', + group_by: 'parent_tag', sort: 'id', direction: 'asc' } + + expect(response).to have_http_status(:ok) + expect(response_materials.map { |m| m['id'] }) + .to include(root_material.id, child_a_material.id, grandchild_material.id) + expect(json['tag_scope']).to eq( + 'tag' => { + 'id' => root.id, + 'name' => 'material_scope_root', + 'category' => 'material' + }, + 'include_descendants' => true + ) + expect(json['groups'].map { |group| group.dig('tag', 'name') }) + .to eq(['material_scope_a', 'material_scope_b', 'material_scope_root']) + expect(json['groups'].find { |group| group.dig('tag', 'id') == child_b.id } + .fetch('material_ids')).to eq([grandchild_material.id]) + end + + it 'returns nil tag_scope when tag_id is unknown' do + get '/materials', params: { tag_id: 999_999, group_by: 'parent_tag' } + + expect(response).to have_http_status(:ok) + expect(json['tag_scope']).to be_nil + expect(json['groups']).to eq([]) + expect(response_materials).to eq([]) + end end describe 'GET /materials/:id' do diff --git a/backend/spec/services/material_file_sha256_spec.rb b/backend/spec/services/material_file_sha256_spec.rb new file mode 100644 index 0000000..37dde5c --- /dev/null +++ b/backend/spec/services/material_file_sha256_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe MaterialFileSha256 do + describe '.metadata_sha256' do + it 'reads sha256 from Hash metadata' do + blob = instance_double(ActiveStorage::Blob, metadata: { 'sha256' => 'abc123' }) + + expect(described_class.metadata_sha256(blob)).to eq('abc123') + end + + it 'reads sha256 from JSON string metadata' do + blob = instance_double(ActiveStorage::Blob, metadata: '{"sha256":"abc123"}') + + expect(described_class.metadata_sha256(blob)).to eq('abc123') + end + + it 'returns nil for invalid metadata' do + blob = instance_double(ActiveStorage::Blob, metadata: '{') + + expect(described_class.metadata_sha256(blob)).to be_nil + end + end +end diff --git a/frontend/src/pages/materials/MaterialListPage.test.tsx b/frontend/src/pages/materials/MaterialListPage.test.tsx index 4c0992b..0c4c26e 100644 --- a/frontend/src/pages/materials/MaterialListPage.test.tsx +++ b/frontend/src/pages/materials/MaterialListPage.test.tsx @@ -126,4 +126,70 @@ describe ('MaterialListPage', () => { expect (screen.getByText ('サイズ:')).toBeInTheDocument () expect (screen.getByText ('2.0 KB')).toBeInTheDocument () }) + + it ('shows the selected tag scope and tag-specific add links', async () => { + api.apiGet.mockResolvedValueOnce ({ + materials: [ + buildMaterial ({ + id: 10, + tag: buildTag ({ id: 30, name: '泣き', category: 'material' }), + }), + ], + count: 1, + tagScope: { + tag: { id: 20, name: '伊地知ニジカ', category: 'material' }, + includeDescendants: true, + }, + groups: [ + { + key: 'tag:30', + tag: { id: 30, name: '泣き', category: 'material' }, + materialIds: [10], + count: 1, + }, + ], + }) + + renderWithProviders ( + , + { route: '/materials?tag_id=20&include_descendants=1&group_by=parent_tag' }, + ) + + expect (await screen.findByText ( + (_, element) => element?.textContent === '伊地知ニジカ 配下の素材を表示中', + )).toBeInTheDocument () + + const addLinks = screen.getAllByRole ('link', { name: 'このタグに素材を追加' }) + expect (addLinks[0]).toHaveAttribute ( + 'href', + '/materials/new?tag=%E4%BC%8A%E5%9C%B0%E7%9F%A5%E3%83%8B%E3%82%B8%E3%82%AB' + + '&return_to=%2Fmaterials%3Ftag_id%3D20%26include_descendants%3D1%26group_by%3Dparent_tag', + ) + expect (addLinks[1]).toHaveAttribute ( + 'href', + '/materials/new?tag=%E6%B3%A3%E3%81%8D' + + '&return_to=%2Fmaterials%3Ftag_id%3D20%26include_descendants%3D1%26group_by%3Dparent_tag', + ) + expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute ( + 'href', + '/materials?material_filter=present', + ) + }) + + it ('shows a clear link when tag_id does not resolve to tag_scope', async () => { + api.apiGet.mockResolvedValueOnce ({ + materials: [], + count: 0, + tagScope: null, + groups: [], + }) + + renderWithProviders (, { route: '/materials?tag_id=999' }) + + expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument () + expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute ( + 'href', + '/materials?material_filter=present', + ) + }) }) diff --git a/frontend/src/pages/materials/MaterialNewPage.test.tsx b/frontend/src/pages/materials/MaterialNewPage.test.tsx index c67e55b..1d3a1e8 100644 --- a/frontend/src/pages/materials/MaterialNewPage.test.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Route, Routes, useLocation } from 'react-router-dom' import MaterialNewPage from '@/pages/materials/MaterialNewPage' import { renderWithProviders } from '@/test/render' @@ -17,6 +18,11 @@ const toastApi = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) vi.mock ('@/components/ui/use-toast', () => toastApi) +const LocationView = () => { + const location = useLocation () + return
    {location.pathname + location.search}
    +} + describe ('MaterialNewPage', () => { beforeEach (() => { vi.clearAllMocks () @@ -69,4 +75,50 @@ describe ('MaterialNewPage', () => { expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2) expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true') }) + + it ('returns only to safe material URLs after successful creation', async () => { + api.apiPost.mockResolvedValueOnce ({}) + + renderWithProviders ( + + }/> + }/> + , + { + route: + '/materials/new?tag=%E8%99%B9%E5%A4%8F' + + '&return_to=%2Fmaterials%3Ftag_id%3D20', + }, + ) + + fireEvent.change (screen.getAllByRole ('textbox')[1], { + target: { value: 'https://example.com/ref' }, + }) + fireEvent.click (screen.getByRole ('button', { name: '追加' })) + + expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument () + }) + + it ('ignores unsafe return_to values after successful creation', async () => { + api.apiPost.mockResolvedValueOnce ({}) + + renderWithProviders ( + + }/> + }/> + , + { + route: + '/materials/new?tag=%E8%99%B9%E5%A4%8F' + + '&return_to=https%3A%2F%2Fexample.com%2Fevil', + }, + ) + + fireEvent.change (screen.getAllByRole ('textbox')[1], { + target: { value: 'https://example.com/ref' }, + }) + fireEvent.click (screen.getByRole ('button', { name: '追加' })) + + expect (await screen.findByText ('/materials')).toBeInTheDocument () + }) }) -- 2.34.1 From 4c0a4f5d9bbba5217f2971a55d013e93c6246371 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 27 Jun 2026 18:27:10 +0900 Subject: [PATCH 17/23] #306 --- AGENTS.md | 7 +- frontend/src/App.tsx | 1 - frontend/src/components/MaterialSidebar.tsx | 356 ++++++++++++------ frontend/src/components/TagLink.tsx | 17 +- frontend/src/components/TopNav.tsx | 45 ++- frontend/src/components/common/PageTitle.tsx | 6 +- frontend/src/lib/materials.ts | 41 +- .../pages/materials/MaterialDetailPage.tsx | 6 - .../pages/materials/MaterialHistoryPage.tsx | 26 +- .../src/pages/materials/MaterialListPage.tsx | 88 ++--- .../pages/materials/MaterialSearchPage.tsx | 51 --- 11 files changed, 350 insertions(+), 294 deletions(-) delete mode 100644 frontend/src/pages/materials/MaterialSearchPage.tsx diff --git a/AGENTS.md b/AGENTS.md index 908fc0f..23c8159 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -271,7 +271,12 @@ const value = - In TypeScript and TSX, convert every leading run of 8 spaces to a tab character. - A leading tab is exactly equivalent to 8 leading spaces. -- Never place a closing parenthesis at the beginning of a line. +- In TypeScript and TSX function declarations, including `const` arrow + function declarations, when the parameter list spans multiple lines, always + put the closing parenthesis at the beginning of its own line before the return + type or `=>`. +- In TypeScript and TSX, never place a closing parenthesis at the beginning of + a line except for a multi-line function declaration parameter list. - Never place a closing square bracket at the beginning of a line. - For object literals and other associative-array-style braces, do not place the closing brace at the beginning of a line. Function, lambda, callback, and diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac69d75..11f784c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,7 +75,6 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> - {/* }/> */} }/> }/> }/> diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index fa2943e..9767c63 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -1,23 +1,36 @@ -import { Fragment, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import PrefetchLink from '@/components/PrefetchLink' -import TagLink from '@/components/TagLink' import SidebarComponent from '@/components/layout/SidebarComponent' -import { materialsKeys } from '@/lib/queryKeys' +import TagLink from '@/components/TagLink' import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials' +import { materialsKeys } from '@/lib/queryKeys' +import { cn } from '@/lib/utils' -import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' +import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types' -const FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] +const FILTERS: MaterialFilter[] = ['missing', 'present', 'any'] +const FILTER_LABELS: Record = { present: '有', missing: '無', any: '全' } -const FILTER_LABELS: Record = { - present: '素材あり', - missing: '素材なし', - any: 'すべて'} + +const px = (value: string): number => { + const parsed = Number.parseFloat (value) + return Number.isFinite (parsed) ? parsed : 0 +} + + +const verticalChrome = (el: HTMLElement): number => { + const style = window.getComputedStyle (el) + + return ( + px (style.paddingTop) + + px (style.paddingBottom) + + px (style.borderTopWidth) + + px (style.borderBottomWidth)) +} const setChildrenById = ( @@ -37,24 +50,12 @@ const setChildrenById = ( const materialPath = ( tagId: number, - materialFilter: MaterialFilter): string => + materialFilter: MaterialFilter, +): string => `/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag` + `&material_filter=${ materialFilter }` -const clearTagSelectionPath = ( - locationSearch: string, - materialFilter: MaterialFilter): string => { - const qs = new URLSearchParams (locationSearch) - qs.delete ('tag_id') - qs.delete ('include_descendants') - qs.delete ('group_by') - qs.delete ('page') - qs.set ('material_filter', materialFilter) - return `/materials?${ qs.toString () }` -} - - const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ id: tag.id, name: tag.name, @@ -72,39 +73,42 @@ const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ const tagSelectionShellClass = (selected: boolean): string => - selected - ? 'rounded-md border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700 ' - + 'dark:border-sky-400 dark:bg-sky-950 dark:text-sky-100' - : 'px-2 py-1' + cn (selected + ? ['rounded-md border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700', + 'dark:border-sky-400 dark:bg-sky-950 dark:text-sky-100'] + : 'px-2 py-1') const updateMaterialFilterQuery = ( pathname: string, locationSearch: string, navigate: ReturnType, - materialFilter: MaterialFilter) => { - const qs = new URLSearchParams (locationSearch) - qs.set ('material_filter', materialFilter) - navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`) + materialFilter: MaterialFilter, +) => { + const qs = new URLSearchParams (locationSearch) + qs.set ('material_filter', materialFilter) + navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`) } -const MaterialFilterButtons: FC<{ - materialFilter: MaterialFilter - onChange: (materialFilter: MaterialFilter) => void -}> = ({ materialFilter, onChange }) => ( -
    +const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter + onChange: (materialFilter: MaterialFilter) => void }> = ( + { materialFilter, onChange }, +) => ( +
    + {FILTERS.map (value => ( - ))}
    ) @@ -132,10 +136,10 @@ const MaterialTreeNode: FC<{ }, [data, onChildren, open, tag.children.length, tag.id]) return ( - + <>
  • -
    -
    +
    +
    {tag.hasChildren && ( )}
    -
    -
    +
    +
    0 && (
      {tag.children.map (child => ( - ))}
    )} - ) + ) } -const MobileMaterialTreeNode: FC<{ - depth?: number - materialFilter: MaterialFilter - selectedTagId: number | null - onChildren: (tagId: number, children: MaterialSidebarTag[]) => void - openTags: Record - setOpenTags: Dispatch>> - tag: MaterialSidebarTag -}> = ({ depth = 0, materialFilter, onChildren, openTags, selectedTagId, - setOpenTags, tag }) => { +const MobileMaterialTreeNode: FC<{ depth?: number + availableInlineSizePx?: number | null + materialFilter: MaterialFilter + selectedTagId: number | null + onChildren: (tagId: number, children: MaterialSidebarTag[]) => + void + openTags: Record + setOpenTags: Dispatch>> + tag: MaterialSidebarTag }> = ( + { + depth = 0, + availableInlineSizePx = null, + materialFilter, + onChildren, + openTags, + selectedTagId, + setOpenTags, + tag, + }, +) => { const open = Boolean (openTags[tag.id]) + const tagColumnRef = useRef (null) + const chipRef = useRef (null) + const buttonRef = useRef (null) + const expansionSlotRef = useRef (null) + const expansionBorderRef = useRef (null) + const [tagChipInlineSizePx, setTagChipInlineSizePx] = useState (null) + const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState (null) + const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState (null) const { data } = useQuery ({ queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), @@ -195,50 +218,155 @@ const MobileMaterialTreeNode: FC<{ onChildren (tag.id, data) }, [data, onChildren, open, tag.children.length, tag.id]) + useEffect (() => { + const tagColumn = tagColumnRef.current + const chip = chipRef.current + if (!(tagColumn) || !(chip)) + return + + const updateTagInlineSize = () => { + const buttonHeight = buttonRef.current?.offsetHeight ?? 0 + const gap = tag.hasChildren ? px (window.getComputedStyle (tagColumn).rowGap) : 0 + const columnHeight = + availableInlineSizePx == null + ? tagColumn.clientHeight + : Math.min (tagColumn.clientHeight, availableInlineSizePx) + const nextChipInlineSize = Math.max (24, columnHeight - buttonHeight - gap) + const nextLinkInlineSize = Math.max ( + 16, + nextChipInlineSize - verticalChrome (chip), + ) + + setTagChipInlineSizePx (prev => prev === nextChipInlineSize ? prev : nextChipInlineSize) + setTagLinkInlineSizePx (prev => prev === nextLinkInlineSize ? prev : nextLinkInlineSize) + } + + updateTagInlineSize () + + const resizeObserver = new ResizeObserver (() => { + updateTagInlineSize () + }) + + resizeObserver.observe (tagColumn) + resizeObserver.observe (chip) + + if (buttonRef.current) + resizeObserver.observe (buttonRef.current) + + return () => { + resizeObserver.disconnect () + } + }, [availableInlineSizePx, open, tag.hasChildren, tag.children.length]) + + useEffect (() => { + const expansionSlot = expansionSlotRef.current + const expansionBorder = expansionBorderRef.current + if (!(expansionSlot) || !(expansionBorder)) + return + + const updateChildInlineSize = () => { + const base = + availableInlineSizePx == null + ? expansionSlot.clientHeight + : availableInlineSizePx + const chrome = verticalChrome (expansionSlot) + verticalChrome (expansionBorder) + const nextInlineSize = Math.max (24, base - chrome) + setChildAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize) + } + + updateChildInlineSize () + + const resizeObserver = new ResizeObserver (() => { + updateChildInlineSize () + }) + + resizeObserver.observe (expansionSlot) + resizeObserver.observe (expansionBorder) + + return () => { + resizeObserver.disconnect () + } + }, [availableInlineSizePx, open, tag.children.length]) + return ( -
    -
    +
    +
    + ref={chipRef} + className={cn ( + tagSelectionShellClass (selectedTagId === tag.id), + 'box-border rounded-xl border px-3 py-2 text-sm shadow-sm', + 'min-h-0 overflow-hidden [max-inline-size:var(--tag-chip-inline-size)]', + '[max-height:var(--tag-chip-inline-size)]', + )} + style={{ + writingMode: 'vertical-rl', + '--tag-chip-inline-size': ( + tagChipInlineSizePx == null + ? undefined + : `${ tagChipInlineSizePx }px`), + '--tag-link-inline-size': ( + tagLinkInlineSizePx == null + ? undefined + : `${ tagLinkInlineSizePx }px`), + } as CSSProperties}> + to={materialPath (tag.id, materialFilter)} + className="block overflow-hidden text-ellipsis whitespace-nowrap + [max-inline-size:var(--tag-link-inline-size)] + [max-height:var(--tag-link-inline-size)]"/>
    {tag.hasChildren && ( )}
    {open && tag.children.length > 0 && (
    -
    )}
    ) } @@ -248,12 +376,14 @@ const MaterialSidebar: FC = () => { const location = useLocation () const navigate = useNavigate () const qs = new URLSearchParams (location.search) - const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present') + const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any') const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null const [desktopTags, setDesktopTags] = useState ([]) const [openTags, setOpenTags] = useState> ({ }) const mobileRailRef = useRef (null) + const [mobileAvailableInlineSizePx, setMobileAvailableInlineSizePx] = + useState (null) const { data: rootTags = [], isLoading, isError } = useQuery ({ queryKey: materialsKeys.tree ({ parentId: null, materialFilter }), @@ -273,6 +403,29 @@ const MaterialSidebar: FC = () => { }) }, [rootTags, materialFilter]) + useEffect (() => { + const el = mobileRailRef.current + if (!(el)) + return + + const updateAvailableInlineSize = () => { + const nextInlineSize = Math.max (24, el.clientHeight) + setMobileAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize) + } + + updateAvailableInlineSize () + + const resizeObserver = new ResizeObserver (() => { + updateAvailableInlineSize () + }) + + resizeObserver.observe (el) + + return () => { + resizeObserver.disconnect () + } + }, [rootTags, materialFilter]) + const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags const setChildren = (tagId: number, children: MaterialSidebarTag[]) => { @@ -288,8 +441,6 @@ const MaterialSidebar: FC = () => { updateMaterialFilterQuery (location.pathname, location.search, navigate, value) } - const clearTagSelection = clearTagSelectionPath (location.search, materialFilter) - const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => ( tags.map (tag => ( { return ( <>
    -
    - - {selectedTagId != null ? '選択解除' : '全素材'} - -
    + dark:text-stone-100 md:hidden flex h-[25dvh] min-h-0 flex-col + overflow-hidden"> -
    -
    +
    +
    {visibleRootTags.map (tag => ( {
    - - {selectedTagId != null ? '選択解除' : '全素材'} - diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 4abdf1a..a32dd4a 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -28,11 +28,12 @@ type Props = const TagLink: FC = ({ tag, - nestLevel = 0, - linkFlg = true, - withWiki = true, - withCount = true, - ...props }) => { + nestLevel = 0, + linkFlg = true, + withWiki = true, + withCount = true, + className, + ...props }) => { const spanClass = cn ( `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) @@ -105,7 +106,7 @@ const TagLink: FC = ({ tag, )} {tag.matchedAlias != null && ( <> - + {tag.matchedAlias} <> → @@ -114,12 +115,12 @@ const TagLink: FC = ({ tag, ? ( {tag.name} ) : ( - {tag.name} )} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index ed2f596..dccd9ee 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -7,30 +7,36 @@ import Separator from '@/components/MenuSeparator' import PrefetchLink from '@/components/PrefetchLink' import TopNavUser from '@/components/TopNavUser' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' -import { tagsKeys, wikiKeys } from '@/lib/queryKeys' +import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { fetchTag, fetchTagByName } from '@/lib/tags' +import { fetchMaterial } from '@/lib/materials' import { cn } from '@/lib/utils' import { fetchWikiPage } from '@/lib/wiki' import type { FC, MouseEvent } from 'react' -import type { Menu, MenuVisibleItem, Tag, User } from '@/types' +import type { Material, 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 +export const menuOutline = ( + { tag, material, wikiId, user, pathName }: { + tag?: Tag | null + material?: Material | null + wikiId: number | null + user: User | null, + pathName: string }, +): Menu => { + const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0 const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) const wikiTitle = pathName.split ('/')[2] ?? '' const tagFlg = /^\/tags\/\d+/.test (pathName) + const materialFlg = /^\/materials\/\d+/.test (pathName) + return [ { name: '広場', to: '/posts', subMenu: [ { name: '一覧', to: '/posts' }, @@ -51,10 +57,15 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { 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: '/materials/changes' }, + { name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' }, + { component: , visible: materialFlg }, + { name: `広場 (${ postCount || 0 })`, + to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`, + visible: materialFlg }, + { name: '履歴', to: `/materials/changes?material_id=${ material?.id }`, + visible: materialFlg }] }, { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new' }, @@ -119,15 +130,23 @@ const TopNav: FC = ({ user }) => { queryFn: () => fetchWikiPage (wikiIdStr, { }) }) const tagFlg = /^\/tags\/\d+/.test (location.pathname) - const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? '' + const materialFlg = /^\/materials\/\d+/.test (location.pathname) + const effectiveTitle = (((tagFlg || materialFlg) + ? location.pathname.split ('/')[2] + : wikiPage?.title) + ?? '') const { data: tag } = useQuery ({ enabled: Boolean (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle), queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) }) + const { data: material } = useQuery ({ + enabled: Boolean (effectiveTitle), + queryKey: materialsKeys.show (effectiveTitle), + queryFn: () => fetchMaterial (effectiveTitle) }) - const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) + const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname }) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) const moreMenu = menu.filter (item => !(item.visible ?? true) diff --git a/frontend/src/components/common/PageTitle.tsx b/frontend/src/components/common/PageTitle.tsx index 5b8a697..e692ba6 100644 --- a/frontend/src/components/common/PageTitle.tsx +++ b/frontend/src/components/common/PageTitle.tsx @@ -1,12 +1,14 @@ import React from 'react' +import { cn } from '@/lib/utils' + import type { FC } from 'react' type Props = { children: React.ReactNode } -const PageTitle: FC = ({ children }) => ( -

    +const PageTitle: FC = ({ children, className, ...rest }) => ( +

    {children}

    ) diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index 2acfd84..ab9e044 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -1,41 +1,31 @@ -import { - apiGet, - isApiError, - apiPost, - apiPut, -} from '@/lib/api' +import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api' -import type { - Material, - MaterialIndexResponse, - MaterialVersion, - FetchMaterialsParams, - MaterialFilter, - MaterialSyncSuppression, - MaterialSidebarTag, - MaterialTagTree, -} from '@/types' +import type { Material, + MaterialIndexResponse, + MaterialVersion, + FetchMaterialsParams, + MaterialFilter, + MaterialSyncSuppression, + MaterialSidebarTag, + MaterialTagTree } from '@/types' export type FetchMaterialTreeParams = { parentId?: number | null - materialFilter: MaterialFilter -} + materialFilter: MaterialFilter } -export type MaterialSyncSuppressionResponse = { - suppressions: MaterialSyncSuppression[] -} +export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] } export type MaterialChangesResponse = { versions: MaterialVersion[] - count: number -} + count: number } const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] export const parseMaterialFilter = ( value: unknown, - fallback: MaterialFilter = 'present'): MaterialFilter => + fallback: MaterialFilter = 'present', +): MaterialFilter => typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter) ? value as MaterialFilter : fallback @@ -45,7 +35,8 @@ export const fetchMaterials = async ( { q, tagState, mediaKind, createdFrom, createdTo, updatedFrom, updatedTo, sort, direction, page, tagId, includeDescendants, groupBy, - limit }: FetchMaterialsParams): Promise => + limit }: FetchMaterialsParams, +): Promise => await apiGet ('/materials', { params: { ...(q && { q }), tag_state: tagState, diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 18b9664..c25791a 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -119,12 +119,6 @@ const MaterialDetailPage: FC = () => { : materialTitle} - - この素材の履歴 - - {(material.file && material.contentType) && ( (/image\/.*/.test (material.contentType) && ( {material.tag?.name)) diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx index 5bc490a..82876db 100644 --- a/frontend/src/pages/materials/MaterialHistoryPage.tsx +++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx @@ -80,14 +80,7 @@ const MaterialHistoryPage: FC = () => {
    -
    - 素材履歴 - - 素材一覧へ戻る - -
    + 素材履歴
    { onChange={e => setTagInput (e.target.value)} className={inputClass (invalid)}/>)} - - - {({ invalid }) => ( - )} -