Add safe Word manuscript cleanup
This commit is contained in:
parent
884a96d166
commit
451e384966
@ -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("""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
2
PlotLine/wwwroot/css/word-companion.min.css
vendored
2
PlotLine/wwwroot/css/word-companion.min.css
vendored
File diff suppressed because one or more lines are too long
@ -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();
|
||||
}
|
||||
})
|
||||
|
||||
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user