Add safe Word manuscript cleanup

This commit is contained in:
Nick Beckley 2026-08-17 15:28:09 +00:00
parent 884a96d166
commit 451e384966
6 changed files with 491 additions and 32 deletions

View File

@ -80,7 +80,12 @@ var tests = new (string Name, Action Test)[]
("Writing Intent route uses canonical story context and planning data", WritingIntentRouteUsesCanonicalStoryContextAndPlanningData),
("Writing Intent client uses shared writing follow helper", WritingIntentClientUsesSharedWritingFollowHelper),
("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation),
("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure)
("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure),
("Word manuscript cleanup classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly),
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
("Word manuscript unlink keeps Story Intelligence data untouched", WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched),
("Word manuscript cleanup remains relinkable and idempotent", WordManuscriptCleanupRemainsRelinkableAndIdempotent)
};
foreach (var test in tests)
@ -264,6 +269,94 @@ static void WritingPairSharesCanonicalFollowInfrastructure()
Assert(helper.Contains("document.addEventListener(window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Both pages should react to the same canonical browser event.");
}
static void WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var classifierStart = script.IndexOf("const classifyPlotDirectorContentControl", StringComparison.Ordinal);
var classifierEnd = script.IndexOf("window.PlotDirectorWordMarkers", classifierStart, StringComparison.Ordinal);
Assert(classifierStart >= 0 && classifierEnd > classifierStart, "Word host should expose a stable PlotDirector marker classifier.");
var classifier = script[classifierStart..classifierEnd];
Assert(classifier.Contains("const tag = String(control?.tag || \"\").trim().toUpperCase()", StringComparison.Ordinal), "Marker cleanup should classify content controls from the tag field.");
Assert(script.Contains("chapter: \"PD-CHAPTER-\"", StringComparison.Ordinal), "Chapter marker prefix is missing.");
Assert(script.Contains("scene: \"PD-SCENE-\"", StringComparison.Ordinal), "Scene marker prefix is missing.");
Assert(classifier.Contains("tag.startsWith(plotDirectorMarkerPrefixes.chapter)", StringComparison.Ordinal), "Classifier should use the chapter tag prefix.");
Assert(classifier.Contains("tag.startsWith(plotDirectorMarkerPrefixes.scene)", StringComparison.Ordinal), "Classifier should use the scene tag prefix.");
Assert(!classifier.Contains("title", StringComparison.OrdinalIgnoreCase), "Marker cleanup must not delete arbitrary controls by display title.");
}
static void WordManuscriptCleanupPreservesContentControlContent()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
Assert(script.Contains("const removePlotDirectorContentControlsPreservingContent", StringComparison.Ordinal), "Clean unlink should have a dedicated marker removal path.");
Assert(script.Contains("context.document.contentControls", StringComparison.Ordinal), "Clean unlink should inspect document content controls.");
Assert(script.Contains("control.delete(true);", StringComparison.Ordinal), "PlotDirector marker cleanup must call Word.ContentControl.delete(true) to preserve visible content.");
Assert(!script.Contains("control.delete(false)", StringComparison.Ordinal), "PlotDirector marker cleanup must not delete marker content.");
Assert(!script.Contains("control.delete();", StringComparison.Ordinal), "PlotDirector marker cleanup must pass keepContent explicitly.");
Assert(script.Contains("await inspectPlotDirectorMarkers();", StringComparison.Ordinal), "Clean unlink should verify marker counts after removal.");
Assert(script.Contains("latestMarkerState.totalCount > 0", StringComparison.Ordinal), "Clean unlink should stop when PlotDirector markers remain.");
Assert(script.Contains("cleanup verification failed", StringComparison.Ordinal), "Clean unlink should fail closed when marker verification cannot complete.");
}
static void WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
Assert(view.Contains("data-marker-binding-status", StringComparison.Ordinal), "Maintenance UI should report document binding status.");
Assert(view.Contains("data-marker-chapter-count", StringComparison.Ordinal), "Maintenance UI should report chapter marker counts.");
Assert(view.Contains("data-marker-scene-count", StringComparison.Ordinal), "Maintenance UI should report scene marker counts.");
Assert(view.Contains("Unlink from PlotDirector", StringComparison.Ordinal), "Existing unlink behavior should remain visible as a separate action.");
Assert(view.Contains("Hidden PlotDirector anchors will be left in place", StringComparison.Ordinal), "Existing unlink dialog should state markers are retained.");
Assert(view.Contains("Clean and unlink document", StringComparison.Ordinal), "Full cleanup should be a separate explicit action.");
Assert(view.Contains("data-clean-unlink-word-document", StringComparison.Ordinal), "Clean unlink button is missing.");
Assert(view.Contains("data-clean-unlink-word-document-dialog", StringComparison.Ordinal), "Clean unlink confirmation dialog is missing.");
Assert(view.Contains("Visible manuscript text, headings, formatting, comments, and tracked changes should remain", StringComparison.Ordinal), "Clean unlink dialog should describe content preservation.");
Assert(script.Contains("requestUnlinkWordDocumentConfirmation", StringComparison.Ordinal), "Existing unlink confirmation path should remain.");
Assert(script.Contains("requestCleanUnlinkWordDocumentConfirmation", StringComparison.Ordinal), "Clean unlink should use its own confirmation path.");
}
static void WordManuscriptUnlinkKeepsStoryIntelligenceDataUntouched()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var unlinkSql = File.ReadAllText(Path.Combine(root, "Sql/108_Phase16Q_ManuscriptUnlink.sql"));
var sceneResultSql = File.ReadAllText(Path.Combine(root, "Sql/115_Phase20I_StoryIntelligenceResultPersistence.sql"));
var importCommitSql = File.ReadAllText(Path.Combine(root, "Sql/121_Phase20V_StoryIntelligenceImportCommit.sql"));
Assert(unlinkSql.Contains("CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_UnlinkBook", StringComparison.Ordinal), "Server unlink procedure is missing.");
Assert(unlinkSql.Contains("IsLinkedToManuscript = 0", StringComparison.Ordinal), "Server unlink should clear manuscript link flags.");
Assert(unlinkSql.Contains("ManuscriptDocumentID = NULL", StringComparison.Ordinal), "Server unlink should clear manuscript document references.");
Assert(unlinkSql.Contains("IsActive = 0", StringComparison.Ordinal), "Server unlink should deactivate only the manuscript document binding row.");
Assert(!unlinkSql.Contains("StoryIntelligence", StringComparison.Ordinal), "Server unlink must not modify Story Intelligence tables.");
Assert(!unlinkSql.Contains("DELETE FROM dbo.Chapters", StringComparison.OrdinalIgnoreCase), "Server unlink must not delete chapters.");
Assert(!unlinkSql.Contains("DELETE FROM dbo.Scenes", StringComparison.OrdinalIgnoreCase), "Server unlink must not delete scenes.");
Assert(sceneResultSql.Contains("StartParagraph", StringComparison.Ordinal) && sceneResultSql.Contains("EndParagraph", StringComparison.Ordinal), "Story Intelligence scene result positions should remain database-backed.");
Assert(importCommitSql.Contains("SourceStartParagraph", StringComparison.Ordinal) && importCommitSql.Contains("SourceEndParagraph", StringComparison.Ordinal), "Story Intelligence source positions should remain database-backed.");
}
static void WordManuscriptCleanupRemainsRelinkableAndIdempotent()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var saveSql = File.ReadAllText(Path.Combine(root, "Sql/094_Phase16B_ManuscriptBindingLinkedStructures.sql"));
var unlinkSql = File.ReadAllText(Path.Combine(root, "Sql/108_Phase16Q_ManuscriptUnlink.sql"));
Assert(script.Contains("const binding = currentDocumentBinding();", StringComparison.Ordinal), "Clean unlink should tolerate missing local binding metadata.");
Assert(script.Contains("if (binding?.documentGuid && Number.isInteger(binding.bookId) && binding.bookId > 0)", StringComparison.Ordinal), "Server unlink should be skipped safely when no binding exists.");
Assert(script.Contains("await removeDocumentBinding();", StringComparison.Ordinal), "Clean unlink should remove local binding settings after marker verification.");
Assert(service.Contains("documentGuid = Guid.NewGuid();", StringComparison.Ordinal), "Relinking should mint a new document GUID when needed.");
Assert(service.Contains("manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId())", StringComparison.Ordinal), "Relinking should still flow through the manuscript document repository.");
Assert(repository.Contains("\"dbo.ManuscriptDocument_Save\"", StringComparison.Ordinal), "Relinking should still use the manuscript document save procedure.");
Assert(saveSql.Contains("ManuscriptDocument_Save", StringComparison.Ordinal) && saveSql.Contains("IsActive", StringComparison.Ordinal), "Server save procedure should support a new active document binding.");
Assert(unlinkSql.Contains("@DocumentGuid IS NULL", StringComparison.Ordinal) && unlinkSql.Contains("00000000-0000-0000-0000-000000000000", StringComparison.Ordinal), "Server unlink should tolerate repeated or metadata-free unlink requests.");
}
static void StripsMarkdownFence()
{
var result = StoryIntelligenceJsonRepair.Repair("""

View File

@ -270,10 +270,28 @@
<span>Manuscript Maintenance</span>
</div>
<p class="word-companion-message">Use these tools when manuscript headings, scene boundaries, or document links need attention.</p>
<div class="word-companion-marker-status" data-marker-status>
<div><span>Document binding</span><strong data-marker-binding-status>Unknown</strong></div>
<div><span>Chapter markers</span><strong data-marker-chapter-count>-</strong></div>
<div><span>Scene markers</span><strong data-marker-scene-count>-</strong></div>
</div>
<div class="word-companion-maintenance-group">
<div>
<strong>Unlink from PlotDirector</strong>
<p>Disconnect this document while leaving hidden PlotDirector anchors in place for possible reuse.</p>
</div>
<button type="button" class="word-companion-danger-action" data-unlink-word-document hidden>Unlink from PlotDirector</button>
</div>
<div class="word-companion-maintenance-group word-companion-maintenance-group-strong">
<div>
<strong>Clean and unlink document</strong>
<p>Remove PlotDirector hidden anchors and binding metadata from this Word document while preserving manuscript text.</p>
</div>
<button type="button" class="word-companion-danger-action" data-clean-unlink-word-document>Clean and unlink</button>
</div>
<div class="word-companion-actions word-companion-maintenance-actions">
<button type="button" data-attach-plotdirector-ids disabled>Attach PlotDirector IDs</button>
<button type="button" data-refresh-document>Refresh Local Cache</button>
<button type="button" class="word-companion-danger-action" data-unlink-word-document hidden>Unlink this Word document</button>
</div>
</section>
<section class="word-companion-section word-companion-structure-preview" aria-label="Structure Sync Preview">
@ -539,15 +557,38 @@
<div class="word-companion-section-header">
<span>Unlink Word Document?</span>
</div>
<p class="word-companion-dialog-copy">This will remove PlotDirector binding metadata from this Word document.</p>
<p class="word-companion-dialog-copy">If PlotDirector is reachable, it will also unlink this Book in PlotDirector.</p>
<p class="word-companion-dialog-copy">This will remove PlotDirector binding metadata from this Word document and unlink this Book in PlotDirector.</p>
<p class="word-companion-dialog-copy">Hidden PlotDirector anchors will be left in place for possible reuse.</p>
<p class="word-companion-dialog-copy">Your manuscript text will not be changed.</p>
<div class="word-companion-marker-status word-companion-dialog-marker-status">
<div><span>Chapter markers kept</span><strong data-unlink-dialog-chapter-count>-</strong></div>
<div><span>Scene markers kept</span><strong data-unlink-dialog-scene-count>-</strong></div>
</div>
<div class="word-companion-dialog-actions">
<button type="button" data-unlink-word-document-confirm>Continue</button>
<button type="button" data-unlink-word-document-cancel>Cancel</button>
</div>
</form>
</dialog>
<dialog class="word-companion-dialog" data-clean-unlink-word-document-dialog>
<form method="dialog">
<div class="word-companion-section-header">
<span>Clean and Unlink?</span>
</div>
<p class="word-companion-dialog-copy">This will remove PlotDirector hidden chapter and scene anchors from this Word document.</p>
<p class="word-companion-dialog-copy">Visible manuscript text, headings, formatting, comments, and tracked changes should remain in the document.</p>
<p class="word-companion-dialog-copy">PlotDirector binding metadata will be removed and the Book will be unlinked in PlotDirector.</p>
<div class="word-companion-marker-status word-companion-dialog-marker-status">
<div><span>Chapter markers removed</span><strong data-clean-dialog-chapter-count>-</strong></div>
<div><span>Scene markers removed</span><strong data-clean-dialog-scene-count>-</strong></div>
</div>
<div class="word-companion-dialog-actions">
<button type="button" data-clean-unlink-word-document-confirm>Clean and unlink</button>
<button type="button" data-clean-unlink-word-document-cancel>Cancel</button>
</div>
</form>
</dialog>
}
</main>
@if (Model.IsAuthenticated)

View File

@ -601,6 +601,67 @@ body {
background: #fef2f2;
}
.word-companion-marker-status {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.word-companion-marker-status div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid rgba(111, 75, 45, 0.16);
border-radius: 6px;
padding: 6px 8px;
background: #fffdf8;
}
.word-companion-marker-status span {
color: var(--companion-muted);
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
}
.word-companion-dialog-marker-status {
margin: 2px 0;
}
.word-companion-maintenance-group {
display: grid;
gap: 8px;
border: 1px solid rgba(111, 75, 45, 0.16);
border-radius: 8px;
padding: 9px;
background: #fffdf8;
}
.word-companion-maintenance-group-strong {
border-color: #fecaca;
background: #fff8f6;
}
.word-companion-maintenance-group p {
margin: 3px 0 0;
color: var(--companion-muted);
font-size: 0.84rem;
}
.word-companion-maintenance-group button {
width: 100%;
min-height: 31px;
border: 1px solid var(--companion-line);
border-radius: 6px;
padding: 6px 8px;
background: #fffdf8;
color: var(--companion-accent-dark);
font: inherit;
font-size: 0.8rem;
font-weight: 800;
}
.word-companion-message:empty {
display: none;
}

File diff suppressed because one or more lines are too long

View File

@ -44,6 +44,9 @@
const unlinkWordDocumentDialog = document.querySelector("[data-unlink-word-document-dialog]");
const unlinkWordDocumentConfirm = document.querySelector("[data-unlink-word-document-confirm]");
const unlinkWordDocumentCancel = document.querySelector("[data-unlink-word-document-cancel]");
const cleanUnlinkWordDocumentDialog = document.querySelector("[data-clean-unlink-word-document-dialog]");
const cleanUnlinkWordDocumentConfirm = document.querySelector("[data-clean-unlink-word-document-confirm]");
const cleanUnlinkWordDocumentCancel = document.querySelector("[data-clean-unlink-word-document-cancel]");
const officeStatus = document.querySelector("[data-office-status]");
const connectionStatus = document.querySelector("[data-connection-status]");
const summaryConnection = document.querySelector("[data-summary-connection]");
@ -75,6 +78,14 @@
const anchorSceneId = document.querySelector("[data-anchor-scene-id]");
const attachPlotDirectorIdsButton = document.querySelector("[data-attach-plotdirector-ids]");
const unlinkWordDocumentButton = document.querySelector("[data-unlink-word-document]");
const cleanUnlinkWordDocumentButton = document.querySelector("[data-clean-unlink-word-document]");
const markerBindingStatus = document.querySelector("[data-marker-binding-status]");
const markerChapterCount = document.querySelector("[data-marker-chapter-count]");
const markerSceneCount = document.querySelector("[data-marker-scene-count]");
const unlinkDialogChapterCount = document.querySelector("[data-unlink-dialog-chapter-count]");
const unlinkDialogSceneCount = document.querySelector("[data-unlink-dialog-scene-count]");
const cleanDialogChapterCount = document.querySelector("[data-clean-dialog-chapter-count]");
const cleanDialogSceneCount = document.querySelector("[data-clean-dialog-scene-count]");
const plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]");
const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]");
const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]");
@ -253,6 +264,34 @@
locations: [],
primaryLocationId: null
};
let latestMarkerState = {
chapterCount: 0,
sceneCount: 0,
totalCount: 0,
available: false,
message: "Not inspected"
};
const plotDirectorMarkerPrefixes = {
chapter: "PD-CHAPTER-",
scene: "PD-SCENE-"
};
const classifyPlotDirectorContentControl = (control) => {
const tag = String(control?.tag || "").trim().toUpperCase();
if (tag.startsWith(plotDirectorMarkerPrefixes.chapter)) {
return "chapter";
}
if (tag.startsWith(plotDirectorMarkerPrefixes.scene)) {
return "scene";
}
return "";
};
window.PlotDirectorWordMarkers = {
classify: classifyPlotDirectorContentControl
};
const runtimeDebug = (message) => {
const now = Date.now();
@ -458,6 +497,112 @@
setHidden(unlinkWordDocumentButton, !firstRunDocumentBinding);
};
const updateMarkerStatusDisplay = (state = latestMarkerState) => {
const binding = currentDocumentBinding();
setText(markerBindingStatus, binding ? "Linked" : "Not linked");
setText(markerChapterCount, state.available ? String(state.chapterCount) : "-");
setText(markerSceneCount, state.available ? String(state.sceneCount) : "-");
setText(unlinkDialogChapterCount, state.available ? String(state.chapterCount) : "-");
setText(unlinkDialogSceneCount, state.available ? String(state.sceneCount) : "-");
setText(cleanDialogChapterCount, state.available ? String(state.chapterCount) : "-");
setText(cleanDialogSceneCount, state.available ? String(state.sceneCount) : "-");
if (cleanUnlinkWordDocumentButton) {
cleanUnlinkWordDocumentButton.disabled = !wordHostAvailable || !officeReady;
}
};
const inspectPlotDirectorMarkers = async () => {
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
latestMarkerState = {
chapterCount: 0,
sceneCount: 0,
totalCount: 0,
available: false,
message: "Word document access is unavailable."
};
updateMarkerStatusDisplay();
return latestMarkerState;
}
try {
latestMarkerState = await window.Word.run(async (context) => {
const controls = context.document.contentControls;
controls.load("items/tag,title");
await context.sync();
let chapterCount = 0;
let sceneCount = 0;
for (const control of controls.items || []) {
const markerType = classifyPlotDirectorContentControl(control);
if (markerType === "chapter") {
chapterCount += 1;
} else if (markerType === "scene") {
sceneCount += 1;
}
}
return {
chapterCount,
sceneCount,
totalCount: chapterCount + sceneCount,
available: true,
message: "Marker inspection complete."
};
});
updateMarkerStatusDisplay(latestMarkerState);
return latestMarkerState;
} catch (error) {
console.error("Unable to inspect PlotDirector markers.", error);
latestMarkerState = {
chapterCount: 0,
sceneCount: 0,
totalCount: 0,
available: false,
message: `Unable to inspect markers: ${errorText(error)}`
};
updateMarkerStatusDisplay();
throw error;
}
};
const removePlotDirectorContentControlsPreservingContent = async () => {
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
throw new Error("Word document access is unavailable.");
}
return await window.Word.run(async (context) => {
const controls = context.document.contentControls;
controls.load("items/tag,title");
await context.sync();
let chapterCount = 0;
let sceneCount = 0;
for (const control of controls.items || []) {
const markerType = classifyPlotDirectorContentControl(control);
if (!markerType) {
continue;
}
if (markerType === "chapter") {
chapterCount += 1;
} else if (markerType === "scene") {
sceneCount += 1;
}
control.delete(true);
}
await context.sync();
return {
chapterCount,
sceneCount,
totalCount: chapterCount + sceneCount,
available: true,
message: "PlotDirector markers removed."
};
});
};
const setSyncStatus = (message) => {
setText(syncStatus, message);
};
@ -1165,6 +1310,7 @@
setText(linkedProjectTitle, runtimeProjectTitle());
setText(linkedBookTitle, runtimeBookTitle());
}
updateMarkerStatusDisplay();
};
const selectedProject = () => {
@ -1557,6 +1703,18 @@
});
});
const saveWordDocumentIfAvailable = () => new Promise((resolve) => {
const document = window.Office?.context?.document;
if (!document || typeof document.saveAsync !== "function") {
resolve(false);
return;
}
document.saveAsync((result) => {
resolve(result.status === window.Office.AsyncResultStatus.Succeeded);
});
});
const ensureDocumentGuid = () => {
if (firstRunDocumentBinding?.documentGuid) {
return firstRunDocumentBinding.documentGuid;
@ -2202,6 +2360,7 @@
await context.sync();
});
await inspectPlotDirectorMarkers().catch(() => null);
};
const friendlyScanError = (error) => {
@ -2924,6 +3083,7 @@
refreshDocumentButton.disabled = false;
refreshDocumentButton.textContent = "Refresh Document Structure";
}
await inspectPlotDirectorMarkers().catch(() => null);
}
};
@ -3138,6 +3298,7 @@
await context.sync();
});
await inspectPlotDirectorMarkers().catch(() => null);
};
const resultSummaryText = (summary) =>
@ -3409,7 +3570,7 @@
const requestUnlinkWordDocumentConfirmation = () => new Promise((resolve) => {
if (!unlinkWordDocumentDialog || typeof unlinkWordDocumentDialog.showModal !== "function") {
resolve(window.confirm("This will remove PlotDirector binding metadata from this Word document.\n\nIf PlotDirector is reachable, it will also unlink this Book in PlotDirector.\n\nYour manuscript text will not be changed.\n\nContinue?"));
resolve(window.confirm(`This will remove PlotDirector binding metadata from this Word document and unlink this Book in PlotDirector.\n\nHidden PlotDirector anchors will be left in place for possible reuse.\n\nCurrent marker count:\n${latestMarkerState.chapterCount} chapter markers\n${latestMarkerState.sceneCount} scene markers\n\nYour manuscript text will not be changed.\n\nContinue?`));
return;
}
@ -3433,6 +3594,33 @@
unlinkWordDocumentDialog.showModal();
});
const requestCleanUnlinkWordDocumentConfirmation = () => new Promise((resolve) => {
const message = `This will remove PlotDirector hidden anchors from this Word document while preserving manuscript text.\n\nThis will remove:\n${latestMarkerState.chapterCount} PlotDirector chapter markers\n${latestMarkerState.sceneCount} PlotDirector scene markers\nthe document's PlotDirector binding\n\nThe document can be linked again later if desired.\n\nContinue?`;
if (!cleanUnlinkWordDocumentDialog || typeof cleanUnlinkWordDocumentDialog.showModal !== "function") {
resolve(window.confirm(message));
return;
}
const cleanup = (confirmed) => {
cleanUnlinkWordDocumentConfirm?.removeEventListener("click", confirmHandler);
cleanUnlinkWordDocumentCancel?.removeEventListener("click", cancelHandler);
cleanUnlinkWordDocumentDialog?.removeEventListener("cancel", cancelEventHandler);
cleanUnlinkWordDocumentDialog?.close();
resolve(confirmed);
};
const confirmHandler = () => cleanup(true);
const cancelHandler = () => cleanup(false);
const cancelEventHandler = (event) => {
event.preventDefault();
cleanup(false);
};
cleanUnlinkWordDocumentConfirm?.addEventListener("click", confirmHandler);
cleanUnlinkWordDocumentCancel?.addEventListener("click", cancelHandler);
cleanUnlinkWordDocumentDialog?.addEventListener("cancel", cancelEventHandler);
cleanUnlinkWordDocumentDialog.showModal();
});
const importFirstRunManuscript = async () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
@ -3740,6 +3928,12 @@
return;
}
try {
await inspectPlotDirectorMarkers();
} catch {
setDocumentMessage("Marker counts are unavailable. You can still unlink without removing hidden anchors.");
}
const confirmed = await requestUnlinkWordDocumentConfirmation();
if (!confirmed) {
setDocumentMessage("Unlink cancelled.");
@ -3758,6 +3952,7 @@
bookId: binding.bookId
});
await removeDocumentBinding();
await inspectPlotDirectorMarkers().catch(() => null);
await returnToUnboundOnboarding("Manuscript unlinked.");
} catch (error) {
console.error("Unable to unlink manuscript binding.", error);
@ -3766,6 +3961,7 @@
if (removeLocalOnly) {
try {
await removeDocumentBinding();
await inspectPlotDirectorMarkers().catch(() => null);
await returnToUnboundOnboarding("Word metadata removed. Unlink the Book manually in PlotDirector if it still shows as linked.");
} catch (metadataError) {
console.error("Unable to remove Word document metadata.", metadataError);
@ -3777,12 +3973,100 @@
} finally {
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = false;
unlinkWordDocumentButton.textContent = "Unlink this Word document";
unlinkWordDocumentButton.textContent = "Unlink from PlotDirector";
}
updateUnlinkWordDocumentButton();
}
};
const cleanAndUnlinkWordDocument = async () => {
console.debug("Word Companion clean and unlink clicked");
const binding = currentDocumentBinding();
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setDocumentMessage("Word document access is unavailable.");
return;
}
setDocumentMessage("Inspecting PlotDirector markers...");
try {
await inspectPlotDirectorMarkers();
} catch (error) {
setDocumentMessage(`Unable to inspect markers: ${errorText(error)}`);
return;
}
const confirmed = await requestCleanUnlinkWordDocumentConfirmation();
if (!confirmed) {
setDocumentMessage("Clean and unlink cancelled.");
return;
}
if (cleanUnlinkWordDocumentButton) {
cleanUnlinkWordDocumentButton.disabled = true;
cleanUnlinkWordDocumentButton.textContent = "Cleaning...";
}
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = true;
}
try {
setDocumentMessage("Removing PlotDirector hidden anchors...");
await removePlotDirectorContentControlsPreservingContent();
await saveWordDocumentIfAvailable();
try {
await inspectPlotDirectorMarkers();
} catch (verificationError) {
console.error("Unable to verify PlotDirector marker cleanup.", verificationError);
setDocumentMessage(`PlotDirector markers were removed, but cleanup verification failed: ${errorText(verificationError)}`);
return;
}
if (latestMarkerState.available && latestMarkerState.totalCount > 0) {
setDocumentMessage(`Cleanup stopped: ${latestMarkerState.totalCount} PlotDirector marker${latestMarkerState.totalCount === 1 ? "" : "s"} remain.`);
return;
}
setDocumentMessage("Removing PlotDirector binding metadata...");
try {
await removeDocumentBinding();
} catch (metadataError) {
console.error("Unable to remove Word document metadata after marker cleanup.", metadataError);
setDocumentMessage(`PlotDirector markers were removed, but Word binding metadata remains: ${errorText(metadataError)}`);
return;
}
let serverMessage = "";
if (binding?.documentGuid && Number.isInteger(binding.bookId) && binding.bookId > 0) {
try {
await postJson("/api/word-companion/manuscript/unlink", {
documentGuid: binding.documentGuid,
bookId: binding.bookId
});
} catch (serverError) {
console.error("Word document cleaned, but PlotDirector server unlink failed.", serverError);
serverMessage = " Word document is clean, but unlink the Book manually in PlotDirector if it still shows as linked.";
}
}
await inspectPlotDirectorMarkers().catch(() => null);
await returnToUnboundOnboarding(`Word document cleaned and unlinked.${serverMessage}`);
} catch (error) {
console.error("Unable to clean PlotDirector markers.", error);
setDocumentMessage(`Cleanup failed before unlinking: ${errorText(error)}`);
} finally {
if (cleanUnlinkWordDocumentButton) {
cleanUnlinkWordDocumentButton.disabled = false;
cleanUnlinkWordDocumentButton.textContent = "Clean and unlink";
}
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = false;
}
updateUnlinkWordDocumentButton();
updateMarkerStatusDisplay();
}
};
const patchJson = (url, body) => fetchJson(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@ -3953,6 +4237,7 @@
ensureAnchorControl(sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE");
await context.sync();
});
await inspectPlotDirectorMarkers().catch(() => null);
detectedChapterAnchorId = resolvedChapterId;
detectedSceneAnchorId = resolvedSceneId;
@ -4005,6 +4290,7 @@
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
await context.sync();
});
await inspectPlotDirectorMarkers().catch(() => null);
detectedChapterAnchorId = resolvedChapterId;
storedAnchorInvalid = false;
@ -5701,6 +5987,7 @@
saveSceneLinksButton?.addEventListener("click", saveSceneLinks);
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument);
cleanUnlinkWordDocumentButton?.addEventListener("click", cleanAndUnlinkWordDocument);
createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter);
linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog);
linkChapterSearch?.addEventListener("input", renderLinkChapterOptions);
@ -5760,6 +6047,7 @@
setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");
} else {
await inspectPlotDirectorMarkers().catch(() => null);
setupSelectionTracking();
}
})

File diff suppressed because one or more lines are too long