Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ final class AppState: ObservableObject {
selectedAudioDeviceName = device?.name ?? ""
}

/// Custom text snippets for expansion
@Published var snippets: [Snippet] = []

// MARK: - Services

let audioEngine: AudioRecording
Expand All @@ -208,6 +211,7 @@ final class AppState: ObservableObject {
let soundManager: SoundPlaying
let cursorOverlay: CursorOverlayManaging
let statsManager: StatsManaging
let snippetExpander: SnippetExpanding
let updateChecker = UpdateChecker()
let permissionManager: any PermissionManaging

Expand Down Expand Up @@ -288,6 +292,7 @@ final class AppState: ObservableObject {
soundManager: SoundPlaying = SoundManager(),
cursorOverlay: CursorOverlayManaging,
statsManager: StatsManaging,
snippetExpander: SnippetExpanding = SnippetExpander(),
permissionManager: (any PermissionManaging)? = nil,
skipSystemIntegration: Bool = false
) {
Expand All @@ -299,10 +304,12 @@ final class AppState: ObservableObject {
self.soundManager = soundManager
self.cursorOverlay = cursorOverlay
self.statsManager = statsManager
self.snippetExpander = snippetExpander
self.permissionManager = permissionManager ?? PermissionManager(audioEngine: audioEngine, hotKeyManager: hotKeyManager)
self.skipSystemIntegration = skipSystemIntegration

VocaLogger.info(.appState, "Initializing... id=\(ObjectIdentifier(self))")
loadSnippets()
if !skipSystemIntegration {
syncLaunchAtLogin()
}
Expand Down Expand Up @@ -529,6 +536,16 @@ final class AppState: ObservableObject {
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)

// Auto-save snippets when changed. @Published emits on willSet, so
// persist the emitted array — reading self.snippets here would save
// the previous state and leave the latest change unsaved.
$snippets
.dropFirst() // skip the subscription replay of the just-loaded value
.sink { [weak self] snippets in
self?.saveSnippets(snippets)
}
.store(in: &cancellables)

// Check permissions
checkPermissions()

Expand Down Expand Up @@ -1023,11 +1040,17 @@ final class AppState: ObservableObject {

let trimmedText = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedText.isEmpty {
let polished = DictationOutputFormatter.apply(
// Polish first, expand second. Snippet expansions are literal
// text the user authored, so auto-capitalization must not
// rewrite them (an email snippet would become Me@example.com).
// Trigger matching is case-insensitive, so capitalizing the
// trigger beforehand still matches.
let polishedSource = DictationOutputFormatter.apply(
trimmedText,
autoCapitalize: autoCapitalize,
appendTrailingSpace: appendTrailingSpace
)
let polished = expandSnippets(in: polishedSource)
if injectResult {
textInjector.inject(
text: polished,
Expand Down Expand Up @@ -1576,4 +1599,33 @@ final class AppState: ObservableObject {
hasCompletedOnboarding = true
VocaLogger.info(.appState, "Onboarding completed")
}

// MARK: - Snippets Management

private func loadSnippets() {
if let data = UserDefaults.standard.data(forKey: "vocamac.snippets") {
do {
snippets = try JSONDecoder().decode([Snippet].self, from: data)
} catch {
VocaLogger.error(.appState, "Failed to decode snippets: \(error)")
}
}
}

func saveSnippets() {
saveSnippets(snippets)
}

private func saveSnippets(_ snippets: [Snippet]) {
do {
let encoded = try JSONEncoder().encode(snippets)
UserDefaults.standard.set(encoded, forKey: "vocamac.snippets")
} catch {
VocaLogger.error(.appState, "Failed to encode snippets: \(error)")
}
}

func expandSnippets(in text: String) -> String {
return snippetExpander.expand(in: text, using: snippets)
}
}
3 changes: 3 additions & 0 deletions Sources/VocaMac/Models/SettingsPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SwiftUI
/// Top-level settings topics shown in the left sidebar.
enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
case dictation
case snippets
case speechModel
case audio
case performance
Expand All @@ -22,6 +23,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
var title: String {
switch self {
case .dictation: return "Dictation"
case .snippets: return "Snippets"
case .speechModel: return "Speech Model"
case .audio: return "Audio"
case .performance: return "Performance"
Expand All @@ -35,6 +37,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
var systemImage: String {
switch self {
case .dictation: return "mic"
case .snippets: return "text.quote"
case .speechModel: return "brain"
case .audio: return "waveform"
case .performance: return "bolt.circle"
Expand Down
9 changes: 9 additions & 0 deletions Sources/VocaMac/Models/SettingsSearchIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ enum SettingsSearchIndex {
keywords: ["capitalize", "output", "sentence", "punctuation"]
),

// Snippets
SettingsSearchEntry(
id: "snippets",
page: .snippets,
title: "Custom Snippets",
subtitle: "Replace spoken triggers with saved text",
keywords: ["snippet", "shortcut", "expansion", "trigger", "replace", "macro", "abbreviation"]
),

// Speech Model
SettingsSearchEntry(
id: "models",
Expand Down
18 changes: 18 additions & 0 deletions Sources/VocaMac/Models/Snippet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Snippet.swift
// VocaMac
//
// Model representing a custom text snippet with a trigger phrase and expansion text.

import Foundation

struct Snippet: Identifiable, Codable, Equatable {
var id: UUID
var trigger: String
var expansion: String

init(id: UUID = UUID(), trigger: String = "", expansion: String = "") {
self.id = id
self.trigger = trigger
self.expansion = expansion
}
}
6 changes: 6 additions & 0 deletions Sources/VocaMac/Services/ServiceProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,9 @@ protocol StatsManaging: AnyObject {
func recordTranscription(_ transcription: VocaTranscription)
func resetStats()
}

// MARK: - SnippetExpanding

protocol SnippetExpanding: AnyObject {
func expand(in text: String, using snippets: [Snippet]) -> String
}
79 changes: 79 additions & 0 deletions Sources/VocaMac/Services/SnippetExpander.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SnippetExpander.swift
// VocaMac
//
// Pure logic for expanding text snippets with regex support.

import Foundation

final class SnippetExpander: SnippetExpanding {
func expand(in text: String, using snippets: [Snippet]) -> String {
guard !snippets.isEmpty else { return text }

// Sort snippets by trigger length descending to prioritize longer triggers
let sortedSnippets = snippets
.filter { !$0.trigger.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.sorted { $0.trigger.count > $1.trigger.count }

guard !sortedSnippets.isEmpty else { return text }

// Build a single combined regex to avoid cascading expansions
// and handle different boundary requirements for word/non-word triggers.
var patterns: [String] = []
for snippet in sortedSnippets {
let trigger = snippet.trigger.trimmingCharacters(in: .whitespacesAndNewlines)
let escapedTrigger = NSRegularExpression.escapedPattern(for: trigger)

let prefix: String
if let first = trigger.first, first.isWordCharacter {
prefix = "\\b"
} else {
prefix = "(?<!\\S)"
}

let suffix: String
if let last = trigger.last, last.isWordCharacter {
suffix = "\\b"
} else {
suffix = "(?!\\S)"
}

// Capture each trigger in its own group to identify which one matched
patterns.append("(\(prefix)\(escapedTrigger)\(suffix))")
}

let combinedPattern = patterns.joined(separator: "|")

guard let regex = try? NSRegularExpression(pattern: combinedPattern, options: [.caseInsensitive]) else {
return text
}

let nsString = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length))

// Replace matches in reverse order to keep ranges valid
var result = text
for match in matches.reversed() {
// Find which group matched (group 0 is the whole match, groups 1..N are our snippets)
for i in 1...match.numberOfRanges - 1 {
let range = match.range(at: i)
if range.location != NSNotFound {
let snippet = sortedSnippets[i - 1]

// Since we are going in reverse, we can just use string replacement on the range.
if let resultRange = Range(match.range, in: result) {
result.replaceSubrange(resultRange, with: snippet.expansion)
}
break
}
}
}

return result
}
}

private extension Character {
var isWordCharacter: Bool {
return self.isLetter || self.isNumber || self == "_"
}
}
Loading
Loading