Files
oai-swift/oAI/Utilities/Extensions/String+Extensions.swift
T
rune d93c233453 Sync conversation notes via Git Sync, add discard shortcut to crash-recovery prompt
Notes files now export to notes/ + notes.json alongside conversations.json,
matching folders.json's manifest pattern: matched by conversation ID, never
overwrites notes a machine already has locally, same empty-state orphan-
cleanup safety guard as the existing conversation/folder sync code. Also
adds Cmd+D to the "Discard" button on the crash-recovery restore prompt.

Fixes two Swift 6 actor-isolation build warnings surfaced along the way:
ConversationNotesService and the String filename-sanitizing extension are
pure, state-free helpers called from nonisolated contexts (DatabaseService,
GitSyncService) but defaulted to @MainActor — marked nonisolated.
2026-08-04 08:31:16 +02:00

119 lines
4.0 KiB
Swift

//
// String+Extensions.swift
// Confab
//
// String utility extensions
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
nonisolated extension String {
// MARK: - Command Parsing
var isSlashCommand: Bool {
hasPrefix("/")
}
func parseCommand() -> (command: String, args: [String])? {
guard isSlashCommand else { return nil }
let parts = self.split(separator: " ", omittingEmptySubsequences: true)
.map(String.init)
guard let command = parts.first else { return nil }
let args = Array(parts.dropFirst())
return (command, args)
}
// MARK: - File Attachment Parsing
func parseFileAttachments() -> (cleanText: String, filePaths: [String]) {
var cleanText = self
var filePaths: [String] = []
// Pattern 1: @<filepath>
let anglePattern = #"@<([^>]+)>"#
if let regex = try? NSRegularExpression(pattern: anglePattern) {
let matches = regex.matches(in: self, range: NSRange(self.startIndex..., in: self))
for match in matches.reversed() {
if let range = Range(match.range(at: 1), in: self) {
let path = String(self[range])
filePaths.insert(path, at: 0)
}
if let fullRange = Range(match.range, in: self) {
cleanText.removeSubrange(fullRange)
}
}
}
// Pattern 2: @filepath (starting with /, ~, ., or drive letter)
let directPattern = #"@([~/.][\S]+|[A-Za-z]:[\\\/][\S]+)"#
if let regex = try? NSRegularExpression(pattern: directPattern) {
let matches = regex.matches(in: cleanText, range: NSRange(cleanText.startIndex..., in: cleanText))
for match in matches.reversed() {
if let range = Range(match.range(at: 1), in: cleanText) {
let path = String(cleanText[range])
if !filePaths.contains(path) {
filePaths.insert(path, at: 0)
}
}
if let fullRange = Range(match.range, in: cleanText) {
cleanText.removeSubrange(fullRange)
}
}
}
return (cleanText.trimmingCharacters(in: .whitespaces), filePaths)
}
// MARK: - Token Estimation
func estimateTokens() -> Int {
// Rough estimation: ~4 characters per token
// This is approximate; Phase 2 will use proper tokenizer
return max(1, count / 4)
}
// MARK: - Nil coalescing helpers
/// Returns nil if the string is empty, otherwise self.
var nonEmptyOrNil: String? {
isEmpty ? nil : self
}
// MARK: - Truncation
func truncated(to length: Int, trailing: String = "...") -> String {
if count <= length {
return self
}
let endIndex = index(startIndex, offsetBy: length - trailing.count)
return String(self[..<endIndex]) + trailing
}
// MARK: - Filename Sanitization
/// Replaces characters invalid in filenames (on macOS/most filesystems) with "-".
func sanitizedForFilename() -> String {
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
return components(separatedBy: invalid).joined(separator: "-")
}
}