|
| 1 | +import { |
| 2 | + ActionPanel, |
| 3 | + Action, |
| 4 | + List, |
| 5 | + Form, |
| 6 | + Toast, |
| 7 | + showToast, |
| 8 | + Clipboard, |
| 9 | + showHUD, |
| 10 | + useNavigation, |
| 11 | + confirmAlert, |
| 12 | + Alert, |
| 13 | +} from "@raycast/api"; |
| 14 | +import { showFailureToast } from "@raycast/utils"; |
| 15 | +import { execFileSync } from "child_process"; |
| 16 | +import { homedir, tmpdir } from "os"; |
| 17 | +import { join } from "path"; |
| 18 | +import { unlinkSync, writeFileSync } from "fs"; |
| 19 | +import { useState, useEffect } from "react"; |
| 20 | + |
| 21 | +// MARK: - Types |
| 22 | + |
| 23 | +interface Replacement { |
| 24 | + dbPK: number; |
| 25 | + phrase: string; |
| 26 | + replacement: string; |
| 27 | +} |
| 28 | + |
| 29 | +interface DbRow { |
| 30 | + Z_PK: number; |
| 31 | + ZSHORTCUT: string; |
| 32 | + ZPHRASE: string; |
| 33 | +} |
| 34 | + |
| 35 | +// MARK: - DB helpers |
| 36 | + |
| 37 | +const DB_PATH = join(homedir(), "Library/KeyboardServices/TextReplacements.db"); |
| 38 | + |
| 39 | +// Resolve Z_ENT dynamically so we're not fragile against CoreData schema changes |
| 40 | +function getZEnt(): number { |
| 41 | + const result = execFileSync( |
| 42 | + "/usr/bin/sqlite3", |
| 43 | + ["--json", DB_PATH, `SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = 'TextReplacementEntry' LIMIT 1;`], |
| 44 | + { encoding: "utf8", timeout: 5000 }, |
| 45 | + ).trim(); |
| 46 | + const rows = JSON.parse(result || "[]") as { Z_ENT: number }[]; |
| 47 | + if (!rows.length) throw new Error("Could not resolve Z_ENT for TextReplacementEntry"); |
| 48 | + return rows[0].Z_ENT; |
| 49 | +} |
| 50 | + |
| 51 | +function sqlEscape(s: string): string { |
| 52 | + return `'${s.replace(/'/g, "''")}'`; |
| 53 | +} |
| 54 | + |
| 55 | +function hasActiveShortcut(phrase: string, excludeDbPK?: number): boolean { |
| 56 | + const excludeClause = excludeDbPK === undefined ? "" : ` AND Z_PK != ${excludeDbPK}`; |
| 57 | + const result = execFileSync( |
| 58 | + "/usr/bin/sqlite3", |
| 59 | + [ |
| 60 | + "--json", |
| 61 | + DB_PATH, |
| 62 | + `SELECT 1 FROM ZTEXTREPLACEMENTENTRY |
| 63 | + WHERE ZSHORTCUT=${sqlEscape(phrase)} |
| 64 | + ${excludeClause} |
| 65 | + AND (ZWASDELETED = 0 OR ZWASDELETED IS NULL) |
| 66 | + LIMIT 1;`, |
| 67 | + ], |
| 68 | + { encoding: "utf8", timeout: 5000 }, |
| 69 | + ).trim(); |
| 70 | + return (JSON.parse(result || "[]") as { 1: number }[]).length > 0; |
| 71 | +} |
| 72 | + |
| 73 | +function loadReplacements(): Replacement[] { |
| 74 | + // Use --json so newlines inside ZPHRASE don't corrupt row parsing |
| 75 | + const output = execFileSync( |
| 76 | + "/usr/bin/sqlite3", |
| 77 | + [ |
| 78 | + "--json", |
| 79 | + DB_PATH, |
| 80 | + `SELECT Z_PK, ZSHORTCUT, ZPHRASE FROM ZTEXTREPLACEMENTENTRY |
| 81 | + WHERE (ZWASDELETED = 0 OR ZWASDELETED IS NULL) |
| 82 | + AND ZSHORTCUT IS NOT NULL AND ZSHORTCUT != '' |
| 83 | + ORDER BY ZSHORTCUT ASC;`, |
| 84 | + ], |
| 85 | + { encoding: "utf8", timeout: 5000 }, |
| 86 | + ).trim(); |
| 87 | + |
| 88 | + const rows = JSON.parse(output || "[]") as DbRow[]; |
| 89 | + return rows.map((r) => ({ dbPK: r.Z_PK, phrase: r.ZSHORTCUT, replacement: r.ZPHRASE ?? "" })); |
| 90 | +} |
| 91 | + |
| 92 | +function insertReplacement(phrase: string, replacement: string): void { |
| 93 | + if (hasActiveShortcut(phrase)) { |
| 94 | + throw new Error("A replacement with that shortcut already exists."); |
| 95 | + } |
| 96 | + |
| 97 | + const zEnt = getZEnt(); |
| 98 | + const uniqueName = `${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| 99 | + // CoreData timestamps are seconds since 2001-01-01, not Unix epoch |
| 100 | + const timestamp = Date.now() / 1000 - 978307200; |
| 101 | + |
| 102 | + // Compute and claim the next PK atomically inside a single transaction to |
| 103 | + // avoid a TOCTOU race if another process inserts concurrently. |
| 104 | + const script = ` |
| 105 | +BEGIN IMMEDIATE; |
| 106 | +UPDATE Z_PRIMARYKEY SET Z_MAX = MAX(Z_MAX, (SELECT MAX(Z_PK) FROM ZTEXTREPLACEMENTENTRY)) + 1 WHERE Z_ENT = ${zEnt}; |
| 107 | +INSERT INTO ZTEXTREPLACEMENTENTRY |
| 108 | + (Z_PK, Z_ENT, Z_OPT, ZNEEDSSAVETOCLOUD, ZWASDELETED, ZTIMESTAMP, ZPHRASE, ZSHORTCUT, ZUNIQUENAME) |
| 109 | +SELECT Z_MAX, ${zEnt}, 1, 1, 0, ${timestamp}, ${sqlEscape(replacement)}, ${sqlEscape(phrase)}, ${sqlEscape(uniqueName)} |
| 110 | +FROM Z_PRIMARYKEY WHERE Z_ENT = ${zEnt}; |
| 111 | +COMMIT;`; |
| 112 | + |
| 113 | + execFileSync("/usr/bin/sqlite3", [DB_PATH], { input: script, encoding: "utf8", timeout: 5000 }); |
| 114 | +} |
| 115 | + |
| 116 | +function updateReplacement(dbPK: number, phrase: string, replacement: string): void { |
| 117 | + if (hasActiveShortcut(phrase, dbPK)) { |
| 118 | + throw new Error("A replacement with that shortcut already exists."); |
| 119 | + } |
| 120 | + |
| 121 | + execFileSync( |
| 122 | + "/usr/bin/sqlite3", |
| 123 | + [ |
| 124 | + DB_PATH, |
| 125 | + `UPDATE ZTEXTREPLACEMENTENTRY SET ZSHORTCUT=${sqlEscape(phrase)}, ZPHRASE=${sqlEscape(replacement)}, Z_OPT=Z_OPT+1, ZNEEDSSAVETOCLOUD=1 WHERE Z_PK=${dbPK};`, |
| 126 | + ], |
| 127 | + { encoding: "utf8", timeout: 5000 }, |
| 128 | + ); |
| 129 | +} |
| 130 | + |
| 131 | +function deleteReplacement(dbPK: number): void { |
| 132 | + execFileSync( |
| 133 | + "/usr/bin/sqlite3", |
| 134 | + [DB_PATH, `UPDATE ZTEXTREPLACEMENTENTRY SET ZWASDELETED=1, ZNEEDSSAVETOCLOUD=1, Z_OPT=Z_OPT+1 WHERE Z_PK=${dbPK};`], |
| 135 | + { encoding: "utf8", timeout: 5000 }, |
| 136 | + ); |
| 137 | +} |
| 138 | + |
| 139 | +function escapeXml(s: string): string { |
| 140 | + // XML 1.0 forbids most ASCII control chars; strip them before escaping. |
| 141 | + const xmlSafe = Array.from(s) |
| 142 | + .filter((char) => { |
| 143 | + const code = char.charCodeAt(0); |
| 144 | + return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; |
| 145 | + }) |
| 146 | + .join(""); |
| 147 | + return xmlSafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
| 148 | +} |
| 149 | + |
| 150 | +function notifySystem(): void { |
| 151 | + const all = loadReplacements(); |
| 152 | + const itemsXml = all |
| 153 | + .map( |
| 154 | + (r) => |
| 155 | + ` <dict>\n <key>replace</key><string>${escapeXml(r.phrase)}</string>\n <key>with</key><string>${escapeXml(r.replacement)}</string>\n </dict>`, |
| 156 | + ) |
| 157 | + .join("\n"); |
| 158 | + |
| 159 | + const plist = `<?xml version="1.0" encoding="UTF-8"?> |
| 160 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 161 | +<plist version="1.0"> |
| 162 | +<dict> |
| 163 | + <key>NSUserReplacementItems</key> |
| 164 | + <array> |
| 165 | +${itemsXml} |
| 166 | + </array> |
| 167 | +</dict> |
| 168 | +</plist>`; |
| 169 | + |
| 170 | + // Use a unique filename per call to avoid write-write races on rapid saves |
| 171 | + const tmpFile = join(tmpdir(), `raycast_replacements_${Date.now()}.plist`); |
| 172 | + writeFileSync(tmpFile, plist, "utf8"); |
| 173 | + try { |
| 174 | + execFileSync("/usr/bin/defaults", ["import", "-g", tmpFile], { timeout: 5000 }); |
| 175 | + } finally { |
| 176 | + // Clean up temp file regardless of success or failure |
| 177 | + try { |
| 178 | + unlinkSync(tmpFile); |
| 179 | + } catch { |
| 180 | + // non-fatal |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + // Flush the WAL so the keyboard services daemon picks up the changes |
| 185 | + try { |
| 186 | + execFileSync("/usr/bin/sqlite3", [DB_PATH, "PRAGMA wal_checkpoint(PASSIVE);"], { timeout: 3000 }); |
| 187 | + } catch { |
| 188 | + // non-fatal |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +// MARK: - Form |
| 193 | + |
| 194 | +function ReplacementForm({ initial, onSave }: { initial?: Replacement; onSave: () => void }) { |
| 195 | + const { pop } = useNavigation(); |
| 196 | + |
| 197 | + async function handleSubmit(values: { phrase: string; replacement: string }) { |
| 198 | + const phrase = values.phrase.trim(); |
| 199 | + const replacement = values.replacement; // preserve intentional leading/trailing spaces |
| 200 | + if (!phrase) { |
| 201 | + await showToast({ style: Toast.Style.Failure, title: "Shortcut cannot be empty" }); |
| 202 | + return; |
| 203 | + } |
| 204 | + try { |
| 205 | + if (initial) { |
| 206 | + updateReplacement(initial.dbPK, phrase, replacement); |
| 207 | + } else { |
| 208 | + insertReplacement(phrase, replacement); |
| 209 | + } |
| 210 | + notifySystem(); |
| 211 | + await showToast({ style: Toast.Style.Success, title: initial ? "Replacement updated" : "Replacement added" }); |
| 212 | + pop(); |
| 213 | + } catch (error) { |
| 214 | + showFailureToast(error, { title: "Failed to save replacement" }); |
| 215 | + } finally { |
| 216 | + onSave(); |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + return ( |
| 221 | + <Form |
| 222 | + navigationTitle={initial ? "Edit Replacement" : "Add Replacement"} |
| 223 | + actions={ |
| 224 | + <ActionPanel> |
| 225 | + <Action.SubmitForm title="Save" onSubmit={handleSubmit} /> |
| 226 | + </ActionPanel> |
| 227 | + } |
| 228 | + > |
| 229 | + <Form.TextField id="phrase" title="Shortcut" placeholder="abbr" defaultValue={initial?.phrase} /> |
| 230 | + <Form.TextArea |
| 231 | + id="replacement" |
| 232 | + title="Expands To" |
| 233 | + placeholder="the full text" |
| 234 | + defaultValue={initial?.replacement} |
| 235 | + /> |
| 236 | + </Form> |
| 237 | + ); |
| 238 | +} |
| 239 | + |
| 240 | +// MARK: - Main list |
| 241 | + |
| 242 | +export default function ManageTextReplacements() { |
| 243 | + const [replacements, setReplacements] = useState<Replacement[]>([]); |
| 244 | + const [isLoading, setIsLoading] = useState(true); |
| 245 | + |
| 246 | + function refresh() { |
| 247 | + setIsLoading(true); |
| 248 | + try { |
| 249 | + setReplacements(loadReplacements()); |
| 250 | + } catch (error) { |
| 251 | + showFailureToast(error, { title: "Failed to load replacements" }); |
| 252 | + } finally { |
| 253 | + setIsLoading(false); |
| 254 | + } |
| 255 | + } |
| 256 | + |
| 257 | + useEffect(() => { |
| 258 | + refresh(); |
| 259 | + }, []); |
| 260 | + |
| 261 | + async function handleDelete(item: Replacement) { |
| 262 | + const confirmed = await confirmAlert({ |
| 263 | + title: `Delete "${item.phrase}"?`, |
| 264 | + message: "This will remove it from System Settings too.", |
| 265 | + primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive }, |
| 266 | + }); |
| 267 | + if (!confirmed) return; |
| 268 | + try { |
| 269 | + deleteReplacement(item.dbPK); |
| 270 | + notifySystem(); |
| 271 | + await showToast({ style: Toast.Style.Success, title: `Deleted "${item.phrase}"` }); |
| 272 | + } catch (error) { |
| 273 | + showFailureToast(error, { title: "Failed to delete replacement" }); |
| 274 | + } finally { |
| 275 | + refresh(); |
| 276 | + } |
| 277 | + } |
| 278 | + |
| 279 | + return ( |
| 280 | + <List searchBarPlaceholder="Search shortcuts…" isLoading={isLoading}> |
| 281 | + {!isLoading && replacements.length === 0 && ( |
| 282 | + <List.EmptyView |
| 283 | + title="No Text Replacements" |
| 284 | + description="Press ⌘N to add your first shortcut" |
| 285 | + actions={ |
| 286 | + <ActionPanel> |
| 287 | + <Action.Push |
| 288 | + title="Add New" |
| 289 | + shortcut={{ modifiers: ["cmd"], key: "n" }} |
| 290 | + target={<ReplacementForm onSave={refresh} />} |
| 291 | + /> |
| 292 | + </ActionPanel> |
| 293 | + } |
| 294 | + /> |
| 295 | + )} |
| 296 | + <List.Section title="Replacements" subtitle={`${replacements.length}`}> |
| 297 | + {replacements.map((item) => ( |
| 298 | + <List.Item |
| 299 | + key={item.dbPK} |
| 300 | + title={item.phrase} |
| 301 | + subtitle={item.replacement} |
| 302 | + actions={ |
| 303 | + <ActionPanel> |
| 304 | + <ActionPanel.Section> |
| 305 | + <Action |
| 306 | + title="Copy Expansion" |
| 307 | + onAction={async () => { |
| 308 | + await Clipboard.copy(item.replacement); |
| 309 | + await showHUD(`Copied expansion for "${item.phrase}"`); |
| 310 | + }} |
| 311 | + /> |
| 312 | + <Action |
| 313 | + title="Paste Expansion" |
| 314 | + onAction={async () => { |
| 315 | + await Clipboard.paste(item.replacement); |
| 316 | + await showHUD(`Pasted expansion for "${item.phrase}"`); |
| 317 | + }} |
| 318 | + /> |
| 319 | + </ActionPanel.Section> |
| 320 | + <ActionPanel.Section> |
| 321 | + <Action.Push |
| 322 | + title="Edit" |
| 323 | + shortcut={{ modifiers: ["cmd"], key: "e" }} |
| 324 | + target={<ReplacementForm initial={item} onSave={refresh} />} |
| 325 | + /> |
| 326 | + <Action.Push |
| 327 | + title="Add New" |
| 328 | + shortcut={{ modifiers: ["cmd"], key: "n" }} |
| 329 | + target={<ReplacementForm onSave={refresh} />} |
| 330 | + /> |
| 331 | + <Action |
| 332 | + title="Delete" |
| 333 | + style={Action.Style.Destructive} |
| 334 | + shortcut={{ modifiers: ["ctrl"], key: "x" }} |
| 335 | + onAction={() => handleDelete(item)} |
| 336 | + /> |
| 337 | + </ActionPanel.Section> |
| 338 | + </ActionPanel> |
| 339 | + } |
| 340 | + /> |
| 341 | + ))} |
| 342 | + </List.Section> |
| 343 | + </List> |
| 344 | + ); |
| 345 | +} |
0 commit comments