Phase 16Q - Manuscript Unlink and Recovery Tools

This commit is contained in:
Nick Beckley 2026-06-29 14:08:56 +01:00
parent db6f0992c1
commit 7356a1c6b1
14 changed files with 265 additions and 2 deletions

View File

@ -91,4 +91,15 @@ public sealed class BooksController(IBookService books) : Controller
TempData["ArchiveMessage"] = "Book archived. You can restore it from Archived Items.";
return RedirectToAction("Details", "Projects", new { id = projectId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UnlinkManuscript(int id)
{
var result = await books.UnlinkManuscriptAsync(id);
TempData["ManuscriptMessage"] = result is null
? "Unable to unlink manuscript. It may already be unlinked, or you may not have access."
: result.Message;
return RedirectToAction(nameof(Details), new { id });
}
}

View File

@ -198,6 +198,13 @@ public sealed class WordCompanionController(
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("manuscript/unlink")]
public async Task<IActionResult> UnlinkManuscript(WordCompanionManuscriptUnlinkRequest request)
{
var response = await wordCompanion.UnlinkManuscriptAsync(request);
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("manuscript/analyse")]
public async Task<IActionResult> AnalyseManuscript(WordCompanionManuscriptAnalyseRequest request)
{

View File

@ -65,6 +65,7 @@ public interface IManuscriptDocumentRepository
Task<ManuscriptDocumentModel?> GetByBookAsync(int bookId, int userId);
Task<ManuscriptDocumentModel?> GetByGuidAsync(Guid documentGuid, int userId);
Task<ManuscriptDocumentModel?> SaveAsync(int bookId, Guid documentGuid, int userId);
Task<ManuscriptDocumentUnlinkResult?> UnlinkBookAsync(int bookId, Guid? documentGuid, int userId);
Task UpdateLastSyncAsync(int manuscriptDocumentId, int userId);
Task UpdateLastOpenedAsync(int manuscriptDocumentId, int userId);
}
@ -2347,6 +2348,15 @@ public sealed class ManuscriptDocumentRepository(ISqlConnectionFactory connectio
}
}
public async Task<ManuscriptDocumentUnlinkResult?> UnlinkBookAsync(int bookId, Guid? documentGuid, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<ManuscriptDocumentUnlinkResult>(
"dbo.ManuscriptDocument_UnlinkBook",
new { BookID = bookId, DocumentGuid = documentGuid, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateLastSyncAsync(int manuscriptDocumentId, int userId)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -269,6 +269,15 @@ public sealed class ManuscriptDocumentModel
public int? CreatedByUserID { get; set; }
}
public sealed class ManuscriptDocumentUnlinkResult
{
public int BookID { get; set; }
public int ManuscriptDocumentID { get; set; }
public int ChaptersUnlinked { get; set; }
public int ScenesUnlinked { get; set; }
public string Message { get; set; } = string.Empty;
}
public sealed class CharacterAlias
{
public int CharacterAliasID { get; set; }

View File

@ -381,6 +381,21 @@ public sealed class WordCompanionManuscriptLinkResponse
public string Message { get; init; } = "Manuscript linked.";
}
public sealed class WordCompanionManuscriptUnlinkRequest
{
public int BookId { get; set; }
public Guid? DocumentGuid { get; set; }
}
public sealed class WordCompanionManuscriptUnlinkResponse
{
public int BookId { get; init; }
public int ManuscriptDocumentId { get; init; }
public int ChaptersUnlinked { get; init; }
public int ScenesUnlinked { get; init; }
public string Message { get; init; } = "Manuscript unlinked.";
}
public sealed class WordCompanionCreateBookRequest
{
public string? Title { get; set; }

View File

@ -101,6 +101,7 @@ public interface IBookService
Task<BookEditViewModel?> GetEditAsync(int bookId);
Task PopulateEditContextAsync(BookEditViewModel model);
Task<BookSaveResult> SaveAsync(BookEditViewModel model);
Task<ManuscriptDocumentUnlinkResult?> UnlinkManuscriptAsync(int bookId);
Task ArchiveAsync(int bookId);
}
@ -1272,6 +1273,13 @@ public sealed class BookService(
return new(bookId, true);
}
public Task<ManuscriptDocumentUnlinkResult?> UnlinkManuscriptAsync(int bookId)
{
return currentUser.UserId is int userId
? manuscriptDocuments.UnlinkBookAsync(bookId, null, userId)
: Task.FromResult<ManuscriptDocumentUnlinkResult?>(null);
}
private static IReadOnlyList<SelectListItem> BuildStatusOptions(string? selectedStatus)
=> BookStatuses.All
.Select(status => new SelectListItem(status, status, string.Equals(status, selectedStatus, StringComparison.OrdinalIgnoreCase)))

View File

@ -30,6 +30,7 @@ public interface IWordCompanionService
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
Task<WordCompanionManuscriptUnlinkResponse?> UnlinkManuscriptAsync(WordCompanionManuscriptUnlinkRequest request);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request);
Task<WordCompanionDiscoverCharactersResponse?> DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request);
@ -434,6 +435,26 @@ public sealed class WordCompanionService(
};
}
public async Task<WordCompanionManuscriptUnlinkResponse?> UnlinkManuscriptAsync(WordCompanionManuscriptUnlinkRequest request)
{
if (request.BookId <= 0)
{
return null;
}
var result = await manuscriptDocuments.UnlinkBookAsync(request.BookId, request.DocumentGuid, RequireUserId());
return result is null
? null
: new WordCompanionManuscriptUnlinkResponse
{
BookId = result.BookID,
ManuscriptDocumentId = result.ManuscriptDocumentID,
ChaptersUnlinked = result.ChaptersUnlinked,
ScenesUnlinked = result.ScenesUnlinked,
Message = result.Message
};
}
public Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request)
{
if (request.ProjectId <= 0

View File

@ -0,0 +1,68 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_UnlinkBook
@BookID int,
@DocumentGuid uniqueidentifier = NULL,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ManuscriptDocumentID int;
DECLARE @ChaptersUnlinked int = 0;
DECLARE @ScenesUnlinked int = 0;
SELECT @ManuscriptDocumentID = md.ManuscriptDocumentID
FROM dbo.ManuscriptDocuments md
INNER JOIN dbo.Books b ON b.BookID = md.BookID
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE md.BookID = @BookID
AND md.IsActive = 1
AND b.IsArchived = 0
AND p.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1
AND (@DocumentGuid IS NULL OR @DocumentGuid = '00000000-0000-0000-0000-000000000000' OR md.DocumentGuid = @DocumentGuid);
IF @ManuscriptDocumentID IS NULL
RETURN;
UPDATE dbo.Chapters
SET IsLinkedToManuscript = 0,
ManuscriptDocumentID = NULL,
ExternalReference = NULL,
LinkedUtc = NULL,
UpdatedDate = SYSUTCDATETIME()
WHERE BookID = @BookID
AND ManuscriptDocumentID = @ManuscriptDocumentID;
SET @ChaptersUnlinked = @@ROWCOUNT;
UPDATE s
SET IsLinkedToManuscript = 0,
ManuscriptDocumentID = NULL,
ExternalReference = NULL,
LinkedUtc = NULL,
UpdatedDate = SYSUTCDATETIME()
FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
WHERE c.BookID = @BookID
AND s.ManuscriptDocumentID = @ManuscriptDocumentID;
SET @ScenesUnlinked = @@ROWCOUNT;
UPDATE dbo.ManuscriptDocuments
SET IsActive = 0
WHERE ManuscriptDocumentID = @ManuscriptDocumentID;
SELECT @BookID AS BookID,
@ManuscriptDocumentID AS ManuscriptDocumentID,
@ChaptersUnlinked AS ChaptersUnlinked,
@ScenesUnlinked AS ScenesUnlinked,
N'Manuscript unlinked. Chapters, scenes, and story data were kept.' AS Message;
END;
GO

View File

@ -16,6 +16,10 @@
{
<div class="alert alert-info">@archiveMessage</div>
}
@if (TempData["ManuscriptMessage"] is string manuscriptMessage)
{
<div class="alert alert-info">@manuscriptMessage</div>
}
<div class="page-heading">
<div>
@ -101,6 +105,10 @@
{
<strong>Manuscript linked</strong>
<span class="muted">Last synced: @(Model.ManuscriptDocument.LastSyncUtc?.ToString("dd MMM yyyy HH:mm") ?? "Never")</span>
<form asp-action="UnlinkManuscript" method="post" class="mt-2" data-confirm-message="This will unlink the Word manuscript from this Book in PlotDirector.&#10;&#10;It will not delete chapters, scenes, characters, assets, locations, or manuscript text.&#10;&#10;If the Word document is still open, you should also unlink it from the Word Companion so the hidden document metadata is removed.&#10;&#10;Continue?">
<input type="hidden" name="id" value="@Model.Book.BookID" />
<button type="submit" class="btn btn-outline-danger btn-sm">Unlink Manuscript</button>
</form>
}
<span class="muted">Manuscript Status</span>
</div>

View File

@ -170,6 +170,7 @@
</div>
<button type="button" data-attach-plotdirector-ids disabled>Attach PlotDirector IDs</button>
<button type="button" class="word-companion-danger-action" data-unlink-word-document hidden>Unlink this Word document</button>
<p class="word-companion-message" data-sync-note></p>
</section>

View File

@ -558,6 +558,16 @@ body {
color: var(--companion-muted);
}
.word-companion-current-scene > .word-companion-danger-action {
border-color: #fecaca;
color: #b91c1c;
}
.word-companion-current-scene > .word-companion-danger-action:hover:not(:disabled) {
border-color: #ef4444;
background: #fef2f2;
}
.word-companion-message:empty {
display: none;
}

File diff suppressed because one or more lines are too long

View File

@ -67,6 +67,7 @@
const anchorChapterId = document.querySelector("[data-anchor-chapter-id]");
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 plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]");
const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]");
const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]");
@ -426,6 +427,10 @@
}
};
const updateUnlinkWordDocumentButton = () => {
setHidden(unlinkWordDocumentButton, !firstRunDocumentBinding);
};
const setSyncStatus = (message) => {
setText(syncStatus, message);
};
@ -1152,6 +1157,7 @@
const setFirstRunMode = (visible) => {
setHidden(firstRunWizard, !visible);
setHidden(tabsNav, visible);
updateUnlinkWordDocumentButton();
for (const panel of tabPanels) {
setHidden(panel, visible ? true : panel.dataset.tabPanel !== "scene" && !panel.classList.contains("is-active"));
}
@ -1357,6 +1363,30 @@
});
});
const removeDocumentBinding = () => new Promise((resolve, reject) => {
const settings = window.Office?.context?.document?.settings;
if (!settings) {
reject(new Error("Word document settings are unavailable."));
return;
}
for (const key of Object.values(documentMetadataKeys)) {
if (typeof settings.remove === "function") {
settings.remove(key);
} else {
settings.set(key, "");
}
}
settings.saveAsync((result) => {
if (result.status === window.Office.AsyncResultStatus.Succeeded) {
resolve();
} else {
reject(result.error || new Error("Unable to remove Word document metadata."));
}
});
});
const ensureDocumentGuid = () => {
if (firstRunDocumentBinding?.documentGuid) {
return firstRunDocumentBinding.documentGuid;
@ -3143,6 +3173,70 @@
setFirstRunMode(true);
};
const returnToUnboundOnboarding = async (message) => {
firstRunDocumentBinding = null;
clearRuntimeCaches();
resetPlotDirectorScene("Not detected");
setSceneTrackingState("Manual");
setConnectionStatus("Connected");
syncFirstRunProjectOptions();
if (selectedFirstRunProject()) {
await loadBooks(selectedFirstRunProject().projectId, null);
} else {
resetSelect(firstRunBookSelect, "Select a project first");
}
resetFirstRunPreview(message || "Select a Project and Book to begin.");
setFirstRunMode(true);
updateUnlinkWordDocumentButton();
};
const unlinkThisWordDocument = async () => {
const binding = firstRunDocumentBinding || readDocumentBinding();
if (!binding) {
setDocumentMessage("This Word document is not linked.");
return;
}
const confirmed = 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?");
if (!confirmed) {
return;
}
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = true;
unlinkWordDocumentButton.textContent = "Unlinking...";
}
try {
await postJson("/api/word-companion/manuscript/unlink", {
documentGuid: binding.documentGuid,
bookId: binding.bookId
});
await removeDocumentBinding();
await returnToUnboundOnboarding("Word document unlinked. Select a Project and Book to begin.");
} catch (error) {
console.error("Unable to unlink manuscript binding.", error);
const removeLocalOnly = window.confirm("PlotDirector could not be updated, but you can still remove the binding from this Word document.\n\nYou may need to unlink the Book manually in PlotDirector later.\n\nRemove Word metadata only?");
if (removeLocalOnly) {
try {
await removeDocumentBinding();
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);
setDocumentMessage(`Unable to remove Word metadata: ${errorText(metadataError)}`);
}
} else {
setDocumentMessage(`Unable to unlink manuscript: ${errorText(error)}`);
}
} finally {
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = false;
unlinkWordDocumentButton.textContent = "Unlink this Word document";
}
updateUnlinkWordDocumentButton();
}
};
const patchJson = (url, body) => fetchJson(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@ -5056,6 +5150,7 @@
syncSceneProgressButton?.addEventListener("click", () => syncSceneProgress({ source: "manual" }));
saveSceneLinksButton?.addEventListener("click", saveSceneLinks);
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument);
createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter);
linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog);
linkChapterSearch?.addEventListener("input", renderLinkChapterOptions);

File diff suppressed because one or more lines are too long