Phase 16J - PlotDirector Follow Word Companion Current Scene
This commit is contained in:
parent
429cd5beb5
commit
efa606f7c7
@ -1,5 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using PlotLine.Hubs;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
|
|
||||||
@ -8,7 +11,9 @@ namespace PlotLine.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/word-companion")]
|
[Route("api/word-companion")]
|
||||||
public sealed class WordCompanionController(IWordCompanionService wordCompanion) : ControllerBase
|
public sealed class WordCompanionController(
|
||||||
|
IWordCompanionService wordCompanion,
|
||||||
|
IHubContext<WordCompanionFollowHub> followHub) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("projects")]
|
[HttpGet("projects")]
|
||||||
public Task<WordCompanionProjectsResponse> GetProjects() => wordCompanion.ListProjectsAsync();
|
public Task<WordCompanionProjectsResponse> GetProjects() => wordCompanion.ListProjectsAsync();
|
||||||
@ -116,6 +121,27 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
|
|||||||
return response is null ? BadRequest() : Ok(response);
|
return response is null ? BadRequest() : Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("runtime/current-scene")]
|
||||||
|
public async Task<IActionResult> UpdateRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneRequest request)
|
||||||
|
{
|
||||||
|
var response = await wordCompanion.ValidateRuntimeCurrentSceneAsync(request);
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
return BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
await BroadcastToCurrentUserAsync("wordCompanionSceneChanged", new
|
||||||
|
{
|
||||||
|
response.ProjectId,
|
||||||
|
response.BookId,
|
||||||
|
response.ChapterId,
|
||||||
|
response.SceneId,
|
||||||
|
response.SceneUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("runtime/characters")]
|
[HttpGet("runtime/characters")]
|
||||||
public async Task<IActionResult> GetRuntimeCharacters([FromQuery] int projectId, [FromQuery] Guid documentGuid)
|
public async Task<IActionResult> GetRuntimeCharacters([FromQuery] int projectId, [FromQuery] Guid documentGuid)
|
||||||
{
|
{
|
||||||
@ -169,6 +195,29 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
|
|||||||
public async Task<IActionResult> CreateDiscoveredCharacters(WordCompanionCreateDiscoveredCharactersRequest request)
|
public async Task<IActionResult> CreateDiscoveredCharacters(WordCompanionCreateDiscoveredCharactersRequest request)
|
||||||
{
|
{
|
||||||
var response = await wordCompanion.CreateDiscoveredCharactersAsync(request);
|
var response = await wordCompanion.CreateDiscoveredCharactersAsync(request);
|
||||||
return response is null ? BadRequest() : Ok(response);
|
if (response is null)
|
||||||
|
{
|
||||||
|
return BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.CreatedCount > 0)
|
||||||
|
{
|
||||||
|
await BroadcastToCurrentUserAsync("wordCompanionCharactersCreated", new
|
||||||
|
{
|
||||||
|
response.ProjectId,
|
||||||
|
CharactersUrl = Url.Action("Index", "Characters", new { projectId = response.ProjectId }) ?? $"/Characters?projectId={response.ProjectId}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BroadcastToCurrentUserAsync(string eventName, object payload)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (!string.IsNullOrWhiteSpace(userId))
|
||||||
|
{
|
||||||
|
await followHub.Clients.User(userId).SendAsync(eventName, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public interface IWordCompanionRepository
|
|||||||
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request);
|
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request);
|
||||||
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn);
|
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn);
|
||||||
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(int userId, WordCompanionRuntimeSceneWordCountRequest request);
|
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(int userId, WordCompanionRuntimeSceneWordCountRequest request);
|
||||||
|
Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(int userId, WordCompanionRuntimeCurrentSceneRequest request);
|
||||||
Task<IReadOnlyList<WordCompanionRuntimeCharacterAliasDto>> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid);
|
Task<IReadOnlyList<WordCompanionRuntimeCharacterAliasDto>> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid);
|
||||||
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request);
|
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request);
|
||||||
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
|
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
|
||||||
@ -255,6 +256,23 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(int userId, WordCompanionRuntimeCurrentSceneRequest request)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<WordCompanionRuntimeCurrentSceneResponse>(
|
||||||
|
"dbo.WordCompanion_Runtime_CurrentScene_Validate",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
UserID = userId,
|
||||||
|
request.DocumentGuid,
|
||||||
|
request.ProjectId,
|
||||||
|
request.BookId,
|
||||||
|
request.ChapterId,
|
||||||
|
request.SceneId
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WordCompanionRuntimeCharacterAliasDto>> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid)
|
public async Task<IReadOnlyList<WordCompanionRuntimeCharacterAliasDto>> ListRuntimeCharacterAliasesAsync(int userId, int projectId, Guid documentGuid)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
9
PlotLine/Hubs/WordCompanionFollowHub.cs
Normal file
9
PlotLine/Hubs/WordCompanionFollowHub.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace PlotLine.Hubs;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public sealed class WordCompanionFollowHub : Hub
|
||||||
|
{
|
||||||
|
}
|
||||||
@ -228,6 +228,24 @@ public sealed class WordCompanionRuntimeSceneWordCountRequest
|
|||||||
public int WordCount { get; set; }
|
public int WordCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class WordCompanionRuntimeCurrentSceneRequest
|
||||||
|
{
|
||||||
|
public Guid DocumentGuid { get; set; }
|
||||||
|
public int ProjectId { get; set; }
|
||||||
|
public int BookId { get; set; }
|
||||||
|
public int ChapterId { get; set; }
|
||||||
|
public int SceneId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WordCompanionRuntimeCurrentSceneResponse
|
||||||
|
{
|
||||||
|
public int ProjectId { get; init; }
|
||||||
|
public int BookId { get; init; }
|
||||||
|
public int ChapterId { get; init; }
|
||||||
|
public int SceneId { get; init; }
|
||||||
|
public string SceneUrl { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class WordCompanionRuntimeCharacterAliasDto
|
public sealed class WordCompanionRuntimeCharacterAliasDto
|
||||||
{
|
{
|
||||||
public int CharacterId { get; init; }
|
public int CharacterId { get; init; }
|
||||||
|
|||||||
@ -6,6 +6,7 @@ using PlotLine.Services;
|
|||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using PlotLine.Hubs;
|
||||||
|
|
||||||
namespace PlotLine;
|
namespace PlotLine;
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ public class Program
|
|||||||
{
|
{
|
||||||
options.Filters.Add<ProjectAccessFilter>();
|
options.Filters.Add<ProjectAccessFilter>();
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSignalR();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddDistributedMemoryCache();
|
builder.Services.AddDistributedMemoryCache();
|
||||||
builder.Services.AddSession(options =>
|
builder.Services.AddSession(options =>
|
||||||
@ -209,6 +211,7 @@ public class Program
|
|||||||
name: "default",
|
name: "default",
|
||||||
pattern: "{controller=Home}/{action=Index}/{id?}")
|
pattern: "{controller=Home}/{action=Index}/{id?}")
|
||||||
.WithStaticAssets();
|
.WithStaticAssets();
|
||||||
|
app.MapHub<WordCompanionFollowHub>("/hubs/word-companion-follow");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public interface IWordCompanionService
|
|||||||
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
|
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
|
||||||
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
|
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
|
||||||
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request);
|
Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request);
|
||||||
|
Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request);
|
||||||
Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid);
|
Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid);
|
||||||
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request);
|
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request);
|
||||||
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
|
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
|
||||||
@ -236,6 +237,17 @@ public sealed class WordCompanionService(
|
|||||||
: repository.UpdateRuntimeSceneWordCountAsync(RequireUserId(), request);
|
: repository.UpdateRuntimeSceneWordCountAsync(RequireUserId(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request)
|
||||||
|
{
|
||||||
|
return request.DocumentGuid == Guid.Empty
|
||||||
|
|| request.ProjectId <= 0
|
||||||
|
|| request.BookId <= 0
|
||||||
|
|| request.ChapterId <= 0
|
||||||
|
|| request.SceneId <= 0
|
||||||
|
? Task.FromResult<WordCompanionRuntimeCurrentSceneResponse?>(null)
|
||||||
|
: repository.ValidateRuntimeCurrentSceneAsync(RequireUserId(), request);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid)
|
public async Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid)
|
||||||
{
|
{
|
||||||
if (projectId <= 0 || documentGuid == Guid.Empty)
|
if (projectId <= 0 || documentGuid == Guid.Empty)
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
SET ANSI_NULLS ON;
|
||||||
|
GO
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Runtime_CurrentScene_Validate
|
||||||
|
@UserID int,
|
||||||
|
@DocumentGuid uniqueidentifier,
|
||||||
|
@ProjectID int,
|
||||||
|
@BookID int,
|
||||||
|
@ChapterID int,
|
||||||
|
@SceneID int
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
IF @DocumentGuid IS NULL
|
||||||
|
OR @DocumentGuid = '00000000-0000-0000-0000-000000000000'
|
||||||
|
OR @ProjectID <= 0
|
||||||
|
OR @BookID <= 0
|
||||||
|
OR @ChapterID <= 0
|
||||||
|
OR @SceneID <= 0
|
||||||
|
RETURN;
|
||||||
|
|
||||||
|
SELECT p.ProjectID AS ProjectId,
|
||||||
|
b.BookID AS BookId,
|
||||||
|
c.ChapterID AS ChapterId,
|
||||||
|
s.SceneID AS SceneId,
|
||||||
|
CONCAT(N'/Scenes/Details/', s.SceneID) AS SceneUrl
|
||||||
|
FROM dbo.Projects p
|
||||||
|
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||||
|
INNER JOIN dbo.Books b ON b.ProjectID = p.ProjectID
|
||||||
|
INNER JOIN dbo.Chapters c ON c.BookID = b.BookID
|
||||||
|
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID
|
||||||
|
INNER JOIN dbo.ManuscriptDocuments md ON md.ProjectID = p.ProjectID
|
||||||
|
AND md.BookID = b.BookID
|
||||||
|
WHERE p.ProjectID = @ProjectID
|
||||||
|
AND b.BookID = @BookID
|
||||||
|
AND c.ChapterID = @ChapterID
|
||||||
|
AND s.SceneID = @SceneID
|
||||||
|
AND md.DocumentGuid = @DocumentGuid
|
||||||
|
AND md.IsActive = 1
|
||||||
|
AND s.IsLinkedToManuscript = 1
|
||||||
|
AND s.ManuscriptDocumentID = md.ManuscriptDocumentID
|
||||||
|
AND p.IsArchived = 0
|
||||||
|
AND b.IsArchived = 0
|
||||||
|
AND c.IsArchived = 0
|
||||||
|
AND s.IsArchived = 0
|
||||||
|
AND pua.UserID = @UserID
|
||||||
|
AND pua.IsActive = 1;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -150,6 +150,9 @@
|
|||||||
@if (User.Identity?.IsAuthenticated == true)
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap me-sm-1">
|
<div class="d-flex align-items-center gap-2 flex-wrap me-sm-1">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm word-companion-follow-toggle" type="button" data-word-companion-follow-toggle aria-pressed="false">
|
||||||
|
Follow Word Companion
|
||||||
|
</button>
|
||||||
<a class="btn btn-outline-secondary btn-sm" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
|
<a class="btn btn-outline-secondary btn-sm" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
|
||||||
Feature Requests
|
Feature Requests
|
||||||
@if (featureRequestAttentionCount > 0)
|
@if (featureRequestAttentionCount > 0)
|
||||||
@ -288,6 +291,10 @@
|
|||||||
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
||||||
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
||||||
|
@if (User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@@microsoft/signalr@8.0.7/dist/browser/signalr.min.js"></script>
|
||||||
|
}
|
||||||
<environment include="Production">
|
<environment include="Production">
|
||||||
<script src="~/js/site.min.js" asp-append-version="true"></script>
|
<script src="~/js/site.min.js" asp-append-version="true"></script>
|
||||||
</environment>
|
</environment>
|
||||||
|
|||||||
@ -623,6 +623,21 @@ td.text-end {
|
|||||||
background: #f3f4f2;
|
background: #f3f4f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.word-companion-follow-toggle[aria-pressed="true"] {
|
||||||
|
border-color: rgba(47, 111, 99, 0.36);
|
||||||
|
color: var(--plotline-accent-dark);
|
||||||
|
background: var(--plotline-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-companion-follow-toggle[aria-pressed="true"]::after {
|
||||||
|
content: " On";
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-companion-follow-toggle[aria-pressed="false"]::after {
|
||||||
|
content: " Off";
|
||||||
|
}
|
||||||
|
|
||||||
.order-buttons {
|
.order-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
@ -63,6 +63,129 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const toggle = document.querySelector("[data-word-companion-follow-toggle]");
|
||||||
|
if (!toggle || !window.signalR) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = "plotdirector.followWordCompanion";
|
||||||
|
const unsafePathPrefixes = [
|
||||||
|
"/pricing",
|
||||||
|
"/about",
|
||||||
|
"/contact",
|
||||||
|
"/privacy",
|
||||||
|
"/terms",
|
||||||
|
"/admin",
|
||||||
|
"/billing",
|
||||||
|
"/account",
|
||||||
|
"/login",
|
||||||
|
"/register",
|
||||||
|
"/feature-requests/admin",
|
||||||
|
"/featurerequests/admin",
|
||||||
|
"/manageaccount"
|
||||||
|
];
|
||||||
|
let lastSceneId = null;
|
||||||
|
let lastCharactersProjectId = null;
|
||||||
|
|
||||||
|
const readEnabled = () => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(storageKey) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeEnabled = (value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, value ? "true" : "false");
|
||||||
|
} catch {
|
||||||
|
// Follow mode is optional, so storage failures should not interrupt the app.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyToggle = (enabled) => {
|
||||||
|
toggle.setAttribute("aria-pressed", String(enabled));
|
||||||
|
toggle.classList.toggle("active", enabled);
|
||||||
|
toggle.title = enabled
|
||||||
|
? "PlotDirector will follow the current Word Companion scene."
|
||||||
|
: "Enable this to let PlotDirector follow the current Word Companion scene.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSafeCurrentPage = () => {
|
||||||
|
const path = window.location.pathname.toLowerCase();
|
||||||
|
if (path === "/" || path === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !unsafePathPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeRelativeUrl = (url, expectedPrefix) => {
|
||||||
|
if (typeof url !== "string" || !url.startsWith(expectedPrefix)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
return parsed.origin === window.location.origin ? `${parsed.pathname}${parsed.search}${parsed.hash}` : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateIfAllowed = (url) => {
|
||||||
|
if (!readEnabled() || !isSafeCurrentPage() || !url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
if (current === url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.assign(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
applyToggle(readEnabled());
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
const enabled = !readEnabled();
|
||||||
|
writeEnabled(enabled);
|
||||||
|
applyToggle(enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl("/hubs/word-companion-follow")
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
connection.on("wordCompanionSceneChanged", (payload) => {
|
||||||
|
const sceneId = Number.parseInt(payload?.sceneId || payload?.SceneId || "", 10);
|
||||||
|
if (!Number.isInteger(sceneId) || sceneId <= 0 || sceneId === lastSceneId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSceneId = sceneId;
|
||||||
|
const sceneUrl = safeRelativeUrl(payload?.sceneUrl || payload?.SceneUrl || "", "/Scenes/Details/");
|
||||||
|
navigateIfAllowed(sceneUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on("wordCompanionCharactersCreated", (payload) => {
|
||||||
|
const projectId = Number.parseInt(payload?.projectId || payload?.ProjectId || "", 10);
|
||||||
|
if (!Number.isInteger(projectId) || projectId <= 0 || projectId === lastCharactersProjectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCharactersProjectId = projectId;
|
||||||
|
const charactersUrl = safeRelativeUrl(payload?.charactersUrl || payload?.CharactersUrl || "", "/Characters");
|
||||||
|
navigateIfAllowed(charactersUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.start().catch((error) => {
|
||||||
|
console.debug("Word Companion follow connection unavailable.", error);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const inspectorRoots = document.querySelectorAll(".scene-inspector-root");
|
const inspectorRoots = document.querySelectorAll(".scene-inspector-root");
|
||||||
if (!inspectorRoots.length) {
|
if (!inspectorRoots.length) {
|
||||||
|
|||||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
@ -215,6 +215,7 @@
|
|||||||
let characterAliasCache = [];
|
let characterAliasCache = [];
|
||||||
let lastSuggestedSceneId = null;
|
let lastSuggestedSceneId = null;
|
||||||
let lastSuggestedCharacterSignature = "";
|
let lastSuggestedCharacterSignature = "";
|
||||||
|
let lastFollowSceneNotificationSignature = "";
|
||||||
let lastLinkedSceneTitle = "";
|
let lastLinkedSceneTitle = "";
|
||||||
let lastLinkedChapterTitle = "";
|
let lastLinkedChapterTitle = "";
|
||||||
let runtimeLastDebugAt = 0;
|
let runtimeLastDebugAt = 0;
|
||||||
@ -929,6 +930,7 @@
|
|||||||
hideSceneCreateLink();
|
hideSceneCreateLink();
|
||||||
setResolvedScene(sceneId, chapterId, resolutionMethod);
|
setResolvedScene(sceneId, chapterId, resolutionMethod);
|
||||||
renderCompanionData(companionData);
|
renderCompanionData(companionData);
|
||||||
|
notifyPlotDirectorCurrentScene(sceneId, chapterId);
|
||||||
scheduleSceneWordCountSync();
|
scheduleSceneWordCountSync();
|
||||||
scheduleCharacterSuggestionDetection();
|
scheduleCharacterSuggestionDetection();
|
||||||
};
|
};
|
||||||
@ -2944,6 +2946,37 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const notifyPlotDirectorCurrentScene = async (sceneId = resolvedSceneId, chapterId = resolvedChapterId) => {
|
||||||
|
const project = selectedProject();
|
||||||
|
const book = selectedBook();
|
||||||
|
const documentGuid = currentDocumentGuid();
|
||||||
|
const numericSceneId = Number.parseInt(sceneId || "", 10);
|
||||||
|
const numericChapterId = Number.parseInt(chapterId || "", 10);
|
||||||
|
if (!project || !book || !documentGuid || !Number.isInteger(numericSceneId) || numericSceneId <= 0 || !Number.isInteger(numericChapterId) || numericChapterId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = `${project.projectId}:${book.bookId}:${numericSceneId}`;
|
||||||
|
if (lastFollowSceneNotificationSignature === signature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFollowSceneNotificationSignature = signature;
|
||||||
|
try {
|
||||||
|
await postJson("/api/word-companion/runtime/current-scene", {
|
||||||
|
documentGuid,
|
||||||
|
projectId: project.projectId,
|
||||||
|
bookId: book.bookId,
|
||||||
|
chapterId: numericChapterId,
|
||||||
|
sceneId: numericSceneId
|
||||||
|
});
|
||||||
|
runtimeDebug("Current scene follow event sent.");
|
||||||
|
} catch (error) {
|
||||||
|
lastFollowSceneNotificationSignature = "";
|
||||||
|
console.debug("Unable to notify PlotDirector of current scene.", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadRuntimeDocumentAwareness = async () => {
|
const loadRuntimeDocumentAwareness = async () => {
|
||||||
const book = selectedBook();
|
const book = selectedBook();
|
||||||
if (!book || !officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
|
if (!book || !officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user