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) if unchanged?(material, source) return Result.new(material:, action: :unchanged, suppressed: false, suppression: nil) end event_type = if material.new_record? :create elsif material.discarded? :restore else :update end uploaded_blob = uploaded_blob! if @import_block return Result.new(material: nil, action: :suppressed, suppressed: true, suppression: @import_block) end Material.transaction do if material.persisted? MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: @attributes[:updated_by_user]) end material.assign_attributes(material_attributes_for(material).merge(source)) material.discarded_at = nil if material.respond_to?(:discarded_at=) material.file.attach(uploaded_blob) if uploaded_blob material.save! 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_for material attrs = @attributes .except(:source_kind, :source_uri, :source_path, :source_file_id, :file_blob, :file_tempfile, :file_downloader, :filename, :content_type, :file_sha256, :export_path, :profile, :tag, :url) if material.new_record? attrs[:tag] = @attributes[:tag] if @attributes.key?(:tag) attrs[:url] = @attributes[:url] if @attributes.key?(:url) return attrs end if @attributes.key?(:tag) && @attributes[:tag].present? attrs[:tag] = @attributes[:tag] end if @attributes.key?(:url) && @attributes[:url].present? attrs[:url] = @attributes[:url] end attrs end def suppression_for source 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 = upload_tempfile return nil unless tempfile tempfile.rewind file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest @attributes[:file_sha256] = file_sha256 @import_block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) if @import_block tempfile.rewind return nil end blob = ActiveStorage::Blob.create_and_upload!( io: tempfile, filename: @attributes[:filename], content_type: @attributes[:content_type]) MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256) tempfile.rewind blob end def upload_tempfile return @upload_tempfile if defined?(@upload_tempfile) @upload_tempfile = @attributes[:file_tempfile] if @upload_tempfile.blank? && @attributes[:file_downloader] @upload_tempfile = @attributes[:file_downloader].call end @upload_tempfile end def upsert_export_item! material export_path = resolved_export_path(material) return if export_path.blank? item = material.material_export_items.find_or_initialize_by(profile: effective_profile) item.export_path = export_path item.enabled = true item.created_by_user ||= @attributes[:created_by_user] item.save! 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 = upload_tempfile return unless tempfile tempfile.close! unless tempfile.closed? rescue StandardError nil end def unchanged? material, source return false if material.new_record? || material.discarded? return false unless same_source_attributes?(material, source) return false unless same_export_path?(material) return false unless same_material_attributes?(material) same_file_snapshot?(material) end def same_source_attributes? material, source material.source_kind == source[:source_kind] \ && material.source_uri == source[:source_uri] \ && material.source_path == source[:source_path] \ && material.source_file_id == source[:source_file_id] end def same_export_path? material expected = resolved_export_path(material) current = material.material_export_items.find { |item| item.profile == effective_profile } return current.blank? if expected.blank? current&.enabled && current.export_path == expected end def same_material_attributes? material same_tag_attribute?(material) && same_url_attribute?(material) end def same_file_snapshot? material expected_sha256 = @attributes[:file_sha256].to_s.presence if expected_sha256.present? return false unless material.file.attached? return MaterialFileSha256.metadata_sha256(material.file.blob) == expected_sha256 end !material.file.attached? end def effective_profile @attributes[:profile].presence || 'legacy_drive' end def resolved_export_path material export_path = @attributes[:export_path].to_s.strip return export_path if export_path.blank? return export_path unless export_path_taken_by_other?(material, effective_profile, export_path) MaterialSyncExportPath.uniquify(export_path, @attributes[:source_file_id]) end def same_tag_attribute? material return true unless @attributes.key?(:tag) return true if @attributes[:tag].blank? material.tag_id == @attributes[:tag].id end def same_url_attribute? material return true unless @attributes.key?(:url) return true if @attributes[:url].blank? material.url == @attributes[:url] end end