From 51c8be1a8571eb405e1d99d4fed48a30859b4246 Mon Sep 17 00:00:00 2001 From: Yaomeng1749 <176285760+Yaomeng1749@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:42:00 +0800 Subject: [PATCH] Support Voice Memos file import --- .../MeetingTranscriptionService.swift | 2 +- .../UI/MeetingTranscriptionFileImport.swift | 319 ++++++++++++++++++ .../Fluid/UI/MeetingTranscriptionView.swift | 250 ++++++++++---- .../DictationE2ETests.swift | 45 +++ 4 files changed, 546 insertions(+), 70 deletions(-) create mode 100644 Sources/Fluid/UI/MeetingTranscriptionFileImport.swift diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index be7391e4..d505e107 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -72,7 +72,7 @@ final class MeetingTranscriptionService: ObservableObject { /// File extensions the OS can actually decode, queried dynamically from AVFoundation. /// Filtered to audio/video types only — excludes subtitles, playlists, etc. - static let supportedFileExtensions: Set = { + nonisolated static let supportedFileExtensions: Set = { let avTypes = AVURLAsset.audiovisualTypes() let extensions = avTypes.compactMap { fileType -> String? in guard let utType = UTType(fileType.rawValue) else { return nil } diff --git a/Sources/Fluid/UI/MeetingTranscriptionFileImport.swift b/Sources/Fluid/UI/MeetingTranscriptionFileImport.swift new file mode 100644 index 00000000..c7b9b6a4 --- /dev/null +++ b/Sources/Fluid/UI/MeetingTranscriptionFileImport.swift @@ -0,0 +1,319 @@ +import AppKit +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +enum MeetingTranscriptionFileImport { + nonisolated static let promisedFileContentTypeIdentifier = "com.apple.pasteboard.promised-file-content-type" + + nonisolated static var dropContentTypes: [UTType] { + self.uniqueTypes([UTType.fileURL, .audio, .movie]) + } + + nonisolated static var pasteboardTypes: [NSPasteboard.PasteboardType] { + self.uniquePasteboardTypes( + [NSPasteboard.PasteboardType.fileURL] + + NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) } + ) + } + + nonisolated static func supportedURLs(from urls: [URL]) -> [URL] { + self.uniqueURLs(urls).filter(self.isSupportedFile) + } + + nonisolated static func isSupportedFile(_ url: URL) -> Bool { + let fileExtension = url.pathExtension.lowercased() + guard !fileExtension.isEmpty else { return false } + return MeetingTranscriptionService.supportedFileExtensions.contains(fileExtension) + } + + nonisolated static func isSupportedTypeIdentifier(_ identifier: String) -> Bool { + guard let type = UTType(identifier) else { + return identifier == self.promisedFileContentTypeIdentifier + } + + if type.conforms(to: .audio) || type.conforms(to: .movie) { + return true + } + + if let fileExtension = type.preferredFilenameExtension?.lowercased(), + MeetingTranscriptionService.supportedFileExtensions.contains(fileExtension) + { + return true + } + + return identifier == self.promisedFileContentTypeIdentifier + } + + nonisolated static func candidateFileRepresentationTypes(for provider: NSItemProvider) -> [String] { + let supported = provider.registeredTypeIdentifiers.filter(self.isSupportedTypeIdentifier) + return self.uniqueIdentifiers(supported) + } + + nonisolated static func promisedFileDestinationDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FluidVoice-PromisedFiles", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + nonisolated static func persistedTemporaryCopy(of url: URL, suggestedName: String?) throws -> URL { + let directory = try self.promisedFileDestinationDirectory() + let rawName = suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) + let fileName: String + if let rawName, !rawName.isEmpty { + fileName = rawName + } else { + fileName = url.lastPathComponent.isEmpty ? UUID().uuidString : url.lastPathComponent + } + let destination = directory.appendingPathComponent(fileName, isDirectory: false) + try FileManager.default.copyItem(at: url, to: destination) + return destination + } + + nonisolated static func loadURLs(from providers: [NSItemProvider], completion: @escaping ([URL]) -> Void) { + guard !providers.isEmpty else { + completion([]) + return + } + + let group = DispatchGroup() + let lock = NSLock() + var urls: [URL] = [] + + func append(_ url: URL?) { + guard let url else { return } + lock.lock() + urls.append(url) + lock.unlock() + } + + for provider in providers { + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + group.enter() + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + defer { group.leave() } + let url = (item as? URL) ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } + append(url) + } + continue + } + + guard let typeIdentifier = self.candidateFileRepresentationTypes(for: provider).first else { + continue + } + + let suggestedName = provider.suggestedName + group.enter() + provider.loadInPlaceFileRepresentation(forTypeIdentifier: typeIdentifier) { url, isInPlace, _ in + defer { group.leave() } + guard let url else { return } + + if isInPlace { + append(url) + return + } + + do { + append(try self.persistedTemporaryCopy(of: url, suggestedName: suggestedName)) + } catch { + DebugLogger.shared.error("Failed to persist dropped file: \(error)", source: "MeetingTranscriptionFileImport") + } + } + } + + group.notify(queue: .main) { + completion(self.supportedURLs(from: urls)) + } + } + + private nonisolated static func uniqueURLs(_ urls: [URL]) -> [URL] { + var seen: Set = [] + var result: [URL] = [] + for url in urls { + let key = url.standardizedFileURL.path + guard seen.insert(key).inserted else { continue } + result.append(url) + } + return result + } + + private nonisolated static func uniqueTypes(_ types: [UTType]) -> [UTType] { + var seen: Set = [] + var result: [UTType] = [] + for type in types { + guard seen.insert(type.identifier).inserted else { continue } + result.append(type) + } + return result + } + + private nonisolated static func uniquePasteboardTypes(_ types: [NSPasteboard.PasteboardType]) -> [NSPasteboard.PasteboardType] { + var seen: Set = [] + var result: [NSPasteboard.PasteboardType] = [] + for type in types { + guard seen.insert(type.rawValue).inserted else { continue } + result.append(type) + } + return result + } + + private nonisolated static func uniqueIdentifiers(_ identifiers: [String]) -> [String] { + var seen: Set = [] + var result: [String] = [] + for identifier in identifiers { + guard seen.insert(identifier).inserted else { continue } + result.append(identifier) + } + return result + } +} + +struct MeetingTranscriptionFileDropTarget: NSViewRepresentable { + @Binding var isTargeted: Bool + let onResolved: ([URL]) -> Void + let onRejected: () -> Void + + func makeNSView(context _: Context) -> DropView { + let view = DropView() + view.registerForDraggedTypes(MeetingTranscriptionFileImport.pasteboardTypes) + view.onTargetedChanged = { targeted in + DispatchQueue.main.async { + self.isTargeted = targeted + } + } + view.onResolved = { urls in + DispatchQueue.main.async { + self.onResolved(urls) + } + } + view.onRejected = { + DispatchQueue.main.async { + self.onRejected() + } + } + return view + } + + func updateNSView(_ nsView: DropView, context _: Context) { + nsView.onTargetedChanged = { targeted in + DispatchQueue.main.async { + self.isTargeted = targeted + } + } + nsView.onResolved = { urls in + DispatchQueue.main.async { + self.onResolved(urls) + } + } + nsView.onRejected = { + DispatchQueue.main.async { + self.onRejected() + } + } + } + + final class DropView: NSView { + var onTargetedChanged: ((Bool) -> Void)? + var onResolved: (([URL]) -> Void)? + var onRejected: (() -> Void)? + + private let promiseQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "FluidVoice.PromiseDropQueue" + queue.qualityOfService = .userInitiated + return queue + }() + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + guard self.canAccept(sender) else { return [] } + self.onTargetedChanged?(true) + return .copy + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + self.canAccept(sender) ? .copy : [] + } + + override func draggingExited(_: NSDraggingInfo?) { + self.onTargetedChanged?(false) + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + self.canAccept(sender) + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + self.onTargetedChanged?(false) + + let pasteboard = sender.draggingPasteboard + let urls = self.fileURLs(from: pasteboard) + let supportedURLs = MeetingTranscriptionFileImport.supportedURLs(from: urls) + if !supportedURLs.isEmpty { + self.onResolved?(supportedURLs) + } + + let receivers = self.filePromiseReceivers(from: pasteboard) + let acceptedPromises = receivers.filter(self.canAcceptPromise) + for receiver in acceptedPromises { + self.receivePromisedFiles(receiver) + } + + let handled = !supportedURLs.isEmpty || !acceptedPromises.isEmpty + if !handled { + self.onRejected?() + } + return handled + } + + private func canAccept(_ sender: NSDraggingInfo) -> Bool { + let pasteboard = sender.draggingPasteboard + if !MeetingTranscriptionFileImport.supportedURLs(from: self.fileURLs(from: pasteboard)).isEmpty { + return true + } + return self.filePromiseReceivers(from: pasteboard).contains(where: self.canAcceptPromise) + } + + private func fileURLs(from pasteboard: NSPasteboard) -> [URL] { + let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] + return pasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [URL] ?? [] + } + + private func filePromiseReceivers(from pasteboard: NSPasteboard) -> [NSFilePromiseReceiver] { + pasteboard.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver] ?? [] + } + + private func canAcceptPromise(_ receiver: NSFilePromiseReceiver) -> Bool { + receiver.fileTypes.isEmpty || receiver.fileTypes.contains(where: MeetingTranscriptionFileImport.isSupportedTypeIdentifier) + } + + private func receivePromisedFiles(_ receiver: NSFilePromiseReceiver) { + do { + let destination = try MeetingTranscriptionFileImport.promisedFileDestinationDirectory() + receiver.receivePromisedFiles( + atDestination: destination, + options: [:], + operationQueue: self.promiseQueue + ) { [weak self] fileURL, error in + guard let self else { return } + if let error { + DebugLogger.shared.error("Promised file drop failed: \(error)", source: "MeetingTranscriptionFileImport") + self.onRejected?() + return + } + + let urls = MeetingTranscriptionFileImport.supportedURLs(from: [fileURL]) + if urls.isEmpty { + self.onRejected?() + } else { + self.onResolved?(urls) + } + } + } catch { + DebugLogger.shared.error("Failed to prepare promised file destination: \(error)", source: "MeetingTranscriptionFileImport") + self.onRejected?() + } + } + } +} diff --git a/Sources/Fluid/UI/MeetingTranscriptionView.swift b/Sources/Fluid/UI/MeetingTranscriptionView.swift index bea63c77..e49324a4 100644 --- a/Sources/Fluid/UI/MeetingTranscriptionView.swift +++ b/Sources/Fluid/UI/MeetingTranscriptionView.swift @@ -5,7 +5,7 @@ struct MeetingTranscriptionView: View { let asrService: ASRService @StateObject private var transcriptionService: MeetingTranscriptionService @ObservedObject private var fileHistoryStore = FileTranscriptionHistoryStore.shared - @State private var selectedFileURL: URL? + @State private var selectedFileURLs: [URL] = [] @Environment(\.theme) private var theme init(asrService: ASRService) { @@ -20,6 +20,7 @@ struct MeetingTranscriptionView: View { @State private var showingCopyConfirmation = false @State private var isDropTargeted = false @State private var dropErrorMessage: String? + @State private var batchStatusMessage: String? enum ExportFormat: String, CaseIterable { case text = "Text (.txt)" @@ -127,52 +128,18 @@ struct MeetingTranscriptionView: View { private var fileSelectionCard: some View { VStack(spacing: 16) { - if let fileURL = selectedFileURL { - // Show selected file - HStack { - Image(systemName: "doc.fill") - .font(.title2) - .foregroundColor(Color.fluidGreen) - - VStack(alignment: .leading, spacing: 4) { - Text(fileURL.lastPathComponent) - .font(.headline) - - Text(self.formatFileSize(fileURL: fileURL)) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Button(action: { - self.selectedFileURL = nil - self.transcriptionService.reset() - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - } - .padding() - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(self.theme.palette.cardBackground) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(self.theme.palette.cardBorder.opacity(0.5), lineWidth: 1) - ) - ) + if !self.selectedFileURLs.isEmpty { + self.selectedFilesSummary // Transcribe Button Button(action: { Task { - await self.transcribeFile() + await self.transcribeSelectedFiles() } }) { HStack { Image(systemName: "waveform") - Text("Transcribe") + Text(self.selectedFileURLs.count == 1 ? "Transcribe" : "Transcribe \(self.selectedFileURLs.count) Files") } .frame(maxWidth: .infinity) .padding(.vertical, 12) @@ -180,6 +147,13 @@ struct MeetingTranscriptionView: View { .buttonStyle(.borderedProminent) .disabled(self.transcriptionService.isTranscribing) + if let batchStatusMessage { + Text(batchStatusMessage) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { // File picker button – whole area is tappable; supports drag-and-drop Button(action: { @@ -214,7 +188,18 @@ struct MeetingTranscriptionView: View { .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [8])) .foregroundColor(Color.fluidGreen.opacity(self.isDropTargeted ? 0.7 : 0.3)) ) - .onDrop(of: [.fileURL], isTargeted: self.$isDropTargeted) { providers in + .background( + MeetingTranscriptionFileDropTarget( + isTargeted: self.$isDropTargeted, + onResolved: { urls in + self.addFileURLs(urls) + }, + onRejected: { + self.showDropError() + } + ) + ) + .onDrop(of: MeetingTranscriptionFileImport.dropContentTypes, isTargeted: self.$isDropTargeted) { providers in self.handleDrop(providers: providers) } } @@ -222,20 +207,82 @@ struct MeetingTranscriptionView: View { .fileImporter( isPresented: self.$showingFilePicker, allowedContentTypes: MeetingTranscriptionService.allowedContentTypes, - allowsMultipleSelection: false + allowsMultipleSelection: true ) { result in switch result { case let .success(urls): - if let url = urls.first { - self.selectedFileURL = url - self.transcriptionService.reset() - } + self.selectFileURLs(urls) case let .failure(error): DebugLogger.shared.error("File picker error: \(error)", source: "MeetingTranscriptionView") } } } + private var selectedFilesSummary: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: self.selectedFileURLs.count == 1 ? "doc.fill" : "doc.on.doc.fill") + .font(.title2) + .foregroundColor(Color.fluidGreen) + + VStack(alignment: .leading, spacing: 4) { + Text(self.selectedFilesTitle) + .font(.headline) + .lineLimit(1) + + Text(self.selectedFilesSubtitle) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + + Button(action: { + self.clearSelectedFiles() + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + + if self.selectedFileURLs.count > 1 { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(self.selectedFileURLs.prefix(4).enumerated()), id: \.element) { _, fileURL in + HStack(spacing: 8) { + Image(systemName: "waveform") + .font(.caption) + .foregroundColor(.secondary) + Text(fileURL.lastPathComponent) + .font(.caption) + .lineLimit(1) + Spacer() + Text(self.formatFileSize(fileURL: fileURL)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + if self.selectedFileURLs.count > 4 { + Text("+ \(self.selectedFileURLs.count - 4) more") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.cardBackground) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.5), lineWidth: 1) + ) + ) + } + // MARK: - Progress Card private var progressCard: some View { @@ -541,37 +588,102 @@ struct MeetingTranscriptionView: View { private static let dropErrorCopy = MeetingTranscriptionService.dropErrorCopy + private var selectedFilesTitle: String { + if self.selectedFileURLs.count == 1 { + return self.selectedFileURLs[0].lastPathComponent + } + return "\(self.selectedFileURLs.count) files selected" + } + + private var selectedFilesSubtitle: String { + if self.selectedFileURLs.count == 1 { + return self.formatFileSize(fileURL: self.selectedFileURLs[0]) + } + + let totalBytes = self.selectedFileURLs.reduce(Int64(0)) { total, fileURL in + let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) + return total + ((attributes?[.size] as? Int64) ?? 0) + } + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: totalBytes) + } + private func handleDrop(providers: [NSItemProvider]) -> Bool { - guard let provider = providers.first else { return false } - provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in - let url: URL? = (item as? URL) ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } - guard let url = url else { return } - let ext = url.pathExtension.lowercased() - guard Self.supportedFileExtensions.contains(ext) else { - DispatchQueue.main.async { - self.dropErrorMessage = Self.dropErrorCopy - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.dropErrorMessage = nil - } - } - return - } - DispatchQueue.main.async { - self.selectedFileURL = url - self.transcriptionService.reset() - self.dropErrorMessage = nil + guard !providers.isEmpty else { return false } + MeetingTranscriptionFileImport.loadURLs(from: providers) { urls in + if urls.isEmpty { + self.showDropError() + } else { + self.selectFileURLs(urls) } } return true } - private func transcribeFile() async { - guard let fileURL = selectedFileURL else { return } + private func selectFileURLs(_ urls: [URL]) { + let supportedURLs = MeetingTranscriptionFileImport.supportedURLs(from: urls) + guard !supportedURLs.isEmpty else { + self.showDropError() + return + } + + self.selectedFileURLs = supportedURLs + self.batchStatusMessage = nil + self.transcriptionService.reset() + self.dropErrorMessage = nil + } + + private func addFileURLs(_ urls: [URL]) { + let supportedURLs = MeetingTranscriptionFileImport.supportedURLs(from: urls) + guard !supportedURLs.isEmpty else { + self.showDropError() + return + } + + self.selectedFileURLs = MeetingTranscriptionFileImport.supportedURLs(from: self.selectedFileURLs + supportedURLs) + self.batchStatusMessage = nil + self.transcriptionService.reset() + self.dropErrorMessage = nil + } + + private func clearSelectedFiles() { + self.selectedFileURLs = [] + self.batchStatusMessage = nil + self.transcriptionService.reset() + } + + private func showDropError() { + self.dropErrorMessage = Self.dropErrorCopy + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.dropErrorMessage = nil + } + } + + private func transcribeSelectedFiles() async { + let urls = self.selectedFileURLs + guard !urls.isEmpty else { return } + + var completedCount = 0 + var failedCount = 0 + for (index, fileURL) in urls.enumerated() { + if urls.count > 1 { + self.batchStatusMessage = "Transcribing \(index + 1) of \(urls.count): \(fileURL.lastPathComponent)" + } + + do { + _ = try await self.transcriptionService.transcribeFile(fileURL) + completedCount += 1 + } catch { + failedCount += 1 + DebugLogger.shared.error("Transcription error: \(error)", source: "MeetingTranscriptionView") + } + } - do { - _ = try await self.transcriptionService.transcribeFile(fileURL) - } catch { - DebugLogger.shared.error("Transcription error: \(error)", source: "MeetingTranscriptionView") + if urls.count > 1 { + self.batchStatusMessage = failedCount == 0 + ? "Finished \(completedCount) files. Recent transcriptions are listed below." + : "Finished \(completedCount) of \(urls.count) files. \(failedCount) failed." } } diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 90b49afe..1cd18eb1 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -1,5 +1,7 @@ +import AppKit @testable import FluidVoice_Debug import Foundation +import UniformTypeIdentifiers import XCTest @MainActor @@ -69,6 +71,49 @@ final class DictationE2ETests: XCTestCase { } } + func testMeetingTranscriptionFileImport_filtersAndDedupesSupportedURLs() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("FluidVoiceImportTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let first = directory.appendingPathComponent("voice-one.m4a") + let duplicate = first + let second = directory.appendingPathComponent("voice-two.mp3") + let unsupported = directory.appendingPathComponent("notes.txt") + try Data([0x00]).write(to: first) + try Data([0x00]).write(to: second) + try Data([0x00]).write(to: unsupported) + + let urls = MeetingTranscriptionFileImport.supportedURLs(from: [first, unsupported, duplicate, second]) + + XCTAssertEqual(urls, [first, second]) + } + + func testMeetingTranscriptionFileImport_registersFileURLAndPromiseDropTypes() { + let dropTypeIDs = Set(MeetingTranscriptionFileImport.dropContentTypes.map(\.identifier)) + let pasteboardTypeIDs = Set(MeetingTranscriptionFileImport.pasteboardTypes.map(\.rawValue)) + + XCTAssertTrue(dropTypeIDs.contains(UTType.fileURL.identifier)) + XCTAssertTrue(pasteboardTypeIDs.contains(NSPasteboard.PasteboardType.fileURL.rawValue)) + for promisedType in NSFilePromiseReceiver.readableDraggedTypes { + XCTAssertTrue(pasteboardTypeIDs.contains(promisedType)) + } + } + + func testMeetingTranscriptionFileImport_detectsAudioItemProviderRepresentations() throws { + let provider = NSItemProvider() + let audioType = try XCTUnwrap(UTType(filenameExtension: "m4a")?.identifier) + provider.registerDataRepresentation(forTypeIdentifier: audioType, visibility: .all) { completion in + completion(Data([0x00]), nil) + return nil + } + + let candidates = MeetingTranscriptionFileImport.candidateFileRepresentationTypes(for: provider) + + XCTAssertEqual(candidates, [audioType]) + } + func testDictionaryTransferDocument_encodesSimpleUserFormat() throws { let document = DictionaryTransferDocument( replacements: [