triliumnext-mcp 0.3.11 ā 0.3.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.
|
@@ -221,6 +221,38 @@ function format_attribute_response(result, noteId, operation) {
|
|
|
221
221
|
text: `š Error details:\n${result.errors.map((err, i) => `${i + 1}. ${err}`).join('\n')}`
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
|
+
// Add specific guidance for common errors
|
|
225
|
+
if (result.errors && result.errors.some(err => err.includes("already exists"))) {
|
|
226
|
+
content.push({
|
|
227
|
+
type: "text",
|
|
228
|
+
text: `š” **Attribute Already Exists**
|
|
229
|
+
|
|
230
|
+
The attribute you're trying to create already exists on this note. Here are your options:
|
|
231
|
+
|
|
232
|
+
1. **Update the existing attribute** (recommended):
|
|
233
|
+
- Use operation: "update" instead of "create"
|
|
234
|
+
- Only the value and position can be updated for labels
|
|
235
|
+
- Only the position can be updated for relations
|
|
236
|
+
|
|
237
|
+
2. **Delete and recreate** (for inheritable changes):
|
|
238
|
+
- First delete with operation: "delete"
|
|
239
|
+
- Then recreate with operation: "create"
|
|
240
|
+
- Use this when you need to change the 'isInheritable' property
|
|
241
|
+
|
|
242
|
+
3. **View current attributes**:
|
|
243
|
+
- Use read_attributes to see all existing attributes
|
|
244
|
+
- Check current values, positions, and inheritable settings
|
|
245
|
+
|
|
246
|
+
š **Example update operation**:
|
|
247
|
+
\`\`\`
|
|
248
|
+
{
|
|
249
|
+
"noteId": "${noteId}",
|
|
250
|
+
"operation": "update",
|
|
251
|
+
"attributes": [{"type": "label", "name": "your_attribute", "value": "new_value"}]
|
|
252
|
+
}
|
|
253
|
+
\`\`\``
|
|
254
|
+
});
|
|
255
|
+
}
|
|
224
256
|
}
|
|
225
257
|
// Add attribute data for successful operations
|
|
226
258
|
if (result.success && result.attributes && result.attributes.length > 0) {
|
|
@@ -246,6 +278,28 @@ function format_attribute_response(result, noteId, operation) {
|
|
|
246
278
|
});
|
|
247
279
|
}
|
|
248
280
|
}
|
|
281
|
+
// Add guidance for batch operations with conflicts
|
|
282
|
+
if (result.success && operation === "batch_create" && result.errors && result.errors.length > 0) {
|
|
283
|
+
const hasConflicts = result.errors.some(err => err.includes("Skipping duplicate") || err.includes("already exist"));
|
|
284
|
+
if (hasConflicts) {
|
|
285
|
+
content.push({
|
|
286
|
+
type: "text",
|
|
287
|
+
text: `ā ļø **Batch Operation Summary**
|
|
288
|
+
|
|
289
|
+
Some attributes were skipped due to conflicts (already existing). This is normal behavior for batch operations:
|
|
290
|
+
|
|
291
|
+
ā
**Successfully created**: ${result.attributes?.length || 0} attributes
|
|
292
|
+
ā **Skipped duplicates**: ${result.errors?.filter(err => err.includes("Skipping duplicate") || err.includes("already exist")).length || 0} attributes
|
|
293
|
+
|
|
294
|
+
š” **To manage skipped attributes**:
|
|
295
|
+
- Use \`read_attributes\` to view current attributes
|
|
296
|
+
- Use \`update\` operation to modify existing attributes
|
|
297
|
+
- Use \`delete\` operation to remove unwanted attributes first
|
|
298
|
+
|
|
299
|
+
This approach prevents accidental overwrites while allowing partial success for batch operations.`
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
249
303
|
return { content };
|
|
250
304
|
}
|
|
251
305
|
/**
|
|
@@ -316,5 +370,11 @@ export function get_attributes_help() {
|
|
|
316
370
|
- Template relations require the target note to exist in your Trilium instance
|
|
317
371
|
- Position values control display order (lower numbers appear first)
|
|
318
372
|
- Use read_attributes to view existing attributes before making changes
|
|
373
|
+
|
|
374
|
+
š”ļø **Validation & Conflict Handling**:
|
|
375
|
+
- Create operations now validate against existing attributes to prevent duplicates
|
|
376
|
+
- If an attribute already exists, you'll get detailed error messages with guidance
|
|
377
|
+
- Batch operations skip duplicates and continue with valid attributes
|
|
378
|
+
- Use "update" to modify existing attributes, "create" for new ones only
|
|
319
379
|
`;
|
|
320
380
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import axios from 'axios';
|
|
6
6
|
import { logVerbose, logVerboseApi, logVerboseAxiosError } from "../utils/verboseUtils.js";
|
|
7
7
|
import { validateAndTranslateTemplate, createTemplateRelationError } from "../utils/templateMapper.js";
|
|
8
|
+
import { cleanAttributeName, generateCleaningMessage } from "../utils/attributeNameCleaner.js";
|
|
8
9
|
/**
|
|
9
10
|
* Manage note attributes with write operations (create, update, delete)
|
|
10
11
|
* This function provides write-only access to note attributes
|
|
@@ -36,12 +37,38 @@ export async function manage_attributes(params, axiosInstance) {
|
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if an attribute already exists on a note
|
|
42
|
+
*/
|
|
43
|
+
async function check_attribute_exists(noteId, attribute, axiosInstance) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await axiosInstance.get(`/notes/${noteId}`);
|
|
46
|
+
const existingAttributes = response.data.attributes.map((attr) => ({
|
|
47
|
+
type: attr.type,
|
|
48
|
+
name: attr.name,
|
|
49
|
+
value: attr.value,
|
|
50
|
+
position: attr.position,
|
|
51
|
+
isInheritable: attr.isInheritable
|
|
52
|
+
}));
|
|
53
|
+
// Find matching attribute by name and type
|
|
54
|
+
const matchingAttribute = existingAttributes.find(attr => attr.name === attribute.name && attr.type === attribute.type);
|
|
55
|
+
return {
|
|
56
|
+
exists: !!matchingAttribute,
|
|
57
|
+
existingAttribute: matchingAttribute,
|
|
58
|
+
allAttributes: existingAttributes
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
// If we can't read attributes, assume it doesn't exist and let the create operation handle the error
|
|
63
|
+
return { exists: false };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
39
66
|
/**
|
|
40
67
|
* Create a single attribute on a note
|
|
41
68
|
*/
|
|
42
69
|
async function create_single_attribute(noteId, attribute, axiosInstance) {
|
|
43
70
|
try {
|
|
44
|
-
// Validate attribute
|
|
71
|
+
// Validate attribute with auto-correction
|
|
45
72
|
const validation = validate_attribute(attribute);
|
|
46
73
|
if (!validation.valid) {
|
|
47
74
|
return {
|
|
@@ -50,20 +77,42 @@ async function create_single_attribute(noteId, attribute, axiosInstance) {
|
|
|
50
77
|
errors: validation.errors
|
|
51
78
|
};
|
|
52
79
|
}
|
|
80
|
+
// Use cleaned attribute if correction was made
|
|
81
|
+
const attributeToUse = validation.cleanedAttribute || attribute;
|
|
82
|
+
// Check if attribute already exists (use cleaned name)
|
|
83
|
+
const existenceCheck = await check_attribute_exists(noteId, attributeToUse, axiosInstance);
|
|
84
|
+
if (existenceCheck.exists && existenceCheck.existingAttribute) {
|
|
85
|
+
const existing = existenceCheck.existingAttribute;
|
|
86
|
+
const availableAttrs = existenceCheck.allAttributes?.map((attr) => `${attr.type}:${attr.name}`).join(', ') || 'none';
|
|
87
|
+
let message = `Attribute '${attributeToUse.name}' of type '${attributeToUse.type}' already exists on note ${noteId}. Available attributes: ${availableAttrs}`;
|
|
88
|
+
// Add cleaning information if name was corrected
|
|
89
|
+
if (validation.cleaningResult && validation.cleaningResult.wasCleaned) {
|
|
90
|
+
message += `\n\nš§ **Note**: Attribute name was auto-corrected from "${validation.cleaningResult.originalName}" to "${attributeToUse.name}"`;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
success: false,
|
|
94
|
+
message,
|
|
95
|
+
errors: [
|
|
96
|
+
"Attribute already exists",
|
|
97
|
+
`Existing ${attributeToUse.type} '${attributeToUse.name}' has value: ${existing.value || 'none'}, position: ${existing.position || 'default'}, inheritable: ${existing.isInheritable || false}`,
|
|
98
|
+
"To modify the existing attribute, use operation: 'update' instead of 'create'"
|
|
99
|
+
]
|
|
100
|
+
};
|
|
101
|
+
}
|
|
53
102
|
// Translate template names to note IDs for template relations
|
|
54
|
-
let processedValue =
|
|
55
|
-
if (
|
|
103
|
+
let processedValue = attributeToUse.value || "";
|
|
104
|
+
if (attributeToUse.type === "relation" && attributeToUse.name === "template" && attributeToUse.value) {
|
|
56
105
|
try {
|
|
57
|
-
processedValue = validateAndTranslateTemplate(
|
|
106
|
+
processedValue = validateAndTranslateTemplate(attributeToUse.value);
|
|
58
107
|
logVerbose("create_single_attribute", `Translated template relation`, {
|
|
59
|
-
from:
|
|
108
|
+
from: attributeToUse.value,
|
|
60
109
|
to: processedValue
|
|
61
110
|
});
|
|
62
111
|
}
|
|
63
112
|
catch (error) {
|
|
64
113
|
return {
|
|
65
114
|
success: false,
|
|
66
|
-
message: createTemplateRelationError(
|
|
115
|
+
message: createTemplateRelationError(attributeToUse.value),
|
|
67
116
|
errors: [error instanceof Error ? error.message : 'Template validation failed']
|
|
68
117
|
};
|
|
69
118
|
}
|
|
@@ -71,17 +120,22 @@ async function create_single_attribute(noteId, attribute, axiosInstance) {
|
|
|
71
120
|
// Prepare attribute data for ETAPI
|
|
72
121
|
const attributeData = {
|
|
73
122
|
noteId: noteId,
|
|
74
|
-
type:
|
|
75
|
-
name:
|
|
123
|
+
type: attributeToUse.type,
|
|
124
|
+
name: attributeToUse.name,
|
|
76
125
|
value: processedValue,
|
|
77
|
-
position:
|
|
78
|
-
isInheritable:
|
|
126
|
+
position: attributeToUse.position || 10,
|
|
127
|
+
isInheritable: attributeToUse.isInheritable || false
|
|
79
128
|
};
|
|
80
129
|
// Make API call to create attribute
|
|
81
130
|
const response = await axiosInstance.post(`/attributes`, attributeData);
|
|
131
|
+
// Build success message with cleaning information
|
|
132
|
+
let successMessage = `Successfully created ${attributeToUse.type} '${attributeToUse.name}' on note ${noteId}`;
|
|
133
|
+
if (validation.cleaningResult && validation.cleaningResult.wasCleaned) {
|
|
134
|
+
successMessage += `\n\n${generateCleaningMessage([validation.cleaningResult])}`;
|
|
135
|
+
}
|
|
82
136
|
return {
|
|
83
137
|
success: true,
|
|
84
|
-
message:
|
|
138
|
+
message: successMessage,
|
|
85
139
|
attributes: [response.data]
|
|
86
140
|
};
|
|
87
141
|
}
|
|
@@ -93,6 +147,50 @@ async function create_single_attribute(noteId, attribute, axiosInstance) {
|
|
|
93
147
|
};
|
|
94
148
|
}
|
|
95
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Validate batch attributes for conflicts and duplicates
|
|
152
|
+
*/
|
|
153
|
+
async function validate_batch_attributes(noteId, attributes, axiosInstance) {
|
|
154
|
+
try {
|
|
155
|
+
// Get all existing attributes once
|
|
156
|
+
const response = await axiosInstance.get(`/notes/${noteId}`);
|
|
157
|
+
const existingAttributes = response.data.attributes.map((attr) => ({
|
|
158
|
+
type: attr.type,
|
|
159
|
+
name: attr.name,
|
|
160
|
+
value: attr.value,
|
|
161
|
+
position: attr.position,
|
|
162
|
+
isInheritable: attr.isInheritable
|
|
163
|
+
}));
|
|
164
|
+
const conflicts = [];
|
|
165
|
+
const validAttributes = [];
|
|
166
|
+
// Check each new attribute against existing ones
|
|
167
|
+
for (const attribute of attributes) {
|
|
168
|
+
const existingMatch = existingAttributes.find(attr => attr.name === attribute.name && attr.type === attribute.type);
|
|
169
|
+
if (existingMatch) {
|
|
170
|
+
conflicts.push({
|
|
171
|
+
attribute,
|
|
172
|
+
existingAttribute: existingMatch
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
validAttributes.push(attribute);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
conflicts,
|
|
181
|
+
validAttributes,
|
|
182
|
+
allExistingAttributes: existingAttributes
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
// If we can't read attributes, assume no conflicts and proceed
|
|
187
|
+
return {
|
|
188
|
+
conflicts: [],
|
|
189
|
+
validAttributes: attributes,
|
|
190
|
+
allExistingAttributes: []
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
96
194
|
/**
|
|
97
195
|
* Create multiple attributes in batch
|
|
98
196
|
*/
|
|
@@ -104,38 +202,58 @@ async function create_batch_attributes(noteId, attributes, axiosInstance) {
|
|
|
104
202
|
attributes: []
|
|
105
203
|
};
|
|
106
204
|
}
|
|
205
|
+
// Validate batch for conflicts first
|
|
206
|
+
const batchValidation = await validate_batch_attributes(noteId, attributes, axiosInstance);
|
|
107
207
|
const results = [];
|
|
108
208
|
const errors = [];
|
|
109
|
-
//
|
|
110
|
-
|
|
209
|
+
// Add conflict warnings if any
|
|
210
|
+
if (batchValidation.conflicts.length > 0) {
|
|
211
|
+
const conflictMessages = batchValidation.conflicts.map(({ attribute, existingAttribute }) => {
|
|
212
|
+
return `Skipping duplicate ${attribute.type} '${attribute.name}' (already exists with value: ${existingAttribute.value || 'none'})`;
|
|
213
|
+
});
|
|
214
|
+
errors.push(...conflictMessages);
|
|
215
|
+
}
|
|
216
|
+
// If all attributes are conflicts, return early
|
|
217
|
+
if (batchValidation.validAttributes.length === 0) {
|
|
218
|
+
return {
|
|
219
|
+
success: false,
|
|
220
|
+
message: `All ${attributes.length} attributes already exist on note ${noteId}`,
|
|
221
|
+
errors,
|
|
222
|
+
attributes: []
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// Create only valid (non-conflicting) attributes in parallel
|
|
226
|
+
const promises = batchValidation.validAttributes.map(async (attribute) => {
|
|
111
227
|
try {
|
|
112
228
|
const validation = validate_attribute(attribute);
|
|
113
229
|
if (!validation.valid) {
|
|
114
230
|
errors.push(`Validation failed for ${attribute.type} '${attribute.name}': ${validation.errors.join(', ')}`);
|
|
115
231
|
return null;
|
|
116
232
|
}
|
|
233
|
+
// Use cleaned attribute if correction was made
|
|
234
|
+
const attributeToUse = validation.cleanedAttribute || attribute;
|
|
117
235
|
// Translate template names to note IDs for template relations
|
|
118
|
-
let processedValue =
|
|
119
|
-
if (
|
|
236
|
+
let processedValue = attributeToUse.value || "";
|
|
237
|
+
if (attributeToUse.type === "relation" && attributeToUse.name === "template" && attributeToUse.value) {
|
|
120
238
|
try {
|
|
121
|
-
processedValue = validateAndTranslateTemplate(
|
|
239
|
+
processedValue = validateAndTranslateTemplate(attributeToUse.value);
|
|
122
240
|
logVerbose("create_batch_attributes", `Translated template relation`, {
|
|
123
|
-
from:
|
|
241
|
+
from: attributeToUse.value,
|
|
124
242
|
to: processedValue
|
|
125
243
|
});
|
|
126
244
|
}
|
|
127
245
|
catch (error) {
|
|
128
|
-
errors.push(createTemplateRelationError(
|
|
246
|
+
errors.push(createTemplateRelationError(attributeToUse.value));
|
|
129
247
|
return null;
|
|
130
248
|
}
|
|
131
249
|
}
|
|
132
250
|
const attributeData = {
|
|
133
251
|
noteId: noteId,
|
|
134
|
-
type:
|
|
135
|
-
name:
|
|
252
|
+
type: attributeToUse.type,
|
|
253
|
+
name: attributeToUse.name,
|
|
136
254
|
value: processedValue,
|
|
137
|
-
position:
|
|
138
|
-
isInheritable:
|
|
255
|
+
position: attributeToUse.position || 10,
|
|
256
|
+
isInheritable: attributeToUse.isInheritable || false
|
|
139
257
|
};
|
|
140
258
|
const response = await axiosInstance.post(`/attributes`, attributeData);
|
|
141
259
|
results.push(response.data);
|
|
@@ -148,7 +266,7 @@ async function create_batch_attributes(noteId, attributes, axiosInstance) {
|
|
|
148
266
|
}
|
|
149
267
|
});
|
|
150
268
|
await Promise.all(promises);
|
|
151
|
-
if (errors.length ===
|
|
269
|
+
if (errors.length === batchValidation.validAttributes.length) {
|
|
152
270
|
return {
|
|
153
271
|
success: false,
|
|
154
272
|
message: "All attribute creation operations failed",
|
|
@@ -156,10 +274,19 @@ async function create_batch_attributes(noteId, attributes, axiosInstance) {
|
|
|
156
274
|
};
|
|
157
275
|
}
|
|
158
276
|
const successCount = results.length;
|
|
277
|
+
const validCount = batchValidation.validAttributes.length;
|
|
278
|
+
const conflictCount = batchValidation.conflicts.length;
|
|
159
279
|
const totalCount = attributes.length;
|
|
280
|
+
let message = `Created ${successCount}/${validCount} valid attributes successfully`;
|
|
281
|
+
if (conflictCount > 0) {
|
|
282
|
+
message += ` (${conflictCount} skipped due to conflicts, ${totalCount} total requested)`;
|
|
283
|
+
}
|
|
284
|
+
if (errors.length > 0) {
|
|
285
|
+
message += ` with ${errors.length} errors`;
|
|
286
|
+
}
|
|
160
287
|
return {
|
|
161
288
|
success: successCount > 0,
|
|
162
|
-
message
|
|
289
|
+
message,
|
|
163
290
|
attributes: results,
|
|
164
291
|
errors: errors.length > 0 ? errors : undefined
|
|
165
292
|
};
|
|
@@ -169,17 +296,28 @@ async function create_batch_attributes(noteId, attributes, axiosInstance) {
|
|
|
169
296
|
*/
|
|
170
297
|
async function update_attribute(noteId, attribute, axiosInstance) {
|
|
171
298
|
try {
|
|
299
|
+
// Clean attribute name first
|
|
300
|
+
const cleaningResult = cleanAttributeName(attribute.name, attribute.type);
|
|
301
|
+
const attributeToUse = {
|
|
302
|
+
...attribute,
|
|
303
|
+
name: cleaningResult.cleanedName
|
|
304
|
+
};
|
|
172
305
|
// For update, we need the attribute ID, which requires finding it first
|
|
173
306
|
const noteResponse = await axiosInstance.get(`/notes/${noteId}`);
|
|
174
307
|
// Debug: Log available attributes
|
|
175
308
|
logVerbose("update_attribute", `Available attributes on note ${noteId}`, noteResponse.data.attributes);
|
|
176
|
-
// Find the attribute to update by name and type
|
|
177
|
-
const targetAttribute = noteResponse.data.attributes.find((attr) => attr.name ===
|
|
309
|
+
// Find the attribute to update by name and type (use cleaned name)
|
|
310
|
+
const targetAttribute = noteResponse.data.attributes.find((attr) => attr.name === attributeToUse.name && attr.type === attributeToUse.type);
|
|
178
311
|
if (!targetAttribute) {
|
|
179
312
|
const availableAttrs = noteResponse.data.attributes.map((attr) => `${attr.type}:${attr.name}`).join(', ');
|
|
313
|
+
let message = `Attribute '${attributeToUse.name}' of type '${attributeToUse.type}' not found on note ${noteId}. Available attributes: ${availableAttrs || 'none'}`;
|
|
314
|
+
// Add cleaning information if name was corrected
|
|
315
|
+
if (cleaningResult.wasCleaned) {
|
|
316
|
+
message += `\n\nš§ **Note**: Attribute name was auto-corrected from "${cleaningResult.originalName}" to "${attributeToUse.name}"`;
|
|
317
|
+
}
|
|
180
318
|
return {
|
|
181
319
|
success: false,
|
|
182
|
-
message
|
|
320
|
+
message,
|
|
183
321
|
errors: ["Attribute not found"]
|
|
184
322
|
};
|
|
185
323
|
}
|
|
@@ -189,20 +327,20 @@ async function update_attribute(noteId, attribute, axiosInstance) {
|
|
|
189
327
|
// - For relations: only position can be updated
|
|
190
328
|
// - isInheritable cannot be updated via PATCH, requires delete+recreate
|
|
191
329
|
const updateData = {};
|
|
192
|
-
if (
|
|
193
|
-
updateData.value =
|
|
330
|
+
if (attributeToUse.type === "label") {
|
|
331
|
+
updateData.value = attributeToUse.value !== undefined ? attributeToUse.value : targetAttribute.value;
|
|
194
332
|
}
|
|
195
|
-
if (
|
|
196
|
-
updateData.position =
|
|
333
|
+
if (attributeToUse.position !== undefined) {
|
|
334
|
+
updateData.position = attributeToUse.position;
|
|
197
335
|
}
|
|
198
336
|
else if (targetAttribute.position !== undefined) {
|
|
199
337
|
updateData.position = targetAttribute.position;
|
|
200
338
|
}
|
|
201
339
|
// Check if isInheritable is being changed (not allowed via PATCH)
|
|
202
|
-
if (
|
|
340
|
+
if (attributeToUse.isInheritable !== undefined && attributeToUse.isInheritable !== targetAttribute.isInheritable) {
|
|
203
341
|
logVerbose("update_attribute", "isInheritable change detected, requires delete+recreate", {
|
|
204
342
|
current: targetAttribute.isInheritable,
|
|
205
|
-
requested:
|
|
343
|
+
requested: attributeToUse.isInheritable
|
|
206
344
|
});
|
|
207
345
|
return {
|
|
208
346
|
success: false,
|
|
@@ -213,9 +351,14 @@ async function update_attribute(noteId, attribute, axiosInstance) {
|
|
|
213
351
|
logVerbose("update_attribute", "Update data", updateData);
|
|
214
352
|
logVerboseApi("PATCH", `/attributes/${targetAttribute.attributeId}`, updateData);
|
|
215
353
|
const response = await axiosInstance.patch(`/attributes/${targetAttribute.attributeId}`, updateData);
|
|
354
|
+
// Build success message with cleaning information
|
|
355
|
+
let successMessage = `Successfully updated ${attributeToUse.type} '${attributeToUse.name}' on note ${noteId}`;
|
|
356
|
+
if (cleaningResult.wasCleaned) {
|
|
357
|
+
successMessage += `\n\n${generateCleaningMessage([cleaningResult])}`;
|
|
358
|
+
}
|
|
216
359
|
return {
|
|
217
360
|
success: true,
|
|
218
|
-
message:
|
|
361
|
+
message: successMessage,
|
|
219
362
|
attributes: [response.data]
|
|
220
363
|
};
|
|
221
364
|
}
|
|
@@ -241,21 +384,37 @@ async function update_attribute(noteId, attribute, axiosInstance) {
|
|
|
241
384
|
*/
|
|
242
385
|
async function delete_attribute(noteId, attribute, axiosInstance) {
|
|
243
386
|
try {
|
|
387
|
+
// Clean attribute name first
|
|
388
|
+
const cleaningResult = cleanAttributeName(attribute.name, attribute.type);
|
|
389
|
+
const attributeToUse = {
|
|
390
|
+
...attribute,
|
|
391
|
+
name: cleaningResult.cleanedName
|
|
392
|
+
};
|
|
244
393
|
// For delete, we need the attribute ID, which requires finding it first
|
|
245
394
|
const noteResponse = await axiosInstance.get(`/notes/${noteId}`);
|
|
246
|
-
// Find the attribute to delete by name and type
|
|
247
|
-
const targetAttribute = noteResponse.data.attributes.find((attr) => attr.name ===
|
|
395
|
+
// Find the attribute to delete by name and type (use cleaned name)
|
|
396
|
+
const targetAttribute = noteResponse.data.attributes.find((attr) => attr.name === attributeToUse.name && attr.type === attributeToUse.type);
|
|
248
397
|
if (!targetAttribute) {
|
|
398
|
+
let message = `Attribute '${attributeToUse.name}' of type '${attributeToUse.type}' not found on note ${noteId}`;
|
|
399
|
+
// Add cleaning information if name was corrected
|
|
400
|
+
if (cleaningResult.wasCleaned) {
|
|
401
|
+
message += `\n\nš§ **Note**: Attribute name was auto-corrected from "${cleaningResult.originalName}" to "${attributeToUse.name}"`;
|
|
402
|
+
}
|
|
249
403
|
return {
|
|
250
404
|
success: false,
|
|
251
|
-
message
|
|
405
|
+
message,
|
|
252
406
|
errors: ["Attribute not found"]
|
|
253
407
|
};
|
|
254
408
|
}
|
|
255
409
|
await axiosInstance.delete(`/attributes/${targetAttribute.attributeId}`);
|
|
410
|
+
// Build success message with cleaning information
|
|
411
|
+
let successMessage = `Successfully deleted ${attributeToUse.type} '${attributeToUse.name}' from note ${noteId}`;
|
|
412
|
+
if (cleaningResult.wasCleaned) {
|
|
413
|
+
successMessage += `\n\n${generateCleaningMessage([cleaningResult])}`;
|
|
414
|
+
}
|
|
256
415
|
return {
|
|
257
416
|
success: true,
|
|
258
|
-
message:
|
|
417
|
+
message: successMessage
|
|
259
418
|
};
|
|
260
419
|
}
|
|
261
420
|
catch (error) {
|
|
@@ -267,7 +426,7 @@ async function delete_attribute(noteId, attribute, axiosInstance) {
|
|
|
267
426
|
}
|
|
268
427
|
}
|
|
269
428
|
/**
|
|
270
|
-
* Validate attribute data
|
|
429
|
+
* Validate attribute data with auto-correction
|
|
271
430
|
*/
|
|
272
431
|
function validate_attribute(attribute) {
|
|
273
432
|
const errors = [];
|
|
@@ -275,8 +434,14 @@ function validate_attribute(attribute) {
|
|
|
275
434
|
if (!["label", "relation"].includes(attribute.type)) {
|
|
276
435
|
errors.push("Attribute type must be either 'label' or 'relation'");
|
|
277
436
|
}
|
|
278
|
-
//
|
|
279
|
-
|
|
437
|
+
// Clean attribute name first
|
|
438
|
+
const cleaningResult = cleanAttributeName(attribute.name, attribute.type);
|
|
439
|
+
const cleanedAttribute = {
|
|
440
|
+
...attribute,
|
|
441
|
+
name: cleaningResult.cleanedName
|
|
442
|
+
};
|
|
443
|
+
// Validate name (using cleaned name)
|
|
444
|
+
if (!cleanedAttribute.name || typeof cleanedAttribute.name !== 'string' || cleanedAttribute.name.trim() === '') {
|
|
280
445
|
errors.push("Attribute name is required and must be a non-empty string");
|
|
281
446
|
}
|
|
282
447
|
// Validate position
|
|
@@ -293,7 +458,9 @@ function validate_attribute(attribute) {
|
|
|
293
458
|
}
|
|
294
459
|
return {
|
|
295
460
|
valid: errors.length === 0,
|
|
296
|
-
errors
|
|
461
|
+
errors,
|
|
462
|
+
cleanedAttribute: cleaningResult.wasCleaned ? cleanedAttribute : undefined,
|
|
463
|
+
cleaningResult: cleaningResult.wasCleaned ? cleaningResult : undefined
|
|
297
464
|
};
|
|
298
465
|
}
|
|
299
466
|
/**
|
|
@@ -6,6 +6,7 @@ import { processContentArray } from '../utils/contentProcessor.js';
|
|
|
6
6
|
import { logVerbose, logVerboseError, logVerboseApi } from '../utils/verboseUtils.js';
|
|
7
7
|
import { getContentRequirements, validateContentForNoteType, extractTemplateRelation } from '../utils/contentRules.js';
|
|
8
8
|
import { validateAndTranslateTemplate, createTemplateRelationError } from '../utils/templateMapper.js';
|
|
9
|
+
import { cleanAttributeName, generateCleaningMessage } from '../utils/attributeNameCleaner.js';
|
|
9
10
|
/**
|
|
10
11
|
* Strip HTML tags from content for text notes
|
|
11
12
|
*/
|
|
@@ -207,8 +208,15 @@ export async function handleCreateNote(args, axiosInstance) {
|
|
|
207
208
|
if (attributes && attributes.length > 0) {
|
|
208
209
|
try {
|
|
209
210
|
logVerbose("handleCreateNote", `Creating ${attributes.length} attributes for note ${noteId}`, attributes);
|
|
210
|
-
await createNoteAttributes(noteId, attributes, axiosInstance);
|
|
211
|
+
const attributeResult = await createNoteAttributes(noteId, attributes, axiosInstance);
|
|
211
212
|
logVerbose("handleCreateNote", `Successfully created all attributes for note ${noteId}`);
|
|
213
|
+
// Add cleaning information to response if any corrections were made
|
|
214
|
+
if (attributeResult.cleaningResults && attributeResult.cleaningResults.length > 0) {
|
|
215
|
+
const cleaningMessage = generateCleaningMessage(attributeResult.cleaningResults);
|
|
216
|
+
if (cleaningMessage) {
|
|
217
|
+
response.data.attributeCleaningMessage = cleaningMessage;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
212
220
|
}
|
|
213
221
|
catch (attributeError) {
|
|
214
222
|
const errorMsg = `Note created but attributes failed to apply: ${attributeError instanceof Error ? attributeError.message : attributeError}`;
|
|
@@ -226,37 +234,49 @@ export async function handleCreateNote(args, axiosInstance) {
|
|
|
226
234
|
* Create attributes for a note (helper function)
|
|
227
235
|
*/
|
|
228
236
|
async function createNoteAttributes(noteId, attributes, axiosInstance) {
|
|
237
|
+
const cleaningResults = [];
|
|
229
238
|
const attributePromises = attributes.map(async (attr) => {
|
|
239
|
+
// Clean attribute name first
|
|
240
|
+
const cleaningResult = cleanAttributeName(attr.name, attr.type);
|
|
241
|
+
if (cleaningResult.wasCleaned) {
|
|
242
|
+
cleaningResults.push(cleaningResult);
|
|
243
|
+
}
|
|
244
|
+
// Use cleaned attribute name
|
|
245
|
+
const cleanedAttr = {
|
|
246
|
+
...attr,
|
|
247
|
+
name: cleaningResult.cleanedName
|
|
248
|
+
};
|
|
230
249
|
// Translate template names to note IDs for template relations
|
|
231
|
-
let processedValue =
|
|
232
|
-
if (
|
|
250
|
+
let processedValue = cleanedAttr.value || '';
|
|
251
|
+
if (cleanedAttr.type === "relation" && cleanedAttr.name === "template" && cleanedAttr.value) {
|
|
233
252
|
try {
|
|
234
|
-
processedValue = validateAndTranslateTemplate(
|
|
253
|
+
processedValue = validateAndTranslateTemplate(cleanedAttr.value);
|
|
235
254
|
logVerbose("createNoteAttributes", `Translated template relation`, {
|
|
236
|
-
from:
|
|
255
|
+
from: cleanedAttr.value,
|
|
237
256
|
to: processedValue
|
|
238
257
|
});
|
|
239
258
|
}
|
|
240
259
|
catch (error) {
|
|
241
260
|
logVerboseError("createNoteAttributes", error);
|
|
242
|
-
throw new Error(createTemplateRelationError(
|
|
261
|
+
throw new Error(createTemplateRelationError(cleanedAttr.value));
|
|
243
262
|
}
|
|
244
263
|
}
|
|
245
264
|
const attributeData = {
|
|
246
265
|
noteId: noteId,
|
|
247
|
-
type:
|
|
248
|
-
name:
|
|
266
|
+
type: cleanedAttr.type,
|
|
267
|
+
name: cleanedAttr.name,
|
|
249
268
|
value: processedValue,
|
|
250
|
-
position:
|
|
251
|
-
isInheritable:
|
|
269
|
+
position: cleanedAttr.position || 10,
|
|
270
|
+
isInheritable: cleanedAttr.isInheritable || false
|
|
252
271
|
};
|
|
253
272
|
logVerboseApi("POST", `/attributes`, attributeData);
|
|
254
273
|
const response = await axiosInstance.post(`/attributes`, attributeData);
|
|
255
|
-
logVerbose("createNoteAttributes", `Created ${
|
|
274
|
+
logVerbose("createNoteAttributes", `Created ${cleanedAttr.type} '${cleanedAttr.name}' for note ${noteId}`, response.data);
|
|
256
275
|
return response;
|
|
257
276
|
});
|
|
258
277
|
const results = await Promise.all(attributePromises);
|
|
259
278
|
logVerbose("createNoteAttributes", `Successfully created ${results.length} attributes for note ${noteId}`);
|
|
279
|
+
return { results, cleaningResults };
|
|
260
280
|
}
|
|
261
281
|
/**
|
|
262
282
|
* Handle update note operation
|
|
@@ -298,6 +318,103 @@ export async function handleUpdateNote(args, axiosInstance) {
|
|
|
298
318
|
};
|
|
299
319
|
}
|
|
300
320
|
}
|
|
321
|
+
// Step 2.5: Type change validation if provided
|
|
322
|
+
if (type && type !== currentNote.data.type) {
|
|
323
|
+
const currentType = currentNote.data.type;
|
|
324
|
+
const newType = type;
|
|
325
|
+
// Check for incompatible type changes with template relations
|
|
326
|
+
const existingAttributes = currentNote.data.attributes || [];
|
|
327
|
+
const templateRelation = existingAttributes.find((attr) => attr.type === 'relation' && attr.name === 'template')?.value;
|
|
328
|
+
if (templateRelation) {
|
|
329
|
+
// Container templates require 'book' type
|
|
330
|
+
const containerTemplates = [
|
|
331
|
+
'board', '_template_board',
|
|
332
|
+
'grid view', '_template_grid_view',
|
|
333
|
+
'list view', '_template_list_view',
|
|
334
|
+
'geo map', '_template_geo_map',
|
|
335
|
+
'calendar', '_template_calendar'
|
|
336
|
+
];
|
|
337
|
+
const isContainerTemplate = containerTemplates.includes(templateRelation.toLowerCase());
|
|
338
|
+
if (isContainerTemplate && newType !== 'book') {
|
|
339
|
+
return {
|
|
340
|
+
noteId,
|
|
341
|
+
message: `TYPE CHANGE CONFLICT: Cannot change note type from "${currentType}" to "${newType}" while it has a "${templateRelation}" template relation.
|
|
342
|
+
|
|
343
|
+
š **Template Compatibility Rules**:
|
|
344
|
+
Container templates (Board, Calendar, Grid View, List View, Geo Map) require type "book" to function properly.
|
|
345
|
+
|
|
346
|
+
š ļø **Solutions**:
|
|
347
|
+
1. **Keep current type**: Use type "book" to maintain template functionality
|
|
348
|
+
2. **Remove template**: Delete the ~template relation first, then change type
|
|
349
|
+
3. **Choose different template**: Switch to a template compatible with type "${newType}"
|
|
350
|
+
|
|
351
|
+
š” **For most use cases**: If you want a regular note with "${templateRelation}" features, consider:
|
|
352
|
+
- Using type "text" with the ~template relation removed
|
|
353
|
+
- Creating a child note inside this container for your content
|
|
354
|
+
|
|
355
|
+
Please remove the template relation first or keep type as "book".`,
|
|
356
|
+
revisionCreated: false,
|
|
357
|
+
conflict: true
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// Book type can only be used with container templates
|
|
361
|
+
if (newType === 'book' && !isContainerTemplate) {
|
|
362
|
+
return {
|
|
363
|
+
noteId,
|
|
364
|
+
message: `TYPE CHANGE CONFLICT: Cannot change note type to "book" while it has a "${templateRelation}" template relation.
|
|
365
|
+
|
|
366
|
+
š **Book Type Requirements**:
|
|
367
|
+
Book type should only be used with container templates (Board, Calendar, Grid View, List View, Geo Map) that provide specialized layouts.
|
|
368
|
+
|
|
369
|
+
š ļø **Solutions**:
|
|
370
|
+
1. **Use type "text"**: Best for regular notes with content
|
|
371
|
+
2. **Switch to container template**: Change ~template to "Board", "Calendar", etc. for specialized layouts
|
|
372
|
+
3. **Keep current type**: Use "${currentType}" for optimal functionality
|
|
373
|
+
|
|
374
|
+
š” **Recommendation**: For regular notes with template relations, type "text" usually provides the best experience.
|
|
375
|
+
|
|
376
|
+
Please choose a compatible template or use type "text".`,
|
|
377
|
+
revisionCreated: false,
|
|
378
|
+
conflict: true
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Validate content compatibility for type changes with content updates
|
|
383
|
+
if (rawContent && currentContent.data) {
|
|
384
|
+
try {
|
|
385
|
+
// Test if existing content is compatible with new type
|
|
386
|
+
await validateContentForNoteType(currentContent.data, newType, currentContent.data, templateRelation);
|
|
387
|
+
}
|
|
388
|
+
catch (validationError) {
|
|
389
|
+
const errorMessage = validationError instanceof Error ? validationError.message : String(validationError);
|
|
390
|
+
return {
|
|
391
|
+
noteId,
|
|
392
|
+
message: `CONTENT COMPATIBILITY ERROR: Cannot change note type from "${currentType}" to "${newType}" with current content.
|
|
393
|
+
|
|
394
|
+
š **Content Requirements for ${newType}**:
|
|
395
|
+
${errorMessage}
|
|
396
|
+
|
|
397
|
+
š ļø **Solutions**:
|
|
398
|
+
1. **Clean content**: Remove HTML, formatting, or incompatible elements
|
|
399
|
+
2. **Keep current type**: Use "${currentType}" for existing content
|
|
400
|
+
3. **Manual conversion**: Convert content manually to match ${newType} requirements
|
|
401
|
+
|
|
402
|
+
š” **Content Guidelines**:
|
|
403
|
+
- **Code notes**: Plain text only (no HTML tags)
|
|
404
|
+
- **Text notes**: HTML content (plain text auto-wrapped in <p> tags)
|
|
405
|
+
- **Mermaid notes**: Plain text diagram definitions only
|
|
406
|
+
|
|
407
|
+
Please modify the content to be compatible with type "${newType}" or keep the current type.`,
|
|
408
|
+
revisionCreated: false,
|
|
409
|
+
conflict: true
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
logVerbose("handleUpdateNote", `Type change validation passed: ${currentType} ā ${newType}`, {
|
|
414
|
+
templateRelation,
|
|
415
|
+
hasContent: !!rawContent
|
|
416
|
+
});
|
|
417
|
+
}
|
|
301
418
|
// Handle title-only update (efficient PATCH operation)
|
|
302
419
|
if (isTitleOnlyUpdate) {
|
|
303
420
|
// For title-only updates, skip revision creation for efficiency
|
|
@@ -306,6 +423,10 @@ export async function handleUpdateNote(args, axiosInstance) {
|
|
|
306
423
|
if (mime) {
|
|
307
424
|
patchData.mime = mime;
|
|
308
425
|
}
|
|
426
|
+
// Add note type if provided (new capability)
|
|
427
|
+
if (type) {
|
|
428
|
+
patchData.type = type;
|
|
429
|
+
}
|
|
309
430
|
logVerboseApi("PATCH", `/notes/${noteId}`, patchData);
|
|
310
431
|
const response = await axiosInstance.patch(`/notes/${noteId}`, patchData, {
|
|
311
432
|
headers: {
|
|
@@ -315,10 +436,11 @@ export async function handleUpdateNote(args, axiosInstance) {
|
|
|
315
436
|
if (response.status !== 200) {
|
|
316
437
|
throw new Error(`Unexpected response status: ${response.status}`);
|
|
317
438
|
}
|
|
439
|
+
const typeMessage = type ? ` and type updated to "${type}"` : "";
|
|
318
440
|
const mimeMessage = mime ? ` and MIME type updated to "${mime}"` : "";
|
|
319
441
|
return {
|
|
320
442
|
noteId,
|
|
321
|
-
message: `Note ${noteId} title updated successfully to "${title}"${mimeMessage}`,
|
|
443
|
+
message: `Note ${noteId} title updated successfully to "${title}"${typeMessage}${mimeMessage}`,
|
|
322
444
|
revisionCreated: false,
|
|
323
445
|
conflict: false
|
|
324
446
|
};
|
|
@@ -391,12 +513,15 @@ export async function handleUpdateNote(args, axiosInstance) {
|
|
|
391
513
|
if (contentResponse.status !== 204) {
|
|
392
514
|
throw new Error(`Unexpected response status: ${contentResponse.status}`);
|
|
393
515
|
}
|
|
394
|
-
// Step 7: Update title and MIME type if provided (multi-parameter update)
|
|
395
|
-
if (isMultiParamUpdate && (title || mime)) {
|
|
516
|
+
// Step 7: Update title, type, and MIME type if provided (multi-parameter update)
|
|
517
|
+
if (isMultiParamUpdate && (title || type || mime)) {
|
|
396
518
|
const patchData = {};
|
|
397
519
|
if (title) {
|
|
398
520
|
patchData.title = title;
|
|
399
521
|
}
|
|
522
|
+
if (type) {
|
|
523
|
+
patchData.type = type;
|
|
524
|
+
}
|
|
400
525
|
if (mime) {
|
|
401
526
|
patchData.mime = mime;
|
|
402
527
|
}
|
|
@@ -417,10 +542,11 @@ export async function handleUpdateNote(args, axiosInstance) {
|
|
|
417
542
|
const correctionMsg = (finalContent !== rawContent) ? " (content auto-corrected)" : "";
|
|
418
543
|
const modeMsg = mode === 'append' ? " (content appended)" : " (content overwritten)";
|
|
419
544
|
const titleMsg = (isMultiParamUpdate && title) ? ` (title updated to "${title}")` : "";
|
|
545
|
+
const typeMsg = (isMultiParamUpdate && type) ? ` (type updated to "${type}")` : "";
|
|
420
546
|
const mimeMsg = (isMultiParamUpdate && mime) ? ` (MIME type updated to "${mime}")` : "";
|
|
421
547
|
return {
|
|
422
548
|
noteId,
|
|
423
|
-
message: `Note ${noteId} updated successfully${revisionMsg}${correctionMsg}${modeMsg}${titleMsg}${mimeMsg}`,
|
|
549
|
+
message: `Note ${noteId} updated successfully${revisionMsg}${correctionMsg}${modeMsg}${titleMsg}${typeMsg}${mimeMsg}`,
|
|
424
550
|
revisionCreated,
|
|
425
551
|
conflict: false
|
|
426
552
|
};
|
|
@@ -9,13 +9,13 @@ export function createWriteTools() {
|
|
|
9
9
|
return [
|
|
10
10
|
{
|
|
11
11
|
name: "create_note",
|
|
12
|
-
description: "Create a new note in TriliumNext with duplicate title detection. When a note with the same title already exists in the same directory, you'll be presented with choices: skip creation or update the existing note. ONLY use this tool when the user explicitly requests note creation (e.g., 'create a note', 'make a new note'). DO NOT use this tool proactively or when the user is only asking questions about their notes. TIP: For code notes, content is plain text (no HTML processing).",
|
|
12
|
+
description: "Create a new note in TriliumNext with duplicate title detection. When a note with the same title already exists in the same directory, you'll be presented with choices: skip creation or update the existing note. ā ļø **PARENT FOLDER SELECTION**: If user references a parent folder by name (e.g., 'create meeting note in Projects folder') and there are multiple folders with that name, ALWAYS use resolve_note_id first to find the exact parent note ID and let user choose the correct location. ONLY use this tool when the user explicitly requests note creation (e.g., 'create a note', 'make a new note'). DO NOT use this tool proactively or when the user is only asking questions about their notes. TIP: For code notes, content is plain text (no HTML processing).",
|
|
13
13
|
inputSchema: {
|
|
14
14
|
type: "object",
|
|
15
15
|
properties: {
|
|
16
16
|
parentNoteId: {
|
|
17
17
|
type: "string",
|
|
18
|
-
description: "ID of the parent note. ā ļø
|
|
18
|
+
description: "ID of the parent note. ā ļø CRITICAL: If you have multiple folders/notes with identical names (e.g., multiple 'Projects' folders, multiple 'Grid View' notes), you MUST use the exact note ID - do NOT guess or auto-select! When multiple potential parents exist, the system will detect conflicts and ask you to choose the specific parent note ID.\n\nš **FINDING PARENT NOTE ID**: Use resolve_note_id to find the exact note ID when you only know the folder name. Example: resolve_note_id({noteName: 'Projects'}) will return the note ID and show alternatives if multiple matches exist.\n\nš **PARENT NOTE REQUIREMENTS**:\n⢠RENDER parents: child must be type='code', mime='text/html' (HTML content for rendering)\n⢠CALENDAR parents: child must have #startDate, #endDate, #startTime, #endTime labels\n⢠BOARD parents: child must have #status label (e.g., 'To Do', 'In Progress', 'Done')\n⢠Missing required labels/relations will cause child notes to not display properly\n\nā ļø **MULTIPLE PARENTS WITH SAME NAME**: When users say 'put note under Projects folder' but there are multiple 'Projects' folders, ALWAYS use resolve_note_id first to let users choose the correct parent, then use the returned note ID for parentNoteId.",
|
|
19
19
|
default: "root"
|
|
20
20
|
},
|
|
21
21
|
title: {
|
|
@@ -74,7 +74,7 @@ export function createWriteTools() {
|
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
76
|
name: "update_note",
|
|
77
|
-
description: "Update note with support for title-only updates, content
|
|
77
|
+
description: "Update note with support for title-only updates, content operations, note type changes, and MIME type updates. ā ļø REQUIRED: ALWAYS call get_note first to obtain current hash and validate current note type. MODE SELECTION: Use 'append' when adding content, 'overwrite' when replacing content, or title-only for efficient title changes. TYPE CHANGES: Convert between note types (e.g., 'text' to 'code') with automatic validation for template compatibility and content requirements. MIME UPDATES: Change content type for code notes (e.g., JavaScript to Python). PREVENTS: Overwriting changes made by other users (hash mismatch) or invalid type conversions. WORKFLOW: get_note ā review current state ā update_note with hash",
|
|
78
78
|
inputSchema: {
|
|
79
79
|
type: "object",
|
|
80
80
|
properties: {
|
|
@@ -84,16 +84,16 @@ export function createWriteTools() {
|
|
|
84
84
|
},
|
|
85
85
|
title: {
|
|
86
86
|
type: "string",
|
|
87
|
-
description: "New title for the note. If provided alone (without content), performs efficient title-only update without affecting note content or blobId."
|
|
87
|
+
description: "New title for the note. If provided alone (without content, type, or mime), performs efficient title-only update without affecting note content or blobId."
|
|
88
88
|
},
|
|
89
89
|
type: {
|
|
90
90
|
type: "string",
|
|
91
91
|
enum: ["text", "code", "render", "search", "relationMap", "book", "noteMap", "mermaid", "webView"],
|
|
92
|
-
description: "
|
|
92
|
+
description: "NEW: Change note type with validation (e.g., convert 'text' to 'code' note). ā ļø IMPORTANT: Type changes are validated for template compatibility - notes with ~template='Board' must remain type='book', ~template='Calendar' must remain type='book', etc. Content compatibility is also validated (e.g., HTML content may not be suitable for code notes). Use this when converting between different note formats (e.g., changing a text note to a code note for better syntax highlighting, or converting a generic note to a Mermaid diagram). Optional - omit if not changing note type."
|
|
93
93
|
},
|
|
94
94
|
mime: {
|
|
95
95
|
type: "string",
|
|
96
|
-
description: "MIME type for code/
|
|
96
|
+
description: "NEW: Update MIME type for code notes (e.g., change 'text/javascript' to 'text/x-python'). Only valid for code-type notes. Use this when switching programming languages or content types. Optional - omit if not changing MIME type."
|
|
97
97
|
},
|
|
98
98
|
content: {
|
|
99
99
|
type: "string",
|
|
@@ -354,7 +354,7 @@ export function createWriteAttributeTools() {
|
|
|
354
354
|
return [
|
|
355
355
|
{
|
|
356
356
|
name: "manage_attributes",
|
|
357
|
-
description: "Manage note attributes with write operations (create, update, delete). Create labels (#tags), template relations (~template), update existing attributes, and organize notes with metadata. ā ļø PRIORITY: Use create_note with attributes parameter for template relations when possible - only use this tool for post-creation modifications or complex scenarios.\n\nā
BEST PRACTICE: Most template relations (~template = 'Board', 'Calendar', etc.) should be added during create_note\nā USE THIS TOOL FOR: ~renderNote relations, custom note-to-note relations, post-creation label updates\n\nIMPORTANT: This tool only provides write access - use read_attributes to view existing attributes. Relations require values pointing to existing notes (e.g., template relations use human-readable names like 'Board', 'Calendar' which are automatically translated to system note IDs; author relations use target note titles or IDs). UPDATE LIMITATIONS: For labels, only value and position can be updated. For relations, only position can be updated. The isInheritable property cannot be changed via update - delete and recreate to modify inheritability. Supports single operations and efficient batch creation for better performance.",
|
|
357
|
+
description: "Manage note attributes with write operations (create, update, delete). Create labels (#tags), template relations (~template), update existing attributes, and organize notes with metadata. ā ļø PRIORITY: Use create_note with attributes parameter for template relations when possible - only use this tool for post-creation modifications or complex scenarios.\n\nā
BEST PRACTICE: Most template relations (~template = 'Board', 'Calendar', etc.) should be added during create_note\nā USE THIS TOOL FOR: ~renderNote relations, custom note-to-note relations, post-creation label updates\n\nIMPORTANT: This tool only provides write access - use read_attributes to view existing attributes. Relations require values pointing to existing notes (e.g., template relations use human-readable names like 'Board', 'Calendar' which are automatically translated to system note IDs; author relations use target note titles or IDs). UPDATE LIMITATIONS: For labels, only value and position can be updated. For relations, only position can be updated. The isInheritable property cannot be changed via update - delete and recreate to modify inheritability. Supports single operations and efficient batch creation for better performance.\n\nš”ļø VALIDATION: Create operations automatically check for existing attributes to prevent duplicates. If an attribute already exists, you'll receive detailed error messages with guidance on using 'update' instead. Batch operations skip duplicates and continue processing valid attributes.",
|
|
358
358
|
inputSchema: {
|
|
359
359
|
type: "object",
|
|
360
360
|
properties: {
|
|
@@ -365,7 +365,7 @@ export function createWriteAttributeTools() {
|
|
|
365
365
|
operation: {
|
|
366
366
|
type: "string",
|
|
367
367
|
enum: ["create", "update", "delete", "batch_create"],
|
|
368
|
-
description: "Operation type: 'create' (new attribute), 'update' (modify existing - limited to label value/position and relation position only), 'delete' (remove attribute), 'batch_create' (multiple new attributes efficiently)"
|
|
368
|
+
description: "Operation type: 'create' (new attribute - validates against existing attributes to prevent duplicates), 'update' (modify existing - limited to label value/position and relation position only), 'delete' (remove attribute), 'batch_create' (multiple new attributes efficiently - skips duplicates and continues with valid attributes)"
|
|
369
369
|
},
|
|
370
370
|
attributes: {
|
|
371
371
|
type: "array",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribute Name Cleaner Utility
|
|
3
|
+
* Auto-corrects common LLM mistakes in attribute names by removing
|
|
4
|
+
* leading/trailing # and ~ symbols that don't belong in attribute names.
|
|
5
|
+
*/
|
|
6
|
+
import { logVerbose } from './verboseUtils.js';
|
|
7
|
+
/**
|
|
8
|
+
* Clean attribute name by removing invalid leading/trailing symbols
|
|
9
|
+
*
|
|
10
|
+
* Trilium ETAPI requires attribute names to be clean without # or ~ symbols.
|
|
11
|
+
* LLMs often mistakenly add these symbols to attribute names.
|
|
12
|
+
*
|
|
13
|
+
* @param attributeName The attribute name to clean
|
|
14
|
+
* @param attributeType The type of attribute (label or relation)
|
|
15
|
+
* @returns CleaningResult with details about any corrections made
|
|
16
|
+
*/
|
|
17
|
+
export function cleanAttributeName(attributeName, attributeType) {
|
|
18
|
+
const originalName = attributeName;
|
|
19
|
+
const corrections = [];
|
|
20
|
+
if (!attributeName || typeof attributeName !== 'string') {
|
|
21
|
+
return {
|
|
22
|
+
cleanedName: attributeName,
|
|
23
|
+
wasCleaned: false,
|
|
24
|
+
originalName,
|
|
25
|
+
corrections: []
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let cleanedName = attributeName.trim();
|
|
29
|
+
// Remove leading # symbol (common LLM mistake for labels)
|
|
30
|
+
if (attributeType === 'label' && cleanedName.startsWith('#')) {
|
|
31
|
+
cleanedName = cleanedName.substring(1).trim();
|
|
32
|
+
corrections.push(`Removed leading '#' symbol`);
|
|
33
|
+
}
|
|
34
|
+
// Remove leading ~ symbol (common LLM mistake for relations)
|
|
35
|
+
if (attributeType === 'relation' && cleanedName.startsWith('~')) {
|
|
36
|
+
cleanedName = cleanedName.substring(1).trim();
|
|
37
|
+
corrections.push(`Removed leading '~' symbol`);
|
|
38
|
+
}
|
|
39
|
+
const wasCleaned = corrections.length > 0;
|
|
40
|
+
// Log corrections if any were made
|
|
41
|
+
if (wasCleaned) {
|
|
42
|
+
logVerbose('cleanAttributeName', `Auto-corrected ${attributeType} name`, {
|
|
43
|
+
original: originalName,
|
|
44
|
+
corrected: cleanedName,
|
|
45
|
+
corrections,
|
|
46
|
+
attributeType
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
cleanedName,
|
|
51
|
+
wasCleaned,
|
|
52
|
+
originalName,
|
|
53
|
+
corrections
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Clean an array of attributes
|
|
58
|
+
*
|
|
59
|
+
* @param attributes Array of attributes to clean
|
|
60
|
+
* @returns Array of cleaned attributes with cleaning information
|
|
61
|
+
*/
|
|
62
|
+
export function cleanAttributeNames(attributes) {
|
|
63
|
+
return attributes.map(attribute => {
|
|
64
|
+
const cleaningResult = cleanAttributeName(attribute.name, attribute.type);
|
|
65
|
+
return {
|
|
66
|
+
...attribute,
|
|
67
|
+
name: cleaningResult.cleanedName,
|
|
68
|
+
cleaningResult: cleaningResult.wasCleaned ? cleaningResult : undefined
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Generate user-friendly message about corrections
|
|
74
|
+
*
|
|
75
|
+
* @param cleaningResults Array of cleaning results
|
|
76
|
+
* @returns User-friendly message describing corrections
|
|
77
|
+
*/
|
|
78
|
+
export function generateCleaningMessage(cleaningResults) {
|
|
79
|
+
const totalCorrections = cleaningResults.filter(r => r.wasCleaned).length;
|
|
80
|
+
if (totalCorrections === 0) {
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
const correctionsByType = cleaningResults.reduce((acc, result) => {
|
|
84
|
+
if (result.wasCleaned) {
|
|
85
|
+
result.corrections.forEach(correction => {
|
|
86
|
+
acc[correction] = (acc[correction] || 0) + 1;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return acc;
|
|
90
|
+
}, {});
|
|
91
|
+
const correctionSummary = Object.entries(correctionsByType)
|
|
92
|
+
.map(([correction, count]) => `${count}Ć ${correction}`)
|
|
93
|
+
.join(', ');
|
|
94
|
+
return `š§ **Auto-Corrections Applied**\n` +
|
|
95
|
+
`Fixed ${totalCorrections} attribute name(s): ${correctionSummary}\n\n` +
|
|
96
|
+
`š” **Common LLM Mistakes Fixed**:\n` +
|
|
97
|
+
`⢠Attribute names should NOT start with # or ~ symbols\n` +
|
|
98
|
+
`⢠# symbols are for attribute values in search queries (e.g., #book)\n` +
|
|
99
|
+
`⢠~ symbols are for attribute values in search queries (e.g., ~template)\n` +
|
|
100
|
+
`⢠Attribute names should be plain text (e.g., "startDate", "template")`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Check if an attribute name needs cleaning
|
|
104
|
+
*
|
|
105
|
+
* @param attributeName The attribute name to check
|
|
106
|
+
* @returns True if the name needs cleaning
|
|
107
|
+
*/
|
|
108
|
+
export function needsCleaning(attributeName, attributeType = 'label') {
|
|
109
|
+
if (!attributeName || typeof attributeName !== 'string') {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const trimmed = attributeName.trim();
|
|
113
|
+
return (attributeType === 'label' && trimmed.startsWith('#')) ||
|
|
114
|
+
(attributeType === 'relation' && trimmed.startsWith('~'));
|
|
115
|
+
}
|