@tekmidian/devonthink-mcp 2.0.0 → 2.0.1

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.
@@ -2,29 +2,24 @@
2
2
  * columnLayout.ts — Read and copy DEVONthink column layouts for smart groups/rules.
3
3
  *
4
4
  * Column layouts for list views are stored in
5
- * ~/Library/Preferences/com.devon-technologies.think.plist under a "Columns" key.
6
- * Each smart group or rule window remembers its column configuration keyed by
7
- * the group/rule name or UUID.
5
+ * ~/Library/Preferences/com.devon-technologies.think.plist under three related keys
6
+ * per smart group or rule (keyed by name or UUID):
7
+ *
8
+ * ListColumnsHorizontal-{name}
9
+ * → Array of visible column identifiers in display order
10
+ *
11
+ * TableView Columns ListColumnsHorizontal-{name}
12
+ * → Array of ALL column identifiers (visible + hidden)
13
+ *
14
+ * TableView Column Widths ListColumnsHorizontal-{name}
15
+ * → Dict of { columnIdentifier: widthFloat }
16
+ *
17
+ * The plist may use a smart group's UUID instead of its display name.
8
18
  *
9
19
  * Tools exported:
10
20
  * get_column_layout — Read columns for a named smart group or rule
11
21
  * copy_column_layout — Copy column layout from one smart group/rule to another
12
22
  */
13
- interface ColumnEntry {
14
- identifier: string | null;
15
- title: string | null;
16
- width: number | null;
17
- visible: boolean | null;
18
- position: number | null;
19
- }
20
- interface GetColumnLayoutResult {
21
- success: boolean;
22
- name?: string;
23
- plistKey?: string;
24
- columns?: ColumnEntry[];
25
- rawValue?: string;
26
- error?: string;
27
- }
28
23
  export declare const getColumnLayoutTool: {
29
24
  name: string;
30
25
  description: string;
@@ -35,21 +30,22 @@ export declare const getColumnLayoutTool: {
35
30
  type: string;
36
31
  description: string;
37
32
  };
38
- plistKey: {
33
+ uuid: {
39
34
  type: string;
40
35
  description: string;
41
36
  };
42
37
  };
43
38
  required: string[];
44
39
  };
45
- run: (args: Record<string, unknown>) => Promise<GetColumnLayoutResult>;
40
+ run: (args: Record<string, unknown>) => Promise<unknown>;
46
41
  };
47
42
  interface CopyColumnLayoutResult {
48
43
  success: boolean;
49
44
  sourceName?: string;
50
45
  targetName?: string;
51
- sourcePlistKey?: string;
52
- targetPlistKey?: string;
46
+ resolvedSourceKey?: string;
47
+ resolvedTargetKey?: string;
48
+ keysCopied?: string[];
53
49
  message?: string;
54
50
  error?: string;
55
51
  }
@@ -67,11 +63,11 @@ export declare const copyColumnLayoutTool: {
67
63
  type: string;
68
64
  description: string;
69
65
  };
70
- sourcePlistKey: {
66
+ sourceUuid: {
71
67
  type: string;
72
68
  description: string;
73
69
  };
74
- targetPlistKey: {
70
+ targetUuid: {
75
71
  type: string;
76
72
  description: string;
77
73
  };
@@ -2,9 +2,19 @@
2
2
  * columnLayout.ts — Read and copy DEVONthink column layouts for smart groups/rules.
3
3
  *
4
4
  * Column layouts for list views are stored in
5
- * ~/Library/Preferences/com.devon-technologies.think.plist under a "Columns" key.
6
- * Each smart group or rule window remembers its column configuration keyed by
7
- * the group/rule name or UUID.
5
+ * ~/Library/Preferences/com.devon-technologies.think.plist under three related keys
6
+ * per smart group or rule (keyed by name or UUID):
7
+ *
8
+ * ListColumnsHorizontal-{name}
9
+ * → Array of visible column identifiers in display order
10
+ *
11
+ * TableView Columns ListColumnsHorizontal-{name}
12
+ * → Array of ALL column identifiers (visible + hidden)
13
+ *
14
+ * TableView Column Widths ListColumnsHorizontal-{name}
15
+ * → Dict of { columnIdentifier: widthFloat }
16
+ *
17
+ * The plist may use a smart group's UUID instead of its display name.
8
18
  *
9
19
  * Tools exported:
10
20
  * get_column_layout — Read columns for a named smart group or rule
@@ -24,7 +34,7 @@ const PLISTBUDDY = "/usr/libexec/PlistBuddy";
24
34
  */
25
35
  function plistRead(command) {
26
36
  try {
27
- const result = execSync(`${PLISTBUDDY} -c "${command}" "${PLIST_PATH}"`, {
37
+ const result = execSync(`${PLISTBUDDY} -c ${shellQuote(command)} ${shellQuote(PLIST_PATH)}`, {
28
38
  encoding: "utf-8",
29
39
  timeout: 10000,
30
40
  });
@@ -35,84 +45,185 @@ function plistRead(command) {
35
45
  }
36
46
  }
37
47
  /**
38
- * Run PlistBuddy with a command that modifies the plist.
39
- * Returns true on success, false on failure.
48
+ * Shell-quote a string by wrapping in single quotes and escaping embedded single quotes.
49
+ */
50
+ function shellQuote(s) {
51
+ return `'${s.replace(/'/g, "'\\''")}'`;
52
+ }
53
+ /**
54
+ * Build the three plist keys for a given base name (name or UUID).
55
+ */
56
+ function keysForName(name) {
57
+ return {
58
+ columnsKey: `ListColumnsHorizontal-${name}`,
59
+ tableViewColumnsKey: `TableView Columns ListColumnsHorizontal-${name}`,
60
+ tableViewWidthsKey: `TableView Column Widths ListColumnsHorizontal-${name}`,
61
+ };
62
+ }
63
+ /**
64
+ * Parse a PlistBuddy "Array { ... }" output into a string array.
65
+ */
66
+ function parseArray(raw) {
67
+ const lines = raw
68
+ .split("\n")
69
+ .map((l) => l.trim())
70
+ .filter((l) => l !== "" && l !== "Array {" && l !== "}");
71
+ return lines;
72
+ }
73
+ /**
74
+ * Parse a PlistBuddy "Dict { key = value ... }" output into a Record<string, number>.
75
+ */
76
+ function parseWidthDict(raw) {
77
+ const result = {};
78
+ const lines = raw
79
+ .split("\n")
80
+ .map((l) => l.trim())
81
+ .filter((l) => l !== "" && l !== "Dict {" && l !== "}");
82
+ for (const line of lines) {
83
+ const match = line.match(/^(\S+)\s*=\s*(.+)$/);
84
+ if (match) {
85
+ result[match[1]] = parseFloat(match[2]);
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+ /**
91
+ * Get all distinct base names that exist in the plist for ListColumnsHorizontal entries.
92
+ * Returns up to `limit` names for error messages.
40
93
  */
41
- function plistWrite(command) {
94
+ function getExistingNames(limit = 20) {
95
+ // Use Python to read the plist keys — more reliable for binary plists
96
+ const script = `
97
+ import plistlib, re, sys
98
+ with open('${PLIST_PATH}', 'rb') as f:
99
+ data = plistlib.load(f)
100
+ prefix = 'ListColumnsHorizontal-'
101
+ names = set()
102
+ for k in data:
103
+ if k.startswith(prefix):
104
+ names.add(k[len(prefix):])
105
+ elif k.startswith('TableView Columns ' + prefix):
106
+ names.add(k[len('TableView Columns ' + prefix):])
107
+ elif k.startswith('TableView Column Widths ' + prefix):
108
+ names.add(k[len('TableView Column Widths ' + prefix):])
109
+ # Exclude Outline variants
110
+ names = [n for n in names if not n.startswith('Outline')]
111
+ names.sort()
112
+ print('\\n'.join(names[:${limit}]))
113
+ `.trim();
42
114
  try {
43
- execSync(`${PLISTBUDDY} -c "${command}" "${PLIST_PATH}"`, {
115
+ const result = execSync(`python3 -c ${shellQuote(script)}`, {
44
116
  encoding: "utf-8",
45
117
  timeout: 10000,
46
118
  });
47
- return true;
119
+ return result.trim().split("\n").filter(Boolean);
48
120
  }
49
121
  catch {
50
- return false;
122
+ return [];
51
123
  }
52
124
  }
53
125
  /**
54
- * Escape a PlistBuddy key name by replacing : with \\:.
55
- * Colons are path separators in PlistBuddy, so any colon in a key value
56
- * must be escaped.
126
+ * Fuzzy-search existing names in the plist for a partial match.
127
+ * Returns all names that include the search term (case-insensitive).
57
128
  */
58
- function escapePBKey(key) {
59
- return key.replace(/:/g, "\\:");
129
+ function findMatchingNames(searchTerm) {
130
+ const allNames = getExistingNames(200);
131
+ const lower = searchTerm.toLowerCase();
132
+ return allNames.filter((n) => n.toLowerCase().includes(lower));
60
133
  }
61
134
  /**
62
- * Read the integer count of items in a PlistBuddy array/dict.
135
+ * Look up column layout data for a single base name (exact match).
136
+ * Returns null if none of the three key variants exist.
63
137
  */
64
- function readCount(path) {
65
- const raw = plistRead(`Print '${path}'`);
66
- if (!raw)
67
- return 0;
68
- // PlistBuddy prints "Dict {", "Array {", or the value — count the entries
69
- const lines = raw.split("\n").filter((l) => l.trim() !== "" && l.trim() !== "{" && l.trim() !== "}");
70
- return lines.length;
138
+ function readLayoutForName(baseName) {
139
+ const { columnsKey, tableViewColumnsKey, tableViewWidthsKey } = keysForName(baseName);
140
+ const rawColumns = plistRead(`Print ':${columnsKey}'`);
141
+ const rawTableViewColumns = plistRead(`Print ':${tableViewColumnsKey}'`);
142
+ const rawWidths = plistRead(`Print ':${tableViewWidthsKey}'`);
143
+ // If none of the three keys exist, this name has no layout stored
144
+ if (!rawColumns && !rawTableViewColumns && !rawWidths) {
145
+ return null;
146
+ }
147
+ const keysFound = [];
148
+ if (rawColumns)
149
+ keysFound.push(columnsKey);
150
+ if (rawTableViewColumns)
151
+ keysFound.push(tableViewColumnsKey);
152
+ if (rawWidths)
153
+ keysFound.push(tableViewWidthsKey);
154
+ return {
155
+ found: true,
156
+ name: baseName,
157
+ resolvedKey: baseName,
158
+ columns: rawColumns ? parseArray(rawColumns) : null,
159
+ tableViewColumns: rawTableViewColumns ? parseArray(rawTableViewColumns) : null,
160
+ widths: rawWidths ? parseWidthDict(rawWidths) : null,
161
+ keysFound,
162
+ };
71
163
  }
72
164
  const getColumnLayout = async (args) => {
73
- const { name, plistKey } = args;
165
+ const { name, uuid } = args;
74
166
  if (!name || typeof name !== "string") {
75
167
  return { success: false, error: "name parameter is required" };
76
168
  }
77
- // Try to find the columns key. DEVONthink stores view settings under various
78
- // top-level keys. We attempt a few common patterns:
79
- const escapedName = escapePBKey(name);
80
- const keysToTry = plistKey
81
- ? [plistKey]
82
- : [
83
- `Columns:${escapedName}`,
84
- `NSTableView Columns ${escapedName}`,
85
- `ViewState:${escapedName}:Columns`,
86
- `SmartGroupColumns:${escapedName}`,
87
- `ColumnState:${escapedName}`,
88
- ];
89
- for (const key of keysToTry) {
90
- const raw = plistRead(`Print '${key}'`);
91
- if (raw) {
169
+ // Try exact name first
170
+ const exactResult = readLayoutForName(name);
171
+ if (exactResult) {
172
+ return {
173
+ success: true,
174
+ ...exactResult,
175
+ };
176
+ }
177
+ // If a UUID was supplied, try that too
178
+ if (uuid && typeof uuid === "string") {
179
+ const uuidResult = readLayoutForName(uuid);
180
+ if (uuidResult) {
92
181
  return {
93
182
  success: true,
94
- name,
95
- plistKey: key,
96
- rawValue: raw,
97
- columns: [],
183
+ ...uuidResult,
184
+ name, // Override with the human-readable name
98
185
  };
99
186
  }
100
187
  }
101
- // Fallback: dump top-level keys so the user can find the right path
102
- const topLevel = plistRead("Print :");
188
+ // Try fuzzy match against existing names
189
+ const fuzzyMatches = findMatchingNames(name);
190
+ if (fuzzyMatches.length === 1) {
191
+ // Single match — use it
192
+ const fuzzyResult = readLayoutForName(fuzzyMatches[0]);
193
+ if (fuzzyResult) {
194
+ return {
195
+ success: true,
196
+ ...fuzzyResult,
197
+ nameSearched: name,
198
+ note: `Exact name not found; matched "${fuzzyMatches[0]}" via partial search`,
199
+ };
200
+ }
201
+ }
202
+ // Not found — return helpful error with examples
203
+ const examples = getExistingNames(15);
204
+ if (fuzzyMatches.length > 1) {
205
+ return {
206
+ success: false,
207
+ name,
208
+ error: `No exact match for "${name}". Multiple partial matches found — be more specific.`,
209
+ partialMatches: fuzzyMatches,
210
+ };
211
+ }
103
212
  return {
104
213
  success: false,
105
214
  name,
106
- error: `Column layout for "${name}" not found under common plist paths. ` +
107
- "Try specifying the plistKey parameter. " +
108
- `Top-level plist keys:\n${topLevel ?? "(could not read plist)"}`,
215
+ error: `No column layout found for "${name}". ` +
216
+ "This smart group may not have a custom layout yet (it will use defaults). " +
217
+ "Use copy_column_layout to copy an existing layout to it.",
218
+ exampleNames: examples,
109
219
  };
110
220
  };
111
221
  export const getColumnLayoutTool = {
112
222
  name: "get_column_layout",
113
223
  description: "Read the column layout for a DEVONthink smart group or smart rule from preferences. " +
114
- "Returns the raw PlistBuddy value so you can inspect column names and widths. " +
115
- 'Input: { "name": "Jobs - To Review" } or { "name": "Jobs - To Review", "plistKey": "Columns:Jobs - To Review" }',
224
+ "Returns the ordered visible columns, all table view columns, and column widths. " +
225
+ "Looks up by name (or UUID). Supports partial name matching. " +
226
+ 'Input: { "name": "Archivieren - Jobs" } or { "name": "Jobs", "uuid": "4A469368-..." }',
116
227
  inputSchema: {
117
228
  type: "object",
118
229
  properties: {
@@ -120,202 +231,184 @@ export const getColumnLayoutTool = {
120
231
  type: "string",
121
232
  description: "Name of the smart group or smart rule whose column layout to read",
122
233
  },
123
- plistKey: {
234
+ uuid: {
124
235
  type: "string",
125
- description: "Optional explicit PlistBuddy key path (e.g. 'Columns:My Group'). " +
126
- "If omitted, common patterns are tried automatically.",
236
+ description: "Optional UUID of the smart group (fallback if name not found). " +
237
+ "DEVONthink sometimes stores layouts under the UUID rather than the display name.",
127
238
  },
128
239
  },
129
240
  required: ["name"],
130
241
  },
131
242
  run: getColumnLayout,
132
243
  };
244
+ /**
245
+ * Use Python plistlib to copy all three column layout keys from source to target.
246
+ * This handles nested structures (Array/Dict) correctly in a single atomic read/write.
247
+ */
248
+ function copyLayoutViaPython(sourceBaseName, targetBaseName) {
249
+ const suffixes = [
250
+ "ListColumnsHorizontal",
251
+ "TableView Columns ListColumnsHorizontal",
252
+ "TableView Column Widths ListColumnsHorizontal",
253
+ ];
254
+ const script = `
255
+ import plistlib, sys, os
256
+
257
+ plist_path = '${PLIST_PATH}'
258
+ source = '${sourceBaseName.replace(/'/g, "\\'")}'
259
+ target = '${targetBaseName.replace(/'/g, "\\'")}'
260
+
261
+ suffixes = [
262
+ 'ListColumnsHorizontal',
263
+ 'TableView Columns ListColumnsHorizontal',
264
+ 'TableView Column Widths ListColumnsHorizontal',
265
+ ]
266
+
267
+ with open(plist_path, 'rb') as f:
268
+ data = plistlib.load(f)
269
+
270
+ copied = []
271
+ for suffix in suffixes:
272
+ src_key = suffix + '-' + source
273
+ tgt_key = suffix + '-' + target
274
+ if src_key in data:
275
+ import copy
276
+ data[tgt_key] = copy.deepcopy(data[src_key])
277
+ copied.append(tgt_key)
278
+
279
+ if not copied:
280
+ print('ERROR: no source keys found for: ' + source, file=sys.stderr)
281
+ sys.exit(1)
282
+
283
+ with open(plist_path, 'wb') as f:
284
+ plistlib.dump(data, f, fmt=plistlib.FMT_BINARY)
285
+
286
+ print('\\n'.join(copied))
287
+ `.trim();
288
+ try {
289
+ const result = execSync(`python3 -c ${shellQuote(script)}`, {
290
+ encoding: "utf-8",
291
+ timeout: 15000,
292
+ });
293
+ const keysCopied = result.trim().split("\n").filter(Boolean);
294
+ return { ok: true, keysCopied };
295
+ }
296
+ catch (err) {
297
+ const errMsg = err instanceof Error ? err.message : String(err);
298
+ return { ok: false, keysCopied: [], error: errMsg };
299
+ }
300
+ }
133
301
  const copyColumnLayout = async (args) => {
134
- const { sourceName, targetName, sourcePlistKey, targetPlistKey } = args;
302
+ const { sourceName, targetName, sourceUuid, targetUuid } = args;
135
303
  if (!sourceName || typeof sourceName !== "string") {
136
304
  return { success: false, error: "sourceName parameter is required" };
137
305
  }
138
306
  if (!targetName || typeof targetName !== "string") {
139
307
  return { success: false, error: "targetName parameter is required" };
140
308
  }
141
- // Resolve source key
142
- const escapedSource = escapePBKey(sourceName);
143
- const sourceKeysToTry = sourcePlistKey
144
- ? [sourcePlistKey]
145
- : [
146
- `Columns:${escapedSource}`,
147
- `NSTableView Columns ${escapedSource}`,
148
- `ViewState:${escapedSource}:Columns`,
149
- `SmartGroupColumns:${escapedSource}`,
150
- `ColumnState:${escapedSource}`,
151
- ];
309
+ // Resolve source: try exact name, then UUID, then fuzzy
152
310
  let resolvedSourceKey = null;
153
- let sourceValue = null;
154
- for (const key of sourceKeysToTry) {
155
- const raw = plistRead(`Print '${key}'`);
156
- if (raw) {
157
- resolvedSourceKey = key;
158
- sourceValue = raw;
159
- break;
160
- }
311
+ if (readLayoutForName(sourceName)) {
312
+ resolvedSourceKey = sourceName;
161
313
  }
162
- if (!resolvedSourceKey || !sourceValue) {
163
- return {
164
- success: false,
165
- sourceName,
166
- targetName,
167
- error: `Source column layout for "${sourceName}" not found. Try specifying sourcePlistKey.`,
168
- };
169
- }
170
- // Resolve target key (same pattern, replacing source name with target name)
171
- const escapedTarget = escapePBKey(targetName);
172
- let resolvedTargetKey;
173
- if (targetPlistKey) {
174
- resolvedTargetKey = targetPlistKey;
314
+ else if (sourceUuid && readLayoutForName(sourceUuid)) {
315
+ resolvedSourceKey = sourceUuid;
175
316
  }
176
317
  else {
177
- // Mirror the source key pattern but with the target name
178
- resolvedTargetKey = resolvedSourceKey.replace(escapedSource, escapedTarget);
179
- }
180
- // Check if target key already exists — if so, delete it first
181
- const existingTarget = plistRead(`Print '${resolvedTargetKey}'`);
182
- if (existingTarget) {
183
- const deleted = plistWrite(`Delete '${resolvedTargetKey}'`);
184
- if (!deleted) {
318
+ const fuzzy = findMatchingNames(sourceName);
319
+ if (fuzzy.length === 1 && readLayoutForName(fuzzy[0])) {
320
+ resolvedSourceKey = fuzzy[0];
321
+ }
322
+ else if (fuzzy.length > 1) {
185
323
  return {
186
324
  success: false,
187
325
  sourceName,
188
326
  targetName,
189
- sourcePlistKey: resolvedSourceKey,
190
- targetPlistKey: resolvedTargetKey,
191
- error: `Could not delete existing target key: ${resolvedTargetKey}`,
327
+ error: `Ambiguous source name "${sourceName}". Multiple matches: ${fuzzy.slice(0, 8).join(", ")}`,
192
328
  };
193
329
  }
194
330
  }
195
- // PlistBuddy does not have a native copy command.
196
- // Strategy: export source value as XML then import it at the target key.
197
- // We use a temp file approach with plutil + PlistBuddy.
198
- const os = await import("node:os");
199
- const fs = await import("node:fs");
200
- const tmpFile = join(os.tmpdir(), `dt-column-copy-${Date.now()}.plist`);
201
- try {
202
- // Export current plist to XML
203
- execSync(`plutil -convert xml1 -o "${tmpFile}" "${PLIST_PATH}"`, {
204
- encoding: "utf-8",
205
- timeout: 10000,
206
- });
207
- // Read the source value from the xml copy (same key)
208
- const sourceFromTmp = plistRead(`Print '${resolvedSourceKey}'`);
209
- if (!sourceFromTmp) {
210
- fs.unlinkSync(tmpFile);
211
- return {
212
- success: false,
213
- error: "Could not re-read source value after XML conversion",
214
- };
215
- }
216
- // The safest way to copy is to use PlistBuddy on the tmp file to
217
- // get the XML fragment, then use Add command on the original.
218
- // However, PlistBuddy cannot directly export a subtree as XML.
219
- // Instead we detect the type and use a simpler approach:
220
- // read individual leaf values and reconstruct with Add commands.
221
- //
222
- // For now, return what we found and indicate manual steps needed for
223
- // complex nested structures. If the source is a simple string:
224
- const trimmed = sourceValue.trim();
225
- const isSimpleValue = !trimmed.startsWith("Dict") && !trimmed.startsWith("Array");
226
- if (isSimpleValue) {
227
- // Determine plist type and add
228
- const numVal = Number(trimmed);
229
- if (!isNaN(numVal) && trimmed !== "") {
230
- plistWrite(`Add '${resolvedTargetKey}' integer ${trimmed}`);
231
- }
232
- else if (trimmed === "true" || trimmed === "false") {
233
- plistWrite(`Add '${resolvedTargetKey}' bool ${trimmed}`);
234
- }
235
- else {
236
- const escaped = trimmed.replace(/'/g, "'\\''");
237
- plistWrite(`Add '${resolvedTargetKey}' string '${escaped}'`);
238
- }
239
- // Verify
240
- const written = plistRead(`Print '${resolvedTargetKey}'`);
241
- fs.unlinkSync(tmpFile);
242
- if (written) {
243
- return {
244
- success: true,
245
- sourceName,
246
- targetName,
247
- sourcePlistKey: resolvedSourceKey,
248
- targetPlistKey: resolvedTargetKey,
249
- message: `Column layout copied from "${sourceName}" to "${targetName}". Value: ${written}`,
250
- };
251
- }
252
- else {
253
- return {
254
- success: false,
255
- error: "Write appeared to succeed but key is not readable after write",
256
- };
257
- }
258
- }
259
- // Complex nested structure — return info so user can inspect and act
260
- fs.unlinkSync(tmpFile);
331
+ if (!resolvedSourceKey) {
332
+ const examples = getExistingNames(10);
261
333
  return {
262
334
  success: false,
263
335
  sourceName,
264
336
  targetName,
265
- sourcePlistKey: resolvedSourceKey,
266
- targetPlistKey: resolvedTargetKey,
267
- error: "Source layout is a complex nested structure (Dict/Array). " +
268
- "Automated copy of nested plist structures requires iterating each child key. " +
269
- `Source value:\n${sourceValue}\n\n` +
270
- "Use get_column_layout to inspect the structure, then use shell PlistBuddy " +
271
- "commands to copy individual entries manually.",
337
+ error: `Source column layout for "${sourceName}" not found. ` +
338
+ "This smart group may not have a custom layout saved yet. " +
339
+ `Known layouts include: ${examples.slice(0, 8).join(", ")}`,
272
340
  };
273
341
  }
274
- catch (err) {
275
- try {
276
- const fs = await import("node:fs");
277
- if (fs.existsSync(tmpFile))
278
- fs.unlinkSync(tmpFile);
279
- }
280
- catch {
281
- // ignore cleanup error
282
- }
342
+ // Resolve target base name: prefer UUID if supplied and different from name,
343
+ // otherwise use the display name (DEVONthink will save new layouts by name).
344
+ const resolvedTargetKey = targetUuid ?? targetName;
345
+ // Perform the copy via Python
346
+ const copyResult = copyLayoutViaPython(resolvedSourceKey, resolvedTargetKey);
347
+ if (!copyResult.ok) {
283
348
  return {
284
349
  success: false,
285
- error: `Failed during copy operation: ${err instanceof Error ? err.message : String(err)}`,
350
+ sourceName,
351
+ targetName,
352
+ resolvedSourceKey,
353
+ resolvedTargetKey,
354
+ error: `Copy failed: ${copyResult.error}`,
286
355
  };
287
356
  }
357
+ // Verify by reading back the target
358
+ const verification = readLayoutForName(resolvedTargetKey);
359
+ if (!verification) {
360
+ return {
361
+ success: false,
362
+ sourceName,
363
+ targetName,
364
+ resolvedSourceKey,
365
+ resolvedTargetKey,
366
+ error: "Copy appeared to succeed but target keys not readable after write",
367
+ };
368
+ }
369
+ return {
370
+ success: true,
371
+ sourceName,
372
+ targetName,
373
+ resolvedSourceKey,
374
+ resolvedTargetKey,
375
+ keysCopied: copyResult.keysCopied,
376
+ message: `Copied column layout from "${sourceName}" to "${targetName}". ` +
377
+ `Keys written: ${copyResult.keysCopied.join(", ")}. ` +
378
+ `Columns: [${verification.columns?.join(", ") ?? "n/a"}]. ` +
379
+ "Restart DEVONthink or close/reopen the smart group window for the change to take effect.",
380
+ };
288
381
  };
289
382
  export const copyColumnLayoutTool = {
290
383
  name: "copy_column_layout",
291
- description: "Copy the column layout from one DEVONthink smart group or smart rule to another. " +
292
- "Reads the source layout from preferences and writes it to the target key. " +
293
- "For simple (non-nested) values, the copy is performed automatically. " +
294
- "For complex nested layouts, the source value is returned for manual inspection. " +
295
- 'Input: { "sourceName": "Jobs - To Review", "targetName": "Jobs - Archive" }',
384
+ description: "Copy the column layout (column order, visible columns, and column widths) from one " +
385
+ "DEVONthink smart group or smart rule to another. " +
386
+ "All three layout keys are copied atomically. Supports partial name matching. " +
387
+ 'Input: { "sourceName": "Archivieren - Jobs", "targetName": "Jobs - To Review" }',
296
388
  inputSchema: {
297
389
  type: "object",
298
390
  properties: {
299
391
  sourceName: {
300
392
  type: "string",
301
- description: "Name of the source smart group or smart rule",
393
+ description: "Name of the source smart group or smart rule (must have a saved layout)",
302
394
  },
303
395
  targetName: {
304
396
  type: "string",
305
397
  description: "Name of the target smart group or smart rule to copy the layout to",
306
398
  },
307
- sourcePlistKey: {
399
+ sourceUuid: {
308
400
  type: "string",
309
- description: "Optional explicit PlistBuddy key path for the source",
401
+ description: "Optional UUID of the source smart group (fallback if name lookup fails). " +
402
+ "DEVONthink sometimes stores layouts under the UUID.",
310
403
  },
311
- targetPlistKey: {
404
+ targetUuid: {
312
405
  type: "string",
313
- description: "Optional explicit PlistBuddy key path for the target",
406
+ description: "Optional UUID of the target smart group. If supplied, the layout is written " +
407
+ "under the UUID key (which DEVONthink prefers for smart groups).",
314
408
  },
315
409
  },
316
410
  required: ["sourceName", "targetName"],
317
411
  },
318
412
  run: copyColumnLayout,
319
413
  };
320
- void readCount; // suppress unused warning — kept for future use
321
414
  //# sourceMappingURL=columnLayout.js.map