@hanna84/mcp-writing 3.12.0 → 3.13.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.
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.13.1](https://github.com/hannasdev/mcp-writing/compare/v3.13.0...v3.13.1)
|
|
8
|
+
|
|
9
|
+
- docs(product): mark M7 complete [`#206`](https://github.com/hannasdev/mcp-writing/pull/206)
|
|
10
|
+
|
|
11
|
+
#### [v3.13.0](https://github.com/hannasdev/mcp-writing/compare/v3.12.0...v3.13.0)
|
|
12
|
+
|
|
13
|
+
> 19 May 2026
|
|
14
|
+
|
|
15
|
+
- feat(structure): add explicit M7 mutation commands [`#205`](https://github.com/hannasdev/mcp-writing/pull/205)
|
|
16
|
+
- Release 3.13.0 [`0e48ea8`](https://github.com/hannasdev/mcp-writing/commit/0e48ea856bf641fe724f9cd4bdda827d8ba230cc)
|
|
17
|
+
|
|
7
18
|
#### [v3.12.0](https://github.com/hannasdev/mcp-writing/compare/v3.11.0...v3.12.0)
|
|
8
19
|
|
|
20
|
+
> 18 May 2026
|
|
21
|
+
|
|
9
22
|
- feat(structure): normalize chapter compatibility resolution [`#204`](https://github.com/hannasdev/mcp-writing/pull/204)
|
|
23
|
+
- Release 3.12.0 [`6f9c540`](https://github.com/hannasdev/mcp-writing/commit/6f9c540e66eef75a224841a9768b485ed9a26557)
|
|
10
24
|
|
|
11
25
|
#### [v3.11.0](https://github.com/hannasdev/mcp-writing/compare/v3.10.0...v3.11.0)
|
|
12
26
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { slugifyChapterValue } from "./structure-inference.js";
|
|
2
|
+
|
|
3
|
+
export function deriveCanonicalChapterId({ chapterId, sortIndex, title }) {
|
|
4
|
+
if (chapterId) return chapterId;
|
|
5
|
+
const slug = slugifyChapterValue(title) || `chapter-${sortIndex}`;
|
|
6
|
+
return `ch-${String(sortIndex).padStart(2, "0")}-${slug}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildCreateChapterPlan(db, {
|
|
10
|
+
projectId,
|
|
11
|
+
title,
|
|
12
|
+
sortIndex,
|
|
13
|
+
chapterId,
|
|
14
|
+
logline = null,
|
|
15
|
+
updatedAt = new Date().toISOString(),
|
|
16
|
+
}) {
|
|
17
|
+
const normalizedTitle = typeof title === "string" ? title.trim() : "";
|
|
18
|
+
const normalizedChapterId = typeof chapterId === "string" ? chapterId.trim() : null;
|
|
19
|
+
const normalizedLogline = typeof logline === "string" && logline.trim() ? logline.trim() : null;
|
|
20
|
+
|
|
21
|
+
if (!normalizedTitle) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: {
|
|
25
|
+
code: "VALIDATION_ERROR",
|
|
26
|
+
message: "Provide a non-empty chapter title.",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!Number.isInteger(sortIndex) || sortIndex < 1) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: {
|
|
35
|
+
code: "VALIDATION_ERROR",
|
|
36
|
+
message: "sort_index must be a positive integer.",
|
|
37
|
+
details: { sort_index: sortIndex },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const project = db.prepare(`
|
|
43
|
+
SELECT project_id
|
|
44
|
+
FROM projects
|
|
45
|
+
WHERE project_id = ?
|
|
46
|
+
`).get(projectId);
|
|
47
|
+
if (!project) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: {
|
|
51
|
+
code: "NOT_FOUND",
|
|
52
|
+
message: `Project '${projectId}' not found.`,
|
|
53
|
+
details: { project_id: projectId },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resolvedChapterId = deriveCanonicalChapterId({
|
|
59
|
+
chapterId: normalizedChapterId,
|
|
60
|
+
sortIndex,
|
|
61
|
+
title: normalizedTitle,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const existingById = db.prepare(`
|
|
65
|
+
SELECT chapter_id, title, sort_index
|
|
66
|
+
FROM chapters
|
|
67
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
68
|
+
`).get(projectId, resolvedChapterId);
|
|
69
|
+
if (existingById) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: {
|
|
73
|
+
code: "ALREADY_EXISTS",
|
|
74
|
+
message: `Chapter '${resolvedChapterId}' already exists in project '${projectId}'.`,
|
|
75
|
+
details: {
|
|
76
|
+
project_id: projectId,
|
|
77
|
+
chapter_id: resolvedChapterId,
|
|
78
|
+
existing_title: existingById.title,
|
|
79
|
+
existing_sort_index: existingById.sort_index,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const existingBySortIndex = db.prepare(`
|
|
86
|
+
SELECT chapter_id, title, sort_index
|
|
87
|
+
FROM chapters
|
|
88
|
+
WHERE project_id = ? AND sort_index = ?
|
|
89
|
+
`).get(projectId, sortIndex);
|
|
90
|
+
if (existingBySortIndex) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: {
|
|
94
|
+
code: "VALIDATION_ERROR",
|
|
95
|
+
message: `Chapter sort_index ${sortIndex} is already used in project '${projectId}'.`,
|
|
96
|
+
details: {
|
|
97
|
+
project_id: projectId,
|
|
98
|
+
sort_index: sortIndex,
|
|
99
|
+
existing_chapter_id: existingBySortIndex.chapter_id,
|
|
100
|
+
existing_title: existingBySortIndex.title,
|
|
101
|
+
next_step: "Use list_chapters to choose an unused sort_index, or wait for reorder_chapter when changing existing order.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const existingByTitle = db.prepare(`
|
|
108
|
+
SELECT chapter_id, title, sort_index
|
|
109
|
+
FROM chapters
|
|
110
|
+
WHERE project_id = ? AND title = ?
|
|
111
|
+
`).get(projectId, normalizedTitle);
|
|
112
|
+
if (existingByTitle) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: {
|
|
116
|
+
code: "VALIDATION_ERROR",
|
|
117
|
+
message: `Chapter title '${normalizedTitle}' is already used in project '${projectId}'.`,
|
|
118
|
+
details: {
|
|
119
|
+
project_id: projectId,
|
|
120
|
+
title: normalizedTitle,
|
|
121
|
+
existing_chapter_id: existingByTitle.chapter_id,
|
|
122
|
+
existing_sort_index: existingByTitle.sort_index,
|
|
123
|
+
next_step: "Use a distinct title, or wait for rename_chapter when changing an existing chapter title.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
chapter: {
|
|
132
|
+
chapter_id: resolvedChapterId,
|
|
133
|
+
project_id: projectId,
|
|
134
|
+
title: normalizedTitle,
|
|
135
|
+
sort_index: sortIndex,
|
|
136
|
+
logline: normalizedLogline,
|
|
137
|
+
source_path: null,
|
|
138
|
+
source_checksum: null,
|
|
139
|
+
metadata_stale: 0,
|
|
140
|
+
updated_at: updatedAt,
|
|
141
|
+
},
|
|
142
|
+
diagnostics: [
|
|
143
|
+
{
|
|
144
|
+
code: "REPRESENTATION_DEFERRED",
|
|
145
|
+
severity: "info",
|
|
146
|
+
message: "Created canonical chapter state only; no scene files, sidecars, or Scrivener-compatible folders were generated.",
|
|
147
|
+
next_step: "Use assign_scene_to_chapter to place unchaptered scenes in this chapter, then run diagnose_structure if folder-derived structure may disagree.",
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function buildRenameChapterPlan(db, {
|
|
154
|
+
projectId,
|
|
155
|
+
chapterId,
|
|
156
|
+
title,
|
|
157
|
+
updatedAt = new Date().toISOString(),
|
|
158
|
+
}) {
|
|
159
|
+
const normalizedTitle = typeof title === "string" ? title.trim() : "";
|
|
160
|
+
|
|
161
|
+
if (!normalizedTitle) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: {
|
|
165
|
+
code: "VALIDATION_ERROR",
|
|
166
|
+
message: "Provide a non-empty chapter title.",
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const chapter = db.prepare(`
|
|
172
|
+
SELECT chapter_id, project_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
|
|
173
|
+
FROM chapters
|
|
174
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
175
|
+
`).get(projectId, chapterId);
|
|
176
|
+
if (!chapter) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
error: {
|
|
180
|
+
code: "NOT_FOUND",
|
|
181
|
+
message: `Chapter '${chapterId}' not found in project '${projectId}'.`,
|
|
182
|
+
details: { project_id: projectId, chapter_id: chapterId },
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const existingByTitle = db.prepare(`
|
|
188
|
+
SELECT chapter_id, title, sort_index
|
|
189
|
+
FROM chapters
|
|
190
|
+
WHERE project_id = ? AND title = ? AND chapter_id != ?
|
|
191
|
+
`).get(projectId, normalizedTitle, chapterId);
|
|
192
|
+
if (existingByTitle) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error: {
|
|
196
|
+
code: "VALIDATION_ERROR",
|
|
197
|
+
message: `Chapter title '${normalizedTitle}' is already used in project '${projectId}'.`,
|
|
198
|
+
details: {
|
|
199
|
+
project_id: projectId,
|
|
200
|
+
title: normalizedTitle,
|
|
201
|
+
existing_chapter_id: existingByTitle.chapter_id,
|
|
202
|
+
existing_sort_index: existingByTitle.sort_index,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const diagnostics = [];
|
|
209
|
+
if (chapter.source_path) {
|
|
210
|
+
diagnostics.push({
|
|
211
|
+
code: "REPRESENTATION_NOT_RENAMED",
|
|
212
|
+
severity: "warning",
|
|
213
|
+
message: "Renamed canonical chapter state and explicit scene compatibility fields only; the existing chapter source folder was not renamed.",
|
|
214
|
+
next_step: "Run diagnose_structure after sync if folder-derived structure still reports the old title.",
|
|
215
|
+
details: {
|
|
216
|
+
source_path: chapter.source_path,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
previousChapter: chapter,
|
|
224
|
+
chapter: {
|
|
225
|
+
...chapter,
|
|
226
|
+
title: normalizedTitle,
|
|
227
|
+
updated_at: updatedAt,
|
|
228
|
+
},
|
|
229
|
+
diagnostics,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function buildReorderChapterPlan(db, {
|
|
234
|
+
projectId,
|
|
235
|
+
chapterId,
|
|
236
|
+
sortIndex,
|
|
237
|
+
updatedAt = new Date().toISOString(),
|
|
238
|
+
}) {
|
|
239
|
+
if (!Number.isInteger(sortIndex) || sortIndex < 1) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
error: {
|
|
243
|
+
code: "VALIDATION_ERROR",
|
|
244
|
+
message: "sort_index must be a positive integer.",
|
|
245
|
+
details: { sort_index: sortIndex },
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const chapter = db.prepare(`
|
|
251
|
+
SELECT chapter_id, project_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
|
|
252
|
+
FROM chapters
|
|
253
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
254
|
+
`).get(projectId, chapterId);
|
|
255
|
+
if (!chapter) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
error: {
|
|
259
|
+
code: "NOT_FOUND",
|
|
260
|
+
message: `Chapter '${chapterId}' not found in project '${projectId}'.`,
|
|
261
|
+
details: { project_id: projectId, chapter_id: chapterId },
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const existingBySortIndex = db.prepare(`
|
|
267
|
+
SELECT chapter_id, title, sort_index
|
|
268
|
+
FROM chapters
|
|
269
|
+
WHERE project_id = ? AND sort_index = ? AND chapter_id != ?
|
|
270
|
+
`).get(projectId, sortIndex, chapterId);
|
|
271
|
+
if (existingBySortIndex) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
error: {
|
|
275
|
+
code: "VALIDATION_ERROR",
|
|
276
|
+
message: `Chapter sort_index ${sortIndex} is already used in project '${projectId}'.`,
|
|
277
|
+
details: {
|
|
278
|
+
project_id: projectId,
|
|
279
|
+
sort_index: sortIndex,
|
|
280
|
+
existing_chapter_id: existingBySortIndex.chapter_id,
|
|
281
|
+
existing_title: existingBySortIndex.title,
|
|
282
|
+
next_step: "Choose an unused sort_index. Automatic resequencing is not part of this command yet.",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const diagnostics = [];
|
|
289
|
+
if (chapter.source_path) {
|
|
290
|
+
diagnostics.push({
|
|
291
|
+
code: "REPRESENTATION_NOT_REORDERED",
|
|
292
|
+
severity: "warning",
|
|
293
|
+
message: "Reordered canonical chapter state and explicit scene compatibility fields only; the existing chapter source folder was not renamed or moved.",
|
|
294
|
+
next_step: "Run diagnose_structure after sync if folder-derived structure still reports the old order.",
|
|
295
|
+
details: {
|
|
296
|
+
source_path: chapter.source_path,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
previousChapter: chapter,
|
|
304
|
+
chapter: {
|
|
305
|
+
...chapter,
|
|
306
|
+
sort_index: sortIndex,
|
|
307
|
+
updated_at: updatedAt,
|
|
308
|
+
},
|
|
309
|
+
diagnostics,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function buildAttachEpigraphPlan(db, {
|
|
314
|
+
projectId,
|
|
315
|
+
epigraphId,
|
|
316
|
+
chapterId,
|
|
317
|
+
updatedAt = new Date().toISOString(),
|
|
318
|
+
}) {
|
|
319
|
+
const normalizedEpigraphId = typeof epigraphId === "string" ? epigraphId.trim() : "";
|
|
320
|
+
const normalizedChapterId = typeof chapterId === "string" ? chapterId.trim() : "";
|
|
321
|
+
|
|
322
|
+
if (!normalizedEpigraphId) {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
error: {
|
|
326
|
+
code: "VALIDATION_ERROR",
|
|
327
|
+
message: "Provide a non-empty epigraph_id.",
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!normalizedChapterId) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: {
|
|
336
|
+
code: "VALIDATION_ERROR",
|
|
337
|
+
message: "Provide a non-empty chapter_id.",
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const chapter = db.prepare(`
|
|
343
|
+
SELECT chapter_id, project_id, title, sort_index, logline, source_path, source_checksum, metadata_stale
|
|
344
|
+
FROM chapters
|
|
345
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
346
|
+
`).get(projectId, normalizedChapterId);
|
|
347
|
+
if (!chapter) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
error: {
|
|
351
|
+
code: "NOT_FOUND",
|
|
352
|
+
message: `Chapter '${normalizedChapterId}' not found in project '${projectId}'.`,
|
|
353
|
+
details: { project_id: projectId, chapter_id: normalizedChapterId },
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const epigraph = db.prepare(`
|
|
359
|
+
SELECT epigraph_id, project_id, chapter_id, body, file_path, prose_checksum, metadata_stale
|
|
360
|
+
FROM epigraphs
|
|
361
|
+
WHERE project_id = ? AND epigraph_id = ?
|
|
362
|
+
`).get(projectId, normalizedEpigraphId);
|
|
363
|
+
if (!epigraph) {
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
error: {
|
|
367
|
+
code: "NOT_FOUND",
|
|
368
|
+
message: `Epigraph '${normalizedEpigraphId}' not found in project '${projectId}'.`,
|
|
369
|
+
details: { project_id: projectId, epigraph_id: normalizedEpigraphId },
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const existingForChapter = db.prepare(`
|
|
375
|
+
SELECT epigraph_id, chapter_id
|
|
376
|
+
FROM epigraphs
|
|
377
|
+
WHERE project_id = ? AND chapter_id = ? AND epigraph_id != ?
|
|
378
|
+
`).get(projectId, normalizedChapterId, normalizedEpigraphId);
|
|
379
|
+
if (existingForChapter) {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
error: {
|
|
383
|
+
code: "VALIDATION_ERROR",
|
|
384
|
+
message: `Chapter '${normalizedChapterId}' already has epigraph '${existingForChapter.epigraph_id}'.`,
|
|
385
|
+
details: {
|
|
386
|
+
project_id: projectId,
|
|
387
|
+
chapter_id: normalizedChapterId,
|
|
388
|
+
epigraph_id: normalizedEpigraphId,
|
|
389
|
+
existing_epigraph_id: existingForChapter.epigraph_id,
|
|
390
|
+
next_step: "Use find_epigraphs to inspect the current chapter epigraph before reattaching another one.",
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const previousChapter = db.prepare(`
|
|
397
|
+
SELECT chapter_id, title, sort_index
|
|
398
|
+
FROM chapters
|
|
399
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
400
|
+
`).get(projectId, epigraph.chapter_id);
|
|
401
|
+
|
|
402
|
+
const diagnostics = [];
|
|
403
|
+
if (epigraph.file_path) {
|
|
404
|
+
diagnostics.push({
|
|
405
|
+
code: "REPRESENTATION_NOT_MOVED",
|
|
406
|
+
severity: "warning",
|
|
407
|
+
message: "Attached canonical epigraph state and explicit epigraph sidecar fields only; the existing epigraph source file was not moved.",
|
|
408
|
+
next_step: "Run diagnose_structure after sync if folder-derived structure still reports the previous chapter.",
|
|
409
|
+
details: {
|
|
410
|
+
file_path: epigraph.file_path,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
ok: true,
|
|
417
|
+
previousChapter,
|
|
418
|
+
chapter,
|
|
419
|
+
epigraph: {
|
|
420
|
+
...epigraph,
|
|
421
|
+
chapter_id: normalizedChapterId,
|
|
422
|
+
updated_at: updatedAt,
|
|
423
|
+
},
|
|
424
|
+
diagnostics,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function insertCanonicalChapter(db, chapter) {
|
|
429
|
+
db.prepare(`
|
|
430
|
+
INSERT INTO chapters (
|
|
431
|
+
chapter_id, project_id, title, sort_index, logline, source_path, source_checksum, metadata_stale, updated_at
|
|
432
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
433
|
+
`).run(
|
|
434
|
+
chapter.chapter_id,
|
|
435
|
+
chapter.project_id,
|
|
436
|
+
chapter.title,
|
|
437
|
+
chapter.sort_index,
|
|
438
|
+
chapter.logline,
|
|
439
|
+
chapter.source_path,
|
|
440
|
+
chapter.source_checksum,
|
|
441
|
+
chapter.metadata_stale,
|
|
442
|
+
chapter.updated_at
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function renameCanonicalChapter(db, chapter) {
|
|
447
|
+
db.prepare(`
|
|
448
|
+
UPDATE chapters
|
|
449
|
+
SET title = ?,
|
|
450
|
+
updated_at = ?
|
|
451
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
452
|
+
`).run(
|
|
453
|
+
chapter.title,
|
|
454
|
+
chapter.updated_at,
|
|
455
|
+
chapter.project_id,
|
|
456
|
+
chapter.chapter_id
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
db.prepare(`
|
|
460
|
+
UPDATE scenes
|
|
461
|
+
SET chapter_title = ?,
|
|
462
|
+
updated_at = ?
|
|
463
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
464
|
+
`).run(
|
|
465
|
+
chapter.title,
|
|
466
|
+
chapter.updated_at,
|
|
467
|
+
chapter.project_id,
|
|
468
|
+
chapter.chapter_id
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function attachCanonicalEpigraph(db, epigraph) {
|
|
473
|
+
db.prepare(`
|
|
474
|
+
UPDATE epigraphs
|
|
475
|
+
SET chapter_id = ?,
|
|
476
|
+
updated_at = ?
|
|
477
|
+
WHERE project_id = ? AND epigraph_id = ?
|
|
478
|
+
`).run(
|
|
479
|
+
epigraph.chapter_id,
|
|
480
|
+
epigraph.updated_at,
|
|
481
|
+
epigraph.project_id,
|
|
482
|
+
epigraph.epigraph_id
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function reorderCanonicalChapter(db, chapter) {
|
|
487
|
+
db.prepare(`
|
|
488
|
+
UPDATE chapters
|
|
489
|
+
SET sort_index = ?,
|
|
490
|
+
updated_at = ?
|
|
491
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
492
|
+
`).run(
|
|
493
|
+
chapter.sort_index,
|
|
494
|
+
chapter.updated_at,
|
|
495
|
+
chapter.project_id,
|
|
496
|
+
chapter.chapter_id
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
db.prepare(`
|
|
500
|
+
UPDATE scenes
|
|
501
|
+
SET chapter = ?,
|
|
502
|
+
chapter_title = ?,
|
|
503
|
+
updated_at = ?
|
|
504
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
505
|
+
`).run(
|
|
506
|
+
chapter.sort_index,
|
|
507
|
+
chapter.title,
|
|
508
|
+
chapter.updated_at,
|
|
509
|
+
chapter.project_id,
|
|
510
|
+
chapter.chapter_id
|
|
511
|
+
);
|
|
512
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { applySceneStructurePatch } from "./structure-inference.js";
|
|
2
2
|
|
|
3
|
+
function buildAssignedChapterPayload({ chapterId, sortIndex, title }) {
|
|
4
|
+
if (!chapterId) return null;
|
|
5
|
+
return {
|
|
6
|
+
chapter_id: chapterId,
|
|
7
|
+
sort_index: sortIndex ?? null,
|
|
8
|
+
title: title ?? null,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
export function buildSceneChapterAssignmentPlan(syncDir, filePath, meta = {}, { chapter } = {}) {
|
|
4
13
|
if (chapter === undefined) {
|
|
5
14
|
return {
|
|
@@ -75,3 +84,64 @@ export function buildSceneChapterAssignmentPlan(syncDir, filePath, meta = {}, {
|
|
|
75
84
|
previousChapterId: meta.chapter_id ?? null,
|
|
76
85
|
};
|
|
77
86
|
}
|
|
87
|
+
|
|
88
|
+
export function buildMoveScenePlan(syncDir, filePath, meta = {}, {
|
|
89
|
+
currentScene,
|
|
90
|
+
chapter,
|
|
91
|
+
timelinePosition,
|
|
92
|
+
} = {}) {
|
|
93
|
+
if (chapter === undefined && timelinePosition === undefined) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: {
|
|
97
|
+
code: "VALIDATION_ERROR",
|
|
98
|
+
message: "Provide chapter_id and/or timeline_position for move_scene.",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (timelinePosition !== undefined && (!Number.isInteger(timelinePosition) || timelinePosition < 1)) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: {
|
|
107
|
+
code: "VALIDATION_ERROR",
|
|
108
|
+
message: "timeline_position must be a positive integer.",
|
|
109
|
+
details: { timeline_position: timelinePosition },
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const assignmentPlan = chapter === undefined
|
|
115
|
+
? {
|
|
116
|
+
ok: true,
|
|
117
|
+
meta,
|
|
118
|
+
assignedChapter: buildAssignedChapterPayload({
|
|
119
|
+
chapterId: meta.chapter_id ?? currentScene?.chapter_id ?? null,
|
|
120
|
+
sortIndex: meta.chapter ?? currentScene?.chapter ?? null,
|
|
121
|
+
title: meta.chapter_title ?? currentScene?.chapter_title ?? null,
|
|
122
|
+
}),
|
|
123
|
+
previousChapterId: meta.chapter_id ?? currentScene?.chapter_id ?? null,
|
|
124
|
+
}
|
|
125
|
+
: buildSceneChapterAssignmentPlan(syncDir, filePath, meta, { chapter });
|
|
126
|
+
|
|
127
|
+
if (!assignmentPlan.ok) return assignmentPlan;
|
|
128
|
+
|
|
129
|
+
const shouldCarryIndexedTimelinePosition = timelinePosition === undefined
|
|
130
|
+
&& chapter !== undefined
|
|
131
|
+
&& meta.timeline_position === undefined
|
|
132
|
+
&& currentScene?.timeline_position != null;
|
|
133
|
+
const movedMeta = {
|
|
134
|
+
...assignmentPlan.meta,
|
|
135
|
+
...(shouldCarryIndexedTimelinePosition ? { timeline_position: currentScene.timeline_position } : {}),
|
|
136
|
+
...(timelinePosition !== undefined ? { timeline_position: timelinePosition } : {}),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
meta: movedMeta,
|
|
142
|
+
assignedChapter: assignmentPlan.assignedChapter,
|
|
143
|
+
previousChapterId: assignmentPlan.previousChapterId ?? currentScene?.chapter_id ?? null,
|
|
144
|
+
previousTimelinePosition: meta.timeline_position ?? currentScene?.timeline_position ?? null,
|
|
145
|
+
timelinePosition: movedMeta.timeline_position ?? null,
|
|
146
|
+
};
|
|
147
|
+
}
|
package/src/tools/metadata.js
CHANGED
|
@@ -4,7 +4,17 @@ import matter from "gray-matter";
|
|
|
4
4
|
import { readMeta, writeMeta, indexSceneFile, applySceneStructurePatch } from "../sync/sync.js";
|
|
5
5
|
import { validateProjectId, validateUniverseId } from "../sync/importer.js";
|
|
6
6
|
import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
|
|
7
|
-
import { buildSceneChapterAssignmentPlan } from "../structure/scene-chapter-assignment.js";
|
|
7
|
+
import { buildMoveScenePlan, buildSceneChapterAssignmentPlan } from "../structure/scene-chapter-assignment.js";
|
|
8
|
+
import {
|
|
9
|
+
buildCreateChapterPlan,
|
|
10
|
+
buildRenameChapterPlan,
|
|
11
|
+
buildReorderChapterPlan,
|
|
12
|
+
buildAttachEpigraphPlan,
|
|
13
|
+
insertCanonicalChapter,
|
|
14
|
+
renameCanonicalChapter,
|
|
15
|
+
reorderCanonicalChapter,
|
|
16
|
+
attachCanonicalEpigraph,
|
|
17
|
+
} from "../structure/chapter-commands.js";
|
|
8
18
|
import {
|
|
9
19
|
persistSceneReferenceLink,
|
|
10
20
|
upsertExplicitReferenceLinkRow,
|
|
@@ -81,6 +91,41 @@ function persistPlaceReferenceLink({ placePath, syncDir, targetDocId, relation }
|
|
|
81
91
|
writeMeta(placePath, nextMeta);
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
function writeStructureSidecarUpdates(updates, { failureCode }) {
|
|
95
|
+
const failures = [];
|
|
96
|
+
let updatedCount = 0;
|
|
97
|
+
|
|
98
|
+
for (const update of updates) {
|
|
99
|
+
try {
|
|
100
|
+
writeMeta(update.filePath, update.meta);
|
|
101
|
+
updatedCount += 1;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
failures.push({
|
|
104
|
+
file_path: update.filePath,
|
|
105
|
+
message: err.message,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
updatedCount,
|
|
112
|
+
diagnostics: failures.length
|
|
113
|
+
? [
|
|
114
|
+
{
|
|
115
|
+
code: failureCode,
|
|
116
|
+
severity: "warning",
|
|
117
|
+
message: "Canonical structure was updated, but one or more explicit sidecar compatibility updates failed.",
|
|
118
|
+
next_step: "Inspect the failed sidecar paths, then run sync and diagnose_structure before making more structure changes.",
|
|
119
|
+
details: {
|
|
120
|
+
failed_sidecar_count: failures.length,
|
|
121
|
+
failures,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
84
129
|
function resolveProjectScopedSource({
|
|
85
130
|
db,
|
|
86
131
|
errorResponse,
|
|
@@ -511,6 +556,541 @@ export function registerMetadataTools(s, {
|
|
|
511
556
|
}
|
|
512
557
|
);
|
|
513
558
|
|
|
559
|
+
// ---- create_chapter ------------------------------------------------------
|
|
560
|
+
s.tool(
|
|
561
|
+
"create_chapter",
|
|
562
|
+
"Create a canonical chapter record through the explicit structure workflow. Writes canonical chapter state only; it does not create scene files, sidecars, or Scrivener-compatible folders. Use assign_scene_to_chapter afterward to place unchaptered scenes in the new chapter.",
|
|
563
|
+
{
|
|
564
|
+
project_id: z.string().describe("Project the chapter belongs to (e.g. 'the-lamb')."),
|
|
565
|
+
title: z.string().describe("Human-readable chapter title."),
|
|
566
|
+
sort_index: z.number().int().min(1).describe("Canonical chapter order within the project. Must be unused."),
|
|
567
|
+
chapter_id: z.string().optional().describe("Optional canonical chapter identifier. If omitted, one is derived from sort_index and title."),
|
|
568
|
+
logline: z.string().optional().describe("Optional chapter-level logline."),
|
|
569
|
+
},
|
|
570
|
+
async ({ project_id, title, sort_index, chapter_id, logline }) => {
|
|
571
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
572
|
+
return errorResponse("READ_ONLY", "Cannot create chapter: sync dir is read-only.");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
576
|
+
if (!projectIdCheck.ok) {
|
|
577
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const plan = buildCreateChapterPlan(db, {
|
|
581
|
+
projectId: project_id,
|
|
582
|
+
title,
|
|
583
|
+
sortIndex: sort_index,
|
|
584
|
+
chapterId: chapter_id,
|
|
585
|
+
logline,
|
|
586
|
+
});
|
|
587
|
+
if (!plan.ok) {
|
|
588
|
+
return errorResponse(plan.error.code, plan.error.message, {
|
|
589
|
+
project_id,
|
|
590
|
+
title,
|
|
591
|
+
sort_index,
|
|
592
|
+
chapter_id: chapter_id ?? null,
|
|
593
|
+
...(plan.error.details ?? {}),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
db.exec("BEGIN");
|
|
599
|
+
insertCanonicalChapter(db, plan.chapter);
|
|
600
|
+
db.exec("COMMIT");
|
|
601
|
+
} catch (err) {
|
|
602
|
+
try {
|
|
603
|
+
db.exec("ROLLBACK");
|
|
604
|
+
} catch (rollbackErr) {
|
|
605
|
+
void rollbackErr;
|
|
606
|
+
}
|
|
607
|
+
return errorResponse("IO_ERROR", `Failed to create chapter '${plan.chapter.chapter_id}': ${err.message}`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return jsonResponse({
|
|
611
|
+
ok: true,
|
|
612
|
+
action: "created",
|
|
613
|
+
chapter: {
|
|
614
|
+
chapter_id: plan.chapter.chapter_id,
|
|
615
|
+
project_id: plan.chapter.project_id,
|
|
616
|
+
title: plan.chapter.title,
|
|
617
|
+
sort_index: plan.chapter.sort_index,
|
|
618
|
+
logline: plan.chapter.logline,
|
|
619
|
+
metadata_stale: plan.chapter.metadata_stale,
|
|
620
|
+
},
|
|
621
|
+
diagnostics: plan.diagnostics,
|
|
622
|
+
next_steps: [
|
|
623
|
+
"Use assign_scene_to_chapter to place unchaptered scenes in this chapter.",
|
|
624
|
+
"Run diagnose_structure if existing folders or sidecars may imply conflicting structure.",
|
|
625
|
+
],
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// ---- rename_chapter ------------------------------------------------------
|
|
631
|
+
s.tool(
|
|
632
|
+
"rename_chapter",
|
|
633
|
+
"Rename a canonical chapter through the explicit structure workflow. Updates canonical chapter state and explicit scene chapter_title compatibility fields; it does not rename scene files, sidecars by path-derived structure, or Scrivener-compatible folders.",
|
|
634
|
+
{
|
|
635
|
+
project_id: z.string().describe("Project the chapter belongs to (e.g. 'the-lamb')."),
|
|
636
|
+
chapter_id: z.string().describe("Canonical chapter identifier. Use list_chapters to find valid values."),
|
|
637
|
+
title: z.string().describe("New human-readable chapter title."),
|
|
638
|
+
},
|
|
639
|
+
async ({ project_id, chapter_id, title }) => {
|
|
640
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
641
|
+
return errorResponse("READ_ONLY", "Cannot rename chapter: sync dir is read-only.");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
645
|
+
if (!projectIdCheck.ok) {
|
|
646
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const plan = buildRenameChapterPlan(db, {
|
|
650
|
+
projectId: project_id,
|
|
651
|
+
chapterId: chapter_id,
|
|
652
|
+
title,
|
|
653
|
+
});
|
|
654
|
+
if (!plan.ok) {
|
|
655
|
+
return errorResponse(plan.error.code, plan.error.message, {
|
|
656
|
+
project_id,
|
|
657
|
+
chapter_id,
|
|
658
|
+
title,
|
|
659
|
+
...(plan.error.details ?? {}),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const linkedScenes = db.prepare(`
|
|
664
|
+
SELECT scene_id, project_id, file_path
|
|
665
|
+
FROM scenes
|
|
666
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
667
|
+
ORDER BY scene_id
|
|
668
|
+
`).all(project_id, chapter_id);
|
|
669
|
+
|
|
670
|
+
const sidecarUpdates = [];
|
|
671
|
+
try {
|
|
672
|
+
for (const scene of linkedScenes) {
|
|
673
|
+
const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
674
|
+
if (meta.chapter_id === chapter_id) {
|
|
675
|
+
sidecarUpdates.push({
|
|
676
|
+
scene,
|
|
677
|
+
filePath: scene.file_path,
|
|
678
|
+
meta: {
|
|
679
|
+
...meta,
|
|
680
|
+
chapter_title: plan.chapter.title,
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
db.exec("BEGIN");
|
|
687
|
+
renameCanonicalChapter(db, plan.chapter);
|
|
688
|
+
db.exec("COMMIT");
|
|
689
|
+
} catch (err) {
|
|
690
|
+
try {
|
|
691
|
+
db.exec("ROLLBACK");
|
|
692
|
+
} catch (rollbackErr) {
|
|
693
|
+
void rollbackErr;
|
|
694
|
+
}
|
|
695
|
+
if (err.code === "ENOENT") {
|
|
696
|
+
return errorResponse("STALE_PATH", `Cannot rename chapter '${chapter_id}': an indexed scene file is missing. Run sync() to refresh.`, {
|
|
697
|
+
project_id,
|
|
698
|
+
chapter_id,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
return errorResponse("IO_ERROR", `Failed to rename chapter '${chapter_id}': ${err.message}`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const sidecarWriteResult = writeStructureSidecarUpdates(sidecarUpdates, {
|
|
705
|
+
failureCode: "SCENE_SIDECAR_UPDATE_FAILED",
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
return jsonResponse({
|
|
709
|
+
ok: true,
|
|
710
|
+
action: "renamed",
|
|
711
|
+
chapter: {
|
|
712
|
+
chapter_id: plan.chapter.chapter_id,
|
|
713
|
+
project_id: plan.chapter.project_id,
|
|
714
|
+
title: plan.chapter.title,
|
|
715
|
+
sort_index: plan.chapter.sort_index,
|
|
716
|
+
logline: plan.chapter.logline,
|
|
717
|
+
metadata_stale: plan.chapter.metadata_stale,
|
|
718
|
+
},
|
|
719
|
+
previous_title: plan.previousChapter.title,
|
|
720
|
+
updated_scene_count: linkedScenes.length,
|
|
721
|
+
updated_sidecar_count: sidecarWriteResult.updatedCount,
|
|
722
|
+
diagnostics: [
|
|
723
|
+
...plan.diagnostics,
|
|
724
|
+
...sidecarWriteResult.diagnostics,
|
|
725
|
+
],
|
|
726
|
+
next_steps: [
|
|
727
|
+
"Use list_chapters to confirm the canonical title.",
|
|
728
|
+
"Run diagnose_structure if folder-derived structure may still use the previous chapter title.",
|
|
729
|
+
],
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// ---- reorder_chapter -----------------------------------------------------
|
|
735
|
+
s.tool(
|
|
736
|
+
"reorder_chapter",
|
|
737
|
+
"Reorder a canonical chapter through the explicit structure workflow. Updates canonical chapter order and explicit scene chapter/chapter_title compatibility fields; it does not rename, move, or resequence scene files, sidecars by path-derived structure, or Scrivener-compatible folders.",
|
|
738
|
+
{
|
|
739
|
+
project_id: z.string().describe("Project the chapter belongs to (e.g. 'the-lamb')."),
|
|
740
|
+
chapter_id: z.string().describe("Canonical chapter identifier. Use list_chapters to find valid values."),
|
|
741
|
+
sort_index: z.number().int().min(1).describe("New canonical chapter order within the project. Must be unused."),
|
|
742
|
+
},
|
|
743
|
+
async ({ project_id, chapter_id, sort_index }) => {
|
|
744
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
745
|
+
return errorResponse("READ_ONLY", "Cannot reorder chapter: sync dir is read-only.");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
749
|
+
if (!projectIdCheck.ok) {
|
|
750
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const plan = buildReorderChapterPlan(db, {
|
|
754
|
+
projectId: project_id,
|
|
755
|
+
chapterId: chapter_id,
|
|
756
|
+
sortIndex: sort_index,
|
|
757
|
+
});
|
|
758
|
+
if (!plan.ok) {
|
|
759
|
+
return errorResponse(plan.error.code, plan.error.message, {
|
|
760
|
+
project_id,
|
|
761
|
+
chapter_id,
|
|
762
|
+
sort_index,
|
|
763
|
+
...(plan.error.details ?? {}),
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const linkedScenes = db.prepare(`
|
|
768
|
+
SELECT scene_id, project_id, file_path
|
|
769
|
+
FROM scenes
|
|
770
|
+
WHERE project_id = ? AND chapter_id = ?
|
|
771
|
+
ORDER BY scene_id
|
|
772
|
+
`).all(project_id, chapter_id);
|
|
773
|
+
|
|
774
|
+
const sidecarUpdates = [];
|
|
775
|
+
try {
|
|
776
|
+
for (const scene of linkedScenes) {
|
|
777
|
+
const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
778
|
+
if (meta.chapter_id === chapter_id) {
|
|
779
|
+
sidecarUpdates.push({
|
|
780
|
+
scene,
|
|
781
|
+
filePath: scene.file_path,
|
|
782
|
+
meta: {
|
|
783
|
+
...meta,
|
|
784
|
+
chapter: plan.chapter.sort_index,
|
|
785
|
+
chapter_title: plan.chapter.title,
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
db.exec("BEGIN");
|
|
792
|
+
reorderCanonicalChapter(db, plan.chapter);
|
|
793
|
+
db.exec("COMMIT");
|
|
794
|
+
} catch (err) {
|
|
795
|
+
try {
|
|
796
|
+
db.exec("ROLLBACK");
|
|
797
|
+
} catch (rollbackErr) {
|
|
798
|
+
void rollbackErr;
|
|
799
|
+
}
|
|
800
|
+
if (err.code === "ENOENT") {
|
|
801
|
+
return errorResponse("STALE_PATH", `Cannot reorder chapter '${chapter_id}': an indexed scene file is missing. Run sync() to refresh.`, {
|
|
802
|
+
project_id,
|
|
803
|
+
chapter_id,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
return errorResponse("IO_ERROR", `Failed to reorder chapter '${chapter_id}': ${err.message}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const sidecarWriteResult = writeStructureSidecarUpdates(sidecarUpdates, {
|
|
810
|
+
failureCode: "SCENE_SIDECAR_UPDATE_FAILED",
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
return jsonResponse({
|
|
814
|
+
ok: true,
|
|
815
|
+
action: "reordered",
|
|
816
|
+
chapter: {
|
|
817
|
+
chapter_id: plan.chapter.chapter_id,
|
|
818
|
+
project_id: plan.chapter.project_id,
|
|
819
|
+
title: plan.chapter.title,
|
|
820
|
+
sort_index: plan.chapter.sort_index,
|
|
821
|
+
logline: plan.chapter.logline,
|
|
822
|
+
metadata_stale: plan.chapter.metadata_stale,
|
|
823
|
+
},
|
|
824
|
+
previous_sort_index: plan.previousChapter.sort_index,
|
|
825
|
+
updated_scene_count: linkedScenes.length,
|
|
826
|
+
updated_sidecar_count: sidecarWriteResult.updatedCount,
|
|
827
|
+
diagnostics: [
|
|
828
|
+
...plan.diagnostics,
|
|
829
|
+
...sidecarWriteResult.diagnostics,
|
|
830
|
+
],
|
|
831
|
+
next_steps: [
|
|
832
|
+
"Use list_chapters to confirm canonical order.",
|
|
833
|
+
"Run diagnose_structure if folder-derived structure may still use the previous order.",
|
|
834
|
+
],
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
// ---- attach_epigraph -----------------------------------------------------
|
|
840
|
+
s.tool(
|
|
841
|
+
"attach_epigraph",
|
|
842
|
+
"Attach an existing canonical epigraph to a canonical chapter through the explicit structure workflow. Updates canonical epigraph linkage and explicit epigraph sidecar fields; it does not move, rename, or create epigraph source files or Scrivener-compatible folders.",
|
|
843
|
+
{
|
|
844
|
+
project_id: z.string().describe("Project the epigraph belongs to (e.g. 'the-lamb')."),
|
|
845
|
+
epigraph_id: z.string().describe("Canonical epigraph identifier. Use find_epigraphs to find valid values."),
|
|
846
|
+
chapter_id: z.string().describe("Canonical chapter identifier. Use list_chapters to find valid values."),
|
|
847
|
+
},
|
|
848
|
+
async ({ project_id, epigraph_id, chapter_id }) => {
|
|
849
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
850
|
+
return errorResponse("READ_ONLY", "Cannot attach epigraph: sync dir is read-only.");
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
854
|
+
if (!projectIdCheck.ok) {
|
|
855
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const plan = buildAttachEpigraphPlan(db, {
|
|
859
|
+
projectId: project_id,
|
|
860
|
+
epigraphId: epigraph_id,
|
|
861
|
+
chapterId: chapter_id,
|
|
862
|
+
});
|
|
863
|
+
if (!plan.ok) {
|
|
864
|
+
return errorResponse(plan.error.code, plan.error.message, {
|
|
865
|
+
project_id,
|
|
866
|
+
epigraph_id,
|
|
867
|
+
chapter_id,
|
|
868
|
+
...(plan.error.details ?? {}),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
try {
|
|
873
|
+
const { meta } = readMeta(plan.epigraph.file_path, SYNC_DIR, { writable: true });
|
|
874
|
+
const sidecarUpdate = {
|
|
875
|
+
filePath: plan.epigraph.file_path,
|
|
876
|
+
meta: {
|
|
877
|
+
...meta,
|
|
878
|
+
kind: meta.kind ?? "epigraph",
|
|
879
|
+
epigraph_id: plan.epigraph.epigraph_id,
|
|
880
|
+
chapter_id: plan.chapter.chapter_id,
|
|
881
|
+
chapter: plan.chapter.sort_index,
|
|
882
|
+
chapter_title: plan.chapter.title,
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
db.exec("BEGIN");
|
|
887
|
+
attachCanonicalEpigraph(db, plan.epigraph);
|
|
888
|
+
db.exec("COMMIT");
|
|
889
|
+
|
|
890
|
+
const sidecarWriteResult = writeStructureSidecarUpdates([sidecarUpdate], {
|
|
891
|
+
failureCode: "EPIGRAPH_SIDECAR_UPDATE_FAILED",
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
return jsonResponse({
|
|
895
|
+
ok: true,
|
|
896
|
+
action: "attached",
|
|
897
|
+
epigraph: {
|
|
898
|
+
epigraph_id: plan.epigraph.epigraph_id,
|
|
899
|
+
project_id: plan.epigraph.project_id,
|
|
900
|
+
chapter_id: plan.epigraph.chapter_id,
|
|
901
|
+
metadata_stale: plan.epigraph.metadata_stale,
|
|
902
|
+
},
|
|
903
|
+
chapter: {
|
|
904
|
+
chapter_id: plan.chapter.chapter_id,
|
|
905
|
+
title: plan.chapter.title,
|
|
906
|
+
sort_index: plan.chapter.sort_index,
|
|
907
|
+
},
|
|
908
|
+
previous_chapter: plan.previousChapter
|
|
909
|
+
? {
|
|
910
|
+
chapter_id: plan.previousChapter.chapter_id,
|
|
911
|
+
title: plan.previousChapter.title,
|
|
912
|
+
sort_index: plan.previousChapter.sort_index,
|
|
913
|
+
}
|
|
914
|
+
: null,
|
|
915
|
+
updated_sidecar_count: sidecarWriteResult.updatedCount,
|
|
916
|
+
diagnostics: [
|
|
917
|
+
...plan.diagnostics,
|
|
918
|
+
...sidecarWriteResult.diagnostics,
|
|
919
|
+
],
|
|
920
|
+
next_steps: [
|
|
921
|
+
"Use find_epigraphs to confirm the canonical epigraph attachment.",
|
|
922
|
+
"Run diagnose_structure if folder-derived structure may still imply the previous chapter.",
|
|
923
|
+
],
|
|
924
|
+
});
|
|
925
|
+
} catch (err) {
|
|
926
|
+
try {
|
|
927
|
+
db.exec("ROLLBACK");
|
|
928
|
+
} catch (rollbackErr) {
|
|
929
|
+
void rollbackErr;
|
|
930
|
+
}
|
|
931
|
+
if (err.code === "ENOENT") {
|
|
932
|
+
return errorResponse("STALE_PATH", `Cannot attach epigraph '${epigraph_id}': the indexed epigraph file is missing. Run sync() to refresh.`, {
|
|
933
|
+
project_id,
|
|
934
|
+
epigraph_id,
|
|
935
|
+
chapter_id,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return errorResponse("IO_ERROR", `Failed to attach epigraph '${epigraph_id}': ${err.message}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
// ---- move_scene ----------------------------------------------------------
|
|
944
|
+
s.tool(
|
|
945
|
+
"move_scene",
|
|
946
|
+
"Move a scene through the explicit structure workflow. Updates canonical chapter linkage and/or timeline_position in the scene sidecar and index; it does not move, rename, or resequence scene files or Scrivener-compatible folders.",
|
|
947
|
+
{
|
|
948
|
+
scene_id: z.string().describe("The scene_id to move (e.g. 'sc-011-sebastian')."),
|
|
949
|
+
project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
|
|
950
|
+
chapter_id: z.string().optional().describe("Optional canonical chapter identifier. Use list_chapters to find valid values. Omit to keep the current chapter."),
|
|
951
|
+
timeline_position: z.number().int().min(1).optional().describe("Optional new position within the target chapter. Must be unused."),
|
|
952
|
+
},
|
|
953
|
+
async ({ scene_id, project_id, chapter_id, timeline_position }) => {
|
|
954
|
+
if (!SYNC_DIR_WRITABLE) {
|
|
955
|
+
return errorResponse("READ_ONLY", "Cannot move scene: sync dir is read-only.");
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const projectIdCheck = validateProjectId(project_id);
|
|
959
|
+
if (!projectIdCheck.ok) {
|
|
960
|
+
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (chapter_id === undefined && timeline_position === undefined) {
|
|
964
|
+
return errorResponse("VALIDATION_ERROR", "Provide chapter_id and/or timeline_position for move_scene.", {
|
|
965
|
+
project_id,
|
|
966
|
+
scene_id,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const scene = db.prepare(`
|
|
971
|
+
SELECT scene_id, project_id, chapter_id, chapter, chapter_title, timeline_position, file_path
|
|
972
|
+
FROM scenes
|
|
973
|
+
WHERE scene_id = ? AND project_id = ?
|
|
974
|
+
`).get(scene_id, project_id);
|
|
975
|
+
if (!scene) {
|
|
976
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
let chapter = undefined;
|
|
980
|
+
if (chapter_id !== undefined) {
|
|
981
|
+
const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
|
|
982
|
+
projectId: project_id,
|
|
983
|
+
chapterId: chapter_id,
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
if (resolvedChapterFilter.error) {
|
|
987
|
+
return errorResponse(
|
|
988
|
+
resolvedChapterFilter.error.code,
|
|
989
|
+
resolvedChapterFilter.error.message,
|
|
990
|
+
{ project_id, chapter_id }
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
chapter = resolvedChapterFilter.chapter;
|
|
995
|
+
if (!chapter) {
|
|
996
|
+
return errorResponse("NOT_FOUND", "Chapter not found for the provided project and identifier.", {
|
|
997
|
+
project_id,
|
|
998
|
+
chapter_id,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
1005
|
+
const plan = buildMoveScenePlan(SYNC_DIR, scene.file_path, meta, {
|
|
1006
|
+
currentScene: scene,
|
|
1007
|
+
chapter,
|
|
1008
|
+
timelinePosition: timeline_position,
|
|
1009
|
+
});
|
|
1010
|
+
if (!plan.ok) {
|
|
1011
|
+
return errorResponse(plan.error.code, plan.error.message, {
|
|
1012
|
+
project_id,
|
|
1013
|
+
scene_id,
|
|
1014
|
+
chapter_id: chapter_id ?? null,
|
|
1015
|
+
timeline_position: timeline_position ?? null,
|
|
1016
|
+
...(plan.error.details ?? {}),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const targetChapterId = plan.meta.chapter_id ?? null;
|
|
1021
|
+
const effectiveTimelinePosition = plan.timelinePosition;
|
|
1022
|
+
const targetChapterChanged = chapter_id !== undefined
|
|
1023
|
+
&& (plan.previousChapterId ?? null) !== targetChapterId;
|
|
1024
|
+
if (effectiveTimelinePosition != null && (timeline_position !== undefined || targetChapterChanged)) {
|
|
1025
|
+
const positionConflict = targetChapterId === null
|
|
1026
|
+
? db.prepare(`
|
|
1027
|
+
SELECT scene_id
|
|
1028
|
+
FROM scenes
|
|
1029
|
+
WHERE project_id = ? AND chapter_id IS NULL AND timeline_position = ? AND scene_id != ?
|
|
1030
|
+
ORDER BY scene_id
|
|
1031
|
+
LIMIT 1
|
|
1032
|
+
`).get(project_id, effectiveTimelinePosition, scene_id)
|
|
1033
|
+
: db.prepare(`
|
|
1034
|
+
SELECT scene_id
|
|
1035
|
+
FROM scenes
|
|
1036
|
+
WHERE project_id = ? AND chapter_id = ? AND timeline_position = ? AND scene_id != ?
|
|
1037
|
+
ORDER BY scene_id
|
|
1038
|
+
LIMIT 1
|
|
1039
|
+
`).get(project_id, targetChapterId, effectiveTimelinePosition, scene_id);
|
|
1040
|
+
|
|
1041
|
+
if (positionConflict) {
|
|
1042
|
+
return errorResponse("VALIDATION_ERROR", `timeline_position ${effectiveTimelinePosition} is already used in the target chapter.`, {
|
|
1043
|
+
project_id,
|
|
1044
|
+
scene_id,
|
|
1045
|
+
chapter_id: targetChapterId,
|
|
1046
|
+
timeline_position: effectiveTimelinePosition,
|
|
1047
|
+
existing_scene_id: positionConflict.scene_id,
|
|
1048
|
+
next_step: "Choose an unused timeline_position. Automatic resequencing is not part of this command yet.",
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
writeMeta(scene.file_path, plan.meta);
|
|
1054
|
+
|
|
1055
|
+
const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
|
|
1056
|
+
indexSceneFile(db, SYNC_DIR, scene.file_path, plan.meta, prose);
|
|
1057
|
+
|
|
1058
|
+
return jsonResponse({
|
|
1059
|
+
ok: true,
|
|
1060
|
+
action: "moved",
|
|
1061
|
+
scene_id,
|
|
1062
|
+
project_id,
|
|
1063
|
+
previous_chapter_id: plan.previousChapterId,
|
|
1064
|
+
previous_timeline_position: plan.previousTimelinePosition,
|
|
1065
|
+
chapter: plan.assignedChapter,
|
|
1066
|
+
timeline_position: plan.timelinePosition,
|
|
1067
|
+
diagnostics: [
|
|
1068
|
+
{
|
|
1069
|
+
code: "REPRESENTATION_NOT_MOVED",
|
|
1070
|
+
severity: "warning",
|
|
1071
|
+
message: "Moved canonical scene structure fields only; the existing scene source file was not moved or renamed.",
|
|
1072
|
+
next_step: "Run diagnose_structure if folder-derived structure may still imply the previous placement.",
|
|
1073
|
+
details: {
|
|
1074
|
+
file_path: scene.file_path,
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
1077
|
+
],
|
|
1078
|
+
next_steps: [
|
|
1079
|
+
"Use find_scenes to confirm the scene's canonical chapter and timeline_position.",
|
|
1080
|
+
"Run diagnose_structure if folder-derived structure may still imply the previous placement.",
|
|
1081
|
+
],
|
|
1082
|
+
});
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
if (err.code === "ENOENT") {
|
|
1085
|
+
return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path. Run sync() to refresh.`, {
|
|
1086
|
+
indexed_path: scene.file_path,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
return errorResponse("IO_ERROR", `Failed to move scene '${scene_id}': ${err.message}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
);
|
|
1093
|
+
|
|
514
1094
|
// ---- assign_scene_to_chapter --------------------------------------------
|
|
515
1095
|
s.tool(
|
|
516
1096
|
"assign_scene_to_chapter",
|
|
@@ -77,11 +77,16 @@ export const WORKFLOW_CATALOGUE = [
|
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
id: "structure_assignment",
|
|
80
|
-
label: "
|
|
81
|
-
use_when: "Use when the user wants to
|
|
80
|
+
label: "Manage chapter structure",
|
|
81
|
+
use_when: "Use when the user wants to create, rename, or reorder a canonical chapter, attach an epigraph to a chapter, move a scene into a canonical chapter or position, repair an explicit scene chapter link, or clear a scene's explicit chapter assignment.",
|
|
82
82
|
steps: [
|
|
83
83
|
{ tool: "find_scenes", note: "Identify the target scene and confirm project_id if the user did not provide both." },
|
|
84
|
-
{ tool: "list_chapters", note: "
|
|
84
|
+
{ tool: "list_chapters", note: "Inspect existing canonical chapters before creating or assigning chapter structure." },
|
|
85
|
+
{ tool: "create_chapter", note: "Use when the intended canonical chapter does not exist yet; this creates chapter state only and does not generate folders or scene files." },
|
|
86
|
+
{ tool: "rename_chapter", note: "Use when the canonical chapter title should change; this does not rename folders or generated representation paths." },
|
|
87
|
+
{ tool: "reorder_chapter", note: "Use when the canonical chapter order should change to an unused sort_index; this does not rename folders or generated representation paths." },
|
|
88
|
+
{ tool: "attach_epigraph", note: "Use when an existing canonical epigraph should belong to a different canonical chapter; this does not move the epigraph source file." },
|
|
89
|
+
{ tool: "move_scene", note: "Use when a scene should move to another canonical chapter and/or unused timeline_position; this does not move the scene source file." },
|
|
85
90
|
{ tool: "assign_scene_to_chapter", note: "Use this named structure workflow for chapter assignment or clearing instead of editing chapter fields through generic metadata updates." },
|
|
86
91
|
{ tool: "diagnose_structure", note: "Run when the assignment is part of a drift repair workflow or when folder-derived structure may disagree with the requested link." },
|
|
87
92
|
],
|