diff --git a/backend/app/controllers/material_sync_suppressions_controller.rb b/backend/app/controllers/material_sync_suppressions_controller.rb new file mode 100644 index 0000000..b250cf1 --- /dev/null +++ b/backend/app/controllers/material_sync_suppressions_controller.rb @@ -0,0 +1,49 @@ +class MaterialSyncSuppressionsController < ApplicationController + def index + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + suppressions = + MaterialSyncSuppression + .includes(:created_by_user) + .order(created_at: :desc, id: :desc) + + render json: { suppressions: suppressions.map { |s| repr(s) } } + end + + def create + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + suppression = + MaterialSyncSuppressionRegistrar.create!(suppression_params, + created_by_user: current_user) + + render json: repr(suppression), status: :created + rescue ActiveRecord::RecordInvalid => e + render_validation_error e.record + end + + private + + def suppression_params + params.permit(:source_kind, + :source_uri, + :drive_path, + :drive_file_id, + :reason) + end + + def repr suppression + { id: suppression.id, + source_kind: suppression.source_kind, + source_uri: suppression.source_uri, + drive_path: suppression.drive_path, + drive_file_id: suppression.drive_file_id, + normalized_source_key: suppression.normalized_source_key, + reason: suppression.reason, + created_by_user: suppression.created_by_user&.as_json(UserRepr::BASE), + created_at: suppression.created_at, + updated_at: suppression.updated_at } + end +end diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 3274186..8747964 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -17,8 +17,6 @@ class MaterialsController < ApplicationController 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] @@ -136,7 +134,6 @@ class MaterialsController < ApplicationController material.assign_attributes(tag:, url:, updated_by_user: current_user) if uploaded_blob material.file.attach(uploaded_blob) - clear_file_suppression!(material) elsif params.key?(:url) && url.present? && file.blank? material.file.detach end @@ -182,37 +179,6 @@ class MaterialsController < ApplicationController 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 material_index_filters @@ -225,9 +191,6 @@ class MaterialsController < ApplicationController 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) @@ -240,7 +203,6 @@ class MaterialsController < ApplicationController { 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), @@ -410,26 +372,6 @@ class MaterialsController < ApplicationController file.tempfile.rewind if file&.tempfile end - def clear_file_suppression! material - material.file_suppressed_at = nil - material.file_suppressed_by_user = nil - material.file_suppression_reason = nil - 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, allow_download: true) } - end - def render_material_import_block block render_validation_error fields: { file: ["抑止された素材です: #{ block.reason }"] } end diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb index b480ed0..d275738 100644 --- a/backend/app/models/material.rb +++ b/backend/app/models/material.rb @@ -9,7 +9,6 @@ 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 @@ -17,13 +16,17 @@ class Material < ApplicationRecord has_one_attached :file, dependent: :purge has_one_attached :thumbnail, dependent: :purge + before_validation :assign_normalized_source_key + validates :tag_id, presence: true, uniqueness: true + validates :source_kind, + inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS }, + allow_blank: true validate :file_must_be_attached validate :tag_must_be_material_category def content_type - return nil if file_suppressed? return nil unless file&.attached? file.blob.content_type @@ -41,14 +44,25 @@ class Material < ApplicationRecord file.blob.filename.to_s 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 assign_normalized_source_key + if source_kind.blank? + self.normalized_source_key = nil + return + end + + self.normalized_source_key = + MaterialSyncSuppression.normalize_source_key(source_kind:, + source_uri:, + drive_path: source_path, + drive_file_id: source_file_id) + end + def file_must_be_attached return if url.present? || file.attached? diff --git a/backend/app/models/material_sync_suppression.rb b/backend/app/models/material_sync_suppression.rb new file mode 100644 index 0000000..cb6a6ba --- /dev/null +++ b/backend/app/models/material_sync_suppression.rb @@ -0,0 +1,68 @@ +class MaterialSyncSuppression < ApplicationRecord + SOURCE_KINDS = [ + 'uri', + 'google_drive_path', + 'google_drive_file', + 'legacy_drive_path' + ].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 + + before_validation :assign_normalized_source_key + + validates :source_kind, presence: true, inclusion: { in: SOURCE_KINDS } + validates :reason, presence: true, inclusion: { in: REASONS } + validates :normalized_source_key, presence: true, uniqueness: true + validate :source_value_must_be_present + + def self.normalize_source_key source_kind:, + source_uri: nil, + drive_path: nil, + drive_file_id: nil, + source_path: nil, + source_file_id: nil + kind = source_kind.to_s + value = + case kind + when 'uri' + source_uri.to_s.strip + when 'google_drive_path', 'legacy_drive_path' + (source_path || drive_path).to_s.strip.gsub(%r{/+}, '/') + when 'google_drive_file' + (source_file_id || drive_file_id).to_s.strip + else + '' + end + + return nil if kind.blank? || value.blank? + + "#{ kind }:#{ value }" + end + + private + + def assign_normalized_source_key + self.normalized_source_key = + self.class.normalize_source_key(source_kind:, + source_uri:, + drive_path:, + drive_file_id:) + end + + def source_value_must_be_present + return if normalized_source_key.present? + + errors.add(:base, '同期元を指定してください.') + end +end diff --git a/backend/app/models/material_version.rb b/backend/app/models/material_version.rb index 9227314..326a8f9 100644 --- a/backend/app/models/material_version.rb +++ b/backend/app/models/material_version.rb @@ -2,8 +2,7 @@ class MaterialVersion < ApplicationRecord EVENT_TYPE_MAP = { create: 'create', update: 'update', discard: 'discard', - restore: 'restore', - suppress: 'suppress' }.freeze + restore: 'restore' }.freeze include VersionRecord diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb index de4ad05..30d21f9 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -2,8 +2,9 @@ module MaterialRepr - BASE = { only: [:id, :url, :version_no, :file_suppressed_at, - :file_suppression_reason, :created_at, :updated_at], + BASE = { only: [:id, :url, :version_no, :source_kind, :source_uri, + :source_path, :source_file_id, :normalized_source_key, + :created_at, :updated_at], methods: [:content_type], include: { tag: TagRepr::BASE, created_by_user: UserRepr::BASE, @@ -13,7 +14,7 @@ module MaterialRepr def base material, host: material.as_json(BASE).merge( - file: if material.file.attached? && !material.file_suppressed? + file: if material.file.attached? Rails.application.routes.url_helpers.rails_storage_proxy_url( material.file, host:) end, @@ -34,6 +35,11 @@ module MaterialRepr { id: material.id, version_no: material.version_no, url: material.url, + 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, tag: compact_tag(material.tag), thumbnail: thumbnail_url(material, host:), thumbnail_fallback_text: thumbnail_fallback_text(material), @@ -41,7 +47,6 @@ module MaterialRepr 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), @@ -68,7 +73,6 @@ module MaterialRepr 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( @@ -84,7 +88,6 @@ module MaterialRepr 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 diff --git a/backend/app/services/material_sync_importer.rb b/backend/app/services/material_sync_importer.rb new file mode 100644 index 0000000..4c35894 --- /dev/null +++ b/backend/app/services/material_sync_importer.rb @@ -0,0 +1,49 @@ +class MaterialSyncImporter + Result = Struct.new(:material, :suppressed, :suppression, keyword_init: true) + + def self.import! attributes + new(attributes).import! + end + + def initialize attributes + @attributes = attributes + end + + def import! + source = source_attributes + 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 + + material = + Material + .unscoped + .find_or_initialize_by(normalized_source_key: key) + material.assign_attributes(material_attributes.merge(source)) + material.save! + + Result.new(material:, suppressed: false, suppression: nil) + end + + private + + def source_attributes + { source_kind: @attributes[:source_kind], + source_uri: @attributes[:source_uri], + source_path: @attributes[:source_path], + source_file_id: @attributes[:source_file_id] } + end + + def normalized_source_key source + MaterialSyncSuppression.normalize_source_key(source_kind: source[:source_kind], + source_uri: source[:source_uri], + source_path: source[:source_path], + source_file_id: source[:source_file_id]) + end + + def material_attributes + @attributes.except(:source_kind, :source_uri, :source_path, :source_file_id) + end +end diff --git a/backend/app/services/material_sync_suppression_matcher.rb b/backend/app/services/material_sync_suppression_matcher.rb new file mode 100644 index 0000000..58f8ce3 --- /dev/null +++ b/backend/app/services/material_sync_suppression_matcher.rb @@ -0,0 +1,22 @@ +class MaterialSyncSuppressionMatcher + def self.match source_kind:, + source_uri: nil, + drive_path: nil, + drive_file_id: nil, + source_path: nil, + source_file_id: nil + key = MaterialSyncSuppression.normalize_source_key(source_kind:, + source_uri:, + drive_path:, + drive_file_id:, + source_path:, + source_file_id:) + return nil if key.blank? + + MaterialSyncSuppression.find_by(normalized_source_key: key) + end + + def self.suppressed?(...) + match(...).present? + end +end diff --git a/backend/app/services/material_sync_suppression_registrar.rb b/backend/app/services/material_sync_suppression_registrar.rb new file mode 100644 index 0000000..5afcb8f --- /dev/null +++ b/backend/app/services/material_sync_suppression_registrar.rb @@ -0,0 +1,37 @@ +class MaterialSyncSuppressionRegistrar + def self.create! attributes, created_by_user: + new(attributes, created_by_user:).create! + end + + def initialize attributes, created_by_user: + @attributes = attributes + @created_by_user = created_by_user + end + + def create! + suppression = nil + + MaterialSyncSuppression.transaction do + suppression = MaterialSyncSuppression.create!( + @attributes.merge(created_by_user: @created_by_user)) + discard_existing_materials!(suppression) + end + + suppression + end + + private + + def discard_existing_materials! suppression + Material.unscoped + .kept + .where(normalized_source_key: suppression.normalized_source_key) + .find_each do |material| + MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: @created_by_user) + material.discard! + MaterialVersionRecorder.record!(material:, + event_type: :discard, + created_by_user: @created_by_user) + end + end +end diff --git a/backend/app/services/material_version_recorder.rb b/backend/app/services/material_version_recorder.rb index cf58198..6049d44 100644 --- a/backend/app/services/material_version_recorder.rb +++ b/backend/app/services/material_version_recorder.rb @@ -1,5 +1,5 @@ class MaterialVersionRecorder < VersionRecorder - EVENT_TYPES = ['create', 'update', 'discard', 'restore', 'suppress'].freeze + EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze def self.record! material:, event_type:, created_by_user:, file_snapshot: nil new(material:, event_type:, created_by_user:, file_snapshot:).record! @@ -40,9 +40,7 @@ class MaterialVersionRecorder < VersionRecorder 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 } + file_sha256: file_snapshot[:file_sha256] } end def build_file_snapshot blob diff --git a/backend/app/services/material_zip_exporter.rb b/backend/app/services/material_zip_exporter.rb index b4a02e9..9ad337a 100644 --- a/backend/app/services/material_zip_exporter.rb +++ b/backend/app/services/material_zip_exporter.rb @@ -40,7 +40,6 @@ class MaterialZipExporter .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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index dfbfb83..46b51c5 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -114,9 +114,6 @@ Rails.application.routes.draw do end get 'materials/download.zip', to: 'materials#download' - resources :materials, only: [:index, :show, :create, :update, :destroy] do - member do - patch :suppress_file - end - end + 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/20260625000000_reconcile_material_sync_suppressions.rb b/backend/db/migrate/20260625000000_reconcile_material_sync_suppressions.rb new file mode 100644 index 0000000..0b1f841 --- /dev/null +++ b/backend/db/migrate/20260625000000_reconcile_material_sync_suppressions.rb @@ -0,0 +1,118 @@ +class ReconcileMaterialSyncSuppressions < ActiveRecord::Migration[8.0] + MATERIAL_VERSION_EVENT_CONSTRAINT = 'material_versions_event_type_valid' + + def up + ensure_material_sync_suppressions! + ensure_material_source_columns! + ensure_material_active_source_key! + remove_material_file_suppression! + remove_material_version_file_suppression! + reconcile_material_version_event_constraint! + end + + def down + raise ActiveRecord::IrreversibleMigration + end + + private + + def ensure_material_sync_suppressions! + return if table_exists?(:material_sync_suppressions) + + create_table :material_sync_suppressions do |t| + t.string :source_kind, null: false + t.string :source_uri + t.string :drive_path + t.string :drive_file_id + t.string :normalized_source_key, null: false + t.string :reason, null: false + t.references :created_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.index :normalized_source_key, unique: true + t.index :source_kind + t.index :drive_file_id + end + end + + def ensure_material_source_columns! + add_column :materials, :source_kind, :string unless column_exists?(:materials, :source_kind) + add_column :materials, :source_uri, :string unless column_exists?(:materials, :source_uri) + add_column :materials, :source_path, :string unless column_exists?(:materials, :source_path) + unless column_exists?(:materials, :source_file_id) + add_column :materials, :source_file_id, :string + end + unless column_exists?(:materials, :normalized_source_key) + add_column :materials, :normalized_source_key, :string + end + + if index_exists?(:materials, :normalized_source_key) + remove_index :materials, column: :normalized_source_key + end + end + + def ensure_material_active_source_key! + unless column_exists?(:materials, :active_normalized_source_key) + change_table :materials do |t| + t.virtual :active_normalized_source_key, + type: :string, + as: 'IF(discarded_at IS NULL, normalized_source_key, NULL)', + stored: false + end + end + + return if index_exists?(:materials, + :active_normalized_source_key, + name: 'index_materials_on_active_normalized_source_key') + + add_index :materials, + :active_normalized_source_key, + unique: true, + name: 'index_materials_on_active_normalized_source_key' + end + + def remove_material_file_suppression! + if foreign_key_exists?(:materials, :users, column: :file_suppressed_by_user_id) + remove_foreign_key :materials, column: :file_suppressed_by_user_id + end + + if index_exists?(:materials, + :file_suppressed_by_user_id, + name: 'index_materials_on_file_suppressed_by_user_id') + remove_index :materials, name: 'index_materials_on_file_suppressed_by_user_id' + end + + remove_column :materials, :file_suppressed_at if column_exists?( + :materials, + :file_suppressed_at) + remove_column :materials, :file_suppressed_by_user_id if column_exists?( + :materials, + :file_suppressed_by_user_id) + remove_column :materials, :file_suppression_reason if column_exists?( + :materials, + :file_suppression_reason) + end + + def remove_material_version_file_suppression! + remove_column :material_versions, :file_suppressed_at if column_exists?( + :material_versions, + :file_suppressed_at) + remove_column :material_versions, :file_suppression_reason if column_exists?( + :material_versions, + :file_suppression_reason) + end + + def reconcile_material_version_event_constraint! + constraint = check_constraints(:material_versions).find do |candidate| + candidate.name == MATERIAL_VERSION_EVENT_CONSTRAINT + end + return if constraint && !constraint.expression.include?('suppress') + + if constraint + remove_check_constraint :material_versions, name: MATERIAL_VERSION_EVENT_CONSTRAINT + end + add_check_constraint :material_versions, + "event_type IN ('create', 'update', 'discard', 'restore')", + name: MATERIAL_VERSION_EVENT_CONSTRAINT + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 7bc7d8e..5daf890 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_23_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_25_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 @@ -72,7 +72,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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| @@ -156,6 +155,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do t.index ["created_by_user_id"], name: "index_material_import_blocks_on_created_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" + t.string "drive_path" + t.string "drive_file_id" + t.string "normalized_source_key", null: false + t.string "reason", 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_sync_suppressions_on_created_by_user_id" + t.index ["drive_file_id"], name: "index_material_sync_suppressions_on_drive_file_id" + t.index ["normalized_source_key"], name: "index_material_sync_suppressions_on_normalized_source_key", unique: true + t.index ["source_kind"], name: "index_material_sync_suppressions_on_source_kind" + 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 @@ -177,8 +192,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do 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" @@ -188,7 +201,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do 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" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "material_versions_event_type_valid" end create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -202,13 +215,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) do 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.string "source_kind" + t.string "source_uri" + t.string "source_path" + t.string "source_file_id" + t.string "normalized_source_key" + t.virtual "active_normalized_source_key", type: :string, as: "if((`discarded_at` is null),`normalized_source_key`,NULL)" + t.index ["active_normalized_source_key"], name: "index_materials_on_active_normalized_source_key", unique: true 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" @@ -232,10 +248,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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", 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 ["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 ["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| @@ -244,14 +259,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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", order: { cos: :desc } + t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end @@ -291,8 +305,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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| @@ -307,7 +319,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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| @@ -354,7 +365,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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", order: { cos: :desc } + t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos" t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end @@ -364,32 +375,30 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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", 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 ["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 ["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| @@ -514,19 +523,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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 @@ -543,13 +539,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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| @@ -594,8 +588,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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" @@ -615,6 +607,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_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_suppressions", "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" @@ -623,7 +616,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_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" @@ -672,8 +664,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_23_000000) 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/App.tsx b/frontend/src/App.tsx index 7796d7a..b07f4bf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage' +import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage' // import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' import MorePage from '@/pages/MorePage' import GekanatorPage from '@/pages/GekanatorPage' @@ -69,7 +70,8 @@ const RouteTransitionWrapper = ({ user, setUser }: { }> }/> }/> - }/> + }/> + }/> {/* }/> */} }/> diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index b0ccf7c..12b2320 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -1,7 +1,6 @@ import { apiGet, isApiError, - apiPatch, apiPost, apiPut, } from '@/lib/api' @@ -10,6 +9,7 @@ import type { Material, FetchMaterialsParams, MaterialFilter, + MaterialSyncSuppression, MaterialSidebarTag, MaterialTagTree, } from '@/types' @@ -24,6 +24,10 @@ export type MaterialIndexResponse = { count: number } +export type MaterialSyncSuppressionResponse = { + suppressions: MaterialSyncSuppression[] +} + const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any'] @@ -36,13 +40,13 @@ export const parseMaterialFilter = ( export const fetchMaterials = async ( - { q, tagState, mediaKind, suppression, createdFrom, createdTo, - updatedFrom, updatedTo, sort, direction, page, limit }: FetchMaterialsParams): Promise => + { q, tagState, mediaKind, createdFrom, createdTo, + updatedFrom, updatedTo, sort, direction, page, + limit }: FetchMaterialsParams): Promise => await apiGet ('/materials', { params: { ...(q && { q }), tag_state: tagState, media_kind: mediaKind, - suppression, ...(createdFrom && { created_from: createdFrom }), ...(createdTo && { created_to: createdTo }), ...(updatedFrom && { updated_from: updatedFrom }), @@ -101,7 +105,22 @@ export const updateMaterial = async ( await apiPut (`/materials/${ id }`, formData) -export const suppressMaterialFile = async ( - id: string, - payload: { reason: string; purge?: boolean }): Promise => - await apiPatch (`/materials/${ id }/suppress_file`, payload) +export const fetchMaterialSyncSuppressions = + async (): Promise => + await apiGet ('/materials/suppressions') + + +export const createMaterialSyncSuppression = async ( + payload: { + sourceKind: string + sourceUri?: string + drivePath?: string + driveFileId?: string + reason: string + }): Promise => + await apiPost ('/materials/suppressions', { + source_kind: payload.sourceKind, + source_uri: payload.sourceUri, + drive_path: payload.drivePath, + drive_file_id: payload.driveFileId, + reason: payload.reason}) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index ffa3b86..33ea46f 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -37,6 +37,7 @@ export const materialsKeys = { byTagName: (name: string, materialFilter: MaterialFilter) => ['materials', 'tag', name, materialFilter] as const, show: (id: string) => ['materials', id] as const, + suppressions: () => ['materials', 'suppressions'] as const, tree: (p: { parentId?: number | null materialFilter: MaterialFilter diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 69fe2aa..b6be85f 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -15,7 +15,6 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { - suppressMaterialFile, fetchMaterial, updateMaterial, } from '@/lib/materials' @@ -25,12 +24,10 @@ import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC } from 'react' -import type { User } from '@/types' - type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths' -const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { +const MaterialDetailPage: FC = () => { const { id } = useParams () const qc = useQueryClient () @@ -91,35 +88,11 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { 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 - - suppressMutation.mutate (reason) - } - return ( {material && ( @@ -145,16 +118,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { : materialTitle} - {material.fileSuppressedAt && ( -
- 素材ファイルは抑止済みです。 - {material.fileSuppressionReason && ( - 理由: {material.fileSuppressionReason})} -
)} - - {(!material.fileSuppressedAt && material.file && material.contentType) && ( + {(material.file && material.contentType) && ( (/image\/.*/.test (material.contentType) && ( {material.tag?.name)) || (/video\/.*/.test (material.contentType) && ( @@ -256,14 +220,6 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => { disabled={updateMutation.isPending}> 更新 - {user?.role === 'admin' && !material.fileSuppressedAt && ( - )} 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