Phase 16Q - Manuscript Unlink and Recovery Tools
This commit is contained in:
parent
db6f0992c1
commit
7356a1c6b1
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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
|
||||
|
||||
68
PlotLine/Sql/108_Phase16Q_ManuscriptUnlink.sql
Normal file
68
PlotLine/Sql/108_Phase16Q_ManuscriptUnlink.sql
Normal 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
|
||||
@ -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. It will not delete chapters, scenes, characters, assets, locations, or manuscript text. If the Word document is still open, you should also unlink it from the Word Companion so the hidden document metadata is removed. 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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
@ -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
Loading…
x
Reference in New Issue
Block a user