-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathutils.lua
More file actions
318 lines (272 loc) · 12.3 KB
/
utils.lua
File metadata and controls
318 lines (272 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
-- utils.lua
-- Utility functions for file operations and temp file handling
local M = {}
function M.file_exists(name)
if not name then return false end
local f = io.open(name, "r")
if f ~= nil then io.close(f) return true end
return false
end
function M.get_file_size(filePath)
local attributes = hs.fs.attributes(filePath)
if attributes then
return attributes.size
end
return nil
end
function M.get_temp_file_path(extension)
local uuid = hs.host.uuid()
return os.tmpname() .. "_" .. uuid .. "." .. (extension or "mp3")
end
-- Get context about the currently active application and window
function M.getCurrentContext(focusedElement)
local win = hs.window.focusedWindow()
if not win then return "" end
local app = win:application()
local appName = app and app:name() or "Unknown"
local bundleID = app and app:bundleID() or "Unknown"
local winTitle = win:title() or "Untitled"
local dateStr = os.date("%Y-%m-%d")
local timeStr = os.date("%H:%M:%S")
local dayName = os.date("%A")
local contextParts = {
"<context>",
" <current_date>" .. dateStr .. " (" .. dayName .. ")</current_date>",
" <current_time>" .. timeStr .. "</current_time>",
" <app_name>" .. appName .. "</app_name>",
" <bundle_id>" .. bundleID .. "</bundle_id>",
" <window_title>" .. winTitle .. "</window_title>"
}
-- Check Accessibility permissions
local hasAx = hs.accessibilityState()
table.insert(contextParts, " <accessibility_enabled>" .. tostring(hasAx) .. "</accessibility_enabled>")
-- Deeper Accessibility extraction (Optimized for Electron/VS Code/Safari)
local function extractFromElement(el)
if not el then return nil end
local data = {}
-- Roles that we specifically want to extract from
local validRoles = {
AXTextArea = true, AXTextField = true, AXStaticText = true,
AXWebArea = true, AXGroup = true, AXScrollArea = true
}
local role = el:attributeValue("AXRole")
if not validRoles[role] then return nil end
-- Attributes to check for value and selection
-- AXTitle and AXHelp are often used for placeholders or descriptive labels in web inputs
local valAttrs = {"AXValue", "AXSharedText", "AXDescription", "AXTitle", "AXHelp", "AXLabel", "AXPlaceholderValue"}
local selAttrs = {"AXSelectedText"}
-- 1. Try directly supported text attributes
for _, attr in ipairs(selAttrs) do
local ok, val = pcall(function() return el:attributeValue(attr) end)
if ok and type(val) == "string" and #val > 0 then
data.selected = val
break
end
end
for _, attr in ipairs(valAttrs) do
local ok, val = pcall(function() return el:attributeValue(attr) end)
if ok and type(val) == "string" and #val > 0 then
-- Check if it's just a placeholder
local okP, placeholder = pcall(function() return el:attributeValue("AXPlaceholderValue") end)
if okP and val == placeholder and #val > 0 then
-- Keep it but don't count it as a "full" value yet
data.placeholder = val
else
data.value = val
break
end
end
end
-- 2. Try TextMarker API for Electron/Safari if selection is missing
if not data.selected then
local okM, mRange = pcall(function() return el:attributeValue("AXSelectedTextMarkerRange") end)
if okM and mRange then
local okS, mStr = pcall(function() return el:attributeValue("AXStringForTextMarkerRange", mRange) end)
if okS and type(mStr) == "string" and #mStr > 0 then
data.selected = mStr
end
end
end
-- 3. Try TextMarker API for Full Text if value is missing
if not data.value then
local pStart, startM = pcall(function() return el:attributeValue("AXStartTextMarker") end)
local pEnd, endM = pcall(function() return el:attributeValue("AXEndTextMarker") end)
if pStart and pEnd and startM and endM then
pcall(function()
local fullRange = hs.axuielement.axtextmarker.newRange(startM, endM)
local fullStr = el:attributeValue("AXStringForTextMarkerRange", fullRange)
if type(fullStr) == "string" and #fullStr > 0 then
data.value = fullStr
end
end)
end
end
-- 4. Concatenate children for rich groups/web areas (LinkedIn/VS Code fix)
if not data.value and (role == "AXGroup" or role == "AXWebArea" or role == "AXScrollArea") then
local children = el:attributeValue("AXChildren")
if type(children) == "table" then
local parts = {}
for _, child in ipairs(children) do
local cRole = child:attributeValue("AXRole")
local cVal = child:attributeValue("AXValue") or child:attributeValue("AXTitle")
if (cRole == "AXStaticText" or cRole == "AXTextArea") and type(cVal) == "string" and #cVal > 0 then
table.insert(parts, cVal)
end
end
if #parts > 0 then
data.value = table.concat(parts, " ")
end
end
end
if role then data.role = role end
return (data.selected or data.value) and data or nil
end
-- Find the true focused element
local focused = focusedElement
if not focused then
-- Strategy A: System Wide
focused = hs.axuielement.systemWideElement():attributeValue("AXFocusedUIElement")
-- Strategy B: App Specific (often more reliable for Electron)
if not focused and app then
local appEl = hs.axuielement.applicationElement(app)
if appEl then focused = appEl:attributeValue("AXFocusedUIElement") end
end
end
local elementData = extractFromElement(focused)
-- Recursive Fallback: Search Parent and Children if direct focus yields nothing
if not elementData and focused then
-- Try Parent
elementData = extractFromElement(focused:attributeValue("AXParent"))
-- Try Children (Shallow search)
if not elementData then
local children = focused:attributeValue("AXChildren")
if type(children) == "table" then
for _, child in ipairs(children) do
elementData = extractFromElement(child)
if elementData then break end
end
end
end
end
-- Last Resort: If we still have nothing, search the entire window for ANY focused text element
if not elementData and win then
local okW, winEl = pcall(function() return hs.axuielement.windowElement(win) end)
if okW and winEl then
-- This is a more horizontal search
local function findText(el, depth)
if depth > 10 then return nil end -- Deep enough for Electron/VS Code
local d = extractFromElement(el)
if d then return d end
local okC, children = pcall(function() return el:attributeValue("AXChildren") end)
if okC and type(children) == "table" then
for _, child in ipairs(children) do
local res = findText(child, depth + 1)
if res then return res end
end
end
return nil
end
elementData = findText(winEl, 0)
end
end
if elementData then
if elementData.selected then
table.insert(contextParts, " <selected_text>" .. elementData.selected .. "</selected_text>")
end
if elementData.value then
local val = elementData.value
-- Limit context to the last 1500 characters
local textContext = #val > 1500 and val:sub(-1500) or val
table.insert(contextParts, " <surrounding_text_readonly>" .. textContext .. "</surrounding_text_readonly>")
end
if elementData.role then
table.insert(contextParts, " <element_role>" .. elementData.role .. "</element_role>")
end
end
-- Clipboard Fallback for Hard-Case apps when AX yields insufficient data
-- We do this as a supplement, NOT just as a fallback if elementData is nil
local hardCaseApps = {
["com.microsoft.VSCode"] = true,
["com.hnc.Discord"] = true,
["com.apple.Safari"] = true,
["com.google.Chrome"] = true,
["com.electron.perplexity"] = true
}
if hardCaseApps[bundleID] or (appName:lower():find("perplexity")) then
-- Only attempt if we don't already have a valid value or if the value is very short
if not elementData or not elementData.value or #elementData.value < 5 then
table.insert(contextParts, " <context_method>clipboard_fallback</context_method>")
-- Save original clipboard safely
local originalClipboard = hs.pasteboard.readAllData()
-- Step 1: Cmd+A (Select All)
hs.eventtap.keyStroke({"cmd"}, "a", 10000) -- Small delay between strokes
-- Step 2: Cmd+C (Copy)
hs.eventtap.keyStroke({"cmd"}, "c", 10000)
-- Wait a bit for clipboard update (synchronous-ish in background)
hs.timer.usleep(80000)
local capturedText = hs.pasteboard.getContents()
if capturedText and #capturedText > 0 then
local textContext = #capturedText > 1500 and capturedText:sub(-1500) or capturedText
table.insert(contextParts, " <surrounding_text_readonly>" .. textContext .. "</surrounding_text_readonly>")
end
-- Step 3: Restore clipboard
if originalClipboard then
hs.pasteboard.writeAllData(originalClipboard)
end
-- Step 4: Deselect (Move cursor to end)
hs.eventtap.keyStroke({}, "right", 10000)
end
end
table.insert(contextParts, "</context>")
return table.concat(contextParts, "\n")
end
-- Validate transcription output to prevent garbage/malicious content
-- Returns: isValid (boolean), errorMessage (string or nil)
function M.validateTranscriptionOutput(text)
if not text or type(text) ~= "string" then
return false, "Ungültige Antwort vom Server"
end
-- Check for empty or whitespace-only text
local trimmed = text:match("^%s*(.-)%s*$")
if not trimmed or trimmed == "" then
return false, "Leere Antwort vom Server"
end
-- Block if the ENTIRE response is just a problematic domain (case-insensitive)
-- This allows domains within normal text, but blocks pure domain outputs
local lowerText = trimmed:lower()
local blockedDomains = {
"www.feyyaz.tv",
"feyyaz.tv",
}
for _, domain in ipairs(blockedDomains) do
if lowerText == domain then
return false, "Ungültige Antwort erkannt"
end
end
-- Block if response is ONLY a URL with nothing else (suspiciously short)
if trimmed:match("^https?://[%w%.%-]+/?$") and #trimmed < 50 then
return false, "Ungültige Antwort erkannt"
end
-- Check if text is suspiciously short for transcription (less than 2 characters)
if #trimmed < 2 then
return false, "Antwort zu kurz"
end
-- Check for suspicious patterns that indicate API errors or garbage
local errorPatterns = {
"^error$",
"^exception$",
"^failed$",
"^unauthorized$",
"^forbidden$",
"^rate limit$",
"^quota exceeded$"
}
-- Only flag as error if the ENTIRE response matches an error pattern (case-insensitive)
for _, errorPattern in ipairs(errorPatterns) do
if lowerText:match(errorPattern) then
return false, "API meldet einen Fehler"
end
end
return true, nil
end
return M