@hasna/recordings 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli/index.js +342 -153
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/index.js +114 -33
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +2 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts +8 -2
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +183 -52
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +20 -3
- package/scripts/install_macos_app.sh +76 -0
- package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +16 -4
- package/src/native/Recordings/Package.resolved +19 -1
- package/src/native/Recordings/Package.swift +16 -5
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +131 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +361 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +126 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +218 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
- package/src/native/Recordings/build.sh +6 -6
- package/bun.lock +0 -250
- package/bunfig.toml +0 -2
- package/src/__tests__/agents.test.ts +0 -136
- package/src/__tests__/config.test.ts +0 -252
- package/src/__tests__/database.test.ts +0 -167
- package/src/__tests__/enhancer.test.ts +0 -574
- package/src/__tests__/preload.ts +0 -4
- package/src/__tests__/projects.test.ts +0 -109
- package/src/__tests__/recorder.test.ts +0 -278
- package/src/__tests__/recordings.test.ts +0 -353
- package/src/__tests__/transcriber.test.ts +0 -322
- package/src/__tests__/types.test.ts +0 -75
- package/src/cli/index.ts +0 -1115
- package/src/db/agents.ts +0 -104
- package/src/db/database.ts +0 -163
- package/src/db/pg-migrations.ts +0 -82
- package/src/db/projects.ts +0 -71
- package/src/db/recordings.ts +0 -225
- package/src/index.ts +0 -81
- package/src/lib/config.ts +0 -183
- package/src/lib/enhancer.ts +0 -167
- package/src/lib/recorder.ts +0 -198
- package/src/lib/transcriber.ts +0 -105
- package/src/mcp/index.ts +0 -464
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +0 -122
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +0 -188
- package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -355
- package/src/native/Recordings/Recordings/SettingsView.swift +0 -93
- package/src/native/Recordings/test_fn.swift +0 -79
- package/src/native/Recordings/test_fn2.swift +0 -33
- package/src/native/RecordingsHelper.swift +0 -395
- package/src/types/index.ts +0 -144
- package/tsconfig.json +0 -21
- /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
@preconcurrency import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum NativePCMRecorderError: LocalizedError {
|
|
5
|
+
case noInputDevice
|
|
6
|
+
case unsupportedInputFormat
|
|
7
|
+
case failedToCreateConverter
|
|
8
|
+
case failedToStart(String)
|
|
9
|
+
|
|
10
|
+
var errorDescription: String? {
|
|
11
|
+
switch self {
|
|
12
|
+
case .noInputDevice:
|
|
13
|
+
return "No microphone input device is available"
|
|
14
|
+
case .unsupportedInputFormat:
|
|
15
|
+
return "The selected microphone format is not supported"
|
|
16
|
+
case .failedToCreateConverter:
|
|
17
|
+
return "Could not prepare microphone audio conversion"
|
|
18
|
+
case .failedToStart(let message):
|
|
19
|
+
return "Could not start microphone capture: \(message)"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
final class NativePCMRecorder: @unchecked Sendable {
|
|
25
|
+
private let engine = AVAudioEngine()
|
|
26
|
+
private let onPCM: @Sendable (Data) -> Void
|
|
27
|
+
private var converter: AVAudioConverter?
|
|
28
|
+
private var inputFormat: AVAudioFormat?
|
|
29
|
+
private var outputFormat: AVAudioFormat?
|
|
30
|
+
private var started = false
|
|
31
|
+
|
|
32
|
+
init(onPCM: @escaping @Sendable (Data) -> Void) {
|
|
33
|
+
self.onPCM = onPCM
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func start() throws {
|
|
37
|
+
guard !started else { return }
|
|
38
|
+
|
|
39
|
+
let inputNode = engine.inputNode
|
|
40
|
+
let inputFormat = inputNode.inputFormat(forBus: 0)
|
|
41
|
+
guard inputFormat.channelCount > 0 else {
|
|
42
|
+
throw NativePCMRecorderError.noInputDevice
|
|
43
|
+
}
|
|
44
|
+
guard inputFormat.sampleRate > 0 else {
|
|
45
|
+
throw NativePCMRecorderError.unsupportedInputFormat
|
|
46
|
+
}
|
|
47
|
+
guard let outputFormat = Self.realtimeOutputFormat() else {
|
|
48
|
+
throw NativePCMRecorderError.unsupportedInputFormat
|
|
49
|
+
}
|
|
50
|
+
guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else {
|
|
51
|
+
throw NativePCMRecorderError.failedToCreateConverter
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
self.converter = converter
|
|
55
|
+
self.inputFormat = inputFormat
|
|
56
|
+
self.outputFormat = outputFormat
|
|
57
|
+
|
|
58
|
+
inputNode.installTap(onBus: 0, bufferSize: 1_024, format: inputFormat) { [weak self] buffer, _ in
|
|
59
|
+
self?.convertAndEmit(buffer)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
do {
|
|
63
|
+
engine.prepare()
|
|
64
|
+
try engine.start()
|
|
65
|
+
started = true
|
|
66
|
+
} catch {
|
|
67
|
+
inputNode.removeTap(onBus: 0)
|
|
68
|
+
self.converter = nil
|
|
69
|
+
self.inputFormat = nil
|
|
70
|
+
self.outputFormat = nil
|
|
71
|
+
throw NativePCMRecorderError.failedToStart(error.localizedDescription)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func stop() {
|
|
76
|
+
guard started else {
|
|
77
|
+
converter = nil
|
|
78
|
+
inputFormat = nil
|
|
79
|
+
outputFormat = nil
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
engine.inputNode.removeTap(onBus: 0)
|
|
84
|
+
engine.stop()
|
|
85
|
+
converter = nil
|
|
86
|
+
inputFormat = nil
|
|
87
|
+
outputFormat = nil
|
|
88
|
+
started = false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
deinit {
|
|
92
|
+
stop()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func convertAndEmit(_ inputBuffer: AVAudioPCMBuffer) {
|
|
96
|
+
guard let converter, let inputFormat, let outputFormat else { return }
|
|
97
|
+
|
|
98
|
+
let sampleRateRatio = outputFormat.sampleRate / inputFormat.sampleRate
|
|
99
|
+
let estimatedFrames = AVAudioFrameCount(Double(inputBuffer.frameLength) * sampleRateRatio) + 512
|
|
100
|
+
guard let outputBuffer = AVAudioPCMBuffer(
|
|
101
|
+
pcmFormat: outputFormat,
|
|
102
|
+
frameCapacity: max(estimatedFrames, 1)
|
|
103
|
+
) else {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let inputSource = AudioConverterInputSource(buffer: inputBuffer)
|
|
108
|
+
var conversionError: NSError?
|
|
109
|
+
converter.convert(to: outputBuffer, error: &conversionError) { _, status in
|
|
110
|
+
inputSource.next(status: status)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
guard conversionError == nil else { return }
|
|
114
|
+
let data = Self.extractPCM16Data(from: outputBuffer)
|
|
115
|
+
if !data.isEmpty {
|
|
116
|
+
onPCM(data)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static func realtimeOutputFormat() -> AVAudioFormat? {
|
|
121
|
+
AVAudioFormat(
|
|
122
|
+
commonFormat: .pcmFormatInt16,
|
|
123
|
+
sampleRate: 24_000,
|
|
124
|
+
channels: 1,
|
|
125
|
+
interleaved: true
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static func extractPCM16Data(from buffer: AVAudioPCMBuffer) -> Data {
|
|
130
|
+
var data = Data()
|
|
131
|
+
for audioBuffer in UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList) {
|
|
132
|
+
guard let bytes = audioBuffer.mData?.assumingMemoryBound(to: UInt8.self),
|
|
133
|
+
audioBuffer.mDataByteSize > 0 else {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
data.append(bytes, count: Int(audioBuffer.mDataByteSize))
|
|
137
|
+
}
|
|
138
|
+
return data
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private final class AudioConverterInputSource: @unchecked Sendable {
|
|
143
|
+
private let buffer: AVAudioPCMBuffer
|
|
144
|
+
private let lock = NSLock()
|
|
145
|
+
private var consumed = false
|
|
146
|
+
|
|
147
|
+
init(buffer: AVAudioPCMBuffer) {
|
|
148
|
+
self.buffer = buffer
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func next(status: UnsafeMutablePointer<AVAudioConverterInputStatus>) -> AVAudioBuffer? {
|
|
152
|
+
lock.lock()
|
|
153
|
+
defer { lock.unlock() }
|
|
154
|
+
|
|
155
|
+
if consumed {
|
|
156
|
+
status.pointee = .noDataNow
|
|
157
|
+
return nil
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
consumed = true
|
|
161
|
+
status.pointee = .haveData
|
|
162
|
+
return buffer
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum OpenAIAPIKeyStore {
|
|
4
|
+
static func load(
|
|
5
|
+
homePath: String,
|
|
6
|
+
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
7
|
+
userDefaultKey: String? = UserDefaults.standard.string(forKey: "openAIAPIKey")
|
|
8
|
+
) -> String {
|
|
9
|
+
if let key = firstNonEmpty(environment["OPENAI_API_KEY"], environment["RECORDINGS_API_KEY"]) {
|
|
10
|
+
return key
|
|
11
|
+
}
|
|
12
|
+
if let key = firstNonEmpty(userDefaultKey) {
|
|
13
|
+
return key
|
|
14
|
+
}
|
|
15
|
+
if let key = loadConfigKey(homePath: homePath, environment: environment) {
|
|
16
|
+
return key
|
|
17
|
+
}
|
|
18
|
+
if let key = loadSecretKey(homePath: homePath) {
|
|
19
|
+
return key
|
|
20
|
+
}
|
|
21
|
+
return ""
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
|
|
25
|
+
let url = URL(fileURLWithPath: homePath)
|
|
26
|
+
.appendingPathComponent(".hasna")
|
|
27
|
+
.appendingPathComponent("recordings")
|
|
28
|
+
.appendingPathComponent("config.json")
|
|
29
|
+
|
|
30
|
+
guard let data = try? Data(contentsOf: url),
|
|
31
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
32
|
+
else { return nil }
|
|
33
|
+
|
|
34
|
+
for key in ["openai_api_key", "api_key"] {
|
|
35
|
+
guard let value = json[key] as? String,
|
|
36
|
+
let resolved = resolve(value: value, environment: environment)
|
|
37
|
+
else { continue }
|
|
38
|
+
return resolved
|
|
39
|
+
}
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private static func loadSecretKey(homePath: String) -> String? {
|
|
44
|
+
let root = URL(fileURLWithPath: homePath).appendingPathComponent(".secrets")
|
|
45
|
+
let fileManager = FileManager.default
|
|
46
|
+
guard let enumerator = fileManager.enumerator(
|
|
47
|
+
at: root,
|
|
48
|
+
includingPropertiesForKeys: [.isRegularFileKey],
|
|
49
|
+
options: [.skipsHiddenFiles]
|
|
50
|
+
) else { return nil }
|
|
51
|
+
|
|
52
|
+
for case let url as URL in enumerator {
|
|
53
|
+
guard url.pathExtension == "env",
|
|
54
|
+
let values = try? parseEnvFile(url: url)
|
|
55
|
+
else { continue }
|
|
56
|
+
|
|
57
|
+
if let key = firstNonEmpty(values["RECORDINGS_API_KEY"], values["OPENAI_API_KEY"]) {
|
|
58
|
+
return key
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return nil
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static func parseEnvFile(url: URL) throws -> [String: String] {
|
|
65
|
+
let content = try String(contentsOf: url, encoding: .utf8)
|
|
66
|
+
var values: [String: String] = [:]
|
|
67
|
+
|
|
68
|
+
for rawLine in content.split(whereSeparator: \.isNewline) {
|
|
69
|
+
var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
70
|
+
guard !line.isEmpty, !line.hasPrefix("#") else { continue }
|
|
71
|
+
if line.hasPrefix("export ") {
|
|
72
|
+
line.removeFirst("export ".count)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
|
|
76
|
+
guard parts.count == 2 else { continue }
|
|
77
|
+
|
|
78
|
+
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
79
|
+
let value = stripQuotes(String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines))
|
|
80
|
+
values[key] = value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return values
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private static func resolve(value: String, environment: [String: String]) -> String? {
|
|
87
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
88
|
+
guard !trimmed.isEmpty else { return nil }
|
|
89
|
+
if trimmed.hasPrefix("$"), trimmed.count > 1 {
|
|
90
|
+
return firstNonEmpty(environment[String(trimmed.dropFirst())])
|
|
91
|
+
}
|
|
92
|
+
return trimmed
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private static func stripQuotes(_ value: String) -> String {
|
|
96
|
+
guard value.count >= 2 else { return value }
|
|
97
|
+
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
|
|
98
|
+
(value.hasPrefix("'") && value.hasSuffix("'")) {
|
|
99
|
+
return String(value.dropFirst().dropLast())
|
|
100
|
+
}
|
|
101
|
+
return value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private static func firstNonEmpty(_ values: String?...) -> String? {
|
|
105
|
+
for value in values {
|
|
106
|
+
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
107
|
+
if !trimmed.isEmpty {
|
|
108
|
+
return trimmed
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return nil
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct RecProject: Codable, Identifiable, Sendable {
|
|
4
|
+
public let id: String
|
|
5
|
+
public var name: String
|
|
6
|
+
var path: String?
|
|
7
|
+
var systemPrompt: String?
|
|
8
|
+
var appBundleIds: [String]?
|
|
9
|
+
|
|
10
|
+
public init(name: String, path: String? = nil, systemPrompt: String? = nil, appBundleIds: [String]? = nil) {
|
|
11
|
+
self.id = UUID().uuidString
|
|
12
|
+
self.name = name
|
|
13
|
+
self.path = path
|
|
14
|
+
self.systemPrompt = systemPrompt
|
|
15
|
+
self.appBundleIds = appBundleIds
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public struct ProjectSettings: Codable, Sendable {
|
|
20
|
+
var globalSystemPrompt: String
|
|
21
|
+
var projects: [RecProject]
|
|
22
|
+
var activeProjectId: String?
|
|
23
|
+
|
|
24
|
+
public init() {
|
|
25
|
+
globalSystemPrompt = ""
|
|
26
|
+
projects = []
|
|
27
|
+
activeProjectId = nil
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@MainActor
|
|
32
|
+
public final class ProjectStore: ObservableObject {
|
|
33
|
+
@Published public var settings = ProjectSettings()
|
|
34
|
+
|
|
35
|
+
private let filePath: String
|
|
36
|
+
|
|
37
|
+
public var activeProject: RecProject? {
|
|
38
|
+
settings.projects.first { $0.id == settings.activeProjectId }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public var effectiveSystemPrompt: String {
|
|
42
|
+
let global = settings.globalSystemPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
43
|
+
let project = activeProject?.systemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
44
|
+
if global.isEmpty && project.isEmpty { return "" }
|
|
45
|
+
if global.isEmpty { return project }
|
|
46
|
+
if project.isEmpty { return global }
|
|
47
|
+
return "\(global)\n\n\(project)"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public init() {
|
|
51
|
+
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
52
|
+
filePath = "\(home)/.hasna/recordings/projects.json"
|
|
53
|
+
load()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func load() {
|
|
57
|
+
guard FileManager.default.fileExists(atPath: filePath),
|
|
58
|
+
let data = FileManager.default.contents(atPath: filePath) else { return }
|
|
59
|
+
do {
|
|
60
|
+
settings = try JSONDecoder().decode(ProjectSettings.self, from: data)
|
|
61
|
+
} catch {
|
|
62
|
+
fputs("[ProjectStore] Failed to load: \(error)\n", stderr)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func save() {
|
|
67
|
+
do {
|
|
68
|
+
let data = try JSONEncoder().encode(settings)
|
|
69
|
+
let dir = (filePath as NSString).deletingLastPathComponent
|
|
70
|
+
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
71
|
+
try data.write(to: URL(fileURLWithPath: filePath))
|
|
72
|
+
} catch {
|
|
73
|
+
fputs("[ProjectStore] Failed to save: \(error)\n", stderr)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func addProject(name: String, path: String? = nil, systemPrompt: String? = nil) {
|
|
78
|
+
let project = RecProject(name: name, path: path, systemPrompt: systemPrompt)
|
|
79
|
+
settings.projects.append(project)
|
|
80
|
+
save()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func updateProject(_ project: RecProject) {
|
|
84
|
+
guard let idx = settings.projects.firstIndex(where: { $0.id == project.id }) else { return }
|
|
85
|
+
settings.projects[idx] = project
|
|
86
|
+
save()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func removeProject(id: String) {
|
|
90
|
+
settings.projects.removeAll { $0.id == id }
|
|
91
|
+
if settings.activeProjectId == id { settings.activeProjectId = nil }
|
|
92
|
+
save()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func setActive(_ id: String?) {
|
|
96
|
+
settings.activeProjectId = id
|
|
97
|
+
save()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// MARK: - Auto-detection
|
|
101
|
+
|
|
102
|
+
nonisolated static func matchProject(windowTitle: String?, bundleId: String?, projects: [RecProject]) -> RecProject? {
|
|
103
|
+
if let bundleId {
|
|
104
|
+
if let match = projects.first(where: { $0.appBundleIds?.contains(bundleId) == true }) {
|
|
105
|
+
return match
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if let title = windowTitle {
|
|
110
|
+
let lower = title.lowercased()
|
|
111
|
+
for project in projects {
|
|
112
|
+
let name = project.name.lowercased()
|
|
113
|
+
if lower.contains(name) { return project }
|
|
114
|
+
let slug = name.replacingOccurrences(of: " ", with: "-")
|
|
115
|
+
if lower.contains(slug) { return project }
|
|
116
|
+
if let path = project.path {
|
|
117
|
+
let dirName = (path as NSString).lastPathComponent.lowercased()
|
|
118
|
+
if lower.contains(dirName) { return project }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return nil
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
}
|