@hanna84/mcp-writing 3.11.0 → 3.13.0

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.
@@ -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
+ }