require 'digest' class MaterialSyncImporter Result = Struct.new(:material, :action, :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 = 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) event_type = if material.new_record? :create elsif material.discarded? :restore else :update end uploaded_blob = uploaded_blob! 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 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, :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