From 8304909c8c857b08aa847c251ecf44eb7ff8e67a Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 26 Jun 2026 00:21:33 +0900 Subject: [PATCH] #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 = {