Skip to content

Commit 522d04d

Browse files
jake-fowler-lego-nerdclaude0xdhrvraycastbot
authored
Add Manage Text Replacements command (#27349)
* Add Manage Text Replacements command Adds a new 'Manage Text Replacements' command that lets users search, copy, add, edit, and delete native macOS text replacements directly from Raycast. Changes sync back to System Settings via defaults import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address bot review feedback - Add List.EmptyView with Add New action so users can create their first replacement even when the list is empty (P1) - Use timestamp-suffixed temp file in notifySystem() to prevent write-write race on rapid successive saves (P2) - Resolve Z_ENT dynamically via Z_PRIMARYKEY.Z_NAME instead of hardcoding 1, guarding against CoreData schema changes (P2) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix Prettier formatting * Fix multi-line expansion handling and minor improvements - Switch loadReplacements to --json mode so newlines inside ZPHRASE don't corrupt row parsing (e.g. email signature expansions) - Use Form.TextArea for expansion field to support multi-line input - Clean up temp plist file after defaults import - Use --json for getZEnt query for consistency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update package-lock.json * Address review feedback for text replacement safety and loading UX. Prevent duplicate shortcut inserts, reset loading state for every refresh, and sanitize XML-illegal control characters before plist import. Made-with: Cursor * Prevent duplicate shortcuts when editing replacements. Reuse shortcut conflict detection in update flow while excluding the current row to avoid creating duplicate active shortcuts. Made-with: Cursor * Ensure refresh occurs after deletion of text replacements to maintain UI consistency. * Update text replacements extension category and enhance user feedback on paste action - Change category from "Applications" to "System" in package.json for better classification. - Add a HUD notification after pasting a text replacement to improve user experience. * Refactor ReplacementForm to ensure onSave is called after save operation - Move onSave() call to the finally block to guarantee it executes regardless of success or failure during the save process. - Enhance user feedback by ensuring the save state is consistently updated. * Update CHANGELOG.md and add platforms field --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Dhruv Suthar <git@dhrv.pw> Co-authored-by: raycastbot <bot@raycast.com>
1 parent 90a65bb commit 522d04d

4 files changed

Lines changed: 366 additions & 5 deletions

File tree

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
# Text Replacement Changelog
22

3-
## [Initial Version] - 2024-04-22
3+
## [Manage Text Replacements] - 2026-04-30
4+
5+
- Add new "Manage Text Replacements" command to search, copy, add, edit, and delete replacements
6+
- Copy or paste any expansion directly from Raycast
7+
- Changes sync back to System Settings automatically
8+
9+
## [Initial Version] - 2024-04-22
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Text Replacements
22

3-
Import macOS's text replacements to Raycast snippets.
3+
Import macOS's text replacements to Raycast snippets.

extensions/text-replacements/package.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@
22
"$schema": "https://www.raycast.com/schemas/extension.json",
33
"name": "text-replacements",
44
"title": "Text Replacements",
5-
"description": "Import macOS's text replacements to Raycast snippets.",
5+
"description": "Search, manage, and import your macOS text replacements.",
66
"icon": "extension-icon.png",
77
"author": "thomaslombart",
88
"owner": "raycast",
99
"access": "public",
1010
"categories": [
11-
"Applications"
11+
"System"
1212
],
1313
"license": "MIT",
1414
"commands": [
15+
{
16+
"name": "manage-text-replacements",
17+
"title": "Manage Text Replacements",
18+
"subtitle": "Text Replacements",
19+
"description": "Search, copy, add, edit, and delete your macOS text replacements.",
20+
"mode": "view"
21+
},
1522
{
1623
"name": "import-text-replacements-to-snippets",
1724
"title": "Import Text Replacements to Snippets",
@@ -38,5 +45,8 @@
3845
"fix-lint": "ray lint --fix",
3946
"lint": "ray lint",
4047
"publish": "npx @raycast/api@latest publish"
41-
}
48+
},
49+
"platforms": [
50+
"macOS"
51+
]
4252
}
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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

Comments
 (0)