diff --git a/Hex/Features/App/AppFeature.swift b/Hex/Features/App/AppFeature.swift index 323da35..9cb8202 100644 --- a/Hex/Features/App/AppFeature.swift +++ b/Hex/Features/App/AppFeature.swift @@ -13,6 +13,7 @@ import SwiftUI struct AppFeature { enum ActiveTab: Equatable { case settings + case transformations case history case about } @@ -157,6 +158,13 @@ struct AppView: View { }.buttonStyle(.plain) .tag(AppFeature.ActiveTab.settings) + Button { + store.send(.setActiveTab(.transformations)) + } label: { + Label("Transformations", systemImage: "wand.and.stars") + }.buttonStyle(.plain) + .tag(AppFeature.ActiveTab.transformations) + Button { store.send(.setActiveTab(.history)) } label: { @@ -176,6 +184,9 @@ struct AppView: View { case .settings: SettingsView(store: store.scope(state: \.settings, action: \.settings)) .navigationTitle("Settings") + case .transformations: + TextTransformationSectionView(store: store.scope(state: \.settings.textTransformation, action: \.settings.textTransformation)) + .navigationTitle("Text Transformations") case .history: HistoryView(store: store.scope(state: \.history, action: \.history)) .navigationTitle("History") diff --git a/Hex/Features/Settings/SettingsFeature.swift b/Hex/Features/Settings/SettingsFeature.swift index 72d97de..6f2c9c0 100644 --- a/Hex/Features/Settings/SettingsFeature.swift +++ b/Hex/Features/Settings/SettingsFeature.swift @@ -44,6 +44,9 @@ struct SettingsFeature { // Model Management var modelDownload = ModelDownloadFeature.State() + + // Text Transformations + var textTransformation = TextTransformationFeature.State() } enum Action: BindableAction { @@ -74,6 +77,9 @@ struct SettingsFeature { // History Management case toggleSaveTranscriptionHistory(Bool) + + // Text Transformations + case textTransformation(TextTransformationFeature.Action) } @Dependency(\.keyEventMonitor) var keyEventMonitor @@ -87,6 +93,10 @@ struct SettingsFeature { Scope(state: \.modelDownload, action: \.modelDownload) { ModelDownloadFeature() } + + Scope(state: \.textTransformation, action: \.textTransformation) { + TextTransformationFeature() + } Reduce { state, action in switch action { @@ -358,6 +368,9 @@ struct SettingsFeature { } return .none + + case .textTransformation: + return .none } } } diff --git a/Hex/Features/Settings/TextTransformationFeature.swift b/Hex/Features/Settings/TextTransformationFeature.swift new file mode 100644 index 0000000..0fa5d2a --- /dev/null +++ b/Hex/Features/Settings/TextTransformationFeature.swift @@ -0,0 +1,181 @@ +import ComposableArchitecture +import Foundation +import HexCore + +@Reducer +struct TextTransformationFeature { + @ObservableState + struct State: Equatable { + @Shared(.hexSettings) var hexSettings: HexSettings + var previewText: String = "Hello World! This is a test." + var previewResult: String = "" + var isPreviewLoading: Bool = false + + var editingReplacement: ReplaceTextConfig? + var editingPrefix: String? + var editingSuffix: String? + } + + enum Action: BindableAction { + case binding(BindingAction) + + // Pipeline control + case togglePipeline + case addTransformation(TransformationType) + case deleteTransformation(UUID) + case toggleTransformation(UUID) + case moveTransformation(from: Int, to: Int) + + // Editing + case startEditingReplacement(UUID?) + case saveReplacement(ReplaceTextConfig) + case cancelEditing + + case startEditingPrefix(UUID?) + case savePrefix(UUID?, String) + + case startEditingSuffix(UUID?) + case saveSuffix(UUID?, String) + + // Preview + case updatePreview + case previewUpdated(String) + } + + var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .togglePipeline: + state.$hexSettings.withLock { settings in + settings.textTransformationPipeline.isEnabled.toggle() + } + return .send(.updatePreview) + + case let .addTransformation(type): + state.$hexSettings.withLock { settings in + settings.textTransformationPipeline.transformations.append( + Transformation(type: type) + ) + } + return .send(.updatePreview) + + case let .deleteTransformation(id): + state.$hexSettings.withLock { settings in + settings.textTransformationPipeline.transformations.removeAll { $0.id == id } + } + return .send(.updatePreview) + + case let .toggleTransformation(id): + state.$hexSettings.withLock { settings in + if let index = settings.textTransformationPipeline.transformations.firstIndex(where: { $0.id == id }) { + settings.textTransformationPipeline.transformations[index].isEnabled.toggle() + } + } + return .send(.updatePreview) + + case let .moveTransformation(from, to): + state.$hexSettings.withLock { settings in + settings.textTransformationPipeline.move(from: from, to: to) + } + return .send(.updatePreview) + + case let .startEditingReplacement(id): + if let id = id, + let transformation = state.hexSettings.textTransformationPipeline.transformations.first(where: { $0.id == id }), + case .replaceText(let config) = transformation.type { + state.editingReplacement = config + } else { + state.editingReplacement = ReplaceTextConfig(pattern: "", replacement: "") + } + return .none + + case let .saveReplacement(config): + state.$hexSettings.withLock { settings in + if let index = settings.textTransformationPipeline.transformations.firstIndex(where: { $0.id == config.id }) { + settings.textTransformationPipeline.transformations[index].type = .replaceText(config) + } else { + settings.textTransformationPipeline.transformations.append( + Transformation(type: .replaceText(config)) + ) + } + } + state.editingReplacement = nil + return .send(.updatePreview) + + case .cancelEditing: + state.editingReplacement = nil + state.editingPrefix = nil + state.editingSuffix = nil + return .none + + case let .startEditingPrefix(id): + if let id = id, + let transformation = state.hexSettings.textTransformationPipeline.transformations.first(where: { $0.id == id }), + case .addPrefix(let prefix) = transformation.type { + state.editingPrefix = prefix + } else { + state.editingPrefix = "" + } + return .none + + case let .savePrefix(id, prefix): + state.$hexSettings.withLock { settings in + if let id = id, + let index = settings.textTransformationPipeline.transformations.firstIndex(where: { $0.id == id }) { + settings.textTransformationPipeline.transformations[index].type = .addPrefix(prefix) + } else { + settings.textTransformationPipeline.transformations.append( + Transformation(type: .addPrefix(prefix)) + ) + } + } + state.editingPrefix = nil + return .send(.updatePreview) + + case let .startEditingSuffix(id): + if let id = id, + let transformation = state.hexSettings.textTransformationPipeline.transformations.first(where: { $0.id == id }), + case .addSuffix(let suffix) = transformation.type { + state.editingSuffix = suffix + } else { + state.editingSuffix = "" + } + return .none + + case let .saveSuffix(id, suffix): + state.$hexSettings.withLock { settings in + if let id = id, + let index = settings.textTransformationPipeline.transformations.firstIndex(where: { $0.id == id }) { + settings.textTransformationPipeline.transformations[index].type = .addSuffix(suffix) + } else { + settings.textTransformationPipeline.transformations.append( + Transformation(type: .addSuffix(suffix)) + ) + } + } + state.editingSuffix = nil + return .send(.updatePreview) + + case .updatePreview: + state.isPreviewLoading = true + let text = state.previewText + let pipeline = state.hexSettings.textTransformationPipeline + + return .run { send in + let result = await pipeline.process(text) + await send(.previewUpdated(result)) + } + + case let .previewUpdated(result): + state.isPreviewLoading = false + state.previewResult = result + return .none + } + } + } +} diff --git a/Hex/Features/Settings/TextTransformationSectionView.swift b/Hex/Features/Settings/TextTransformationSectionView.swift new file mode 100644 index 0000000..f03aec6 --- /dev/null +++ b/Hex/Features/Settings/TextTransformationSectionView.swift @@ -0,0 +1,396 @@ +import ComposableArchitecture +import HexCore +import Inject +import SwiftUI + +struct TextTransformationSectionView: View { + @ObserveInjection var inject + @Bindable var store: StoreOf + + var body: some View { + VStack(spacing: 0) { + // Header with toggle + HStack { + Toggle("Enable Transformations", isOn: Binding( + get: { store.hexSettings.textTransformationPipeline.isEnabled }, + set: { _ in store.send(.togglePipeline) } + )) + .toggleStyle(.switch) + .font(.title3) + + Spacer() + } + .padding() + + Divider() + + Group { + // Preview section + VStack(alignment: .leading, spacing: 12) { + Text("Preview") + .font(.headline) + + VStack(alignment: .leading, spacing: 8) { + Text("Input") + .font(.caption) + .foregroundStyle(.secondary) + + TextField("Test text", text: $store.previewText, axis: .vertical) + .textFieldStyle(.plain) + .padding(10) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(6) + .lineLimit(3...5) + .onChange(of: store.previewText) { _, _ in + store.send(.updatePreview) + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Output") + .font(.caption) + .foregroundStyle(.secondary) + + if store.isPreviewLoading { + ProgressView() + .scaleEffect(0.6) + .frame(width: 16, height: 16) + } + } + + Text(store.previewResult.isEmpty ? store.previewText : store.previewResult) + .font(.body.monospaced()) + .foregroundStyle(store.previewResult.isEmpty ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(nsColor: .textBackgroundColor).opacity(0.5)) + .cornerRadius(6) + .lineLimit(3...5) + } + } + .padding() + + Divider() + + // Transformations list + if store.hexSettings.textTransformationPipeline.transformations.isEmpty { + VStack(spacing: 12) { + Image(systemName: "wand.and.stars") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text("No transformations yet") + .font(.title3) + .foregroundStyle(.secondary) + Text("Add your first transformation below") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(Array(store.hexSettings.textTransformationPipeline.transformations.enumerated()), id: \.element.id) { index, transformation in + TransformationRow( + transformation: transformation, + index: index + 1, + onToggle: { store.send(.toggleTransformation(transformation.id)) }, + onEdit: { + switch transformation.type { + case .replaceText: + store.send(.startEditingReplacement(transformation.id)) + case .addPrefix: + store.send(.startEditingPrefix(transformation.id)) + case .addSuffix: + store.send(.startEditingSuffix(transformation.id)) + default: + break + } + }, + onDelete: { store.send(.deleteTransformation(transformation.id)) } + ) + .listRowInsets(EdgeInsets()) + } + .onMove { from, to in + if let fromIndex = from.first { + store.send(.moveTransformation(from: fromIndex, to: to)) + } + } + } + .listStyle(.plain) + } + + Divider() + + // Add button + HStack { + Menu { + Section("Text Case") { + Button("UPPERCASE") { store.send(.addTransformation(.uppercase)) } + Button("lowercase") { store.send(.addTransformation(.lowercase)) } + Button("Title Case") { store.send(.addTransformation(.capitalize)) } + Button("Capitalize first") { store.send(.addTransformation(.capitalizeFirst)) } + Button("sPoNgEbOb cAsE") { store.send(.addTransformation(.spongebobCase)) } + } + + Section("Whitespace") { + Button("Trim whitespace") { store.send(.addTransformation(.trimWhitespace)) } + Button("Remove extra spaces") { store.send(.addTransformation(.removeExtraSpaces)) } + } + + Section("Custom") { + Button("Replace Text...") { store.send(.startEditingReplacement(nil)) } + Button("Add Prefix...") { store.send(.startEditingPrefix(nil)) } + Button("Add Suffix...") { store.send(.startEditingSuffix(nil)) } + } + } label: { + Label("Add Transformation", systemImage: "plus.circle.fill") + .font(.body) + } + .menuStyle(.button) + .buttonStyle(.borderedProminent) + + Spacer() + + if !store.hexSettings.textTransformationPipeline.transformations.isEmpty { + Text("\(store.hexSettings.textTransformationPipeline.transformations.count) transformation\(store.hexSettings.textTransformationPipeline.transformations.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding() + } + .disabled(!store.hexSettings.textTransformationPipeline.isEnabled) + .opacity(store.hexSettings.textTransformationPipeline.isEnabled ? 1 : 0.5) + } + .sheet(item: $store.editingReplacement) { config in + ReplaceTextSheet(config: config, store: store) + } + .sheet(item: Binding( + get: { store.editingPrefix.map { EditingString(value: $0) } }, + set: { store.editingPrefix = $0?.value } + )) { editingString in + PrefixSheet(prefix: editingString.value, store: store) + } + .sheet(item: Binding( + get: { store.editingSuffix.map { EditingString(value: $0) } }, + set: { store.editingSuffix = $0?.value } + )) { editingString in + SuffixSheet(suffix: editingString.value, store: store) + } + .task { + await store.send(.updatePreview).finish() + } + .enableInjection() + } +} + +struct EditingString: Identifiable { + let id = UUID() + var value: String +} + +struct TransformationRow: View { + let transformation: Transformation + let index: Int + let onToggle: () -> Void + let onEdit: () -> Void + let onDelete: () -> Void + + var canEdit: Bool { + switch transformation.type { + case .replaceText, .addPrefix, .addSuffix: + return true + default: + return false + } + } + + var body: some View { + HStack(spacing: 12) { + // Drag handle + Image(systemName: "line.3.horizontal") + .font(.body) + .foregroundStyle(.tertiary) + .frame(width: 20) + + // Index + Text("\(index)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 20, alignment: .trailing) + + // Checkbox + Toggle(isOn: Binding(get: { transformation.isEnabled }, set: { _ in onToggle() })) { + EmptyView() + } + .toggleStyle(.checkbox) + + // Name + Text(transformation.name) + .font(.body) + .foregroundStyle(transformation.isEnabled ? .primary : .secondary) + + Spacer() + + // Edit button + if canEdit { + Button(action: onEdit) { + Image(systemName: "pencil.circle.fill") + .font(.body) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Edit") + } + + // Delete button + Button(action: onDelete) { + Image(systemName: "trash.circle.fill") + .font(.body) + } + .buttonStyle(.plain) + .foregroundStyle(.red.opacity(0.8)) + .help("Delete") + } + .padding(.horizontal) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } +} + +struct ReplaceTextSheet: View { + @State var config: ReplaceTextConfig + let store: StoreOf + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Replace Text") + .font(.title2) + + Form { + TextField("Find", text: $config.pattern) + .textFieldStyle(.roundedBorder) + + TextField("Replace with", text: $config.replacement) + .textFieldStyle(.roundedBorder) + + Toggle("Case Sensitive", isOn: $config.caseSensitive) + Toggle("Use Regex", isOn: $config.useRegex) + } + .formStyle(.grouped) + + HStack { + Button("Cancel") { + dismiss() + store.send(.cancelEditing) + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Save") { + dismiss() + store.send(.saveReplacement(config)) + } + .keyboardShortcut(.defaultAction) + .disabled(config.pattern.isEmpty) + } + .padding(.horizontal) + } + .padding() + .frame(width: 450, height: 280) + } +} + +struct PrefixSheet: View { + @State var prefix: String + let store: StoreOf + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Add Prefix") + .font(.title2) + + VStack(alignment: .leading, spacing: 8) { + Text("Prefix text") + .font(.caption) + .foregroundStyle(.secondary) + + TextField("Prefix", text: $prefix, axis: .vertical) + .textFieldStyle(.plain) + .padding(8) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(6) + .lineLimit(3...8) + } + .padding(.horizontal) + + HStack { + Button("Cancel") { + dismiss() + store.send(.cancelEditing) + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Save") { + dismiss() + store.send(.savePrefix(nil, prefix)) + } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal) + } + .padding() + .frame(width: 450, height: 220) + } +} + +struct SuffixSheet: View { + @State var suffix: String + let store: StoreOf + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Add Suffix") + .font(.title2) + + VStack(alignment: .leading, spacing: 8) { + Text("Suffix text") + .font(.caption) + .foregroundStyle(.secondary) + + TextField("Suffix", text: $suffix, axis: .vertical) + .textFieldStyle(.plain) + .padding(8) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(6) + .lineLimit(3...8) + } + .padding(.horizontal) + + HStack { + Button("Cancel") { + dismiss() + store.send(.cancelEditing) + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Save") { + dismiss() + store.send(.saveSuffix(nil, suffix)) + } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal) + } + .padding() + .frame(width: 450, height: 220) + } +} diff --git a/Hex/Features/Transcription/TranscriptionFeature.swift b/Hex/Features/Transcription/TranscriptionFeature.swift index 5d6c339..3a25b4d 100644 --- a/Hex/Features/Transcription/TranscriptionFeature.swift +++ b/Hex/Features/Transcription/TranscriptionFeature.swift @@ -357,17 +357,25 @@ private extension TranscriptionFeature { return .none } - // Compute how long we recorded + // Capture values before async closure let duration = state.recordingStartTime.map { Date().timeIntervalSince($0) } ?? 0 + let pipeline = state.hexSettings.textTransformationPipeline + let sourceAppBundleID = state.sourceAppBundleID + let sourceAppName = state.sourceAppName + let transcriptionHistory = state.$transcriptionHistory // Continue with storing the final result in the background - return finalizeRecordingAndStoreTranscript( - result: result, - duration: duration, - sourceAppBundleID: state.sourceAppBundleID, - sourceAppName: state.sourceAppName, - transcriptionHistory: state.$transcriptionHistory - ) + return .run { send in + let transformedResult = await pipeline.process(result) + await finalizeRecordingAndStoreTranscript( + result: transformedResult, + duration: duration, + sourceAppBundleID: sourceAppBundleID, + sourceAppName: sourceAppName, + transcriptionHistory: transcriptionHistory, + send: send + ) + } } func handleTranscriptionError( @@ -389,9 +397,9 @@ private extension TranscriptionFeature { duration: TimeInterval, sourceAppBundleID: String?, sourceAppName: String?, - transcriptionHistory: Shared - ) -> Effect { - .run { send in + transcriptionHistory: Shared, + send: Send + ) async { do { let originalURL = await recording.stopRecording() @@ -453,7 +461,6 @@ private extension TranscriptionFeature { } catch { await send(.transcriptionError(error)) } - } } } diff --git a/Hex/Models/HexSettings.swift b/Hex/Models/HexSettings.swift index 4965553..9aad398 100644 --- a/Hex/Models/HexSettings.swift +++ b/Hex/Models/HexSettings.swift @@ -54,6 +54,7 @@ struct HexSettings: Codable, Equatable { var pasteLastTranscriptHotkey: HotKey? = HexSettings.defaultPasteLastTranscriptHotkey var hasCompletedModelBootstrap: Bool = false var hasCompletedStorageMigration: Bool = false + var textTransformationPipeline: TextTransformationPipeline = .init() // Define coding keys to match struct properties enum CodingKeys: String, CodingKey { @@ -75,6 +76,7 @@ struct HexSettings: Codable, Equatable { case pasteLastTranscriptHotkey case hasCompletedModelBootstrap case hasCompletedStorageMigration + case textTransformationPipeline } init( @@ -95,7 +97,8 @@ struct HexSettings: Codable, Equatable { maxHistoryEntries: Int? = nil, pasteLastTranscriptHotkey: HotKey? = HexSettings.defaultPasteLastTranscriptHotkey, hasCompletedModelBootstrap: Bool = false, - hasCompletedStorageMigration: Bool = false + hasCompletedStorageMigration: Bool = false, + textTransformationPipeline: TextTransformationPipeline = .init() ) { self.soundEffectsEnabled = soundEffectsEnabled self.hotkey = hotkey @@ -115,6 +118,7 @@ struct HexSettings: Codable, Equatable { self.pasteLastTranscriptHotkey = pasteLastTranscriptHotkey self.hasCompletedModelBootstrap = hasCompletedModelBootstrap self.hasCompletedStorageMigration = hasCompletedStorageMigration + self.textTransformationPipeline = textTransformationPipeline } // Custom decoder that handles missing fields @@ -153,6 +157,8 @@ struct HexSettings: Codable, Equatable { try container.decodeIfPresent(Bool.self, forKey: .hasCompletedModelBootstrap) ?? false hasCompletedStorageMigration = try container.decodeIfPresent(Bool.self, forKey: .hasCompletedStorageMigration) ?? false + textTransformationPipeline = + try container.decodeIfPresent(TextTransformationPipeline.self, forKey: .textTransformationPipeline) ?? .init() } } diff --git a/HexCore/Sources/HexCore/Logic/TextTransformationPipeline.swift b/HexCore/Sources/HexCore/Logic/TextTransformationPipeline.swift new file mode 100644 index 0000000..59be90d --- /dev/null +++ b/HexCore/Sources/HexCore/Logic/TextTransformationPipeline.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct TextTransformationPipeline: Codable, Equatable, Sendable { + public var transformations: [Transformation] + public var isEnabled: Bool + + public init(transformations: [Transformation] = [], isEnabled: Bool = true) { + self.transformations = transformations + self.isEnabled = isEnabled + } + + /// Execute all enabled transformations in sequence + public func process(_ text: String) async -> String { + guard isEnabled else { return text } + + var currentText = text + for transformation in transformations where transformation.isEnabled { + currentText = await transformation.transform(currentText) + } + return currentText + } + + /// Move transformation from one index to another + public mutating func move(from sourceIndex: Int, to destinationIndex: Int) { + guard sourceIndex != destinationIndex, + transformations.indices.contains(sourceIndex), + transformations.indices.contains(destinationIndex) else { + return + } + + let item = transformations.remove(at: sourceIndex) + transformations.insert(item, at: destinationIndex) + } +} diff --git a/HexCore/Sources/HexCore/Models/TextTransformation.swift b/HexCore/Sources/HexCore/Models/TextTransformation.swift new file mode 100644 index 0000000..558baef --- /dev/null +++ b/HexCore/Sources/HexCore/Models/TextTransformation.swift @@ -0,0 +1,199 @@ +import Foundation + +// MARK: - Transformation Types + +public enum TransformationType: Codable, Equatable, Sendable { + case uppercase + case lowercase + case capitalize + case capitalizeFirst + case spongebobCase + case trimWhitespace + case removeExtraSpaces + case replaceText(ReplaceTextConfig) + case addPrefix(String) + case addSuffix(String) +} + +public struct ReplaceTextConfig: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var pattern: String + public var replacement: String + public var caseSensitive: Bool + public var useRegex: Bool + + public init( + id: UUID = UUID(), + pattern: String, + replacement: String, + caseSensitive: Bool = false, + useRegex: Bool = false + ) { + self.id = id + self.pattern = pattern + self.replacement = replacement + self.caseSensitive = caseSensitive + self.useRegex = useRegex + } +} + +// MARK: - Transformation + +public struct Transformation: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var isEnabled: Bool + public var type: TransformationType + + public init(id: UUID = UUID(), isEnabled: Bool = true, type: TransformationType) { + self.id = id + self.isEnabled = isEnabled + self.type = type + } + + public var name: String { + switch type { + case .uppercase: return "UPPERCASE" + case .lowercase: return "lowercase" + case .capitalize: return "Title Case" + case .capitalizeFirst: return "Capitalize first" + case .spongebobCase: return "sPoNgEbOb cAsE" + case .trimWhitespace: return "Trim whitespace" + case .removeExtraSpaces: return "Remove extra spaces" + case .replaceText(let config): return "Replace: \(config.pattern)" + case .addPrefix(let prefix): return "Prefix: \(prefix)" + case .addSuffix(let suffix): return "Suffix: \(suffix)" + } + } + + public func transform(_ text: String) async -> String { + guard isEnabled else { return text } + + switch type { + case .uppercase: + return text.uppercased() + + case .lowercase: + return text.lowercased() + + case .capitalize: + return text.capitalized + + case .capitalizeFirst: + guard !text.isEmpty else { return text } + return text.prefix(1).uppercased() + text.dropFirst() + + case .spongebobCase: + return text.enumerated().map { index, char in + index % 2 == 0 ? String(char).lowercased() : String(char).uppercased() + }.joined() + + case .trimWhitespace: + return text.trimmingCharacters(in: .whitespacesAndNewlines) + + case .removeExtraSpaces: + return text.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + + case .replaceText(let config): + return applyReplacement(text, config: config) + + case .addPrefix(let prefix): + return prefix + text + + case .addSuffix(let suffix): + return text + suffix + } + } + + private func applyReplacement(_ text: String, config: ReplaceTextConfig) -> String { + if config.useRegex { + let options: String.CompareOptions = config.caseSensitive + ? .regularExpression + : [.regularExpression, .caseInsensitive] + return text.replacingOccurrences( + of: config.pattern, + with: config.replacement, + options: options + ) + } else { + let options: String.CompareOptions = config.caseSensitive ? [] : .caseInsensitive + return text.replacingOccurrences( + of: config.pattern, + with: config.replacement, + options: options + ) + } + } +} + +// MARK: - Codable for TransformationType + +extension TransformationType { + private enum CodingKeys: String, CodingKey { + case uppercase, lowercase, capitalize, capitalizeFirst + case spongebobCase, trimWhitespace, removeExtraSpaces + case replaceText, addPrefix, addSuffix + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .uppercase: + try container.encode(true, forKey: .uppercase) + case .lowercase: + try container.encode(true, forKey: .lowercase) + case .capitalize: + try container.encode(true, forKey: .capitalize) + case .capitalizeFirst: + try container.encode(true, forKey: .capitalizeFirst) + case .spongebobCase: + try container.encode(true, forKey: .spongebobCase) + case .trimWhitespace: + try container.encode(true, forKey: .trimWhitespace) + case .removeExtraSpaces: + try container.encode(true, forKey: .removeExtraSpaces) + case .replaceText(let config): + try container.encode(config, forKey: .replaceText) + case .addPrefix(let prefix): + try container.encode(prefix, forKey: .addPrefix) + case .addSuffix(let suffix): + try container.encode(suffix, forKey: .addSuffix) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if container.contains(.uppercase) { + self = .uppercase + } else if container.contains(.lowercase) { + self = .lowercase + } else if container.contains(.capitalize) { + self = .capitalize + } else if container.contains(.capitalizeFirst) { + self = .capitalizeFirst + } else if container.contains(.spongebobCase) { + self = .spongebobCase + } else if container.contains(.trimWhitespace) { + self = .trimWhitespace + } else if container.contains(.removeExtraSpaces) { + self = .removeExtraSpaces + } else if let config = try? container.decode(ReplaceTextConfig.self, forKey: .replaceText) { + self = .replaceText(config) + } else if let prefix = try? container.decode(String.self, forKey: .addPrefix) { + self = .addPrefix(prefix) + } else if let suffix = try? container.decode(String.self, forKey: .addSuffix) { + self = .addSuffix(suffix) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown transformation type" + ) + ) + } + } +} diff --git a/HexCore/Tests/HexCoreTests/TextTransformationTests.swift b/HexCore/Tests/HexCoreTests/TextTransformationTests.swift new file mode 100644 index 0000000..6fc4989 --- /dev/null +++ b/HexCore/Tests/HexCoreTests/TextTransformationTests.swift @@ -0,0 +1,208 @@ +import XCTest +@testable import HexCore + +final class TextTransformationTests: XCTestCase { + + // MARK: - Individual Transformation Tests + + func testUppercase() async { + let transform = Transformation(type: .uppercase) + let result = await transform.transform("hello world") + XCTAssertEqual(result, "HELLO WORLD") + } + + func testLowercase() async { + let transform = Transformation(type: .lowercase) + let result = await transform.transform("HELLO WORLD") + XCTAssertEqual(result, "hello world") + } + + func testCapitalize() async { + let transform = Transformation(type: .capitalize) + let result = await transform.transform("hello world") + XCTAssertEqual(result, "Hello World") + } + + func testCapitalizeFirst() async { + let transform = Transformation(type: .capitalizeFirst) + let result = await transform.transform("hello world") + XCTAssertEqual(result, "Hello world") + } + + func testSpongebobCase() async { + let transform = Transformation(type: .spongebobCase) + let result = await transform.transform("hello") + XCTAssertEqual(result, "hElLo") + } + + func testTrimWhitespace() async { + let transform = Transformation(type: .trimWhitespace) + let result = await transform.transform(" hello world ") + XCTAssertEqual(result, "hello world") + } + + func testRemoveExtraSpaces() async { + let transform = Transformation(type: .removeExtraSpaces) + let result = await transform.transform("hello world") + XCTAssertEqual(result, "hello world") + } + + func testReplaceTextCaseInsensitive() async { + let config = ReplaceTextConfig( + pattern: "hello", + replacement: "hi", + caseSensitive: false + ) + let transform = Transformation(type: .replaceText(config)) + let result = await transform.transform("Hello world") + XCTAssertEqual(result, "hi world") + } + + func testReplaceTextCaseSensitive() async { + let config = ReplaceTextConfig( + pattern: "Hello", + replacement: "Hi", + caseSensitive: true + ) + let transform = Transformation(type: .replaceText(config)) + let result1 = await transform.transform("hello world") + let result2 = await transform.transform("Hello world") + XCTAssertEqual(result1, "hello world") + XCTAssertEqual(result2, "Hi world") + } + + func testReplaceTextRegex() async { + let config = ReplaceTextConfig( + pattern: "\\d+", + replacement: "X", + useRegex: true + ) + let transform = Transformation(type: .replaceText(config)) + let result = await transform.transform("I have 42 apples and 7 oranges") + XCTAssertEqual(result, "I have X apples and X oranges") + } + + func testAddPrefix() async { + let transform = Transformation(type: .addPrefix(">> ")) + let result = await transform.transform("hello") + XCTAssertEqual(result, ">> hello") + } + + func testAddSuffix() async { + let transform = Transformation(type: .addSuffix("\n\nBest,\nJohn")) + let result = await transform.transform("Thanks for your help") + XCTAssertEqual(result, "Thanks for your help\n\nBest,\nJohn") + } + + func testDisabledTransformation() async { + var transform = Transformation(type: .uppercase) + transform.isEnabled = false + let result = await transform.transform("hello") + XCTAssertEqual(result, "hello") + } + + // MARK: - Pipeline Tests + + func testEmptyPipeline() async { + let pipeline = TextTransformationPipeline() + let result = await pipeline.process("hello") + XCTAssertEqual(result, "hello") + } + + func testDisabledPipeline() async { + var pipeline = TextTransformationPipeline() + pipeline.isEnabled = false + pipeline.transformations = [ + Transformation(type: .uppercase) + ] + let result = await pipeline.process("hello") + XCTAssertEqual(result, "hello") + } + + func testPipelineSequence() async { + var pipeline = TextTransformationPipeline() + pipeline.transformations = [ + Transformation(type: .trimWhitespace), + Transformation(type: .removeExtraSpaces), + Transformation(type: .capitalize) + ] + + let result = await pipeline.process(" hello world ") + XCTAssertEqual(result, "Hello World") + } + + func testPipelineWithDisabledTransformation() async { + var pipeline = TextTransformationPipeline() + var uppercaseTransform = Transformation(type: .uppercase) + uppercaseTransform.isEnabled = false + + pipeline.transformations = [ + uppercaseTransform, + Transformation(type: .addPrefix(">> ")) + ] + + let result = await pipeline.process("hello") + XCTAssertEqual(result, ">> hello") + } + + func testComplexPipeline() async { + var pipeline = TextTransformationPipeline() + let replaceConfig = ReplaceTextConfig( + pattern: "my email", + replacement: "user@example.com" + ) + + pipeline.transformations = [ + Transformation(type: .trimWhitespace), + Transformation(type: .replaceText(replaceConfig)), + Transformation(type: .addSuffix("\n\nSent from Hex")) + ] + + let result = await pipeline.process(" Please contact my email ") + XCTAssertEqual(result, "Please contact user@example.com\n\nSent from Hex") + } + + func testPipelineMove() { + var pipeline = TextTransformationPipeline() + pipeline.transformations = [ + Transformation(type: .uppercase), + Transformation(type: .lowercase), + Transformation(type: .capitalize) + ] + + pipeline.move(from: 0, to: 2) + + XCTAssertEqual(pipeline.transformations[0].type, .lowercase) + XCTAssertEqual(pipeline.transformations[1].type, .capitalize) + XCTAssertEqual(pipeline.transformations[2].type, .uppercase) + } + + // MARK: - Codable Tests + + func testTransformationEncodeDecode() throws { + let original = Transformation(type: .replaceText( + ReplaceTextConfig(pattern: "foo", replacement: "bar") + )) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Transformation.self, from: encoded) + + XCTAssertEqual(original, decoded) + } + + func testPipelineEncodeDecode() throws { + var original = TextTransformationPipeline() + original.transformations = [ + Transformation(type: .uppercase), + Transformation(type: .lowercase), + Transformation(type: .replaceText( + ReplaceTextConfig(pattern: "test", replacement: "prod") + )) + ] + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(TextTransformationPipeline.self, from: encoded) + + XCTAssertEqual(original, decoded) + } +} diff --git a/README.md b/README.md index 71b3f32..a5a10d3 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,7 @@ Press-and-hold a hotkey to transcribe your voice and paste the result wherever y **[Download Hex for macOS](https://hex-updates.s3.us-east-1.amazonaws.com/hex-latest.dmg)** > **Note:** Hex is currently only available for **Apple Silicon** Macs. -I've opened-sourced the project in the hopes that others will find it useful! We rely on the awesome [WhisperKit](https://github.com/argmaxinc/WhisperKit) for transcription, and the incredible [Swift Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) for structuring the app. Please open issues with any questions or feedback! ❤️ - -Join our [Discord community](https://discord.gg/5UzVCqWmav) for support, discussions, and updates! +I've opened-sourced the project in the hopes that others will find it useful! Hex supports both [Parakeet TDT v3](https://github.com/FluidInference/FluidAudio) via the awesome [FluidAudio](https://github.com/FluidInference/FluidAudio) (the default—it's frickin' unbelievable: fast, multilingual, and cloud-optimized) and the awesome [WhisperKit](https://github.com/argmaxinc/WhisperKit) for on-device transcription. We use the incredible [Swift Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) for structuring the app. Please open issues with any questions or feedback! ❤️ ## Contributing @@ -24,52 +22,7 @@ Once you've configured a global hotkey, there are **two recording modes**: 1. **Press-and-hold** the hotkey to begin recording, say whatever you want, and then release the hotkey to start the transcription process. 2. **Double-tap** the hotkey to *lock recording*, say whatever you want, and then **tap** the hotkey once more to start the transcription process. -> ⚠️ Note: The first time you run Hex, it will download and compile the Whisper model for your machine. During this process, you may notice high CPU usage from a background process called ANECompilerService. This is macOS optimizing the model for the Apple Neural Engine (ANE), and it's a normal one-time setup step. -> -> Depending on your CPU and the size of the model, this may take anywhere from a few seconds to a few minutes. -## Project Structure - -Hex is organized into several directories, each serving a specific purpose: - -- **`App/`** - - Contains the main application entry point (`HexApp.swift`) and the app delegate (`HexAppDelegate.swift`), which manage the app's lifecycle and initial setup. - -- **`Clients/`** - - `PasteboardClient.swift` - - Manages pasteboard operations for copying transcriptions. - - `SoundEffect.swift` - - Controls sound effects for user feedback. - - `RecordingClient.swift` - - Manages audio recording and microphone access. - - `KeyEventMonitorClient.swift` - - Monitors global key events for hotkey detection. - - `TranscriptionClient.swift` - - Interfaces with WhisperKit for transcription services. - -- **`Features/`** - - `AppFeature.swift` - - The root feature that composes transcription, settings, and history. - - `TranscriptionFeature.swift` - - Manages the core transcription logic and recording flow. - - `SettingsFeature.swift` - - Handles app settings, including hotkey configuration and permissions. - - `HistoryFeature.swift` - - Manages the transcription history view. - -- **`Models/`** - - `HexSettings.swift` - - Stores user preferences like hotkey settings and sound preferences. - - `HotKey.swift` - - Represents the hotkey configuration. - -- **`Resources/`** - - Contains the app's assets, including the app icon and sound effects. - - `changelog.md` - - A log of changes to the app. - - `Data/languages.json` - - A list of supported languages for transcription. - - `Audio/` - - Sound effects for user feedback. +> 💡 **Note:** The first time you run Hex, it will auto-download the Parakeet model in the background. This is incredibly fast and optimized for the cloud—you'll be up and running in no time! ## License