Compare commits

...
36 Commits
Author SHA1 Message Date
novelly-ci af65fd22a9 chore: refresh coverage badge [skip ci] 2026-08-21 18:02:10 +00:00
James Wampler e1df79b31e Update docs and solution file for the stdio MCP server's removal
CI / build-and-push (push) Successful in 47s
CI / deploy (push) Successful in 10s
The prior commit's git add silently dropped these five files because
one path in the same invocation didn't exist. Docs, solution file, and
example config still needed the paired update: Novelly.slnx drops the
Novelly.Mcp project entry, README and CLAUDE.md describe MCP as an
in-API HTTP endpoint instead of a stdio binary, .mcp.json.example uses
the type: http shape, and outline-importer.md's tool references are
fixed to real tool names.
2026-08-21 11:00:25 -07:00
James Wampler 2d6bb9fc57 Delete the stdio MCP server now that the API serves /mcp directly
src/Novelly.Mcp was a separate stdio process, unbuilt by CI, that
looped back over HTTP to the same REST API the previous commit's /mcp
endpoint now calls in-process. Nothing else referenced it (not CI, not
Docker, not the AppHost), so removal is just the project, its solution
entry, and scripts/publish-mcp.sh.

Updates .mcp.json / .mcp.json.example to the type: http form, fixes
.claude/agents/outline-importer.md's already-stale tool references
(list_projects/create_project/etc. never existed; the real names are
list_novels/create_novel/etc.), and rewrites README + CLAUDE.md's
description of the MCP surface and how to verify it at runtime.
2026-08-21 10:59:52 -07:00
James Wampler ab773615f8 Serve the unified tool registry over MCP Streamable HTTP at /mcp
Adds ModelContextProtocol.AspNetCore and registers AddMcpServer with
WithListToolsHandler/WithCallToolHandler rather than 45 attribute
methods, so both handlers resolve the scoped NovelAgentToolset per
request and reuse its hand-built schemas directly instead of fighting
the SDK's delegate-based schema inference.

NovelMcpTools (src/Novelly.Api/Mcp/) is the adapter: it injects a
required novelId property into the advertised schema for tools that
need one and extracts it back out at call time, since the web agent
gets novelId ambiently from its route but an MCP client has no route
to supply it from.

/mcp inherits auth from the existing fallback policy (cookie or
X-Novelly-Api-Key) by adding no authorization metadata of its own —
chaining .RequireAuthorization() would apply the default,
cookie-only policy instead and break the API key. Verified end to end
against a running instance: initialize advertises capabilities.tools,
tools/list returns all 45 with novelId injected only where needed,
tool errors map to result.isError rather than a JSON-RPC error, and a
write (create_tag) round-trips correctly with the service user's
identity intact.
2026-08-21 10:56:24 -07:00
James Wampler 897fb442a1 Unify MCP and agent tool surfaces onto one registry in NovelAgentToolset
Fixes NovelAgentService continuing a conversation under the wrong
novel's route, since FindConversationAsync matched by id alone. Then
extends NovelAgentToolset to all 45 tools the stdio MCP server offered
(tag/location CRUD, character relationships, arc-stage beat pinning,
question editing, cross-novel novel listing/creation), tagging each
with whether it needs an explicit novel scope so a later MCP adapter
can inject it. Renames the toolset's 33 existing schemas from
snake_case to camelCase to match .NET/REST convention, since nothing
external consumes them.

Lays the groundwork to serve this same registry over MCP at /mcp and
retire the separate stdio Novelly.Mcp project (docs/plans/api/mcp_http_merge_plan.md).
2026-08-21 10:52:50 -07:00
James Wampler bb2a499569 Expose Novelly API port + service-key auth for MCP access to QA deploy
CI / build-and-push (push) Successful in 46s
CI / deploy (push) Successful in 9s
MCP server is a stdio process run outside docker, pointed at the API
over HTTP via NOVELLY_API_URL. The api container previously had no
port mapping, so it was unreachable outside the compose network.
2026-08-21 09:42:10 -07:00
novelly-ci 64fff4f1f7 chore: refresh coverage badge [skip ci] 2026-08-21 00:33:01 +00:00
James Wampler 13fa29e8e9 Pin container names in QA compose to match Caddy's upstream hostnames
CI / build-and-push (push) Successful in 53s
CI / deploy (push) Successful in 9s
Caddy reverse-proxies to novelly-web/novelly-api by DNS name, but compose
only registered aliases web/api (service names) plus novelly-web-1/-api-1
(container-number suffixed). Any Caddy restart re-resolves DNS and 502s
until the alias exists again. Pin container_name so the alias is stable
across every redeploy.
2026-08-20 17:32:30 -07:00
James Wampler aca26588f9 Add soft delete + trash, keyboard-first web overhaul, move chapter tags to bottom
CI / build-and-push (push) Failing after 31s
CI / deploy (push) Has been skipped
Adds SoftDelete/Trash across characters, chapters, locations, beats with a
purge schedule and Trash page. Reworks the web client for keyboard-driven
navigation (focus helpers, help overlay, keyboard.md doc). Moves the
ChapterPage tag editor to the bottom of the page to match CharacterDetailPage.
2026-08-20 16:39:09 -07:00
James Wampler 7df1fffdca Tweaks to Import
CI / build-and-push (push) Failing after 37s
CI / deploy (push) Has been skipped
2026-08-20 14:21:44 -07:00
James Wampler 1423977ed4 Modernize web client: dark phase-driven theme, sidebar shell, global agent panel
Replaces the warm-paper/serif look with a dark palette where the novel's
lifecycle phase drives the accent color app-wide. Sidebar nav replaces the
old header/tab-bar. Dashboard leads with quick actions (new chapter, new
character, continue writing) instead of just showing history. Agent chat
is now a context-aware slide-out panel reachable from any page in a novel,
replacing the buried /agent tab.
2026-08-20 14:21:44 -07:00
James Wampler 661f2917ea Add zip upload and mapped-path picker for outline import
Sandbox source paths under a configured Imports:RootPath, browse it
from the web dialog, upload a zip that extracts into staging, and
import a single markdown file (agent infers chapter vs character).
2026-08-20 14:21:44 -07:00
novelly-ci e3d410da0b chore: refresh coverage badge [skip ci] 2026-08-20 01:13:30 +00:00
James Wampler 3795ddd541 Add chapter kind to the web client
CI / build-and-push (push) Successful in 54s
CI / deploy (push) Successful in 9s
Chapter and its cross-referencing chips (tags, locations, questions,
character arcs) now carry kind/displayNumber/label fields end to end.
Chapter detail page gets a Kind selector; chapter chips across the
app render "Foreword"/"Afterword" instead of a misleading number for
front and back matter.
2026-08-19 18:07:21 -07:00
James Wampler 6b0cdd0d71 Surface chapter kind through the MCP server
create_chapter/update_chapter now pass kind through to the API,
matching the FrontMatter/Body/BackMatter option added to the embedded
agent's toolset.
2026-08-19 18:03:18 -07:00
James Wampler 7f56c79b20 Label chapter chips by kind, not raw number
Beats, tags, locations, open questions, and character/arc-stage
responses that reference a chapter now carry a ChapterLabel/DisplayNumber
alongside the raw Number, computed via the new
ChapterDisplayNumberLookup. Front/back matter chips show their title;
body chapters show "Chapter N: Title".
2026-08-19 18:02:53 -07:00
James Wampler ef5260a111 Add ChapterKind for front/back matter chapters
Chapters can now be marked FrontMatter/Body/BackMatter. Number stays
the manuscript sort key for every chapter; the author-facing display
number is now computed per-request as the chapter's ordinal among
Body chapters only, so a foreword or afterword no longer shifts the
numbering of the rest of the book. Surfaced through the API, agent
toolset, and import toolset.
2026-08-19 17:54:20 -07:00
James Wampler 71953220aa Add GitHub-style activity contribution graph
CI / build-and-push (push) Successful in 52s
CI / deploy (push) Successful in 9s
Record create/update/delete events across novel content (chapters, beats,
characters, arc stages, tags, locations, questions) into an append-only
ActivityEvent log, aggregate by UTC day, and surface as a heatmap on the
novels list and each novel's dashboard. Backfills history from existing
CreatedAt timestamps on first boot after the migration.
2026-08-19 17:39:13 -07:00
novelly-ci 51f3176bd0 chore: refresh coverage badge [skip ci] 2026-08-19 22:47:39 +00:00
James Wampler 45afc980d3 Simplify character dossier fields, add ShowPronouns setting
CI / build-and-push (push) Successful in 51s
CI / deploy (push) Successful in 9s
Collapse Want/Need into Motivation and Internal/External Conflict into
Conflict, drop Arc summary field, and move tags below open questions on
the character page. Gate Pronouns display behind new UiSettings:ShowPronouns
config (default off).
2026-08-19 15:33:14 -07:00
James Wampler bbd5e66777 QA deploy: override Agent model to Sonnet 5 (was Opus default)
CI / build-and-push (push) Successful in 45s
CI / deploy (push) Successful in 9s
2026-08-19 15:01:03 -07:00
James Wampler a45e8a59c7 Gate CI to Gitea only for now
CI / build-and-push (push) Successful in 42s
CI / deploy (push) Successful in 9s
GitHub pushes no longer run build/test/coverage at all — the job-level
condition previously only skipped the docker/deploy steps, so every GitHub
push still burned CI minutes on a build+test that went nowhere. GitHub gets
its own release-triggered workflow later.
2026-08-19 14:51:41 -07:00
James Wampler 2d7c2a93b7 fix: bind mount path is /mnt/storage/apps (typo)
CI / build-and-push (push) Successful in 41s
CI / deploy (push) Successful in 9s
2026-08-19 14:48:15 -07:00
James Wampler 304b8b4c59 Switch novelly-data to a bind mount at /mnt/storage/app/novelly/data
CI / build-and-push (push) Successful in 41s
CI / deploy (push) Successful in 8s
Named docker volume replaced with an explicit host path per user request.
Requires the path to exist on the QA server and any data from the old
novelly-data volume to be copied over manually before the next deploy.
2026-08-19 14:46:35 -07:00
James Wampler 8f93dce065 Guard deploys against a broken migration
CI / build-and-push (push) Successful in 46s
CI / deploy (push) Successful in 9s
Program.cs: wrap the boot-time MigrateAsync in try/catch (was an unhandled
exception into a restart:unless-stopped crash-loop), log critical and exit(1)
on failure, and add a --migrate-only flag that applies migrations then exits
0 without starting the web host.

deploy.sh: run migrations as a preflight via the new --migrate-only image
against the live novelly-data volume, before the running (old-image) stack
is touched. A failing migration now aborts the deploy with the old
containers still serving traffic, instead of swapping to a crash-looping
new container first and finding out from the health-check timeout.
2026-08-19 14:23:34 -07:00
James Wampler 7a1c726af8 chore: trigger CI deploy 2026-08-18 19:08:34 -07:00
James Wampler 44f722019b Mirror mic-check's Gitea/GitHub CI-CD pipeline for novelly
CI / build-and-push (push) Successful in 58s
CI / deploy (push) Successful in 10s
Dual-engine workflow (.github/workflows/ci.yml, read by both GitHub Actions
and Gitea Actions): build, test, coverage badge on every push; on Gitea main
pushes only, build+push API/web images to the Gitea registry and redeploy
the persistent LAN stack via the shared [self-hosted, qa] runner. Replaces
the ad hoc docker-compose.deploy.yml manual workflow with
deploy/qa/docker-compose.qa.yml, pulled by CI — data volume preserved
across deploys (no -v on down), unlike mic-check's throwaway QA stack.
2026-08-18 18:25:01 -07:00
James Wampler 56e64c6f06 Update README for locations, Novel rename, and scene removal
Docs had drifted from the code across several prior renames; refreshes the
data model, API surface table, and tool counts to match, and documents the
new Locations feature and chapter character summary alongside the actual
current commit.
2026-08-18 11:40:39 -07:00
James Wampler c620ddd626 Replace chapter setting with multi-select locations; add chapter character summary
Chapters now carry many Locations (new Tags-style entity with cross-referencing)
instead of a single free-text Setting field, with a Locations tab on the novel
for browsing them and seeing every chapter set at each one. Also surfaces the
distinct characters appearing in a chapter's beats, linked, under the beat/word
count on the outline tab.
2026-08-18 11:36:13 -07:00
James Wampler 4313c8f206 Rename Project concept to Novel across the stack
Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
2026-08-17 23:03:09 -07:00
James Wampler 0ab4f568b5 Drop braces on single-line conditionals in BeatService 2026-08-17 22:54:09 -07:00
James Wampler bc85a36e18 Dashboard: show 10 chapters titled Chapters; bump chapter mtime on beat changes
Beat create/update/delete/reorder/assign/move now touch the parent
chapter's UpdatedAt so the dashboard's recency sort reflects beat edits,
not just chapter-level prose/tag changes.
2026-08-17 22:53:05 -07:00
James Wampler 17facba3b9 Group character beats into arc-stage sections; reciprocal relationship types
Arc stages now group the beats that establish or pay off that stage of a
character's arc (many-to-many via ArcStageBeats), and carry a Result field
(renamed from Description) describing what the stage results in for the
character. Assigning a beat to a stage moves it out of any other stage of
the same character. New endpoint POST /api/arc-stages/{id}/beats, MCP tool
set_arc_stage_beats, and frontend grouping UI in CharacterArc/CharacterBeats.

Also records a relationship's reciprocal type so both characters' dossiers
show the correct direction (e.g. "sister" / "brother") instead of mirroring
the same label.
2026-08-17 22:45:07 -07:00
James Wampler eb1efcf9f8 Stop tracking .mcp.json; it carries a real API key
.mcp.json needs NOVELLY_API_KEY to match the API's Auth:ServiceApiKey
user secret, so it can't be a checked-in file — gitignore it and keep
.mcp.json.example (with the key blanked out) as the template. README
walks through copying the example and setting the matching user secret.
2026-08-17 18:39:56 -07:00
James Wampler c879d9bfce Add publish-mcp.sh so the standalone MCP binary stops going stale
Aspire doesn't run or manage src/Novelly.Mcp — it's a separate stdio
process the MCP client spawns from a published binary that nothing
rebuilds automatically. It had drifted 12 days out of date and was
silently missing the service-API-key auth header, causing confusing
401s. Script wraps the existing dotnet publish command via the shared
ensure_dotnet helper; README points at it instead of the raw command.

Also registers a UserSecretsId on Novelly.Api so Auth:ServiceApiKey
can be set locally without landing in appsettings.
2026-08-17 18:38:48 -07:00
James Wampler cb66ef7343 Add dashboard tag cloud and spectrum tag color picker
- Dashboard's Characters column gains a tag cloud below it, sized by
  usage count, linking into the tags page.
- Tags page color editor replaced with 16 preset swatches across the
  spectrum plus a "Custom..." link to the native hex picker.
- Fix: tag color/name updates weren't invalidating the tag-references
  query, so the reference panel showed a stale color after editing.
- Tag cloud/tag list selection now round-trips through a ?tag= query
  param so clicking a cloud tag selects it on the tags page.
2026-08-17 17:52:43 -07:00
204 changed files with 19290 additions and 3401 deletions
+5 -5
View File
@@ -1,7 +1,7 @@
--- ---
name: outline-importer name: outline-importer
description: Imports an author's existing novel outline (chapters + character dossiers, in the Kingdom Sleeps folder format) into a Novelly project over the MCP server. Invoke explicitly with a source folder path — this agent does not run proactively. description: Imports an author's existing novel outline (chapters + character dossiers, in the Kingdom Sleeps folder format) into a Novelly project over the MCP server. Invoke explicitly with a source folder path — this agent does not run proactively.
tools: Read, Glob, Grep, Write, mcp__novelly__list_projects, mcp__novelly__get_project_brief, mcp__novelly__create_project, mcp__novelly__update_project_brief, mcp__novelly__list_chapters, mcp__novelly__get_chapter, mcp__novelly__create_chapter, mcp__novelly__update_chapter, mcp__novelly__get_chapter_outline, mcp__novelly__create_beat, mcp__novelly__update_beat, mcp__novelly__list_characters, mcp__novelly__get_character, mcp__novelly__create_character, mcp__novelly__update_character, mcp__novelly__get_character_arc, mcp__novelly__add_arc_stage, mcp__novelly__list_tags tools: Read, Glob, Grep, Write, mcp__novelly__list_novels, mcp__novelly__get_novel_brief, mcp__novelly__create_novel, mcp__novelly__update_novel_brief, mcp__novelly__list_chapters, mcp__novelly__get_chapter, mcp__novelly__create_chapter, mcp__novelly__update_chapter, mcp__novelly__get_chapter_outline, mcp__novelly__create_beat, mcp__novelly__update_beat, mcp__novelly__list_characters, mcp__novelly__get_character, mcp__novelly__create_character, mcp__novelly__update_character, mcp__novelly__get_character_arc, mcp__novelly__add_arc_stage, mcp__novelly__list_tags
model: inherit model: inherit
--- ---
@@ -101,9 +101,9 @@ in your final report.
Do not skip ahead — each pass depends on ids the previous one minted. If you're picking up a Do not skip ahead — each pass depends on ids the previous one minted. If you're picking up a
resumed run, jump straight to the first incomplete pass. resumed run, jump straight to the first incomplete pass.
**0. Preflight.** Call `list_projects` to confirm the API is reachable at all — if this fails, stop **0. Preflight.** Call `list_novels` to confirm the API is reachable at all — if this fails, stop
and tell the user to start the API (`ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`) and tell the user to start the API (`ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`).
and that `.mcp.json` must point at a published `Novelly.Mcp` binary. Glob the source root for Glob the source root for
`outline.md`, `outlines/*.md` or `chapters/*.md`, and `characters/*.md`. If `outline.md` is `outline.md`, `outlines/*.md` or `chapters/*.md`, and `characters/*.md`. If `outline.md` is
missing, stop — that's the one file every pass depends on. Report the file counts found before missing, stop — that's the one file every pass depends on. Report the file counts found before
proceeding. proceeding.
@@ -111,7 +111,7 @@ proceeding.
**1. Project.** Skip if `completedPasses` already has `"project"`. Read `outline.md`. Its heading is **1. Project.** Skip if `completedPasses` already has `"project"`. Read `outline.md`. Its heading is
`# Outline — <Title> (<Author>)` or similar — parse title and author out of it; if there's no `# Outline — <Title> (<Author>)` or similar — parse title and author out of it; if there's no
author, leave it null. The paragraph(s) before the chapter table are the blurb — pass as `notes` author, leave it null. The paragraph(s) before the chapter table are the blurb — pass as `notes`
argument to `create_project` (there's no dedicated blurb field; `synopsis` may be filled in later argument to `create_novel` (there's no dedicated blurb field; `synopsis` may be filled in later
by the author). Record `projectId` in the ledger, mark `"project"` complete. by the author). Record `projectId` in the ledger, mark `"project"` complete.
**2. Characters — dossier fields only, not arcs yet.** Skip files whose name (matched **2. Characters — dossier fields only, not arcs yet.** Skip files whose name (matched
+3
View File
@@ -104,6 +104,9 @@ dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:warning dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning dotnet_style_qualification_for_event = false:warning
dotnet_diagnostic.CA1873.severity = silent
[*.cs] [*.cs]
csharp_using_directive_placement = outside_namespace:silent csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion csharp_prefer_simple_using_statement = true:suggestion
-1
View File
@@ -1 +0,0 @@
ANTHROPIC_API_KEY=
+76
View File
@@ -0,0 +1,76 @@
# CI/CD pipeline for Novelly. Read by both Gitea Actions and GitHub Actions (both look
# under .github/workflows/). Every non-checkout step just invokes a bash script under
# scripts/ci/, so the entire pipeline is reproducible by running the same scripts
# locally — no marketplace build/test/push actions.
#
# Gitea (origin) is the only remote that runs this on push — gated by the
# `github.server_url` check below (identical on both engines: https://github.com on
# GitHub, the Gitea instance URL on Gitea). GitHub pushes intentionally do nothing for
# now; GitHub will get its own release-triggered workflow later.
name: CI
on:
push:
paths-ignore: [badges/**]
jobs:
build-and-push:
if: github.server_url != 'https://github.com'
runs-on: ubuntu-latest
permissions:
contents: write
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Build
run: ./scripts/ci/build.sh
- name: Test
run: ./scripts/ci/test.sh
- name: Coverage report
run: ./scripts/ci/coverage.sh
- name: Publish coverage badge
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/ci/publish-coverage-badge.sh
- name: Build Docker images
if: github.ref_name == 'main'
run: ./scripts/ci/docker-build.sh
- name: Push Docker images
if: github.ref_name == 'main'
run: ./scripts/ci/docker-push.sh
deploy:
needs: build-and-push
if: success() && github.ref_name == 'main'
runs-on: [self-hosted, qa]
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
WEB_PORT: ${{ vars.WEB_PORT }}
API_PORT: ${{ vars.API_PORT }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
steps:
# actions/checkout@v4 is a Node-based action; this runner has no node in PATH, so
# checkout plain git instead of via marketplace action.
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${{ github.token }}" fetch --depth=1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Deploy
run: ./scripts/ci/deploy.sh
+6
View File
@@ -12,6 +12,9 @@
*.env *.env
.env.deploy .env.deploy
# Local outline drop-box for the import feature (Imports:RootPath)
/imports/
# User-specific files (MonoDevelop/Xamarin Studio) # User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs *.userprefs
@@ -176,6 +179,7 @@ _TeamCity*
coverage*.json coverage*.json
coverage*.xml coverage*.xml
coverage*.info coverage*.info
coverage/
# Visual Studio code coverage results # Visual Studio code coverage results
*.coverage *.coverage
@@ -436,9 +440,11 @@ dist/
*.db-shm *.db-shm
*.db-wal *.db-wal
mcp-server/ mcp-server/
.mcp.json
# Toolchains installed locally by scripts/ci/lib.sh # Toolchains installed locally by scripts/ci/lib.sh
.dotnet/ .dotnet/
.dotnet-tools/
.node/ .node/
.idea/ .idea/
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"novelly": {
"command": "/home/james/src/novelly/mcp-server/Novelly.Mcp",
"env": {
"NOVELLY_API_URL": "http://localhost:5080",
"DOTNET_ROOT": "/home/james/.dotnet"
}
}
}
}
+4 -3
View File
@@ -1,9 +1,10 @@
{ {
"mcpServers": { "mcpServers": {
"novelly": { "novelly": {
"command": "./mcp-server/Novelly.Mcp", "type": "http",
"env": { "url": "http://localhost:5080/mcp",
"NOVELLY_API_URL": "http://localhost:5080" "headers": {
"X-Novelly-Api-Key": "<matches the API's Auth:ServiceApiKey user secret>"
} }
} }
} }
+7 -6
View File
@@ -8,13 +8,12 @@ Novelly: software plan + write novel. ASP.NET Core 10, C#, TypeScript, React, .N
## Structure ## Structure
- `src/Novelly.Api/` — whole back end, organised by feature. One folder per feature holds - `src/Novelly.Api/` — whole back end, organised by feature, plus the MCP endpoint. One folder
entity, DTOs, service, endpoints together: `Projects/`, `Characters/`, `Chapters/`, `Beats/`, per feature holds entity, DTOs, service, endpoints together: `Novels/`, `Characters/`,
`Scenes/`, `Tags/`, `Agent/`. `Common/` holds what crosses features; `Data/` holds `Chapters/`, `Beats/`, `Scenes/`, `Tags/`, `Agent/`, `Mcp/`. `Common/` holds what crosses
`DbContext` + EF migrations. features; `Data/` holds `DbContext` + EF migrations.
- `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client - `src/Novelly.AppHost/` — .NET Aspire orchestration; run this to bring up API + web client
- `src/Novelly.ServiceDefaults/` — shared Aspire wiring: OpenTelemetry, health checks, service discovery - `src/Novelly.ServiceDefaults/` — shared Aspire wiring: OpenTelemetry, health checks, service discovery
- `src/Novelly.Mcp/` — MCP stdio server
- `src/Novelly.Web/` — React + Vite client - `src/Novelly.Web/` — React + Vite client
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
@@ -59,6 +58,7 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A
- `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`. - `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`.
- Enums cross wire as names, never ordinals - Enums cross wire as names, never ordinals
- All frontend components should have an id attribute that identifies them uniquely. - All frontend components should have an id attribute that identifies them uniquely.
- Web client is keyboard-first: read `docs/keyboard.md` before adding any interactive UI (forms, editable rows, create flows).
## Testing ## Testing
@@ -85,7 +85,7 @@ Build + tests passing ≠ working. Anything touching endpoint, agent loop, or MC
Vite dev server on :5173, dashboard for logs + traces Vite dev server on :5173, dashboard for logs + traces
- API alone: `ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`, then exercise route with curl - API alone: `ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api`, then exercise route with curl
- Web alone: `cd src/Novelly.Web && npm run dev` — proxies `/api` to :5080 - Web alone: `cd src/Novelly.Web && npm run dev` — proxies `/api` to :5080
- MCP: build it, then drive over stdio JSON-RPC (`initialize``notifications/initialized``tools/list``tools/call`) - MCP: with the API running, drive `/mcp` over HTTP (`initialize``notifications/initialized``tools/list``tools/call`) — Streamable HTTP, so responses are SSE-framed and requests need `Accept: application/json, text/event-stream`
Several real bugs here — SQLite refusing ORDER BY DateTimeOffset, agent's model client throwing at construction + taking read-only endpoints down with it — passed build + test suite, only showed up when app actually ran. Several real bugs here — SQLite refusing ORDER BY DateTimeOffset, agent's model client throwing at construction + taking read-only endpoints down with it — passed build + test suite, only showed up when app actually ran.
@@ -103,5 +103,6 @@ Several real bugs here — SQLite refusing ORDER BY DateTimeOffset, agent's mode
- Anthropic model id lives in `appsettings.json` under `Agent:Model`. Don't hardcode. - Anthropic model id lives in `appsettings.json` under `Agent:Model`. Don't hardcode.
- API key comes from `ANTHROPIC_API_KEY` or `Agent:ApiKey` — never commit one. App must stay fully usable without key; only agent endpoints require it. - API key comes from `ANTHROPIC_API_KEY` or `Agent:ApiKey` — never commit one. App must stay fully usable without key; only agent endpoints require it.
- EF migrations: `dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations`. API migrates on boot. - EF migrations: `dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations`. API migrates on boot.
- Outline import root lives in `appsettings.json` under `Imports:RootPath` (`Imports__RootPath` env var). When set, it's the only folder the browse/upload import endpoints and the source picker can reach; unset, those endpoints are disabled and the dialog falls back to a typed path with no sandbox. Created at boot if missing.
- `git push` runs `scripts/ci/prepush.sh` through Husky: build, test, then web build. Run `npm install` - `git push` runs `scripts/ci/prepush.sh` through Husky: build, test, then web build. Run `npm install`
once at repo root to install hook. once at repo root to install hook.
-1
View File
@@ -8,7 +8,6 @@
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/Novelly.Api/Novelly.Api.csproj" /> <Project Path="src/Novelly.Api/Novelly.Api.csproj" />
<Project Path="src/Novelly.AppHost/Novelly.AppHost.csproj" /> <Project Path="src/Novelly.AppHost/Novelly.AppHost.csproj" />
<Project Path="src/Novelly.Mcp/Novelly.Mcp.csproj" />
<Project Path="src/Novelly.ServiceDefaults/Novelly.ServiceDefaults.csproj" /> <Project Path="src/Novelly.ServiceDefaults/Novelly.ServiceDefaults.csproj" />
<Project Path="src/Novelly.Web/Novelly.Web.esproj"> <Project Path="src/Novelly.Web/Novelly.Web.esproj">
<Build /> <Build />
+64 -45
View File
@@ -1,27 +1,30 @@
# Novelly # Novelly
[![GitHub CI](https://github.com/wamplerj/novelly/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wamplerj/novelly/actions/workflows/ci.yml)
[![Gitea CI](https://git.wampler.us/wamplerj/novelly/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.wampler.us/wamplerj/novelly/actions?workflow=ci.yml)
![Coverage](badges/coverage.svg)
Software for planning and writing a novel. You outline the book, keep character Software for planning and writing a novel. You outline the book, keep character
dossiers, break chapters into scenes, and draft prose — with a Claude-powered agent dossiers, break chapters into scenes, and draft prose — with a Claude-powered agent
embedded in the app that can read and edit the same data you can, and an MCP server that embedded in the app that can read and edit the same data you can, and an MCP endpoint that
exposes that data to Claude Code, Claude Desktop, or any other MCP client. exposes that same data to Claude Code, Claude Desktop, or any other MCP client.
The point of the three-way arrangement is that there is exactly one source of truth. The The point of the three-way arrangement is that there is exactly one source of truth. The
React UI, the embedded agent, and the MCP server all go through the same REST API, so an React UI's REST calls, the embedded agent, and MCP clients all resolve to the same
edit made from a chat in Claude Code and an edit made by typing in the browser are the application services in-process, so an edit made from a chat in Claude Code and an edit
same edit. made by typing in the browser are the same edit.
## Stack ## Stack
| Piece | Built with | | Piece | Built with |
|---|---| |---|---|
| `Novelly.Api` | ASP.NET Core 10 minimal APIs, EF Core 10 + SQLite, Anthropic SDK, OpenAPI | | `Novelly.Api` | ASP.NET Core 10 minimal APIs, EF Core 10 + SQLite, Anthropic SDK, OpenAPI, MCP over Streamable HTTP (`ModelContextProtocol.AspNetCore`) |
| `Novelly.AppHost` | .NET Aspire orchestration for the API and the web client | | `Novelly.AppHost` | .NET Aspire orchestration for the API and the web client |
| `Novelly.ServiceDefaults` | Shared OpenTelemetry, health checks and service discovery | | `Novelly.ServiceDefaults` | Shared OpenTelemetry, health checks and service discovery |
| `Novelly.Mcp` | MCP stdio server (`ModelContextProtocol`) |
| `Novelly.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 | | `Novelly.Web` | React 19, TypeScript, Vite, TanStack Query, Tailwind v4 |
The back end is one project organised by feature, not by layer. Each feature folder — The back end is one project organised by feature, not by layer. Each feature folder —
`Projects/`, `Characters/`, `Chapters/`, `Beats/`, `Scenes/`, `Tags/`, `Agent/` — holds its `Novels/`, `Characters/`, `Chapters/`, `Beats/`, `Tags/`, `Locations/`, `Agent/` — holds its
entity, DTOs, service and endpoints together, so adding a capability means touching one entity, DTOs, service and endpoints together, so adding a capability means touching one
folder rather than four. `Common/` holds what genuinely crosses features and `Data/` holds folder rather than four. `Common/` holds what genuinely crosses features and `Data/` holds
the `DbContext` and migrations. the `DbContext` and migrations.
@@ -65,7 +68,7 @@ and everything else keeps working.
### Tests ### Tests
```bash ```bash
dotnet test # 73 tests dotnet test # 174 tests
cd src/Novelly.Web && npm run build # typecheck + bundle cd src/Novelly.Web && npm run build # typecheck + bundle
``` ```
@@ -98,11 +101,11 @@ out of `appsettings.json` and use user-secrets or the environment.
## The data model ## The data model
``` ```
Project ──┬── Character ──┬── CharacterRelationship Novel ──┬── Character ──┬── CharacterRelationship
│ └── CharacterArcStage (the arc: flat, ordered) │ └── CharacterArcStage (the arc: flat, ordered)
├── Chapter ──── Beat (the outline: flat, ordered) ├── Chapter ──── Beat (the outline: flat, ordered)
│ └── Scene (the prose)
├── Tag (applied to characters, chapters and beats) ├── Tag (applied to characters, chapters and beats)
├── Location (applied to chapters)
├── OpenQuestion (attached to a chapter and/or a character) ├── OpenQuestion (attached to a chapter and/or a character)
└── AgentConversation ── AgentMessage └── AgentConversation ── AgentMessage
``` ```
@@ -113,18 +116,20 @@ Project ──┬── Character ──┬── CharacterRelationship
| Column | What goes in it | | Column | What goes in it |
|---|---| |---|---|
| Beat | A three-to-five word handle — "she burns the atlas", not a sentence | | Beat | A three-to-five word handle — "she burns the atlas", not a sentence |
| Character | Whose beat it is. Optional; not every beat belongs to one person | | Characters | Who's in the beat. Optional; not every beat belongs to anyone, and a beat can name more than one |
| What happened | The event itself | | What happened | The event itself |
| What's next | What it sets in motion — the hook into the following beat | | What's next | What it sets in motion — the hook into the following beat |
| Scene | Optional grouping: which scene will carry this beat's prose |
Beats are flat and ordered by `SortOrder` within their chapter. There is no nesting and Beats are flat and ordered by `SortOrder` within their chapter. There is no nesting and
no tree — reordering is one call that takes the beat ids in the order wanted. no tree — reordering is one call that takes the beat ids in the order wanted. The outline
tab also rolls up the distinct set of characters across a chapter's beats under the beat
and word counts, each linking to that character's page — a quick cast list without
opening every beat.
**Beats plan; scenes carry prose.** The two layers are deliberately separate: an outline **Beats plan; the chapter's `Prose` carries the draft.** A chapter has one prose field,
is for working out what happens, and a scene is where you write it. A beat's `SceneId` is written and redrafted in place; `WordCount` is recomputed from it whenever it changes.
the optional link between them, and it is nullable in both directions — deleting a scene There is no separate scene entity — the outline (beats) and the draft (prose) are the
ungroups its beats rather than deleting the plan. two views of the same chapter.
**Main characters carry the book; supporting characters hold it up.** A character's **Main characters carry the book; supporting characters hold it up.** A character's
`Importance` (`Main` or `Supporting`) is separate from their `Role` — role is the part they `Importance` (`Main` or `Supporting`) is separate from their `Role` — role is the part they
@@ -149,19 +154,26 @@ append the decision to the notes of whatever it was attached to — so a settled
up where you re-read it rather than in a list you have stopped looking at. Resolved up where you re-read it rather than in a list you have stopped looking at. Resolved
questions drop off the list unless you ask for them. questions drop off the list unless you ask for them.
**Tags cross-reference the book.** A tag is scoped to one project, unique by name **Tags cross-reference the book.** A tag is scoped to one novel, unique by name
(case-insensitively), and can be attached to any character, chapter or beat. Applying an (case-insensitively), and can be attached to any character, chapter or beat. Applying an
unknown tag by name creates it, so tagging is one action rather than two. `GET unknown tag by name creates it, so tagging is one action rather than two. `GET
/api/tags/{id}/references` returns everything carrying a tag, which is how you trace a /api/tags/{id}/references` returns everything carrying a tag, which is how you trace a
motif or a thread across all three kinds at once. motif or a thread across all three kinds at once.
**A chapter can have several locations.** Locations replaced the old single free-text
`Setting` field: a chapter now carries a multi-select list of locations (where and when
it takes place), each a novel-scoped, name-deduped entity — applying an unknown location
by name creates it, same as tags. A "Locations" tab on the novel lists every location
with its chapter count, and `GET /api/locations/{id}/references` lists every chapter set
there.
## The embedded agent ## The embedded agent
`NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools `NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools
Claude asks for, feeds every result back in a single user turn, and repeats until Claude Claude asks for, feeds every result back in a single user turn, and repeats until Claude
stops asking. It has 29 tools covering the brief, characters and their arcs, chapter outlines stops asking. It has 33 tools covering the brief, characters and their arcs, chapter
(beats), scenes, tags and open questions — all of them going through the same application outlines (beats), tags, locations and open questions — all of them going through the same
services the REST API uses. application services the REST API uses.
A few deliberate choices worth knowing about: A few deliberate choices worth knowing about:
@@ -179,30 +191,36 @@ A few deliberate choices worth knowing about:
## The MCP server ## The MCP server
A stdio MCP server exposing 38 tools over the same REST API. It holds no domain logic of The API itself serves MCP over Streamable HTTP at `POST /mcp`, exposing 45 tools that call
its own — it is a second front end, not a second implementation. the same application services the REST endpoints and the embedded web agent call — it holds
no domain logic of its own, and there's nothing to build or publish separately. The API
process just needs to be running; there's no separate subprocess to keep in sync with it.
Build it, then point your MCP client at the produced binary: Copy `.mcp.json.example` to `.mcp.json` (gitignored, since it carries your API key) and
fill in the key:
```bash
dotnet publish src/Novelly.Mcp -c Release -o ./mcp-server
```
`.mcp.json` (or Claude Desktop's config):
```jsonc ```jsonc
{ {
"mcpServers": { "mcpServers": {
"novelly": { "novelly": {
"command": "/absolute/path/to/mcp-server/Novelly.Mcp", "type": "http",
"env": { "NOVELLY_API_URL": "http://localhost:5080" } "url": "http://localhost:5080/mcp",
"headers": {
"X-Novelly-Api-Key": "<matches the API's Auth:ServiceApiKey user secret>"
}
} }
} }
} }
``` ```
The API must be running. If it is not, the tools say so in a message the model can act on The API must be running, with `Auth:ServiceApiKey` set (e.g. via
rather than failing opaquely. `dotnet user-secrets set Auth:ServiceApiKey <key> -p src/Novelly.Api`) to the same value
as the `X-Novelly-Api-Key` header above. If the API is not running, or the key is missing or
mismatched, the request 401s.
Tool argument names are camelCase, matching the REST API and every other MCP argument name
this project has ever used. `create_novel` called over MCP is owned by the seeded service
user (an Admin), not whichever person is signed into the web app.
### Importing an existing outline ### Importing an existing outline
@@ -219,19 +237,20 @@ running and `.mcp.json` is set up.
| Resource | Routes | | Resource | Routes |
|---|---| |---|---|
| Projects | `GET\|POST /api/projects`, `GET\|PATCH\|DELETE /api/projects/{id}` | | Novels | `GET\|POST /api/novels`, `GET\|PATCH\|DELETE /api/novels/{id}` |
| Characters | `GET\|POST /api/projects/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` | | Characters | `GET\|POST /api/novels/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` |
| Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` | | Chapters | `GET\|POST /api/novels/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` |
| Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` | | Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` |
| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` | | Tags | `GET\|POST /api/novels/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` |
| Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` | | Locations | `GET\|POST /api/novels/{id}/locations`, `GET /api/locations/{id}/references`, `PATCH\|DELETE /api/locations/{id}` |
| Arcs | `GET\|POST /api/characters/{id}/arc`, `POST /api/characters/{id}/arc/reorder`, `GET\|PATCH\|DELETE /api/arc-stages/{id}` | | Arcs | `GET\|POST /api/characters/{id}/arc`, `POST /api/characters/{id}/arc/reorder`, `GET\|PATCH\|DELETE /api/arc-stages/{id}` |
| Questions | `GET\|POST /api/projects/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` | | Questions | `GET\|POST /api/novels/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` |
| Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` | | Agent | `GET /api/novels/{id}/agent/conversations`, `POST /api/novels/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` |
`PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A `PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A
`tags` array replaces that item's tags outright and creates any names the project has not `tags` array replaces that item's tags outright and creates any names the novel has not
seen; omitting it leaves tags untouched. seen; the same is true of a chapter's `locations` array. Omitting either leaves it
untouched.
Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the
OpenAPI document is at `/openapi/v1.json`. OpenAPI document is at `/openapi/v1.json`.
+138
View File
@@ -0,0 +1,138 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="155" height="20">
<style type="text/css">
<![CDATA[
@keyframes fade1 {
0% { visibility: visible; opacity: 1; }
23% { visibility: visible; opacity: 1; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: visible; opacity: 1; }
}
@keyframes fade2 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: visible; opacity: 1; }
48% { visibility: visible; opacity: 1; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade3 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: visible; opacity: 1; }
73% { visibility: visible; opacity: 1; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade4 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: visible; opacity: 1; }
98% { visibility: visible; opacity: 1; }
100% { visibility: hidden; opacity: 0; }
}
.linecoverage {
animation-duration: 15s;
animation-name: fade1;
animation-iteration-count: infinite;
}
.branchcoverage {
animation-duration: 15s;
animation-name: fade2;
animation-iteration-count: infinite;
}
.methodcoverage {
animation-duration: 15s;
animation-name: fade3;
animation-iteration-count: infinite;
}
.fullmethodcoverage {
animation-duration: 15s;
animation-name: fade4;
animation-iteration-count: infinite;
}
]]>
</style>
<title>Code coverage</title>
<defs>
<linearGradient id="gradient" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<linearGradient id="c">
<stop offset="0" stop-color="#d40000"/>
<stop offset="1" stop-color="#ff2a2a"/>
</linearGradient>
<linearGradient id="a">
<stop offset="0" stop-color="#e0e0de"/>
<stop offset="1" stop-color="#fff"/>
</linearGradient>
<linearGradient id="b">
<stop offset="0" stop-color="#37c837"/>
<stop offset="1" stop-color="#217821"/>
</linearGradient>
<linearGradient xlink:href="#a" id="e" x1="106.44" x2="69.96" y1="-11.96" y2="-46.84" gradientTransform="matrix(-.8426 -.00045 -.00045 -.8426 -94.27 -75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="f" x1="56.19" x2="77.97" y1="-23.45" y2="10.62" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#c" id="g" x1="79.98" x2="132.9" y1="10.79" y2="10.79" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<mask id="mask">
<rect width="155" height="20" rx="3" fill="#fff"/>
</mask>
<g id="icon" transform="matrix(.04486 0 0 .04481 -.48 -.63)">
<rect width="52.92" height="52.92" x="-109.72" y="-27.13" fill="url(#e)" transform="rotate(-135)"/>
<rect width="52.92" height="52.92" x="70.19" y="-39.18" fill="url(#f)" transform="rotate(45)"/>
<rect width="52.92" height="52.92" x="80.05" y="-15.74" fill="url(#g)" transform="rotate(45)"/>
</g>
</defs>
<g mask="url(#mask)">
<rect x="0" y="0" width="90" height="20" fill="#444"/>
<rect x="90" y="0" width="20" height="20" fill="#c00"/>
<rect x="110" y="0" width="45" height="20" fill="#00B600"/>
<rect x="0" y="0" width="155" height="20" fill="url(#gradient)"/>
</g>
<g>
<path class="" stroke="#fff" d="M94 6.5 h12 M94 10.5 h12 M94 14.5 h12"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Arial,Geneva,sans-serif" font-size="11">
<a xlink:href="https://github.com/danielpalme/ReportGenerator" target="_top">
<title>Generated by: ReportGenerator 5.5.11.0</title>
<use xlink:href="#icon" transform="translate(3,1) scale(3.5)"/>
</a>
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">66.5%</text><text class="" x="132.5" y="14">66.5%</text>
</g>
<g>
<rect class="" x="90" y="0" width="65" height="20" fill-opacity="0"><title>Line coverage</title></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

+62
View File
@@ -0,0 +1,62 @@
name: novelly
# Persistent LAN deployment, pulled and recreated by CI on every push to main.
# Unlike a throwaway QA stack, this one keeps its data across deploys — `down` is run
# without `-v` and /data is a bind mount to /mnt/storage/apps/novelly/data on the host,
# not a docker-managed volume, so the author's novel data survives a redeploy.
services:
api:
image: ${API_IMAGE}:latest
container_name: novelly-api
restart: unless-stopped
environment:
ConnectionStrings__Novel: "Data Source=/data/novel.db"
Cors__Origins__0: "http://localhost:${WEB_PORT:-6173}"
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
Agent__Model: claude-sonnet-5
Agent__Effort: high
Imports__RootPath: /data/imports
Auth__ServiceApiKey: ${MCP_API_KEY:-}
volumes:
- /mnt/storage/apps/novelly/data:/data
networks:
- novelly
ports:
- "${API_PORT:-5080}:8080"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
web:
image: ${WEB_IMAGE}:latest
container_name: novelly-web
restart: unless-stopped
depends_on:
api:
condition: service_healthy
networks:
- novelly
ports:
- "${WEB_PORT:-6173}:80"
# Preflight migration check, run by deploy.sh via `--profile tools run --rm migrate`
# against the newly pulled image before the running stack is touched. Excluded from
# `up -d` by the tools profile.
migrate:
image: ${API_IMAGE}:latest
command: ["dotnet", "Novelly.Api.dll", "--migrate-only"]
environment:
ConnectionStrings__Novel: "Data Source=/data/novel.db"
volumes:
- /mnt/storage/apps/novelly/data:/data
networks:
- novelly
profiles: ["tools"]
networks:
novelly:
name: novelly-net
-27
View File
@@ -1,27 +0,0 @@
services:
api:
build:
context: .
dockerfile: src/Novelly.Api/Dockerfile
restart: unless-stopped
environment:
ConnectionStrings__Novel: "Data Source=/data/novel.db"
Cors__Origins__0: "http://localhost:6173"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
volumes:
- novelly-data:/data
ports:
- "6080:8080"
web:
build:
context: src/Novelly.Web
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- api
ports:
- "6173:80"
volumes:
novelly-data:
+60
View File
@@ -0,0 +1,60 @@
# Keyboard conventions
Novelly's web client is built to be driven entirely from the keyboard. New interactive
components should follow these rules so the app stays consistent as it grows.
## Escape cancels or closes — it never destroys already-saved work
In an editor that commits per field (a beat row, a chapter's title), Escape reverts only the
field you're currently in and then closes the editor. Fields you already tabbed past and
committed stay saved — Escape is honest about this, not a full undo. Anywhere a component *can*
offer a true "discard everything" cancel (a create form that hasn't saved anything yet), do
that instead.
## Enter commits a single-line field and advances
Pressing Enter in a single-line field is equivalent to Tab: it commits the field's value and
moves focus to the next field. Shift+Enter moves to the previous field. This is what
`AutoField` (`src/components/ui.tsx`) does by default — reuse it rather than hand-rolling a
text input's key handling.
## mod+Enter commits a multiline field or completes a record
A `<textarea>` needs plain Enter to insert a newline, so multiline fields commit on
`mod+Enter` (Cmd or Ctrl) instead. The same combo, handled at the row/form level, means "I'm
done with this record" — closing a beat row, submitting a question. This mirrors the app's
original convention in `AgentPanel.tsx` (`mod+Enter` sends a message).
## Bare single letters create the primary thing on the page
`n` is the default create-hotkey across the app (new character, new chapter, new location). A
page with a second creatable thing uses a mnemonic instead (`b` for beat, `q` for question, `a`
for arc stage). Register these with `useHotkey` from the component that owns the create action,
so the shortcut is scoped to that page/section and unregisters when it unmounts — never
register a bare letter globally.
## Creating something puts focus in its first editable field
A create action that leaves the user hunting for the thing they just made is a bug. Land focus
in the new item's first field (or, when a mutation's response id isn't the field's DOM node
yet, request focus for that id and let it land once the row/page actually renders — see the
`focusRequestId`/`onAutoFocused` pattern used for beats and arc stages).
## Chip inputs commit on Enter, comma, or blur
`TagEditor`, `LocationEditor`, `CharacterMultiSelect`, and `AliasEditor` all add their draft
value to the list on Enter, comma, or losing focus. Follow the same shape for any new
chip-style input.
## Destructive confirmations use `ConfirmModal`
Never use the native `confirm()`/`alert()` dialogs — they're not stylable, not consistent with
the rest of the app, and (depending on browser) can be genuinely awkward to dismiss from the
keyboard. Use `ConfirmModal` (`src/components/ConfirmModal.tsx`), which wraps `Modal` and gets
focus-trapping and Escape-to-close for free.
## The exception, not the rule: `allowInInputs`
`useHotkey` shortcuts don't fire while a text field is focused, unless registered with
`allowInInputs: true`. Reserve that for shortcuts that make sense mid-typing (`mod+Enter` to
submit, `Escape` to close) — never a bare letter.
+180
View File
@@ -0,0 +1,180 @@
# Merge the MCP server into Novelly.Api as Streamable HTTP `/mcp`
## Context
Novelly has two duplicate tool surfaces that must be kept in sync by hand:
- **`src/Novelly.Mcp/`** — 45 tools as `[McpServerTool]` static methods, stdio-only, each calling the REST API back over HTTP via `NovelApiClient`. Built by no CI job, in no container, unknown to the AppHost. It only works if someone remembers to run `scripts/publish-mcp.sh` and re-point `.mcp.json` at the published binary. Zero tests.
- **`src/Novelly.Api/Agent/NovelAgentToolset.cs`** — 33 tools for the embedded web agent, calling application services directly in-process.
The 33 are a **strict subset** of the 45, matching name-for-name. The gap is pure capability loss for the web agent, not a design distinction.
Two problems follow. The MCP server is unreachable from anything but a local stdio subprocess, so the QA deploy can't serve it without shipping a binary around. And every new capability has to be written twice, in two idioms, with nothing enforcing that they agree.
**Outcome:** one tool registry, called directly by both surfaces. The API serves MCP over Streamable HTTP at `/mcp`, so any MCP client reaches it over the network with an API key and no binary to distribute. The stdio project is deleted. The web agent gains all 12 tools it was missing.
## Decisions taken
- **Delete `src/Novelly.Mcp` entirely.** No stdio proxy is kept.
- **The web agent gets all 45 tools**, including cross-novel `list_novels` and `create_novel`. Full parity, one list, no filtering.
- **camelCase argument names** everywhere. MCP specifies nothing about argument naming — `inputSchema` is plain JSON Schema — so this is a free choice, and camelCase matches .NET convention, the REST API's `JsonSerializerDefaults.Web` output, and the current MCP surface. Existing MCP clients keep working unchanged. The cost lands on `NovelAgentToolset`'s 33 hand-built schemas, which are today the only snake_case thing in the repo and must be rewritten.
- **Tool names stay snake_case** (`list_novels`, `get_novel_brief`) — that part *is* genuine MCP convention, and both surfaces already agree on it.
## Design
A single registry — the existing `AgentTool` shape in `NovelAgentToolset`, extended to all 45 tools — with two thin adapters over it.
**MCP adapter.** Register the SDK's dynamic-tools handlers rather than 45 attribute-decorated methods:
```csharp
services.AddMcpServer(o => o.ServerInfo = new Implementation { Name = "novelly", Version = "1.0.0" })
.WithHttpTransport()
.WithListToolsHandler((request, ct) => ...)
.WithCallToolHandler((request, ct) => ...);
```
Both handlers resolve `request.Services!.GetRequiredService<NovelAgentToolset>()` per request and delegate to a pure adapter class. This avoids fighting the SDK's schema inference (`McpServerToolCreateOptions` has no `InputSchema` property) and avoids hoisting the scoped, nine-dependency toolset into a static catalog, which `WithTools(IEnumerable<McpServerTool>)` would force.
**Agent adapter.** Unchanged — `NovelAgentService.SendMessageAsync``toolset.ExecuteAsync(name, novelId, input, ct)`, with `novelId` still ambient from the route and never shown to the model.
**Novel scoping, one entry / two shapes.** `AgentTool` and `AgentToolDefinition` each gain a trailing `bool RequiresNovelId = false`, so the 33 existing construction sites keep compiling. The MCP adapter injects a required `novelId` property into the advertised schema for those tools and extracts it at call time; the agent adapter supplies it from the route. `list_novels` / `create_novel` need no scope at all.
The 11 currently novel-scoped tools are identifiable mechanically — `grep -n 'async (novelId' src/Novelly.Api/Agent/NovelAgentToolset.cs`: `get_novel_brief`, `update_novel_brief`, `list_characters`, `create_character`, `list_tags`, `list_locations`, `list_chapters`, `create_chapter`, `get_character_beats`, `list_open_questions`, `raise_open_question`. Of the 12 new tools, `create_tag` and `create_location` are novel-scoped; the other ten are child-id-scoped or unscoped.
### Verified before planning
The design rests on SDK behaviour that build-and-test would not catch, so it was checked against a running probe app rather than inferred from docs:
- `WithListToolsHandler` / `WithCallToolHandler` exist in `ModelContextProtocol` 2.1.0; `Tool.InputSchema` is a settable `JsonElement` whose setter validates exactly what `JsonSchemaBuilder.Build()` already emits.
- **`capabilities.tools` *is* advertised** on `initialize` with handlers and no `ToolCollection` — this was the main open risk and it is closed.
- **`request.Services` is non-null and yields a fresh DI scope per request** (three calls returned three distinct scope ids). This is what makes the scoped `NovelDbContext` and `NovelAgentToolset` correct here. `HttpServerTransportOptions.Stateless` defaults to `true` and `PerSessionExecutionContext` to `false` in 2.1.0, so no options need restating.
- A hand-built `JsonElement` schema survives verbatim onto `tools/list` output, and `IsError` maps to `result.isError` rather than a JSON-RPC error object.
- `GET /mcp` returns 405 in stateless mode. Harmless; clients only POST.
## Chunks
Each builds, tests and commits independently.
### Chunk 0 — Fix the cross-novel conversation leak
Independent; do it first to keep it out of the main diff.
`NovelAgentService.FindConversationAsync(Guid conversationId, ...)` matches on id alone, so a conversation belonging to novel X can be continued under novel Y's route, after which every tool call runs against Y with X's transcript. Add an optional novel filter, passed from `SendMessageAsync` only — `GetConversationAsync`/`DeleteConversationAsync` are reached via `/api/conversations/{id}`, which has no novel in the route, and keep passing `null`. Log the miss at Warning with `{ConversationId}`/`{NovelId}`.
**Files:** `src/Novelly.Api/Agent/NovelAgentService.cs`, `tests/Novelly.Api.Tests/NovelAgentServiceTests.cs`
**Tests:** `Continuing_a_conversation_under_a_different_novel_is_rejected`, plus `Continuing_a_conversation_under_its_own_novel_still_works` as the guard against over-tightening.
### Chunk 1 — One registry, all 45 tools (no MCP wiring yet)
Delivers the parity decision on its own, verifiable through the existing web agent.
- `src/Novelly.Api/Agent/AgentContracts.cs` — add `RequiresNovelId` to `AgentToolDefinition`. Safe: `AnthropicAgentModelClient.ToSdkTool` maps `Name`/`Description`/`InputSchema` explicitly, so the flag never reaches the model.
- `src/Novelly.Api/Agent/NovelAgentToolset.cs` — add `RequiresNovelId` to `AgentTool`, set it on the 11 tools above, flow it into `Definitions`, and add the 12 new tools reusing the existing `OrNotFound` / `DeletedOrNotFound` / `ToolNotFound` idioms and the descriptions from the corresponding `src/Novelly.Mcp/Tools/*.cs` methods.
- **Rename the 33 existing schemas to camelCase** in the same file — both the `JsonSchemaBuilder` property keys and the matching `JsonInput` lookup strings, which must stay in lockstep (`.Str("character_id", …)` / `JsonInput.RequiredGuid(input, "character_id")``"characterId"`). Mechanical and contained to this one file, but it is the bulk of the chunk's diff and a mismatched pair fails silently as a missing argument rather than a compile error — so the per-tool tests below are what actually catch it. The 12 new tools are written camelCase from the start, matching the names their `src/Novelly.Mcp/Tools/*.cs` equivalents already used.
The 12 new tools and their existing service calls — no service-layer work is needed:
| tool | scope | service call |
|---|---|---|
| `list_novels` | none | `novels.ListAsync` |
| `create_novel` | none | `novels.CreateAsync` |
| `get_character` | child | `characters.GetAsync` |
| `relate_characters` | child | `characters.AddRelationshipAsync` — note `CreateRelationshipRequest`'s parameter order differs from the old MCP method's |
| `set_arc_stage_beats` | child | `arcs.SetBeatsAsync` |
| `create_tag` | **novel** | `tags.CreateAsync` |
| `update_tag` / `delete_tag` | child | `tags.UpdateAsync` / `DeleteAsync` |
| `create_location` | **novel** | `locations.CreateAsync` |
| `update_location` / `delete_location` | child | `locations.UpdateAsync` / `DeleteAsync` |
| `update_open_question` | child | `questions.UpdateAsync` |
Guid-list arguments follow the existing `JsonInput.Guids` idiom used by `reorder_beats`.
**Tests:** new `tests/Novelly.Api.Tests/NovelAgentToolsetTests.cs`, driving `ExecuteAsync` directly in the style of `ImportAgentToolsetTests.cs` (`ServiceTestFixture` already wires every service the toolset needs). BDD names, one per new capability — e.g. `Relating_two_characters_shows_the_pair_on_both_dossiers`, `Deleting_a_tag_leaves_the_characters_that_carried_it_alone`, `Updating_an_open_question_can_detach_it_from_its_chapter`. Two structural tests carry the most weight:
- `The_toolset_offers_every_tool_the_stdio_server_offered` — assert the 45 names against a hard-coded array. This is the anti-drift test.
- Assert no `RequiresNovelId` tool's schema already declares `novelId`, since the MCP adapter injects it and a duplicate would be silent.
**Runtime verify:** AppHost up, open a novel's agent panel, ask it to create a tag and list tags; confirm in the Aspire trace.
### Chunk 2 — Serve the registry at `/mcp`
- `src/Novelly.Api/Novelly.Api.csproj` — add `ModelContextProtocol.AspNetCore` 2.1.0 (brings Core transitively; don't reference it directly). **Not in the local NuGet cache — first restore needs network.** Pin 2.1.0 to match the verified surface.
- New feature folder `src/Novelly.Api/Mcp/`:
- `NovelMcpTools.cs` — the adapter, as pure static methods: `Describe(definitions)` maps to `Tool` records, injecting `novelId` where `RequiresNovelId`; `CallAsync(toolset, parameters, ct)` serializes arguments to a `JsonElement`, extracts `novelId` when required (`Guid.Empty` otherwise), calls `ExecuteAsync`, and maps `AgentToolResult``CallToolResult`. Catch the `ArgumentException` from a missing/malformed `novelId` and return it as `IsError` rather than letting it escape as a JSON-RPC error. **Log `{Tool}` and `{NovelId}` only — never the arguments, which carry prose (`what_happened`, `synopsis`, `notes`).**
- `McpEndpoints.cs``MapNovelMcp()` calling `app.MapMcp("/mcp")`, matching the repo's `Map*Endpoints` convention.
- `src/Novelly.Api/Common/NovellyServiceRegistration.cs` — the `AddMcpServer(...)` registration shown above.
- `src/Novelly.Api/Program.cs``.MapNovelMcp()` after `UseAuthentication()`/`UseAuthorization()`.
**Auth — the trap.** Do **not** chain `.RequireAuthorization()` onto `MapMcp`. The parameterless overload applies the *default* policy, which authenticates `IdentityConstants.ApplicationScheme` only and would reject the API key. The fallback policy already registered in `NovellyServiceRegistration` lists both that scheme *and* `ServiceApiKeyAuthenticationHandler.SchemeName`, and applies to any endpoint carrying no authorization metadata — which `MapMcp` adds none of. `/mcp` inherits the right protection by doing nothing. If explicitness is wanted, register a named policy listing both schemes; never the parameterless call.
External clients send `X-Novelly-Api-Key: <Auth:ServiceApiKey>`, resolving to `ServiceUser` (Admin), which sees every novel. If `Auth:ServiceApiKey` is unset the service user is never seeded and every call 401s.
CORS needs no change — the origin-restricted default policy is irrelevant to non-browser clients, and stateless mode exposes no `Mcp-Session-Id` header to read.
**Tests:** new `tests/Novelly.Api.Tests/NovelMcpToolsTests.cs`, against the pure adapter methods — no live session, no `WebApplicationFactory`. Cover: all 45 advertised with unique names; novel-scoped tools declare a required `novelId` and child-id tools don't; `list_novels` needs none; every advertised schema is a valid `type: object` (what `Tool.InputSchema`'s setter enforces, worth asserting before the SDK throws at startup); a missing `novelId` and a not-found id both come back as `IsError` results.
**Deliberately not adding a `WebApplicationFactory` harness.** None exists in the repo; adding one means an MVC.Testing reference, overriding the connection string, working around `Program.cs`'s boot-time `MigrateAsync` + `Environment.Exit(1)`, seeding the key, and parsing SSE. Its main payoff — proving the SDK wires up — is delivered more honestly by the curl walkthrough below, which exercises real Kestrel including auth, Serilog and the exception handler. Worth a separate chunk later if a regression harness is wanted.
### Chunk 3 — Delete the stdio server
Only after Chunk 2 is verified, so there's never a window with no MCP surface.
Remove `src/Novelly.Mcp/`, `scripts/publish-mcp.sh`, the `mcp-server/` publish output (gitignored; working-tree cleanup only), and the project line in `Novelly.slnx`. Verified as *not* referencing it: `scripts/ci/build.sh` (publishes the API by path), `prepush.sh`, `test.sh`, both Dockerfiles, the AppHost.
Docs to update:
- `README.md` — fold the `Novelly.Mcp` stack-table row into the API's; rewrite "The MCP server" section (same 45 tools, now in-process at `POST /mcp`, no build step, `X-Novelly-Api-Key` auth). Note that MCP argument names are unchanged (camelCase), so existing clients need no edits, and that `create_novel` is owned by the signed-in user over the web agent but by the service user over MCP.
- `.mcp.json` / `.mcp.json.example` — switch to `type: "http"`, `url: http://localhost:5080/mcp`, with the key in `headers`.
- `.claude/agents/outline-importer.md` — drop the published-binary requirement. Its `tools:` frontmatter is **already stale** (`mcp__novelly__list_projects`, `get_project_brief`, `create_project` exist in neither surface); fix to real names while here.
- `CLAUDE.md` — the Structure list still calls `src/Novelly.Mcp/` the "MCP stdio server" and Verifying still says "drive over stdio JSON-RPC". Propose these edits rather than slipping them in; CLAUDE.md is user-owned.
Leave `docs/plans/api/users_and_roles_plan.md` alone — historical.
**Verify:** `dotnet build Novelly.slnx` and `./scripts/ci/prepush.sh` both pass.
## Verification
Streamable HTTP needs `Accept: application/json, text/event-stream` and replies SSE-framed, so pipe through `sed -n 's/^data: //p'`.
```bash
dotnet user-secrets set Auth:ServiceApiKey devkey -p src/Novelly.Api
ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api
MCP=http://localhost:5080/mcp
H=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "X-Novelly-Api-Key: devkey")
# handshake — expect capabilities.tools present
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' | sed -n 's/^data: //p' | jq .
curl -sS "${H[@]}" -o /dev/null -w '%{http_code}\n' "$MCP" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# expect exactly 45
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | sed -n 's/^data: //p' | jq '.result.tools | length'
# novelId injection: present on list_tags, absent on delete_tag and list_novels
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}' | sed -n 's/^data: //p' \
| jq '.result.tools[] | select(.name=="list_tags" or .name=="delete_tag" or .name=="list_novels") | {name, props:(.inputSchema.properties|keys), required:.inputSchema.required}'
# unscoped call, then a novel-scoped one (proves NovelUserContext resolved the service user in-handler)
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_novels","arguments":{}}}' | sed -n 's/^data: //p' | jq .
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_novel_brief","arguments":{"novelId":"<id from above>"}}}' | sed -n 's/^data: //p' | jq .
# error mapping — expect result.isError true, not a JSON-RPC error
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_tags","arguments":{}}}' | sed -n 's/^data: //p' | jq .
# auth — expect 401 with no key
curl -sS -o /dev/null -w '%{http_code}\n' -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
"$MCP" -d '{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}'
# one write, end to end
curl -sS "${H[@]}" "$MCP" -d '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"create_tag","arguments":{"novelId":"<id>","name":"Salt","color":"#9a4a2f"}}}' | sed -n 's/^data: //p' | jq .
```
Then reconnect a real client: rewrite `.mcp.json` to the `type: "http"` form and run `/mcp` in Claude Code to confirm 45 tools. Finally confirm the web agent still drives the same registry (Aspire up, agent panel, exercise one of the 12 new tools) and that the dashboard shows `/mcp` requests with no prose in the log lines.
## Risks
- **`ModelContextProtocol.AspNetCore` is not cached locally.** First restore needs network.
- **The web agent's argument names change** (snake_case → camelCase) across all 33 existing tools. Nothing external consumes those schemas — they are built fresh per request and handed to the model each turn — so there is no compatibility surface, but a `JsonSchemaBuilder` key left out of step with its `JsonInput` lookup fails silently as a missing argument rather than a compile error. External MCP clients are unaffected: their argument names were already camelCase.
- **`create_novel` ownership differs by surface** — signed-in user vs service user. Not a bug, but surprising; document it.
- **Not fixed here:** child-id-scoped tools carry no novel context in *either* surface, so a caller holding a foreign beat/tag/question id can reach across novels, subject only to `NovelAccessService`. Pre-existing for 20+ tools and unchanged by this work. Worth a follow-up.
- **QA deploy:** once merged, `/mcp` rides the existing API container and its already-exposed port. `MCP_API_KEY` must be set in Gitea for `Auth__ServiceApiKey`, or every MCP call 401s.
@@ -0,0 +1,48 @@
# Frontend Modernization — Output
Implemented per `docs/plans/web/frontend-modernization_plan.md`, all 5 chunks.
## Chunk 1 — Design tokens + primitives
- `src/index.css`: full token rewrite. Dark-first palette (`--canvas`, `--surface`, `--surface-sunken`, `--ink`, `--ink-muted`, `--line`), violet `--accent` default, light-mode override via `prefers-color-scheme` + `data-theme`.
- Five-stage color ramp `--stage-1..5` (violet → blue → coral → teal → gold), shared by `NovelPhase` and `DraftStatus` via `src/api/stage.ts` (`novelPhaseColor`, `draftStatusColor`) — both are 5-step progressions, one hue system backs both.
- Fonts self-hosted via `@fontsource-variable/*` (no CDN dep): Fraunces (display), Inter (UI), Source Serif 4 (prose/markdown), JetBrains Mono (utility). Imported in `src/main.tsx`.
- `src/components/ui.tsx` primitives (`.card`, `.btn`, `.input`, `StatusBadge`, etc.) rebuilt on the new tokens.
## Chunk 2 — Sidebar shell
- `src/pages/NovelLayout.tsx` rebuilt: left sidebar (wordmark, novel title, phase pill, icon nav) replaces the old header + horizontal tab bar. Kills the old back-link-next-to-title layout — top bar is now just a breadcrumb.
- New `src/components/icons.tsx` — small hand-written inline SVG icon set (no icon library dependency).
- **Signature element**: novel's `phase` sets `--accent`/`--accent-soft` for the whole layout, scoped via inline style on the layout root. Nav active state, buttons, focus rings, phase pill all recolor together when phase changes.
- Bug fixed during build: breadcrumb section-matching used a suffix `startsWith` check that broke on exact segment matches (`chapters` vs `chapters/`) — replaced with explicit segment split/compare.
## Chunk 3 — Dashboard rebuild
- `src/pages/DashboardPage.tsx`: quick-actions row now leads the page — **New chapter** (creates + jumps into the editor), **New character** (reuses the add-character modal, now exported from `CharactersPage.tsx`), and **Continue writing** (jumps to the most-recently-updated chapter, or **View chapters** if nothing's drafted).
- Brainstorming phase gets its own pair above the notes field: **Add a character** / **Move to outlining**.
- Renamed `OutliningDashboard``WorkDashboard` (it covers Outlining/Writing/Editing/Complete, not just Outlining — old name was misleading).
- History content (activity graph, recent chapters/characters, tag cloud) unchanged, just repositioned under the new hero row.
## Chunk 4 — Global agent panel
- `/agent` route and `pages/AgentPage.tsx` retired.
- New `src/components/AgentPanel.tsx`: fixed slide-out drawer mounted in `NovelLayout` (shell level), reachable from every page in a novel via the sidebar "Agent" toggle or `g a`. Non-modal — background stays interactive.
- **Context-aware**: panel shows "Talking about {X}" — resolves to the specific chapter/character title on detail pages, falls back to section name elsewhere. Each outgoing message gets a `Context: {label}` line prepended (server has no route awareness, so this is how the agent learns what page you're on); stripped back out and shown as a small "re: …" tag on render rather than raw text in the transcript.
- Compacted the old two-pane (sidebar list + chat) layout into a single column with a conversation-switcher dropdown — panel width doesn't fit a full list rail.
- No backend changes — works within the existing `SendAgentMessageRequest` shape.
## Chunk 5 — Polish
- Global `:focus-visible` ring via `box-shadow` (not `outline`, to avoid clobbering `TagColorPicker`'s outline-based selection indicator or `.input`'s own focus ring). Ring color follows the phase accent.
- Global `prefers-reduced-motion: reduce` override (`!important` on `animation-duration`/`transition-duration`/`scroll-behavior`) — neutralizes the agent panel's slide transition too, since author `!important` beats a normal-priority inline style in the cascade.
- Fixed two leftover hardcoded `#9a4a2f` (old terracotta accent) defaults in `TagColorPicker.tsx` and `TagsPage.tsx` → new violet `#7c5cff`.
- Audited remaining pages (Settings, Locations, Tags, Characters, Chapters) — all inherit cleanly from the chunk-1 primitives already, no stale styling found.
## Verification
Every chunk built clean (`npm run build`) and was clicked through live in Chrome against a local API + SQLite instance — login/signup, novel creation, phase switching (confirmed accent recolor live: violet → blue → coral), chapter/character creation flows, agent panel open/close/context-swap across navigation, keyboard focus ring.
## Deferred / not done
- Sidebar collapse toggle — mentioned in the original plan's layout description ("persistent, icon+label, collapsible") but never implemented; flagged as deferred in chunk 2 and again in chunk 5. Would need its own pass (collapsed-width icon rail, persisted preference).
- No backend/API changes anywhere in this arc — all five chunks were frontend-only.
@@ -0,0 +1,41 @@
# Frontend Modernization Plan
## Design direction
Drop warm-paper/serif "manuscript" look — reads dated, low-contrast, single dull accent. New identity: **phase-driven color**. Novelly already models a novel's lifecycle as phases (`Brainstorming → Outlining → Drafting → Revising → Final`, see `novelPhases`, `StatusBadge` tones). Make that real data drive the whole app's mood instead of hiding in a badge — the active novel's phase sets an accent hue across nav, buttons, focus rings, charts. Writer sees at a glance "I'm in draft mode" vs "polishing." Distinctive, grounded in the product's own model, not decoration.
### Tokens
Color (base neutrals, dark-first):
- `--ink: #14121a` / `--ink-muted: #8b859a`
- `--surface: #1b1825` (panel/card) / `--surface-sunken: #100e17`
- `--canvas: #0c0a12` (app background)
- `--line: #2c2838`
- Light mode mirrors with `--canvas:#f7f6fb`, `--surface:#ffffff`, `--ink:#14121a`
Phase accents (used for `--accent` + `--accent-soft`, swapped by `novel.phase`):
- Brainstorming — `#7c5cff` violet
- Outlining — `#2f8fe0` blue
- Drafting — `#ff7a45` coral
- Revising — `#14b88a` teal
- Final — `#d9a404` gold
Type:
- Display (headlines, dashboard hero, page titles): **Fraunces** — variable serif w/ real character, used large/sparingly
- UI (nav, buttons, body chrome): **Inter**
- Prose editing (chapter/beat text, agent transcript): keep a serif for long-form reading — **Source Serif 4** replaces Iowan/Palatino (renders consistently, not Mac-only)
- Utility/data (counts, timestamps, mono bits): **JetBrains Mono**
Layout: left sidebar nav (persistent, icon+label, collapsible), agent as a right-docked slide-out panel triggered from anywhere (sidebar icon, always visible), main content full-bleed under a slim top bar (breadcrumb + phase pill + user menu — no more "← Novels" link floating left of the title).
Signature element: the phase-accent system itself — nav active states, primary buttons, focus rings, and the dashboard's activity graph all recolor together when phase changes. Nothing else in the app competes for boldness; everything else stays a disciplined dark neutral.
## Chunks (each independently buildable/committable)
1. **Design tokens + primitives** — rewrite `index.css` theme (colors, fonts incl. `@font-face`/Google Fonts imports, spacing), update `ui.tsx` primitives (`btn`, `card`, `input`, `StatusBadge`) to new tokens. No layout changes yet — existing pages just reskin. Fastest way to see the new palette/type everywhere at once.
2. **App shell: sidebar nav** — replace `NovelLayout`'s header+tab-bar with left sidebar (novel switcher, section nav, phase pill), slim top bar. Fixes the back-link-left-of-title complaint structurally. Agent gets a nav icon but no panel yet (still routes to `/agent` page).
3. **Dashboard rebuild** — make it the true home: recent activity + work history (already there) alongside prominent "start new work" actions (new chapter, new character, continue last chapter) above the fold. This is the biggest content/layout change, isolated to one page.
4. **Global agent panel** — extract `AgentPage`'s chat UI into a slide-out panel mounted at the app shell level (outside `<Outlet>`), triggered from the sidebar on any route, passes current route/entity as context. Retire the standalone `/agent` route once panel covers it.
5. **Polish pass** — motion (panel slide, nav active-state transitions, dashboard load-in), empty states, focus-visible/reduced-motion audit, remaining pages (Characters/Chapters/Tags/Locations/Settings) get spacing/type touch-ups to match new primitives from chunk 1.
Suggest reviewing after each chunk before starting the next — chunk 2 and 4 both touch navigation/shell so seeing 12 landed first will make it obvious if the sidebar direction is right before the agent panel builds on top of it.
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Compiles the API (Release) and builds the web SPA. Acts as the compile gate before
# tests/image builds run.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
log "Restoring and publishing Novelly.Api (Release)"
dotnet publish src/Novelly.Api/Novelly.Api.csproj -c Release
log "Installing web dependencies (npm ci)"
npm --prefix src/Novelly.Web ci
log "Building the web client (vite build)"
npm --prefix src/Novelly.Web run build
log "build.sh complete"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Turns the Cobertura output from test.sh into a badge + summary via reportgenerator,
# prints it, appends a build-report summary when running under Actions, and refreshes
# the coverage badge committed at badges/coverage.svg. Readme embeds that badge via a
# relative path, which resolves on both GitHub and Gitea since the same repo content is
# pushed to both remotes.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
ensure_reportgenerator
REPORT_DIR="$CI_ROOT/coverage/report"
log "Generating coverage report with reportgenerator"
reportgenerator \
-reports:"coverage/dotnet/coverage.cobertura.xml" \
-targetdir:"$REPORT_DIR" \
-reporttypes:"Badges;MarkdownSummaryGithub;TextSummary"
cat "$REPORT_DIR/Summary.txt"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
cat "$REPORT_DIR/SummaryGithub.md" >> "$GITHUB_STEP_SUMMARY"
fi
mkdir -p "$CI_ROOT/badges"
cp "$REPORT_DIR/badge_linecoverage.svg" "$CI_ROOT/badges/coverage.svg"
log "coverage.sh complete"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Deploys the freshly-pushed :latest images to the persistent LAN novelly stack and
# waits for the API to report healthy. Unlike a throwaway QA stack, `down` is run
# without `-v` — the novelly-data volume (the author's actual novel) must survive
# every redeploy. Recreating containers against an unchanged image is a no-op, so
# this is safe to re-run.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
registry_login
export API_IMAGE WEB_IMAGE
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
export WEB_PORT="${WEB_PORT:-6173}"
export API_PORT="${API_PORT:-5080}"
export MCP_API_KEY="${MCP_API_KEY:-}"
COMPOSE="docker compose -f deploy/qa/docker-compose.qa.yml"
log "Pulling latest :latest images"
$COMPOSE pull
# Applies pending migrations against the live novelly-data volume using the new image,
# before the running (old-image) stack is touched. If a migration is broken, this fails
# here and the old containers keep serving traffic — `down`/`up` below never runs, so
# there is nothing to roll back.
log "Running preflight migration check"
if ! $COMPOSE --profile tools run --rm migrate; then
fail "migration failed against the new image; old novelly stack left running untouched"
fi
log "Recreating the novelly stack (data volume preserved)"
$COMPOSE down
$COMPOSE up -d
log "Waiting for the API health check"
attempts=30
until $COMPOSE exec -T api curl -fsS http://localhost:8080/api/health > /dev/null 2>&1; do
attempts=$((attempts - 1))
if [[ "$attempts" -le 0 ]]; then
fail "novelly stack did not become healthy in time"
fi
sleep 2
done
log "novelly deployed and healthy at http://localhost:${WEB_PORT}"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Builds the API and web images and tags them with both the current git sha and
# "latest" (the tag the deploy compose stack pulls). Both Dockerfiles are already
# self-contained multi-stage builds (used directly by docker-compose.deploy.yml today),
# so no separate CI-only Dockerfile variant is needed.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
log "Building $API_IMAGE:$GIT_SHA / :latest"
docker build \
-f src/Novelly.Api/Dockerfile \
-t "$API_IMAGE:$GIT_SHA" \
-t "$API_IMAGE:latest" \
.
log "Building $WEB_IMAGE:$GIT_SHA / :latest"
docker build \
-f src/Novelly.Web/Dockerfile \
-t "$WEB_IMAGE:$GIT_SHA" \
-t "$WEB_IMAGE:latest" \
src/Novelly.Web
log "docker-build.sh complete"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Pushes the images built by docker-build.sh (git-sha and latest tags) to the Gitea
# container registry.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
image_names
registry_login
for tag in "$GIT_SHA" latest; do
log "Pushing $API_IMAGE:$tag"
docker push "$API_IMAGE:$tag"
log "Pushing $WEB_IMAGE:$tag"
docker push "$WEB_IMAGE:$tag"
done
log "docker-push.sh complete"
+46
View File
@@ -25,6 +25,10 @@ DOTNET_CHANNEL="${DOTNET_CHANNEL:-10.0}"
# one as a last resort, so a bare runner behaves the same as a dev machine. # one as a last resort, so a bare runner behaves the same as a dev machine.
ensure_dotnet() { ensure_dotnet() {
if command -v dotnet > /dev/null 2>&1; then if command -v dotnet > /dev/null 2>&1; then
# DOTNET_ROOT is unset by default even when dotnet is already on PATH (e.g. a
# per-user install at ~/.dotnet) — apphost binaries like reportgenerator's fail to
# find the runtime without it.
export DOTNET_ROOT="${DOTNET_ROOT:-$(dirname "$(command -v dotnet)")}"
return 0 return 0
fi fi
@@ -51,3 +55,45 @@ ensure_dotnet() {
ensure_node() { ensure_node() {
command -v npm > /dev/null 2>&1 || fail "npm not found on PATH; install Node.js to build the web client" command -v npm > /dev/null 2>&1 || fail "npm not found on PATH; install Node.js to build the web client"
} }
ensure_reportgenerator() {
if command -v reportgenerator > /dev/null 2>&1; then
return 0
fi
local tool_dir="$CI_ROOT/.dotnet-tools"
if [[ ! -x "$tool_dir/reportgenerator" ]]; then
log "reportgenerator not found on PATH; installing dotnet-reportgenerator-globaltool"
dotnet tool install dotnet-reportgenerator-globaltool --tool-path "$tool_dir"
fi
export PATH="$tool_dir:$PATH"
}
# Registry configuration. All values come from the environment (CI secrets or a
# developer's shell) — nothing is hardcoded, per project convention.
REGISTRY="${REGISTRY:-}"
REGISTRY_OWNER="${REGISTRY_OWNER:-}"
REGISTRY_USER="${REGISTRY_USER:-}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
GIT_SHA="$(git -C "$CI_ROOT" rev-parse --short HEAD)"
require_registry_vars() {
[[ -n "$REGISTRY" ]] || fail "REGISTRY env var is required (e.g. git.wampler.us)"
[[ -n "$REGISTRY_OWNER" ]] || fail "REGISTRY_OWNER env var is required (e.g. your gitea org/user)"
}
# Populates API_IMAGE / WEB_IMAGE, e.g. git.wampler.us/wamplerj/novelly-api
image_names() {
require_registry_vars
API_IMAGE="$REGISTRY/$REGISTRY_OWNER/novelly-api"
WEB_IMAGE="$REGISTRY/$REGISTRY_OWNER/novelly-web"
}
registry_login() {
require_registry_vars
[[ -n "$REGISTRY_USER" ]] || fail "REGISTRY_USER env var is required to push images"
[[ -n "$REGISTRY_TOKEN" ]] || fail "REGISTRY_TOKEN env var is required to push images"
log "Logging in to $REGISTRY as $REGISTRY_USER"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Commits the coverage badge refreshed by coverage.sh straight back to the
# branch that triggered this run, so readme.md's relative badges/coverage.svg
# link stays current. GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_REF_NAME are
# default context env vars on both GitHub Actions and Gitea Actions (Gitea's
# engine is GitHub-Actions-compatible); GITHUB_TOKEN must be passed in
# explicitly from the workflow (${{ github.token }}) on both platforms.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
[[ -n "${GITHUB_TOKEN:-}" ]] || fail "GITHUB_TOKEN env var is required to push the badge commit"
[[ -n "${GITHUB_SERVER_URL:-}" ]] || fail "GITHUB_SERVER_URL env var is required to push the badge commit"
[[ -n "${GITHUB_REPOSITORY:-}" ]] || fail "GITHUB_REPOSITORY env var is required to push the badge commit"
[[ -n "${GITHUB_REF_NAME:-}" ]] || fail "GITHUB_REF_NAME env var is required to push the badge commit"
if git diff --quiet -- badges/coverage.svg; then
log "badges/coverage.svg unchanged; nothing to publish"
exit 0
fi
git config user.name "novelly-ci"
git config user.email "ci@novelly.local"
git add badges/coverage.svg
git commit -m "chore: refresh coverage badge [skip ci]"
remote_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${GITHUB_TOKEN}" push "$remote_url" "HEAD:${GITHUB_REF_NAME}"
log "publish-coverage-badge.sh complete"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Runs Novelly.Api.Tests with coverage collection. No web test suite exists yet
# (src/Novelly.Web/package.json has no "test" script) — nothing to run there.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
# Coverlet doesn't clear prior output — on a runner that reuses its workspace
# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past runs would
# otherwise get merged in by coverage.sh and silently skew the combined percentage.
rm -rf "$CI_ROOT/coverage/dotnet"
log "Running Novelly.Api.Tests"
dotnet test tests/Novelly.Api.Tests/Novelly.Api.Tests.csproj -c Release --logger trx \
/p:CollectCoverage=true /p:CoverletOutputFormat=cobertura \
/p:CoverletOutput="$CI_ROOT/coverage/dotnet/" \
/p:Exclude="[Novelly.ServiceDefaults]*" \
/p:ExcludeByFile="**/Data/Migrations/*.cs"
log "test.sh complete"
@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Activity;
public static class ActivityBackfill
{
public static async Task RunAsync(INovelDbContext db, ILogger logger, CancellationToken ct = default)
{
if (await db.ActivityEvents.AnyAsync(ct))
{
return;
}
logger.LogInformation("Backfilling activity events from existing rows");
var events = new List<ActivityEvent>();
var novels = await db.Novels.AsNoTracking().Select(n => new { n.Id, n.CreatedAt }).ToListAsync(ct);
events.AddRange(novels.Select(n => Backfilled(n.Id, ActivityEntityKind.Novel, n.Id, n.CreatedAt)));
var chapters = await db.Chapters.AsNoTracking().Select(c => new { c.Id, c.NovelId, c.CreatedAt, c.WordCount }).ToListAsync(ct);
events.AddRange(chapters.Select(c => Backfilled(c.NovelId, ActivityEntityKind.Chapter, c.Id, c.CreatedAt, c.WordCount)));
var characters = await db.Characters.AsNoTracking().Select(c => new { c.Id, c.NovelId, c.CreatedAt }).ToListAsync(ct);
events.AddRange(characters.Select(c => Backfilled(c.NovelId, ActivityEntityKind.Character, c.Id, c.CreatedAt)));
var arcStages = await db.CharacterArcStages.AsNoTracking().Select(s => new { s.Id, s.CreatedAt, NovelId = s.Character!.NovelId }).ToListAsync(ct);
events.AddRange(arcStages.Select(s => Backfilled(s.NovelId, ActivityEntityKind.ArcStage, s.Id, s.CreatedAt)));
var beats = await db.Beats.AsNoTracking().Select(b => new { b.Id, b.CreatedAt, NovelId = b.Chapter!.NovelId }).ToListAsync(ct);
events.AddRange(beats.Select(b => Backfilled(b.NovelId, ActivityEntityKind.Beat, b.Id, b.CreatedAt)));
var tags = await db.Tags.AsNoTracking().Select(t => new { t.Id, t.NovelId, t.CreatedAt }).ToListAsync(ct);
events.AddRange(tags.Select(t => Backfilled(t.NovelId, ActivityEntityKind.Tag, t.Id, t.CreatedAt)));
var locations = await db.Locations.AsNoTracking().Select(l => new { l.Id, l.NovelId, l.CreatedAt }).ToListAsync(ct);
events.AddRange(locations.Select(l => Backfilled(l.NovelId, ActivityEntityKind.Location, l.Id, l.CreatedAt)));
var questions = await db.OpenQuestions.AsNoTracking().Select(q => new { q.Id, q.NovelId, q.CreatedAt }).ToListAsync(ct);
events.AddRange(questions.Select(q => Backfilled(q.NovelId, ActivityEntityKind.Question, q.Id, q.CreatedAt)));
if (events.Count == 0)
{
return;
}
db.ActivityEvents.AddRange(events);
await db.SaveChangesAsync(ct);
logger.LogInformation("Backfilled {Count} activity events", events.Count);
}
private static ActivityEvent Backfilled(Guid novelId, ActivityEntityKind kind, Guid entityId, DateTimeOffset occurredAt, int wordDelta = 0) =>
new()
{
NovelId = novelId,
UserId = null,
OccurredAt = occurredAt,
DayKey = ActivityDayKey.For(occurredAt),
EntityKind = kind,
Action = ActivityAction.Created,
EntityId = entityId,
WordDelta = wordDelta
};
}
@@ -0,0 +1,5 @@
namespace Novelly.Api.Activity;
public record ActivityDayResponse(DateOnly Date, int Words, int Edits);
public record ActivityCalendarResponse(DateOnly From, DateOnly To, int TotalWords, int TotalEdits, IReadOnlyList<ActivityDayResponse> Days);
@@ -0,0 +1,28 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Activity;
public static class ActivityEndpoints
{
public static IEndpointRouteBuilder MapActivityEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/activity").WithTags("Activity")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, int? days, ActivityService service, CancellationToken ct) =>
Results.Ok(await service.GetForNovelAsync(novelId, days, ct)))
.WithSummary("Get a novel's daily activity calendar.");
var mine = app.MapGroup("/api/activity").WithTags("Activity")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
mine.MapGet("/", async (int? days, ActivityService service, CancellationToken ct) =>
Results.Ok(await service.GetForCurrentUserAsync(days, ct)))
.WithSummary("Get the current user's daily activity calendar across every visible novel.");
return app;
}
}
+74
View File
@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Novels;
using Novelly.Api.Users;
namespace Novelly.Api.Activity;
public enum ActivityEntityKind
{
Novel,
Chapter,
Beat,
Character,
ArcStage,
Tag,
Location,
Question
}
public enum ActivityAction
{
Created,
Updated,
Deleted,
Restored
}
public class ActivityEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; }
public Novel? Novel { get; set; }
public Guid? UserId { get; set; }
public NovellyUser? User { get; set; }
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
public int DayKey { get; set; } = ActivityDayKey.For(DateTimeOffset.UtcNow);
public ActivityEntityKind EntityKind { get; set; }
public ActivityAction Action { get; set; }
public Guid EntityId { get; set; }
public int WordDelta { get; set; }
}
public static class ActivityDayKey
{
public static int For(DateTimeOffset occurredAt) =>
occurredAt.UtcDateTime.Year * 10000 + occurredAt.UtcDateTime.Month * 100 + occurredAt.UtcDateTime.Day;
public static int For(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
public static DateOnly ToDate(int dayKey) => new(dayKey / 10000, dayKey / 100 % 100, dayKey % 100);
}
public class ActivityEventEntityTypeConfiguration : IEntityTypeConfiguration<ActivityEvent>
{
public void Configure(EntityTypeBuilder<ActivityEvent> entity)
{
entity.Property(e => e.EntityKind).HasConversion<string>().HasMaxLength(32);
entity.Property(e => e.Action).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(e => new { e.NovelId, e.DayKey });
entity.HasIndex(e => new { e.UserId, e.DayKey });
entity.HasOne(e => e.Novel).WithMany()
.HasForeignKey(e => e.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User).WithMany()
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.SetNull);
}
}
+28
View File
@@ -0,0 +1,28 @@
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Activity;
public class ActivityLog(INovelDbContext db, INovelUserContext userContext, ILogger<ActivityLog> logger)
{
public void Record(Guid novelId, ActivityEntityKind kind, ActivityAction action, Guid entityId, int wordDelta = 0)
{
var occurredAt = DateTimeOffset.UtcNow;
logger.LogDebug(
"Recording activity {Action} on {EntityKind} {EntityId} for novel {NovelId}, word delta {WordDelta}",
action, kind, entityId, novelId, wordDelta);
db.ActivityEvents.Add(new ActivityEvent
{
NovelId = novelId,
UserId = userContext.UserId,
OccurredAt = occurredAt,
DayKey = ActivityDayKey.For(occurredAt),
EntityKind = kind,
Action = action,
EntityId = entityId,
WordDelta = wordDelta
});
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Activity;
public class ActivityService(INovelDbContext db, NovelAccessService access, ILogger<ActivityService> logger)
{
private const int MinDays = 1;
private const int MaxDays = 400;
private const int DefaultDays = 365;
public async Task<ActivityCalendarResponse> GetForNovelAsync(Guid novelId, int? days, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Getting activity calendar for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await BuildCalendarAsync(db.ActivityEvents.Where(e => e.NovelId == novelId), days, ct);
}
public async Task<ActivityCalendarResponse> GetForCurrentUserAsync(int? days, CancellationToken ct = default)
{
logger.LogInformation("Getting activity calendar for current user");
var visibleNovelIds = await access.VisibleNovels().Select(n => n.Id).ToListAsync(ct);
return await BuildCalendarAsync(db.ActivityEvents.Where(e => visibleNovelIds.Contains(e.NovelId)), days, ct);
}
private static async Task<ActivityCalendarResponse> BuildCalendarAsync(
IQueryable<ActivityEvent> query, int? requestedDays, CancellationToken ct)
{
var days = Math.Clamp(requestedDays ?? DefaultDays, MinDays, MaxDays);
var to = DateOnly.FromDateTime(DateTime.UtcNow);
var from = to.AddDays(-(days - 1));
var fromKey = ActivityDayKey.For(from);
var grouped = await query
.Where(e => e.DayKey >= fromKey)
.GroupBy(e => e.DayKey)
.Select(g => new { DayKey = g.Key, Words = g.Sum(e => e.WordDelta), Edits = g.Count() })
.ToListAsync(ct);
var responseDays = grouped
.OrderBy(g => g.DayKey)
.Select(g => new ActivityDayResponse(ActivityDayKey.ToDate(g.DayKey), g.Words, g.Edits))
.ToList();
return new ActivityCalendarResponse(
from,
to,
responseDays.Sum(d => d.Words),
responseDays.Sum(d => d.Edits),
responseDays);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ using System.Text.Json;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema); public record AgentToolDefinition(string Name, string Description, JsonElement InputSchema, bool RequiresNovelId = false);
public abstract record AgentContentBlock; public abstract record AgentContentBlock;
+3 -3
View File
@@ -1,14 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public class AgentConversation public class AgentConversation
{ {
public Guid Id { get; init; } = Guid.NewGuid(); public Guid Id { get; init; } = Guid.NewGuid();
public Guid ProjectId { get; init; } public Guid NovelId { get; init; }
public Project? Project { get; init; } public Novel? Novel { get; init; }
public string Title { get; init; } = "New conversation"; public string Title { get; init; } = "New conversation";
+8 -8
View File
@@ -7,22 +7,22 @@ public static class AgentEndpoints
{ {
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/agent").WithTags("Agent")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/conversations", async ( novelScoped.MapGet("/conversations", async (
Guid projectId, NovelAgentService agent, CancellationToken ct) => Guid novelId, NovelAgentService agent, CancellationToken ct) =>
Results.Ok(await agent.ListConversationsAsync(projectId, ct))) Results.Ok(await agent.ListConversationsAsync(novelId, ct)))
.WithSummary("List the project's agent conversations."); .WithSummary("List the novel's agent conversations.");
projectScoped.MapPost("/messages", async ( novelScoped.MapPost("/messages", async (
Guid projectId, Guid novelId,
SendAgentMessageRequest request, SendAgentMessageRequest request,
NovelAgentService agent, NovelAgentService agent,
CancellationToken ct) => CancellationToken ct) =>
{ {
var reply = await agent.SendMessageAsync(projectId, request, ct); var reply = await agent.SendMessageAsync(novelId, request, ct);
return reply is null return reply is null
? Results.NotFound() ? Results.NotFound()
: Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse())); : Results.Ok(new AgentTurnResponse(reply.ConversationId, reply.ToResponse()));
+3 -3
View File
@@ -4,9 +4,9 @@ using Novelly.Api.Common.Validation;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public record ConversationSummaryResponse(Guid Id, Guid ProjectId, string Title, int MessageCount, DateTimeOffset UpdatedAt); public record ConversationSummaryResponse(Guid Id, Guid NovelId, string Title, int MessageCount, DateTimeOffset UpdatedAt);
public record ConversationResponse(Guid Id, Guid ProjectId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt); public record ConversationResponse(Guid Id, Guid NovelId, string Title, IReadOnlyList<AgentMessageResponse> Messages, DateTimeOffset UpdatedAt);
public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt); public record AgentMessageResponse(Guid Id, AgentRole Role, string Content, IReadOnlyList<ToolCallResponse> ToolCalls, DateTimeOffset CreatedAt);
@@ -46,7 +46,7 @@ public static class AgentMapping
public static ConversationResponse ToResponse(this AgentConversation conversation) => new( public static ConversationResponse ToResponse(this AgentConversation conversation) => new(
conversation.Id, conversation.Id,
conversation.ProjectId, conversation.NovelId,
conversation.Title, conversation.Title,
[.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())], [.. conversation.Messages.OrderBy(m => m.Sequence).Select(m => m.ToResponse())],
conversation.UpdatedAt); conversation.UpdatedAt);
+43 -35
View File
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Options;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
@@ -26,14 +26,14 @@ public class NovelAgentService(
private readonly AgentOptions _options = options.Value; private readonly AgentOptions _options = options.Value;
public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync( public async Task<IReadOnlyList<ConversationSummaryResponse>> ListConversationsAsync(
Guid projectId, CancellationToken ct = default) Guid novelId, CancellationToken ct = default)
{ {
logger.LogInformation("Listing agent conversations for project {ProjectId}", projectId); logger.LogInformation("Listing agent conversations for novel {NovelId}", novelId);
return await db.Conversations return await db.Conversations
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.OrderByDescending(c => c.UpdatedAt) .OrderByDescending(c => c.UpdatedAt)
.Select(c => new ConversationSummaryResponse(c.Id, c.ProjectId, c.Title, c.Messages.Count, c.UpdatedAt)) .Select(c => new ConversationSummaryResponse(c.Id, c.NovelId, c.Title, c.Messages.Count, c.UpdatedAt))
.ToListAsync(ct); .ToListAsync(ct);
} }
@@ -43,7 +43,7 @@ public class NovelAgentService(
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId); logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
return await FindConversationAsync(conversationId, ct); return await FindConversationAsync(conversationId, null, ct);
} }
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default) public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
@@ -52,7 +52,7 @@ public class NovelAgentService(
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId); logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
var conversation = await FindConversationAsync(conversationId, ct); var conversation = await FindConversationAsync(conversationId, null, ct);
if (conversation is null) if (conversation is null)
{ {
return false; return false;
@@ -63,39 +63,39 @@ public class NovelAgentService(
return true; return true;
} }
public async Task<AgentMessage?> SendMessageAsync(Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) public async Task<AgentMessage?> SendMessageAsync(Guid novelId, SendAgentMessageRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
sendMessageValidator.Validate(request).ThrowIfInvalid(logger); sendMessageValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation( logger.LogInformation(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}", "Sending agent message for novel {NovelId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length); novelId, request.ConversationId, request.Message.Length);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct); var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == novelId, ct);
if (project is null) if (novel is null)
{ {
logger.LogWarning("Project {ProjectId} not found", projectId); logger.LogWarning("Novel {NovelId} not found", novelId);
return null; return null;
} }
var conversation = request.ConversationId is { } id var conversation = request.ConversationId is { } id
? await FindConversationAsync(id, ct) ? await FindConversationAsync(id, novelId, ct)
: StartConversation(projectId, request.Message); : StartConversation(novelId, request.Message);
if (conversation is null) return null; if (conversation is null) return null;
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct); await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
var systemPrompt = BuildSystemPrompt(project); var systemPrompt = BuildSystemPrompt(novel);
var transcript = BuildTranscript(conversation); var transcript = BuildTranscript(conversation);
var toolCalls = new List<ToolCallResponse>(); var toolCalls = new List<ToolCallResponse>();
var text = new StringBuilder(); var text = new StringBuilder();
for (var iteration = 0; iteration < _options.MaxIterations; iteration++) for (var iteration = 0; iteration < _options.MaxIterations; iteration++)
{ {
logger.LogDebug("Agent iteration {Iteration} for project {ProjectId}", iteration, projectId); logger.LogDebug("Agent iteration {Iteration} for novel {NovelId}", iteration, novelId);
var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct); var response = await model.CompleteAsync(systemPrompt, transcript, toolset.Definitions, ct);
@@ -113,9 +113,9 @@ public class NovelAgentService(
var results = new List<AgentContentBlock>(); var results = new List<AgentContentBlock>();
foreach (var call in requestedTools) foreach (var call in requestedTools)
{ {
var outcome = await toolset.ExecuteAsync(call.Name, projectId, call.Input, ct); var outcome = await toolset.ExecuteAsync(call.Name, novelId, call.Input, ct);
logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on project {ProjectId} {Outcome}", call.Name, projectId, outcome.IsError ? "failed" : "succeeded"); logger.Log(outcome.IsError ? LogLevel.Warning : LogLevel.Information, "Agent tool {Tool} on novel {NovelId} {Outcome}", call.Name, novelId, outcome.IsError ? "failed" : "succeeded");
toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content)); toolCalls.Add(new ToolCallResponse(call.Name, call.Input.ToString(), outcome.Content));
results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError)); results.Add(new AgentToolResultBlock(call.Id, outcome.Content, outcome.IsError));
@@ -125,7 +125,7 @@ public class NovelAgentService(
if (iteration != _options.MaxIterations - 1) continue; if (iteration != _options.MaxIterations - 1) continue;
logger.LogWarning("Agent hit the {Max}-iteration ceiling on project {ProjectId}", _options.MaxIterations, projectId); logger.LogWarning("Agent hit the {Max}-iteration ceiling on novel {NovelId}", _options.MaxIterations, novelId);
text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._"); text.AppendLine("_I reached my tool-call limit for this turn. Ask me to continue if there's more to do._");
} }
@@ -165,23 +165,23 @@ public class NovelAgentService(
return message; return message;
} }
private AgentConversation StartConversation(Guid projectId, string firstMessage) private AgentConversation StartConversation(Guid novelId, string firstMessage)
{ {
logger.LogDebug("Starting new agent conversation for project {ProjectId}", projectId); logger.LogDebug("Starting new agent conversation for novel {NovelId}", novelId);
var conversation = new AgentConversation var conversation = new AgentConversation
{ {
ProjectId = projectId, NovelId = novelId,
Title = Summarise(firstMessage) Title = Summarise(firstMessage)
}; };
db.Conversations.Add(conversation); db.Conversations.Add(conversation);
logger.LogDebug("Started agent conversation {ConversationId} for project {ProjectId}", conversation.Id, projectId); logger.LogDebug("Started agent conversation {ConversationId} for novel {NovelId}", conversation.Id, novelId);
return conversation; return conversation;
} }
private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, CancellationToken ct) private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, Guid? novelId, CancellationToken ct)
{ {
logger.LogDebug("Finding agent conversation {ConversationId}", conversationId); logger.LogDebug("Finding agent conversation {ConversationId}", conversationId);
@@ -195,6 +195,14 @@ public class NovelAgentService(
return conversation; return conversation;
} }
if (novelId is { } expectedNovelId && conversation.NovelId != expectedNovelId)
{
logger.LogWarning(
"AgentConversation {ConversationId} belongs to novel {ActualNovelId}, not requested novel {NovelId}",
conversationId, conversation.NovelId, expectedNovelId);
return null;
}
logger.LogDebug("Found agent conversation {ConversationId}", conversationId); logger.LogDebug("Found agent conversation {ConversationId}", conversationId);
return conversation; return conversation;
} }
@@ -209,25 +217,25 @@ public class NovelAgentService(
[new AgentTextBlock(m.Content)])) [new AgentTextBlock(m.Content)]))
]; ];
private static string BuildSystemPrompt(Project project) private static string BuildSystemPrompt(Novel novel)
{ {
var brief = new StringBuilder(); var brief = new StringBuilder();
brief.AppendLine($"Title: {project.Title}"); brief.AppendLine($"Title: {novel.Title}");
if (!string.IsNullOrWhiteSpace(project.Genre)) brief.AppendLine($"Genre: {project.Genre}"); if (!string.IsNullOrWhiteSpace(novel.Genre)) brief.AppendLine($"Genre: {novel.Genre}");
if (!string.IsNullOrWhiteSpace(project.Logline)) brief.AppendLine($"Logline: {project.Logline}"); if (!string.IsNullOrWhiteSpace(novel.Logline)) brief.AppendLine($"Logline: {novel.Logline}");
if (project.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words"); if (novel.TargetWordCount is { } target) brief.AppendLine($"Target length: {target:N0} words");
return $""" return $"""
You are a developmental editor and writing partner embedded in the software the You are a developmental editor and writing partner embedded in the software the
writer is using to plan their novel. You have tools that read and write the writer is using to plan their novel. You have tools that read and write the
project's real data: the brief, character dossiers, the outline (beats) and each novel's real data: the brief, character dossiers, the outline (beats) and each
chapter's drafted prose. chapter's drafted prose.
The project you are working on: The novel you are working on:
{brief} {brief}
Working principles: Working principles:
- Read before you write. Call get_project_brief, get_outline, or list_characters - Read before you write. Call get_novel_brief, get_outline, or list_characters
to ground yourself rather than assuming what is already there. to ground yourself rather than assuming what is already there.
- The book is the writer's. Ask about the choices that define the story — what a - The book is the writer's. Ask about the choices that define the story — what a
character wants, what the ending costs them — instead of deciding for them. character wants, what the ending costs them — instead of deciding for them.
@@ -239,7 +247,7 @@ public class NovelAgentService(
genuinely in tension, what the outline is missing — over line-level polish, genuinely in tension, what the outline is missing — over line-level polish,
unless the writer asks for prose. unless the writer asks for prose.
- When drafting a chapter's prose, match the voice already established in the - When drafting a chapter's prose, match the voice already established in the
project. Write the chapter, then stop; do not append notes about your choices. novel. Write the chapter, then stop; do not append notes about your choices.
- Destructive operations (deleting outline nodes) need the writer's explicit - Destructive operations (deleting outline nodes) need the writer's explicit
go-ahead first. go-ahead first.
File diff suppressed because it is too large Load Diff
+3
View File
@@ -19,6 +19,8 @@ public class Beat
public List<Character> Characters { get; set; } = []; public List<Character> Characters { get; set; } = [];
public List<CharacterArcStage> ArcStages { get; set; } = [];
public string? WhatHappened { get; set; } public string? WhatHappened { get; set; }
public string? WhatsNext { get; set; } public string? WhatsNext { get; set; }
@@ -35,6 +37,7 @@ public class BeatEntityTypeConfiguration : IEntityTypeConfiguration<Beat>
{ {
entity.Property(b => b.Title).IsRequired().HasMaxLength(200); entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(b => new { b.ChapterId, b.SortOrder }); entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
entity.HasQueryFilter(b => b.Chapter!.DeletedAt == null);
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats) entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
+8 -4
View File
@@ -81,10 +81,12 @@ public record CharacterBeatResponse(
Guid ChapterId, Guid ChapterId,
int ChapterNumber, int ChapterNumber,
string ChapterTitle, string ChapterTitle,
string ChapterLabel,
int SortOrder, int SortOrder,
string Title, string Title,
string? WhatHappened, string? WhatHappened,
string? WhatsNext); string? WhatsNext,
Guid? ArcStageId);
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds); public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
@@ -144,19 +146,21 @@ public static class BeatMapping
b.ChapterId, b.ChapterId,
b.SortOrder, b.SortOrder,
b.Title, b.Title,
[.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))], [.. b.Characters.Where(c => c.DeletedAt is null).OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
b.WhatHappened, b.WhatHappened,
b.WhatsNext, b.WhatsNext,
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
b.UpdatedAt); b.UpdatedAt);
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b) => new( public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b, Guid characterId, string? chapterLabel = null) => new(
b.Id, b.Id,
b.ChapterId, b.ChapterId,
b.Chapter?.Number ?? 0, b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)", b.Chapter?.Title ?? "(unknown chapter)",
chapterLabel ?? b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder, b.SortOrder,
b.Title, b.Title,
b.WhatHappened, b.WhatHappened,
b.WhatsNext); b.WhatsNext,
b.ArcStages.FirstOrDefault(s => s.CharacterId == characterId)?.Id);
} }
+16 -2
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
@@ -45,8 +46,21 @@ public static class BeatEndpoints
.WithSummary("Move one or more beats to another chapter, appending them to its end."); .WithSummary("Move one or more beats to another chapter, appending them to its end.");
app.MapGet("/api/characters/{characterId:guid}/beats", async ( app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) => Guid characterId, BeatService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult()) {
var characterBeats = await service.ListForCharacterAsync(characterId, ct);
if (characterBeats is null)
{
return Results.NotFound();
}
var displayNumbers = characterBeats.Count > 0
? await chapterLabels.ForNovelAsync(characterBeats[0].Chapter!.NovelId, ct)
: new Dictionary<Guid, int>();
return Results.Ok(characterBeats.Select(b =>
b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers))).ToList());
})
.WithTags("Beats") .WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order."); .WithSummary("Every beat this character appears in, in manuscript order.");
+57 -34
View File
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
@@ -11,8 +12,9 @@ namespace Novelly.Api.Beats;
public class BeatService( public class BeatService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
ActivityLog activity,
ILogger<BeatService> logger, ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator, IModelValidator<CreateBeatRequest> createValidator,
IModelValidator<UpdateBeatRequest> updateValidator, IModelValidator<UpdateBeatRequest> updateValidator,
@@ -26,7 +28,7 @@ public class BeatService(
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId); logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Read, ct); await RequireChapterAccessAsync(chapterId, NovelPermission.Read, ct);
return await Query() return await Query()
.Where(b => b.ChapterId == chapterId) .Where(b => b.ChapterId == chapterId)
@@ -46,7 +48,7 @@ public class BeatService(
return null; return null;
} }
await RequireBeatAccessAsync(beat, ProjectPermission.Read, ct); await RequireBeatAccessAsync(beat, NovelPermission.Read, ct);
return beat; return beat;
} }
@@ -57,17 +59,18 @@ public class BeatService(
logger.LogInformation("Listing beats for character {CharacterId}", characterId); logger.LogInformation("Listing beats for character {CharacterId}", characterId);
var characterProjectId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.ProjectId).FirstOrDefaultAsync(ct); var characterNovelId = await db.Characters.Where(c => c.Id == characterId).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct);
if (characterProjectId is null) if (characterNovelId is null)
{ {
logger.LogWarning("Character {CharacterId} not found", characterId); logger.LogWarning("Character {CharacterId} not found", characterId);
return null; return null;
} }
await access.RequireAsync(characterProjectId.Value, ProjectPermission.Read, ct); await access.RequireAsync(characterNovelId.Value, NovelPermission.Read, ct);
var beats = await db.Beats var beats = await db.Beats
.Include(b => b.Chapter) .Include(b => b.Chapter)
.Include(b => b.ArcStages.Where(s => s.Character!.DeletedAt == null))
.Where(b => b.Characters.Any(c => c.Id == characterId)) .Where(b => b.Characters.Any(c => c.Id == characterId))
.ToListAsync(ct); .ToListAsync(ct);
@@ -94,7 +97,7 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.CreateContent, ct);
var beat = new Beat var beat = new Beat
{ {
@@ -107,15 +110,18 @@ public class BeatService(
if (request.CharacterIds is { } characterIds) if (request.CharacterIds is { } characterIds)
{ {
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct); beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
} }
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
} }
chapter.UpdatedAt = DateTimeOffset.UtcNow;
db.Beats.Add(beat); db.Beats.Add(beat);
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Created, beat.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(beat.Id, ct))!; return (await FindAsync(beat.Id, ct))!;
@@ -142,24 +148,26 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title; beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder; beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened); beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext); beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
beat.UpdatedAt = DateTimeOffset.UtcNow; beat.UpdatedAt = DateTimeOffset.UtcNow;
chapter.UpdatedAt = beat.UpdatedAt;
if (request.CharacterIds is { } characterIds) if (request.CharacterIds is { } characterIds)
{ {
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct); beat.Characters = await ResolveCharactersAsync(chapter.NovelId, characterIds, ct);
} }
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); beat.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
} }
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Updated, beat.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!; return (await FindAsync(id, ct))!;
} }
@@ -176,7 +184,14 @@ public class BeatService(
return false; return false;
} }
await RequireBeatAccessAsync(beat, ProjectPermission.DeleteContent, ct); await RequireBeatAccessAsync(beat, NovelPermission.DeleteContent, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is not null)
{
chapter.UpdatedAt = DateTimeOffset.UtcNow;
activity.Record(chapter.NovelId, ActivityEntityKind.Beat, ActivityAction.Deleted, beat.Id);
}
db.Beats.Remove(beat); db.Beats.Remove(beat);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -192,7 +207,7 @@ public class BeatService(
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId); logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
await RequireChapterAccessAsync(chapterId, ProjectPermission.Write, ct); await RequireChapterAccessAsync(chapterId, NovelPermission.Write, ct);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct); var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
@@ -214,6 +229,9 @@ public class BeatService(
beat.SortOrder = order++; beat.SortOrder = order++;
} }
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is not null) chapter.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return await ListAsync(chapterId, ct); return await ListAsync(chapterId, ct);
} }
@@ -236,15 +254,15 @@ public class BeatService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var character = await db.Characters var character = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct); .FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.NovelId == chapter.NovelId, ct);
if (character is null) if (character is null)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected character assignment: character {CharacterId} not found in project {ProjectId}", "Rejected character assignment: character {CharacterId} not found in novel {NovelId}",
request.CharacterId, chapter.ProjectId); request.CharacterId, chapter.NovelId);
return null; return null;
} }
@@ -261,6 +279,7 @@ public class BeatService(
{ {
beat.Characters.Add(character); beat.Characters.Add(character);
beat.UpdatedAt = DateTimeOffset.UtcNow; beat.UpdatedAt = DateTimeOffset.UtcNow;
chapter.UpdatedAt = beat.UpdatedAt;
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -286,16 +305,16 @@ public class BeatService(
} }
var targetChapter = await db.Chapters.FirstOrDefaultAsync( var targetChapter = await db.Chapters.FirstOrDefaultAsync(
c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct); c => c.Id == request.TargetChapterId && c.NovelId == chapter.NovelId, ct);
if (targetChapter is null) if (targetChapter is null)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}", "Rejected beat move: target chapter {TargetChapterId} not found in novel {NovelId}",
request.TargetChapterId, chapter.ProjectId); request.TargetChapterId, chapter.NovelId);
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct); var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
@@ -311,21 +330,25 @@ public class BeatService(
} }
var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct); var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct);
var now = DateTimeOffset.UtcNow;
foreach (var beatId in request.BeatIds) foreach (var beatId in request.BeatIds)
{ {
var beat = beats.Single(b => b.Id == beatId); var beat = beats.Single(b => b.Id == beatId);
beat.ChapterId = request.TargetChapterId; beat.ChapterId = request.TargetChapterId;
beat.SortOrder = nextSortOrder++; beat.SortOrder = nextSortOrder++;
beat.UpdatedAt = DateTimeOffset.UtcNow; beat.UpdatedAt = now;
} }
chapter.UpdatedAt = now;
targetChapter.UpdatedAt = now;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return beats; return beats;
} }
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct) private async Task<List<Character>> ResolveCharactersAsync(Guid novelId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
{ {
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId); logger.LogDebug("Resolving {Count} characters for novel {NovelId}", characterIds.Count, novelId);
var distinct = characterIds.Distinct().ToList(); var distinct = characterIds.Distinct().ToList();
if (distinct.Count == 0) if (distinct.Count == 0)
@@ -334,17 +357,17 @@ public class BeatService(
} }
var found = await db.Characters var found = await db.Characters
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id)) .Where(c => c.NovelId == novelId && distinct.Contains(c.Id))
.ToListAsync(ct); .ToListAsync(ct);
if (found.Count != distinct.Count) if (found.Count != distinct.Count)
{ {
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId); logger.LogWarning("Rejected beat reference: one or more characters do not belong to novel {NovelId}", novelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"A beat's characters must belong to the same project as its chapter."); "A beat's characters must belong to the same novel as its chapter.");
} }
logger.LogDebug("Resolved {Count} characters for project {ProjectId}", found.Count, projectId); logger.LogDebug("Resolved {Count} characters for novel {NovelId}", found.Count, novelId);
return found; return found;
} }
@@ -361,18 +384,18 @@ public class BeatService(
return next; return next;
} }
private async Task RequireChapterAccessAsync(Guid chapterId, ProjectPermission permission, CancellationToken ct) private async Task RequireChapterAccessAsync(Guid chapterId, NovelPermission permission, CancellationToken ct)
{ {
var projectId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct); var novelId = await db.Chapters.Where(c => c.Id == chapterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(projectId, permission, ct); await access.RequireAsync(novelId, permission, ct);
} }
private Task RequireBeatAccessAsync(Beat beat, ProjectPermission permission, CancellationToken ct) => private Task RequireBeatAccessAsync(Beat beat, NovelPermission permission, CancellationToken ct) =>
RequireChapterAccessAsync(beat.ChapterId, permission, ct); RequireChapterAccessAsync(beat.ChapterId, permission, ct);
private IQueryable<Beat> Query() => private IQueryable<Beat> Query() =>
db.Beats db.Beats
.Include(b => b.Characters) .Include(b => b.Characters.Where(c => c.DeletedAt == null))
.Include(b => b.Tags); .Include(b => b.Tags);
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct) private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
+12 -6
View File
@@ -2,24 +2,26 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
public class Chapter public class Chapter : ISoftDeletable
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public int Number { get; set; } public int Number { get; set; }
public ChapterKind Kind { get; set; } = ChapterKind.Body;
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string? Summary { get; set; } public string? Summary { get; set; }
public string? Setting { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public DraftStatus Status { get; set; } = DraftStatus.Planned; public DraftStatus Status { get; set; } = DraftStatus.Planned;
@@ -31,10 +33,12 @@ public class Chapter
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
public List<Beat> Beats { get; set; } = []; public List<Beat> Beats { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
public List<Location> Locations { get; set; } = [];
} }
public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter> public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
@@ -43,6 +47,8 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
{ {
entity.Property(c => c.Title).IsRequired().HasMaxLength(300); entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.ProjectId, c.Number }); entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.NovelId, c.Number });
entity.HasQueryFilter(c => c.DeletedAt == null);
} }
} }
+26 -17
View File
@@ -1,17 +1,20 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Locations;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
public record ChapterSummaryResponse( public record ChapterSummaryResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
int Number, int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title, string Title,
string? Summary, string? Summary,
string? Setting, IReadOnlyList<LocationResponse> Locations,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
int BeatCount, int BeatCount,
@@ -21,11 +24,13 @@ public record ChapterSummaryResponse(
public record ChapterResponse( public record ChapterResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
int Number, int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title, string Title,
string? Summary, string? Summary,
string? Setting, IReadOnlyList<LocationResponse> Locations,
string? Notes, string? Notes,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
@@ -38,8 +43,9 @@ public record ChapterResponse(
public record CreateChapterRequest( public record CreateChapterRequest(
string Title, string Title,
int? Number = null, int? Number = null,
ChapterKind Kind = ChapterKind.Body,
string? Summary = null, string? Summary = null,
string? Setting = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
DraftStatus Status = DraftStatus.Planned, DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null, int? TargetWordCount = null,
@@ -53,7 +59,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
var result = new ValidationResult(); var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200); result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result; return result;
} }
@@ -62,8 +68,9 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
public record UpdateChapterRequest( public record UpdateChapterRequest(
string? Title = null, string? Title = null,
int? Number = null, int? Number = null,
ChapterKind? Kind = null,
string? Summary = null, string? Summary = null,
string? Setting = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
DraftStatus? Status = null, DraftStatus? Status = null,
int? TargetWordCount = null, int? TargetWordCount = null,
@@ -77,7 +84,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200); result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result; return result;
} }
@@ -86,7 +93,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
file static class ChapterValidation file static class ChapterValidation
{ {
public static void OptionalFields( public static void OptionalFields(
int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose, int? number, string? summary, IReadOnlyList<string>? locations, string? notes, int? targetWordCount, string? prose,
IReadOnlyList<string>? tags, ValidationResult result) IReadOnlyList<string>? tags, ValidationResult result)
{ {
if (number is <= 0) if (number is <= 0)
@@ -95,8 +102,8 @@ file static class ChapterValidation
if (summary is { Length: > 20000 }) if (summary is { Length: > 20000 })
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer."); result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
if (setting is { Length: > 500 }) if (locations is not null && locations.Any(string.IsNullOrWhiteSpace))
result.AddError("Setting", "'Setting' must be 500 characters or fewer."); result.AddError("Locations", "'Locations' must not contain blank entries.");
if (notes is { Length: > 20000 }) if (notes is { Length: > 20000 })
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer."); result.AddError("Notes", "'Notes' must be 20,000 characters or fewer.");
@@ -114,18 +121,20 @@ file static class ChapterValidation
public static class ChapterMapping public static class ChapterMapping
{ {
public static ChapterResponse ToResponse(this Chapter c) => new( public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
c.Setting, c.Notes, [.. c.Locations.Where(l => l.DeletedAt is null).OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
c.Prose, c.WordCount, c.Prose, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
c.Setting, c.Status, c.TargetWordCount, [.. c.Locations.Where(l => l.DeletedAt is null).OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount, c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Chapters;
public class ChapterDisplayNumberLookup(INovelDbContext db)
{
public async Task<IReadOnlyDictionary<Guid, int>> ForNovelAsync(Guid novelId, CancellationToken ct = default)
{
var chapters = await db.Chapters.AsNoTracking().Where(c => c.NovelId == novelId).ToListAsync(ct);
return ChapterNumbering.DisplayNumbers(chapters);
}
public async Task<int?> ForChapterAsync(Chapter chapter, CancellationToken ct = default)
{
if (chapter.Kind != ChapterKind.Body)
return null;
return await db.Chapters.CountAsync(
c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct);
}
public async Task<IReadOnlyDictionary<Guid, int>> ForChaptersAsync(IEnumerable<Chapter> chapters, CancellationToken ct = default)
{
var displayNumbers = new Dictionary<Guid, int>();
foreach (var chapter in chapters.DistinctBy(c => c.Id))
{
if (await ForChapterAsync(chapter, ct) is { } number)
displayNumbers[chapter.Id] = number;
}
return displayNumbers;
}
public string LabelFor(Chapter chapter, IReadOnlyDictionary<Guid, int> displayNumbers) =>
ChapterNumbering.Label(chapter.Kind, displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
}
+36 -12
View File
@@ -7,24 +7,30 @@ public static class ChapterEndpoints
{ {
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/chapters").WithTags("Chapters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())))
.WithSummary("List a project's chapters in manuscript order.");
projectScoped.MapPost("/", async (
Guid projectId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
{ {
var chapter = await service.CreateAsync(projectId, request, ct); var chapters = await service.ListAsync(novelId, ct);
var displayNumbers = ChapterNumbering.DisplayNumbers(chapters);
return Results.Ok(chapters.Select(c =>
c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null)));
})
.WithSummary("List a novel's chapters in manuscript order.");
novelScoped.MapPost("/", async (
Guid novelId, CreateChapterRequest request, ChapterService service, CancellationToken ct) =>
{
var chapter = await service.CreateAsync(novelId, request, ct);
if (chapter is null) if (chapter is null)
{ {
return Results.NotFound(); return Results.NotFound();
} }
var created = chapter.ToResponse(); var displayNumber = await service.DisplayNumberAsync(chapter, ct);
var created = chapter.ToResponse(displayNumber);
return Results.Created($"/api/chapters/{created.Id}", created); return Results.Created($"/api/chapters/{created.Id}", created);
}) })
.WithSummary("Add a chapter."); .WithSummary("Add a chapter.");
@@ -34,17 +40,35 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) {
var chapter = await service.GetAsync(id, ct);
if (chapter is null)
{
return Results.NotFound();
}
var displayNumber = await service.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber).ToApiResult();
})
.WithSummary("Read a chapter with its beats and prose."); .WithSummary("Read a chapter with its beats and prose.");
chapters.MapPatch("/{id:guid}", async ( chapters.MapPatch("/{id:guid}", async (
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var chapter = await service.UpdateAsync(id, request, ct);
if (chapter is null)
{
return Results.NotFound();
}
var displayNumber = await service.DisplayNumberAsync(chapter, ct);
return chapter.ToResponse(displayNumber).ToApiResult();
})
.WithSummary("Update a chapter."); .WithSummary("Update a chapter.");
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a chapter."); .WithSummary("Move a chapter to the trash.");
return app; return app;
} }
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Chapters;
public enum ChapterKind
{
FrontMatter,
Body,
BackMatter
}
@@ -0,0 +1,25 @@
namespace Novelly.Api.Chapters;
public static class ChapterNumbering
{
public static IReadOnlyDictionary<Guid, int> DisplayNumbers(IEnumerable<Chapter> novelChapters)
{
var displayNumbers = new Dictionary<Guid, int>();
var next = 1;
foreach (var chapter in novelChapters.OrderBy(c => c.Number))
{
if (chapter.Kind != ChapterKind.Body)
continue;
displayNumbers[chapter.Id] = next++;
}
return displayNumbers;
}
public static string Label(ChapterKind kind, int? displayNumber, string title) =>
kind == ChapterKind.Body && displayNumber is { } number
? $"Chapter {number}: {title}"
: title;
}
+56 -29
View File
@@ -1,7 +1,9 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Locations;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -9,24 +11,28 @@ namespace Novelly.Api.Chapters;
public class ChapterService( public class ChapterService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
LocationService locations,
ChapterDisplayNumberLookup displayNumbers,
ActivityLog activity,
ILogger<ChapterService> logger, ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator, IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator) IModelValidator<UpdateChapterRequest> updateValidator)
{ {
public async Task<IReadOnlyList<Chapter>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Chapter>> ListAsync(Guid novelId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing chapters for project {ProjectId}", projectId); logger.LogInformation("Listing chapters for novel {NovelId}", novelId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Chapters return await db.Chapters
.Include(c => c.Beats) .Include(c => c.Beats)
.Include(c => c.Tags) .Include(c => c.Tags)
.Where(c => c.ProjectId == projectId) .Include(c => c.Locations.Where(l => l.DeletedAt == null))
.Where(c => c.NovelId == novelId)
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.ToListAsync(ct); .ToListAsync(ct);
} }
@@ -43,33 +49,33 @@ public class ChapterService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Read, ct);
return chapter; return chapter;
} }
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) public async Task<Chapter?> CreateAsync(Guid novelId, CreateChapterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId); logger.LogInformation("Creating chapter {Title} for novel {NovelId}", request.Title, novelId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected chapter creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var chapter = new Chapter var chapter = new Chapter
{ {
ProjectId = projectId, NovelId = novelId,
Title = request.Title, Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct), Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Kind = request.Kind,
Summary = request.Summary, Summary = request.Summary,
Setting = request.Setting,
Notes = request.Notes, Notes = request.Notes,
Status = request.Status, Status = request.Status,
TargetWordCount = request.TargetWordCount, TargetWordCount = request.TargetWordCount,
@@ -79,10 +85,16 @@ public class ChapterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
chapter.Tags = await tags.ResolveAsync(projectId, names, ct); chapter.Tags = await tags.ResolveAsync(novelId, names, ct);
}
if (request.Locations is { } locationNames)
{
chapter.Locations = await locations.ResolveAsync(novelId, locationNames, ct);
} }
db.Chapters.Add(chapter); db.Chapters.Add(chapter);
activity.Record(novelId, ActivityEntityKind.Chapter, ActivityAction.Created, chapter.Id, chapter.WordCount);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(chapter.Id, ct))!; return (await FindAsync(chapter.Id, ct))!;
@@ -102,16 +114,18 @@ public class ChapterService(
return null; return null;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.Write, ct);
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number; chapter.Number = request.Number ?? chapter.Number;
chapter.Kind = request.Kind ?? chapter.Kind;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status; chapter.Status = request.Status ?? chapter.Status;
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
var wordCountBeforeEdit = chapter.WordCount;
if (request.Prose is not null) if (request.Prose is not null)
{ {
chapter.Prose = Patch.Apply(chapter.Prose, request.Prose); chapter.Prose = Patch.Apply(chapter.Prose, request.Prose);
@@ -122,9 +136,15 @@ public class ChapterService(
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
} }
if (request.Locations is { } locationNames)
{
chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct);
}
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Updated, chapter.Id, chapter.WordCount - wordCountBeforeEdit);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!; return (await FindAsync(id, ct))!;
} }
@@ -133,31 +153,37 @@ public class ChapterService(
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
logger.LogInformation("Deleting chapter {ChapterId}", id); logger.LogInformation("Moving chapter {ChapterId} to trash", id);
var chapter = await FindAsync(id, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null) if (chapter is null)
{ {
logger.LogWarning("Chapter {ChapterId} not found", id);
return false; return false;
} }
await access.RequireAsync(chapter.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
db.Chapters.Remove(chapter); chapter.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
} }
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct) public Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) =>
displayNumbers.ForChapterAsync(chapter, ct);
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
{ {
logger.LogDebug("Computing next chapter number for project {ProjectId}", projectId); logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
var max = await db.Chapters var max = await db.Chapters
.Where(c => c.ProjectId == projectId) .IgnoreQueryFilters()
.Where(c => c.NovelId == novelId)
.MaxAsync(c => (int?)c.Number, ct); .MaxAsync(c => (int?)c.Number, ct);
var next = (max ?? 0) + 1; var next = (max ?? 0) + 1;
logger.LogDebug("Next chapter number for project {ProjectId} is {Number}", projectId, next); logger.LogDebug("Next chapter number for novel {NovelId} is {Number}", novelId, next);
return next; return next;
} }
@@ -166,9 +192,10 @@ public class ChapterService(
logger.LogDebug("Finding chapter {ChapterId}", id); logger.LogDebug("Finding chapter {ChapterId}", id);
var chapter = await db.Chapters var chapter = await db.Chapters
.Include(c => c.Beats).ThenInclude(b => b.Characters) .Include(c => c.Beats).ThenInclude(b => b.Characters.Where(ch => ch.DeletedAt == null))
.Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.Locations.Where(l => l.DeletedAt == null))
.FirstOrDefaultAsync(c => c.Id == id, ct); .FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null) if (chapter is null)
+12 -12
View File
@@ -2,16 +2,17 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Projects; using Novelly.Api.Common;
using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
public class Character public class Character : ISoftDeletable
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid NovelId { get; set; }
public Project? Project { get; set; } public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public CharacterRole Role { get; set; } = CharacterRole.Supporting; public CharacterRole Role { get; set; } = CharacterRole.Supporting;
@@ -26,14 +27,9 @@ public class Character
public string? Personality { get; set; } public string? Personality { get; set; }
public string? Backstory { get; set; } public string? Backstory { get; set; }
public string? Want { get; set; } public string? Motivation { get; set; }
public string? Need { get; set; } public string? Conflict { get; set; }
public string? InternalConflict { get; set; }
public string? ExternalConflict { get; set; }
public string? ArcSummary { get; set; }
public string? Voice { get; set; } public string? Voice { get; set; }
@@ -51,6 +47,7 @@ public class Character
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
public List<CharacterRelationship> Relationships { get; set; } = []; public List<CharacterRelationship> Relationships { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
@@ -82,8 +79,9 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
entity.Property(c => c.Name).IsRequired().HasMaxLength(200); entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => c.ProjectId); entity.HasIndex(c => c.NovelId);
entity.HasIndex(c => c.SameCharacterAsId); entity.HasIndex(c => c.SameCharacterAsId);
entity.HasQueryFilter(c => c.DeletedAt == null);
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!) entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
@@ -107,5 +105,7 @@ public class CharacterRelationshipEntityTypeConfiguration : IEntityTypeConfigura
entity.HasOne(r => r.RelatedCharacter).WithMany() entity.HasOne(r => r.RelatedCharacter).WithMany()
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
entity.HasQueryFilter(r => r.Character!.DeletedAt == null && r.RelatedCharacter!.DeletedAt == null);
} }
} }
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
@@ -8,11 +9,13 @@ namespace Novelly.Api.Characters;
public class CharacterArcService( public class CharacterArcService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
ActivityLog activity,
ILogger<CharacterArcService> logger, ILogger<CharacterArcService> logger,
IModelValidator<CreateArcStageRequest> createValidator, IModelValidator<CreateArcStageRequest> createValidator,
IModelValidator<UpdateArcStageRequest> updateValidator, IModelValidator<UpdateArcStageRequest> updateValidator,
IModelValidator<ReorderArcStagesRequest> reorderValidator) IModelValidator<ReorderArcStagesRequest> reorderValidator,
IModelValidator<SetArcStageBeatsRequest> setBeatsValidator)
{ {
public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default) public async Task<IReadOnlyList<CharacterArcStage>> ListAsync(Guid characterId, CancellationToken ct = default)
{ {
@@ -20,7 +23,7 @@ public class CharacterArcService(
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId); logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
await RequireCharacterAccessAsync(characterId, ProjectPermission.Read, ct); await RequireCharacterAccessAsync(characterId, NovelPermission.Read, ct);
var stages = await Query() var stages = await Query()
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
@@ -42,7 +45,7 @@ public class CharacterArcService(
return null; return null;
} }
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.Read, ct); await RequireCharacterAccessAsync(stage.CharacterId, NovelPermission.Read, ct);
return stage; return stage;
} }
@@ -62,19 +65,20 @@ public class CharacterArcService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(character.NovelId, NovelPermission.CreateContent, ct);
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
var stage = new CharacterArcStage var stage = new CharacterArcStage
{ {
CharacterId = characterId, CharacterId = characterId,
Title = request.Title, Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct), SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct),
Description = request.Description, Result = request.Result,
ChapterId = request.ChapterId ChapterId = request.ChapterId
}; };
db.CharacterArcStages.Add(stage); db.CharacterArcStages.Add(stage);
activity.Record(character.NovelId, ActivityEntityKind.ArcStage, ActivityAction.Created, stage.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(stage.Id, ct))!; return (await FindAsync(stage.Id, ct))!;
@@ -102,15 +106,16 @@ public class CharacterArcService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct); await EnsureChapterIsInSameNovelAsync(character, request.ChapterId, ct);
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title; stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
stage.SortOrder = request.SortOrder ?? stage.SortOrder; stage.SortOrder = request.SortOrder ?? stage.SortOrder;
stage.Description = Patch.Apply(stage.Description, request.Description); stage.Result = Patch.Apply(stage.Result, request.Result);
stage.ChapterId = request.ChapterId ?? stage.ChapterId; stage.ChapterId = request.ChapterId ?? stage.ChapterId;
stage.UpdatedAt = DateTimeOffset.UtcNow; stage.UpdatedAt = DateTimeOffset.UtcNow;
activity.Record(character.NovelId, ActivityEntityKind.ArcStage, ActivityAction.Updated, stage.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!; return (await FindAsync(id, ct))!;
} }
@@ -127,9 +132,11 @@ public class CharacterArcService(
return false; return false;
} }
await RequireCharacterAccessAsync(stage.CharacterId, ProjectPermission.DeleteContent, ct); var novelId = await db.Characters.Where(c => c.Id == stage.CharacterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(novelId, NovelPermission.DeleteContent, ct);
db.CharacterArcStages.Remove(stage); db.CharacterArcStages.Remove(stage);
activity.Record(novelId, ActivityEntityKind.ArcStage, ActivityAction.Deleted, stage.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
} }
@@ -143,7 +150,7 @@ public class CharacterArcService(
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId); logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
await RequireCharacterAccessAsync(characterId, ProjectPermission.Write, ct); await RequireCharacterAccessAsync(characterId, NovelPermission.Write, ct);
var stages = await db.CharacterArcStages var stages = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId) .Where(s => s.CharacterId == characterId)
@@ -171,7 +178,68 @@ public class CharacterArcService(
return await ListAsync(characterId, ct); return await ListAsync(characterId, ct);
} }
private async Task EnsureChapterIsInSameProjectAsync( public async Task<CharacterArcStage?> SetBeatsAsync(
Guid stageId, SetArcStageBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(stageId, nameof(stageId));
Guard.Null(request, nameof(request));
setBeatsValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Setting {Count} beats for arc stage {ArcStageId}", request.BeatIds.Count, stageId);
var stage = await FindAsync(stageId, ct);
if (stage is null)
{
return null;
}
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null)
{
logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", stageId, stage.CharacterId);
return null;
}
await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
var beats = await db.Beats
.Include(b => b.Characters)
.Include(b => b.ArcStages)
.Where(b => request.BeatIds.Contains(b.Id))
.ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogWarning("Rejected arc stage beat assignment: arc stage {ArcStageId} referenced missing beat {BeatId}", stageId, missing[0]);
return null;
}
var unrelated = beats.Where(b => b.Characters.All(c => c.Id != stage.CharacterId)).ToList();
if (unrelated.Count > 0)
{
logger.LogWarning(
"Rejected arc stage beat assignment: beat {BeatId} does not include character {CharacterId}",
unrelated[0].Id, stage.CharacterId);
throw new InvalidOperationException("A beat can only be grouped into an arc stage for a character who appears in it.");
}
foreach (var beat in beats)
{
foreach (var sibling in beat.ArcStages.Where(s => s.CharacterId == stage.CharacterId && s.Id != stageId).ToList())
{
beat.ArcStages.Remove(sibling);
}
}
stage.Beats = beats;
stage.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(stageId, ct))!;
}
private async Task EnsureChapterIsInSameNovelAsync(
Character character, Guid? chapterId, CancellationToken ct) Character character, Guid? chapterId, CancellationToken ct)
{ {
if (chapterId is not { } id) if (chapterId is not { } id)
@@ -179,18 +247,18 @@ public class CharacterArcService(
return; return;
} }
logger.LogDebug("Checking chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId); logger.LogDebug("Checking chapter {ChapterId} belongs to novel {NovelId}", id, character.NovelId);
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct); var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.NovelId == character.NovelId, ct);
if (!belongs) if (!belongs)
{ {
logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to project {ProjectId}", id, character.ProjectId); logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to novel {NovelId}", id, character.NovelId);
throw new InvalidOperationException( throw new InvalidOperationException(
"An arc stage can only point at a chapter in the same project as its character."); "An arc stage can only point at a chapter in the same novel as its character.");
} }
logger.LogDebug("Chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId); logger.LogDebug("Chapter {ChapterId} belongs to novel {NovelId}", id, character.NovelId);
} }
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct) private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
@@ -206,13 +274,16 @@ public class CharacterArcService(
return next; return next;
} }
private async Task RequireCharacterAccessAsync(Guid characterId, ProjectPermission permission, CancellationToken ct) private async Task RequireCharacterAccessAsync(Guid characterId, NovelPermission permission, CancellationToken ct)
{ {
var projectId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.ProjectId).FirstOrDefaultAsync(ct); var novelId = await db.Characters.Where(c => c.Id == characterId).Select(c => c.NovelId).FirstOrDefaultAsync(ct);
await access.RequireAsync(projectId, permission, ct); await access.RequireAsync(novelId, permission, ct);
} }
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter); private IQueryable<CharacterArcStage> Query() =>
db.CharacterArcStages
.Include(s => s.Chapter)
.Include(s => s.Beats.Where(b => b.Chapter!.DeletedAt == null)).ThenInclude(b => b.Chapter);
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct) private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
{ {
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
@@ -15,11 +16,13 @@ public class CharacterArcStage
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string? Description { get; set; } public string? Result { get; set; }
public Guid? ChapterId { get; set; } public Guid? ChapterId { get; set; }
public Chapter? Chapter { get; init; } public Chapter? Chapter { get; init; }
public List<Beat> Beats { get; set; } = [];
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
} }
@@ -30,8 +33,12 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration
{ {
entity.Property(s => s.Title).IsRequired().HasMaxLength(200); entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(s => new { s.CharacterId, s.SortOrder }); entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
entity.HasQueryFilter(s => s.Character!.DeletedAt == null);
entity.HasOne(s => s.Chapter).WithMany() entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
entity.HasMany(s => s.Beats).WithMany(b => b.ArcStages)
.UsingEntity(join => join.ToTable("ArcStageBeats"));
} }
} }
@@ -1,3 +1,5 @@
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -5,7 +7,7 @@ namespace Novelly.Api.Characters;
public record CharacterResponse( public record CharacterResponse(
Guid Id, Guid Id,
Guid ProjectId, Guid NovelId,
string Name, string Name,
CharacterRole Role, CharacterRole Role,
CharacterImportance Importance, CharacterImportance Importance,
@@ -15,11 +17,8 @@ public record CharacterResponse(
string? Appearance, string? Appearance,
string? Personality, string? Personality,
string? Backstory, string? Backstory,
string? Want, string? Motivation,
string? Need, string? Conflict,
string? InternalConflict,
string? ExternalConflict,
string? ArcSummary,
string? Voice, string? Voice,
string? Notes, string? Notes,
IReadOnlyList<string> Aliases, IReadOnlyList<string> Aliases,
@@ -27,6 +26,7 @@ public record CharacterResponse(
string? SameCharacterAsName, string? SameCharacterAsName,
Guid? RevealedInChapterId, Guid? RevealedInChapterId,
int? RevealedInChapterNumber, int? RevealedInChapterNumber,
string? RevealedInChapterLabel,
string? IdentityNote, string? IdentityNote,
IReadOnlyList<CharacterIdentityResponse> OtherIdentities, IReadOnlyList<CharacterIdentityResponse> OtherIdentities,
IReadOnlyList<RelationshipResponse> Relationships, IReadOnlyList<RelationshipResponse> Relationships,
@@ -53,11 +53,8 @@ public record CreateCharacterRequest(
string? Appearance = null, string? Appearance = null,
string? Personality = null, string? Personality = null,
string? Backstory = null, string? Backstory = null,
string? Want = null, string? Motivation = null,
string? Need = null, string? Conflict = null,
string? InternalConflict = null,
string? ExternalConflict = null,
string? ArcSummary = null,
string? Voice = null, string? Voice = null,
string? Notes = null, string? Notes = null,
IReadOnlyList<string>? Tags = null, IReadOnlyList<string>? Tags = null,
@@ -72,7 +69,7 @@ public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRe
result.AddRequiredTextErrors("Name", "Name", model.Name, 200); result.AddRequiredTextErrors("Name", "Name", model.Name, 200);
CharacterValidation.OptionalFields( CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory, model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice, model.Motivation, model.Conflict, model.Voice,
model.Notes, model.Tags, model.Aliases, result); model.Notes, model.Tags, model.Aliases, result);
return result; return result;
@@ -89,11 +86,8 @@ public record UpdateCharacterRequest(
string? Appearance = null, string? Appearance = null,
string? Personality = null, string? Personality = null,
string? Backstory = null, string? Backstory = null,
string? Want = null, string? Motivation = null,
string? Need = null, string? Conflict = null,
string? InternalConflict = null,
string? ExternalConflict = null,
string? ArcSummary = null,
string? Voice = null, string? Voice = null,
string? Notes = null, string? Notes = null,
IReadOnlyList<string>? Tags = null, IReadOnlyList<string>? Tags = null,
@@ -108,7 +102,7 @@ public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRe
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a character", 200); result.AddUnclearableTextErrors("Name", "Name", model.Name, "a character", 200);
CharacterValidation.OptionalFields( CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory, model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice, model.Motivation, model.Conflict, model.Voice,
model.Notes, model.Tags, model.Aliases, result); model.Notes, model.Tags, model.Aliases, result);
return result; return result;
@@ -119,7 +113,7 @@ file static class CharacterValidation
{ {
public static void OptionalFields( public static void OptionalFields(
string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory, string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory,
string? want, string? need, string? internalConflict, string? externalConflict, string? arcSummary, string? voice, string? motivation, string? conflict, string? voice,
string? notes, IReadOnlyList<string>? tags, IReadOnlyList<string>? aliases, ValidationResult result) string? notes, IReadOnlyList<string>? tags, IReadOnlyList<string>? aliases, ValidationResult result)
{ {
Cap(age, "Age", 100, result); Cap(age, "Age", 100, result);
@@ -128,11 +122,8 @@ file static class CharacterValidation
Cap(appearance, "Appearance", 20000, result); Cap(appearance, "Appearance", 20000, result);
Cap(personality, "Personality", 20000, result); Cap(personality, "Personality", 20000, result);
Cap(backstory, "Backstory", 20000, result); Cap(backstory, "Backstory", 20000, result);
Cap(want, "Want", 2000, result); Cap(motivation, "Motivation", 2000, result);
Cap(need, "Need", 2000, result); Cap(conflict, "Conflict", 2000, result);
Cap(internalConflict, "InternalConflict", 2000, result);
Cap(externalConflict, "ExternalConflict", 2000, result);
Cap(arcSummary, "ArcSummary", 20000, result);
Cap(voice, "Voice", 2000, result); Cap(voice, "Voice", 2000, result);
Cap(notes, "Notes", 20000, result); Cap(notes, "Notes", 20000, result);
@@ -162,7 +153,8 @@ file static class CharacterValidation
public record CreateRelationshipRequest( public record CreateRelationshipRequest(
Guid RelatedCharacterId, Guid RelatedCharacterId,
string RelationshipType, string RelationshipType,
string? Description = null); string? Description = null,
string? ReciprocalRelationshipType = null);
public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest> public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest>
{ {
@@ -175,6 +167,7 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100); result.AddRequiredTextErrors("RelationshipType", "Relationship Type", model.RelationshipType, 100);
result.AddOptionalTextErrors("Description", "Description", model.Description, 2000); result.AddOptionalTextErrors("Description", "Description", model.Description, 2000);
result.AddOptionalTextErrors("ReciprocalRelationshipType", "Reciprocal Relationship Type", model.ReciprocalRelationshipType, 100);
return result; return result;
} }
@@ -205,16 +198,18 @@ public record ArcStageResponse(
Guid CharacterId, Guid CharacterId,
int SortOrder, int SortOrder,
string Title, string Title,
string? Description, string? Result,
Guid? ChapterId, Guid? ChapterId,
int? ChapterNumber, int? ChapterNumber,
string? ChapterTitle, string? ChapterTitle,
string? ChapterLabel,
IReadOnlyList<CharacterBeatResponse> Beats,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record CreateArcStageRequest( public record CreateArcStageRequest(
string Title, string Title,
int? SortOrder = null, int? SortOrder = null,
string? Description = null, string? Result = null,
Guid? ChapterId = null); Guid? ChapterId = null);
public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest> public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest>
@@ -224,7 +219,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
var result = new ValidationResult(); var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200); result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result); ArcStageValidation.OptionalFields(model.SortOrder, model.Result, result);
return result; return result;
} }
@@ -233,7 +228,7 @@ public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequ
public record UpdateArcStageRequest( public record UpdateArcStageRequest(
string? Title = null, string? Title = null,
int? SortOrder = null, int? SortOrder = null,
string? Description = null, string? Result = null,
Guid? ChapterId = null); Guid? ChapterId = null);
public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest> public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest>
@@ -243,7 +238,7 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "an arc stage", 200); result.AddUnclearableTextErrors("Title", "Title", model.Title, "an arc stage", 200);
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result); ArcStageValidation.OptionalFields(model.SortOrder, model.Result, result);
return result; return result;
} }
@@ -251,13 +246,13 @@ public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequ
file static class ArcStageValidation file static class ArcStageValidation
{ {
public static void OptionalFields(int? sortOrder, string? description, ValidationResult result) public static void OptionalFields(int? sortOrder, string? result_, ValidationResult result)
{ {
if (sortOrder is < 0) if (sortOrder is < 0)
result.AddError("SortOrder", "'Sort Order' must be zero or greater."); result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
if (description is { Length: > 20000 }) if (result_ is { Length: > 20000 })
result.AddError("Description", "'Description' must be 20,000 characters or fewer."); result.AddError("Result", "'Result' must be 20,000 characters or fewer.");
} }
} }
@@ -276,38 +271,69 @@ public class ReorderArcStagesRequestValidator : IModelValidator<ReorderArcStages
} }
} }
public record SetArcStageBeatsRequest(IReadOnlyList<Guid> BeatIds);
public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeatsRequest>
{
public ValidationResult Validate(SetArcStageBeatsRequest model)
{
var result = new ValidationResult();
if (model.BeatIds is null)
result.AddError("BeatIds", "'Beat Ids' must not be null.");
return result;
}
}
public static class CharacterMapping public static class CharacterMapping
{ {
public static CharacterResponse ToResponse(this Character c) => new( public static CharacterResponse ToResponse(this Character c, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation, c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need, c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
[.. c.Aliases], [.. c.Aliases],
c.SameCharacterAsId, c.SameCharacterAsId,
c.SameCharacterAs?.Name, c.SameCharacterAs is { DeletedAt: null } canonical ? canonical.Name : null,
c.RevealedInChapterId, c.RevealedInChapterId,
c.RevealedInChapter?.Number, c.RevealedInChapter is { DeletedAt: null } revealedInChapter ? revealedInChapter.Number : null,
c.RevealedInChapter is { DeletedAt: null } revealedInChapter2 ? ChapterLabel(revealedInChapter2, displayNumbers) : null,
c.IdentityNote, c.IdentityNote,
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))], [.. c.OtherIdentities.Where(o => o.DeletedAt is null).OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
[.. c.Relationships.Select(r => new RelationshipResponse( [.. c.Relationships
.Where(r => r.RelatedCharacter is { DeletedAt: null })
.Select(r => new RelationshipResponse(
r.Id, r.Id,
r.RelatedCharacterId, r.RelatedCharacterId,
r.RelatedCharacter?.Name ?? "(unknown)", r.RelatedCharacter!.Name,
r.RelationshipType, r.RelationshipType,
r.Description))], r.Description))],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())], [.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse(displayNumbers))],
c.UpdatedAt); c.UpdatedAt);
public static ArcStageResponse ToResponse(this CharacterArcStage s) => new( public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null)
{
var chapter = s.Chapter is { DeletedAt: null } ? s.Chapter : null;
return new(
s.Id, s.Id,
s.CharacterId, s.CharacterId,
s.SortOrder, s.SortOrder,
s.Title, s.Title,
s.Description, s.Result,
s.ChapterId, s.ChapterId,
s.Chapter?.Number, chapter?.Number,
s.Chapter?.Title, chapter?.Title,
chapter is not null ? ChapterLabel(chapter, displayNumbers) : null,
[.. s.Beats
.Where(b => b.Chapter is { DeletedAt: null })
.OrderBy(b => b.Chapter!.Number)
.ThenBy(b => b.SortOrder)
.Select(b => b.ToCharacterBeatResponse(s.CharacterId, ChapterLabel(b.Chapter!, displayNumbers)))],
s.UpdatedAt); s.UpdatedAt);
}
private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary<Guid, int>? displayNumbers) =>
ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
} }
+166 -30
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
@@ -5,26 +6,66 @@ namespace Novelly.Api.Characters;
public static class CharacterEndpoints public static class CharacterEndpoints
{ {
private static IEnumerable<Chapter> ChaptersOf(Character c)
{
if (c.RevealedInChapter is { } revealed)
{
yield return revealed;
}
foreach (var chapter in c.ArcStages.SelectMany(ChaptersOf))
{
yield return chapter;
}
}
private static IEnumerable<Chapter> ChaptersOf(CharacterArcStage stage)
{
if (stage.Chapter is { } chapter)
{
yield return chapter;
}
foreach (var beat in stage.Beats)
{
if (beat.Chapter is { } beatChapter)
{
yield return beatChapter;
}
}
}
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
Results.Ok((await service.ListAsync(projectId, ct)).Select(c => c.ToResponse())))
.WithSummary("List a project's character dossiers.");
projectScoped.MapPost("/", async (
Guid projectId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
{ {
var character = await service.CreateAsync(projectId, request, ct); var list = await service.ListAsync(novelId, ct);
var responses = new List<CharacterResponse>();
foreach (var character in list)
{
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
responses.Add(character.ToResponse(displayNumbers));
}
return Results.Ok(responses);
})
.WithSummary("List a novel's character dossiers.");
novelScoped.MapPost("/", async (
Guid novelId, CreateCharacterRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
{
var character = await service.CreateAsync(novelId, request, ct);
if (character is null) if (character is null)
{ {
return Results.NotFound(); return Results.NotFound();
} }
var created = character.ToResponse(); var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
var created = character.ToResponse(displayNumbers);
return Results.Created($"/api/characters/{created.Id}", created); return Results.Created($"/api/characters/{created.Id}", created);
}) })
.WithSummary("Add a character dossier."); .WithSummary("Add a character dossier.");
@@ -33,23 +74,50 @@ public static class CharacterEndpoints
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) {
var character = await service.GetAsync(id, ct);
if (character is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
return character.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Read a character dossier."); .WithSummary("Read a character dossier.");
characters.MapPatch("/{id:guid}", async ( characters.MapPatch("/{id:guid}", async (
Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) => Guid id, UpdateCharacterRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var character = await service.UpdateAsync(id, request, ct);
if (character is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
return character.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Update a character dossier."); .WithSummary("Update a character dossier.");
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a character."); .WithSummary("Move a character to the trash.");
characters.MapPost("/{id:guid}/relationships", async ( characters.MapPost("/{id:guid}/relationships", async (
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) => Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult()) {
.WithSummary("Relate this character to another in the same project."); var character = await service.AddRelationshipAsync(id, request, ct);
if (character is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
return character.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Relate this character to another in the same novel.");
characters.MapDelete("/relationships/{relationshipId:guid}", async ( characters.MapDelete("/relationships/{relationshipId:guid}", async (
Guid relationshipId, CharacterService service, CancellationToken ct) => Guid relationshipId, CharacterService service, CancellationToken ct) =>
@@ -57,9 +125,18 @@ public static class CharacterEndpoints
.WithSummary("Remove a relationship."); .WithSummary("Remove a relationship.");
characters.MapPut("/{id:guid}/identity", async ( characters.MapPut("/{id:guid}/identity", async (
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) => Guid id, LinkCharacterIdentityRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult()) {
.WithSummary("Link this character as another identity of a character in the same project."); var character = await service.LinkIdentityAsync(id, request, ct);
if (character is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
return character.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Link this character as another identity of a character in the same novel.");
characters.MapDelete("/{id:guid}/identity", async ( characters.MapDelete("/{id:guid}/identity", async (
Guid id, CharacterService service, CancellationToken ct) => Guid id, CharacterService service, CancellationToken ct) =>
@@ -67,12 +144,22 @@ public static class CharacterEndpoints
.WithSummary("Remove this character's identity link."); .WithSummary("Remove this character's identity link.");
characters.MapGet("/{id:guid}/arc", async ( characters.MapGet("/{id:guid}/arc", async (
Guid id, CharacterArcService service, CancellationToken ct) => Guid id, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse()))) {
var stages = await service.ListAsync(id, ct);
var responses = new List<ArcStageResponse>();
foreach (var stage in stages)
{
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
responses.Add(stage.ToResponse(displayNumbers));
}
return Results.Ok(responses);
})
.WithSummary("Read a character's arc: its stages, in order."); .WithSummary("Read a character's arc: its stages, in order.");
characters.MapPost("/{id:guid}/arc", async ( characters.MapPost("/{id:guid}/arc", async (
Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) => Guid id, CreateArcStageRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
{ {
var stage = await service.CreateAsync(id, request, ct); var stage = await service.CreateAsync(id, request, ct);
if (stage is null) if (stage is null)
@@ -80,33 +167,82 @@ public static class CharacterEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var created = stage.ToResponse(); var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
var created = stage.ToResponse(displayNumbers);
return Results.Created($"/api/arc-stages/{created.Id}", created); return Results.Created($"/api/arc-stages/{created.Id}", created);
}) })
.WithSummary("Add a stage to a character's arc."); .WithSummary("Add a stage to a character's arc.");
characters.MapPost("/{id:guid}/arc/reorder", async ( characters.MapPost("/{id:guid}/arc/reorder", async (
Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) => Guid id, ReorderArcStagesRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.ReorderAsync(id, request, ct))?.Select(s => s.ToResponse()).ToList().ToApiResult()) {
var stages = await service.ReorderAsync(id, request, ct);
if (stages is null)
{
return Results.NotFound();
}
var responses = new List<ArcStageResponse>();
foreach (var stage in stages)
{
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
responses.Add(stage.ToResponse(displayNumbers));
}
return Results.Ok(responses);
})
.WithSummary("Renumber a character's arc to match the order given."); .WithSummary("Renumber a character's arc to match the order given.");
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters") var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) {
var stage = await service.GetAsync(id, ct);
if (stage is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
return stage.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Read one arc stage."); .WithSummary("Read one arc stage.");
arcStages.MapPatch("/{id:guid}", async ( arcStages.MapPatch("/{id:guid}", async (
Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) => Guid id, UpdateArcStageRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var stage = await service.UpdateAsync(id, request, ct);
if (stage is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
return stage.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Update an arc stage."); .WithSummary("Update an arc stage.");
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete an arc stage."); .WithSummary("Delete an arc stage.");
arcStages.MapPost("/{id:guid}/beats", async (
Guid id, SetArcStageBeatsRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
{
var stage = await service.SetBeatsAsync(id, request, ct);
if (stage is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
return stage.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Set which beats belong to this arc stage, replacing its current set. "
+ "A beat moved into this stage leaves any other stage of the same character it was in.");
return app; return app;
} }
} }
+59 -44
View File
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
@@ -9,24 +10,25 @@ namespace Novelly.Api.Characters;
public class CharacterService( public class CharacterService(
INovelDbContext db, INovelDbContext db,
ProjectAccessService access, NovelAccessService access,
TagService tags, TagService tags,
ActivityLog activity,
ILogger<CharacterService> logger, ILogger<CharacterService> logger,
IModelValidator<CreateCharacterRequest> createValidator, IModelValidator<CreateCharacterRequest> createValidator,
IModelValidator<UpdateCharacterRequest> updateValidator, IModelValidator<UpdateCharacterRequest> updateValidator,
IModelValidator<CreateRelationshipRequest> relationshipValidator, IModelValidator<CreateRelationshipRequest> relationshipValidator,
IModelValidator<LinkCharacterIdentityRequest> identityValidator) IModelValidator<LinkCharacterIdentityRequest> identityValidator)
{ {
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Character>> ListAsync(Guid novelId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing characters for project {ProjectId}", projectId); logger.LogInformation("Listing characters for novel {NovelId}", novelId);
await access.RequireAsync(projectId, ProjectPermission.Read, ct); await access.RequireAsync(novelId, NovelPermission.Read, ct);
var characters = await Query() var characters = await Query()
.Where(c => c.ProjectId == projectId) .Where(c => c.NovelId == novelId)
.ToListAsync(ct); .ToListAsync(ct);
return OrderedInMemoryBySignificanceThenName(characters); return OrderedInMemoryBySignificanceThenName(characters);
@@ -52,29 +54,29 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Read, ct); await access.RequireAsync(character.NovelId, NovelPermission.Read, ct);
return character; return character;
} }
public async Task<Character?> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) public async Task<Character?> CreateAsync(Guid novelId, CreateCharacterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId)); Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger); createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance); logger.LogInformation("Creating character {Name} for novel {NovelId}, role {Role}, importance {Importance}", request.Name, novelId, request.Role, request.Importance);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{ {
logger.LogWarning("Rejected character creation: project {ProjectId} not found", projectId); logger.LogWarning("Rejected character creation: novel {NovelId} not found", novelId);
return null; return null;
} }
await access.RequireAsync(projectId, ProjectPermission.CreateContent, ct); await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var character = new Character var character = new Character
{ {
ProjectId = projectId, NovelId = novelId,
Name = request.Name, Name = request.Name,
Role = request.Role, Role = request.Role,
Importance = request.Importance, Importance = request.Importance,
@@ -84,18 +86,15 @@ public class CharacterService(
Appearance = request.Appearance, Appearance = request.Appearance,
Personality = request.Personality, Personality = request.Personality,
Backstory = request.Backstory, Backstory = request.Backstory,
Want = request.Want, Motivation = request.Motivation,
Need = request.Need, Conflict = request.Conflict,
InternalConflict = request.InternalConflict,
ExternalConflict = request.ExternalConflict,
ArcSummary = request.ArcSummary,
Voice = request.Voice, Voice = request.Voice,
Notes = request.Notes Notes = request.Notes
}; };
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
character.Tags = await tags.ResolveAsync(projectId, names, ct); character.Tags = await tags.ResolveAsync(novelId, names, ct);
} }
if (request.Aliases is { } aliases) if (request.Aliases is { } aliases)
@@ -104,6 +103,7 @@ public class CharacterService(
} }
db.Characters.Add(character); db.Characters.Add(character);
activity.Record(novelId, ActivityEntityKind.Character, ActivityAction.Created, character.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(character.Id, ct))!; return (await FindAsync(character.Id, ct))!;
@@ -123,7 +123,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name; character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role; character.Role = request.Role ?? character.Role;
@@ -134,18 +134,15 @@ public class CharacterService(
character.Appearance = Patch.Apply(character.Appearance, request.Appearance); character.Appearance = Patch.Apply(character.Appearance, request.Appearance);
character.Personality = Patch.Apply(character.Personality, request.Personality); character.Personality = Patch.Apply(character.Personality, request.Personality);
character.Backstory = Patch.Apply(character.Backstory, request.Backstory); character.Backstory = Patch.Apply(character.Backstory, request.Backstory);
character.Want = Patch.Apply(character.Want, request.Want); character.Motivation = Patch.Apply(character.Motivation, request.Motivation);
character.Need = Patch.Apply(character.Need, request.Need); character.Conflict = Patch.Apply(character.Conflict, request.Conflict);
character.InternalConflict = Patch.Apply(character.InternalConflict, request.InternalConflict);
character.ExternalConflict = Patch.Apply(character.ExternalConflict, request.ExternalConflict);
character.ArcSummary = Patch.Apply(character.ArcSummary, request.ArcSummary);
character.Voice = Patch.Apply(character.Voice, request.Voice); character.Voice = Patch.Apply(character.Voice, request.Voice);
character.Notes = Patch.Apply(character.Notes, request.Notes); character.Notes = Patch.Apply(character.Notes, request.Notes);
character.UpdatedAt = DateTimeOffset.UtcNow; character.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names) if (request.Tags is { } names)
{ {
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct); character.Tags = await tags.ResolveAsync(character.NovelId, names, ct);
} }
if (request.Aliases is { } aliases) if (request.Aliases is { } aliases)
@@ -153,6 +150,7 @@ public class CharacterService(
character.Aliases = [.. aliases]; character.Aliases = [.. aliases];
} }
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Updated, character.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!; return (await FindAsync(id, ct))!;
} }
@@ -161,7 +159,7 @@ public class CharacterService(
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
logger.LogInformation("Deleting character {CharacterId}", id); logger.LogInformation("Moving character {CharacterId} to trash", id);
var character = await FindAsync(id, ct); var character = await FindAsync(id, ct);
if (character is null) if (character is null)
@@ -169,9 +167,10 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.DeleteContent, ct); await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
db.Characters.Remove(character); character.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
} }
@@ -191,7 +190,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct); var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
if (related is null) if (related is null)
@@ -200,10 +199,10 @@ public class CharacterService(
return null; return null;
} }
if (related.ProjectId != character.ProjectId) if (related.NovelId != character.NovelId)
{ {
logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different projects", characterId, request.RelatedCharacterId); logger.LogWarning("Rejected relationship: character {CharacterId} and {RelatedCharacterId} belong to different novels", characterId, request.RelatedCharacterId);
throw new InvalidOperationException("Characters must belong to the same project to be related."); throw new InvalidOperationException("Characters must belong to the same novel to be related.");
} }
db.CharacterRelationships.Add(new CharacterRelationship db.CharacterRelationships.Add(new CharacterRelationship
@@ -214,6 +213,14 @@ public class CharacterService(
Description = request.Description Description = request.Description
}); });
db.CharacterRelationships.Add(new CharacterRelationship
{
CharacterId = request.RelatedCharacterId,
RelatedCharacterId = characterId,
RelationshipType = request.ReciprocalRelationshipType ?? request.RelationshipType,
Description = request.Description
});
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct))!; return (await FindAsync(characterId, ct))!;
} }
@@ -233,9 +240,14 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(relationship.Character!.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(relationship.Character!.NovelId, NovelPermission.Write, ct);
var reciprocals = await db.CharacterRelationships
.Where(r => r.CharacterId == relationship.RelatedCharacterId && r.RelatedCharacterId == relationship.CharacterId)
.ToListAsync(ct);
db.CharacterRelationships.Remove(relationship); db.CharacterRelationships.Remove(relationship);
db.CharacterRelationships.RemoveRange(reciprocals);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
} }
@@ -256,7 +268,7 @@ public class CharacterService(
return null; return null;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
if (request.SameCharacterAsId == characterId) if (request.SameCharacterAsId == characterId)
{ {
@@ -273,12 +285,12 @@ public class CharacterService(
return null; return null;
} }
if (target.ProjectId != character.ProjectId) if (target.NovelId != character.NovelId)
{ {
logger.LogWarning( logger.LogWarning(
"Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different projects", "Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different novels",
characterId, request.SameCharacterAsId); characterId, request.SameCharacterAsId);
throw new InvalidOperationException("Characters must belong to the same project to be linked."); throw new InvalidOperationException("Characters must belong to the same novel to be linked.");
} }
if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct)) if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct))
@@ -291,11 +303,11 @@ public class CharacterService(
if (request.RevealedInChapterId is { } chapterId) if (request.RevealedInChapterId is { } chapterId)
{ {
var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct); var chapterInNovel = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.NovelId == character.NovelId, ct);
if (!chapterInProject) if (!chapterInNovel)
{ {
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId); logger.LogWarning("Rejected identity link: chapter {ChapterId} not in novel {NovelId}", chapterId, character.NovelId);
throw new InvalidOperationException("The reveal chapter must belong to the same project."); throw new InvalidOperationException("The reveal chapter must belong to the same novel.");
} }
} }
@@ -320,7 +332,7 @@ public class CharacterService(
return false; return false;
} }
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct); await access.RequireAsync(character.NovelId, NovelPermission.Write, ct);
character.SameCharacterAsId = null; character.SameCharacterAsId = null;
character.RevealedInChapterId = null; character.RevealedInChapterId = null;
@@ -338,6 +350,9 @@ public class CharacterService(
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.ArcStages) .Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter) .ThenInclude(s => s.Chapter)
.Include(c => c.ArcStages)
.ThenInclude(s => s.Beats.Where(b => b.Chapter!.DeletedAt == null))
.ThenInclude(b => b.Chapter)
.Include(c => c.SameCharacterAs) .Include(c => c.SameCharacterAs)
.Include(c => c.OtherIdentities) .Include(c => c.OtherIdentities)
.Include(c => c.RevealedInChapter); .Include(c => c.RevealedInChapter);
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Agent; using Novelly.Api.Agent;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
@@ -13,10 +14,14 @@ using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Mcp;
using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users; using Novelly.Api.Users;
using ModelContextProtocol.Protocol;
namespace Novelly.Api.Common; namespace Novelly.Api.Common;
@@ -25,6 +30,8 @@ public static class NovellyServiceRegistration
{ {
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<UiSettingsOptions>(configuration.GetSection(UiSettingsOptions.SectionName));
var connectionString = configuration.GetConnectionString("Novel") var connectionString = configuration.GetConnectionString("Novel")
?? "Data Source=novel.db"; ?? "Data Source=novel.db";
@@ -48,7 +55,9 @@ public static class NovellyServiceRegistration
services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>(); services.AddScoped<IUserClaimsPrincipalFactory<NovellyUser>, NovellyUserClaimsPrincipalFactory>();
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddScoped<INovelUserContext, NovelUserContext>(); services.AddScoped<INovelUserContext, NovelUserContext>();
services.AddScoped<ProjectAccessService>(); services.AddScoped<NovelAccessService>();
services.AddScoped<ActivityLog>();
services.AddScoped<ActivityService>();
services.ConfigureApplicationCookie(options => services.ConfigureApplicationCookie(options =>
{ {
@@ -70,15 +79,17 @@ public static class NovellyServiceRegistration
}); });
services.AddScoped<UserAccountService>(); services.AddScoped<UserAccountService>();
services.AddScoped<ProjectMemberService>(); services.AddScoped<NovelMemberService>();
services.AddScoped<ProjectService>(); services.AddScoped<NovelService>();
services.AddScoped<CharacterService>(); services.AddScoped<CharacterService>();
services.AddScoped<CharacterArcService>(); services.AddScoped<CharacterArcService>();
services.AddScoped<BeatService>(); services.AddScoped<BeatService>();
services.AddScoped<TagService>(); services.AddScoped<TagService>();
services.AddScoped<LocationService>();
services.AddScoped<GenreService>(); services.AddScoped<GenreService>();
services.AddScoped<ChapterService>(); services.AddScoped<ChapterService>();
services.AddScoped<ChapterDisplayNumberLookup>();
services.AddScoped<OpenQuestionService>(); services.AddScoped<OpenQuestionService>();
services.AddScoped<NovelAgentToolset>(); services.AddScoped<NovelAgentToolset>();
services.AddScoped<NovelAgentService>(); services.AddScoped<NovelAgentService>();
@@ -86,14 +97,35 @@ public static class NovellyServiceRegistration
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName)); services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>(); services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
services.Configure<ImportOptions>(configuration.GetSection(ImportOptions.SectionName));
services.AddSingleton(Channel.CreateUnbounded<Guid>()); services.AddSingleton(Channel.CreateUnbounded<Guid>());
services.AddScoped<ImportService>(); services.AddScoped<ImportService>();
services.AddScoped<ImportBrowseService>();
services.AddScoped<ImportZipExtractor>();
services.AddScoped<ImportAgentToolset>(); services.AddScoped<ImportAgentToolset>();
services.AddScoped<ImportAgentService>(); services.AddScoped<ImportAgentService>();
services.AddHostedService<ImportJobRunner>(); services.AddHostedService<ImportJobRunner>();
services.Configure<TrashOptions>(configuration.GetSection(TrashOptions.SectionName));
services.AddScoped<TrashService>();
services.AddSingleton(TimeProvider.System);
services.AddHostedService<TrashPurgeRunner>();
services.AddModelValidatorsFromAssemblyContaining<Program>(); services.AddModelValidatorsFromAssemblyContaining<Program>();
services.AddMcpServer(options => options.ServerInfo = new Implementation { Name = "novelly", Version = "1.0.0" })
.WithHttpTransport()
.WithListToolsHandler((request, ct) =>
{
var toolset = request.Services!.GetRequiredService<NovelAgentToolset>();
return ValueTask.FromResult(new ListToolsResult { Tools = [.. NovelMcpTools.Describe(toolset.Definitions)] });
})
.WithCallToolHandler((request, ct) =>
{
var toolset = request.Services!.GetRequiredService<NovelAgentToolset>();
return new ValueTask<CallToolResult>(NovelMcpTools.CallAsync(toolset, toolset.Definitions, request.Params!, ct));
});
return services; return services;
} }
} }
+6
View File
@@ -0,0 +1,6 @@
namespace Novelly.Api.Common;
public interface ISoftDeletable
{
DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Options;
namespace Novelly.Api.Common;
public static class UiSettingsEndpoints
{
public static IEndpointRouteBuilder MapUiSettingsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/ui-settings", (IOptions<UiSettingsOptions> options) =>
Results.Ok(new UiSettingsResponse(options.Value.ShowPronouns)))
.WithTags("UiSettings")
.AllowAnonymous();
return app;
}
}
public record UiSettingsResponse(bool ShowPronouns);
@@ -0,0 +1,8 @@
namespace Novelly.Api.Common;
public class UiSettingsOptions
{
public const string SectionName = "UiSettings";
public bool ShowPronouns { get; set; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddArcStageResultAndBeats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Description",
table: "CharacterArcStages",
newName: "Result");
migrationBuilder.CreateTable(
name: "ArcStageBeats",
columns: table => new
{
ArcStagesId = table.Column<Guid>(type: "TEXT", nullable: false),
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ArcStageBeats", x => new { x.ArcStagesId, x.BeatsId });
table.ForeignKey(
name: "FK_ArcStageBeats_Beats_BeatsId",
column: x => x.BeatsId,
principalTable: "Beats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ArcStageBeats_CharacterArcStages_ArcStagesId",
column: x => x.ArcStagesId,
principalTable: "CharacterArcStages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ArcStageBeats_BeatsId",
table: "ArcStageBeats",
column: "BeatsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ArcStageBeats");
migrationBuilder.RenameColumn(
name: "Result",
table: "CharacterArcStages",
newName: "Description");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,363 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RenameProjectToNovel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapters_Projects_ProjectId",
table: "Chapters");
migrationBuilder.DropForeignKey(
name: "FK_Characters_Projects_ProjectId",
table: "Characters");
migrationBuilder.DropForeignKey(
name: "FK_Conversations_Projects_ProjectId",
table: "Conversations");
migrationBuilder.DropForeignKey(
name: "FK_OpenQuestions_Projects_ProjectId",
table: "OpenQuestions");
migrationBuilder.DropForeignKey(
name: "FK_Tags_Projects_ProjectId",
table: "Tags");
migrationBuilder.DropForeignKey(
name: "FK_ProjectMembers_Projects_ProjectId",
table: "ProjectMembers");
migrationBuilder.RenameTable(
name: "Projects",
newName: "Novels");
migrationBuilder.RenameTable(
name: "ProjectMembers",
newName: "NovelMembers");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Tags",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Tags_ProjectId_Name",
table: "Tags",
newName: "IX_Tags_NovelId_Name");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "OpenQuestions",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_OpenQuestions_ProjectId",
table: "OpenQuestions",
newName: "IX_OpenQuestions_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "ImportJobs",
newName: "NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Conversations",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Conversations_ProjectId",
table: "Conversations",
newName: "IX_Conversations_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Characters",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Characters_ProjectId",
table: "Characters",
newName: "IX_Characters_NovelId");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "Chapters",
newName: "NovelId");
migrationBuilder.RenameIndex(
name: "IX_Chapters_ProjectId_Number",
table: "Chapters",
newName: "IX_Chapters_NovelId_Number");
migrationBuilder.RenameColumn(
name: "ProjectId",
table: "NovelMembers",
newName: "NovelId");
migrationBuilder.RenameColumn(
name: "ProjectRole",
table: "NovelMembers",
newName: "NovelRole");
migrationBuilder.RenameIndex(
name: "IX_ProjectMembers_ProjectId_UserId",
table: "NovelMembers",
newName: "IX_NovelMembers_NovelId_UserId");
migrationBuilder.RenameIndex(
name: "IX_ProjectMembers_UserId",
table: "NovelMembers",
newName: "IX_NovelMembers_UserId");
migrationBuilder.RenameIndex(
name: "IX_Projects_OwnerId",
table: "Novels",
newName: "IX_Novels_OwnerId");
migrationBuilder.DropForeignKey(
name: "FK_ProjectMembers_AspNetUsers_UserId",
table: "NovelMembers");
migrationBuilder.AddForeignKey(
name: "FK_NovelMembers_AspNetUsers_UserId",
table: "NovelMembers",
column: "UserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_NovelMembers_Novels_NovelId",
table: "NovelMembers",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Chapters_Novels_NovelId",
table: "Chapters",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Characters_Novels_NovelId",
table: "Characters",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Conversations_Novels_NovelId",
table: "Conversations",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_OpenQuestions_Novels_NovelId",
table: "OpenQuestions",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tags_Novels_NovelId",
table: "Tags",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapters_Novels_NovelId",
table: "Chapters");
migrationBuilder.DropForeignKey(
name: "FK_Characters_Novels_NovelId",
table: "Characters");
migrationBuilder.DropForeignKey(
name: "FK_Conversations_Novels_NovelId",
table: "Conversations");
migrationBuilder.DropForeignKey(
name: "FK_OpenQuestions_Novels_NovelId",
table: "OpenQuestions");
migrationBuilder.DropForeignKey(
name: "FK_Tags_Novels_NovelId",
table: "Tags");
migrationBuilder.DropForeignKey(
name: "FK_NovelMembers_Novels_NovelId",
table: "NovelMembers");
migrationBuilder.DropForeignKey(
name: "FK_NovelMembers_AspNetUsers_UserId",
table: "NovelMembers");
migrationBuilder.AddForeignKey(
name: "FK_ProjectMembers_AspNetUsers_UserId",
table: "NovelMembers",
column: "UserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.RenameIndex(
name: "IX_Novels_OwnerId",
table: "Novels",
newName: "IX_Projects_OwnerId");
migrationBuilder.RenameIndex(
name: "IX_NovelMembers_UserId",
table: "NovelMembers",
newName: "IX_ProjectMembers_UserId");
migrationBuilder.RenameIndex(
name: "IX_NovelMembers_NovelId_UserId",
table: "NovelMembers",
newName: "IX_ProjectMembers_ProjectId_UserId");
migrationBuilder.RenameColumn(
name: "NovelRole",
table: "NovelMembers",
newName: "ProjectRole");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "NovelMembers",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Chapters_NovelId_Number",
table: "Chapters",
newName: "IX_Chapters_ProjectId_Number");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Chapters",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Characters_NovelId",
table: "Characters",
newName: "IX_Characters_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Characters",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Conversations_NovelId",
table: "Conversations",
newName: "IX_Conversations_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Conversations",
newName: "ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "ImportJobs",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_OpenQuestions_NovelId",
table: "OpenQuestions",
newName: "IX_OpenQuestions_ProjectId");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "OpenQuestions",
newName: "ProjectId");
migrationBuilder.RenameIndex(
name: "IX_Tags_NovelId_Name",
table: "Tags",
newName: "IX_Tags_ProjectId_Name");
migrationBuilder.RenameColumn(
name: "NovelId",
table: "Tags",
newName: "ProjectId");
migrationBuilder.RenameTable(
name: "NovelMembers",
newName: "ProjectMembers");
migrationBuilder.RenameTable(
name: "Novels",
newName: "Projects");
migrationBuilder.AddForeignKey(
name: "FK_ProjectMembers_Projects_ProjectId",
table: "ProjectMembers",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Chapters_Projects_ProjectId",
table: "Chapters",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Characters_Projects_ProjectId",
table: "Characters",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Conversations_Projects_ProjectId",
table: "Conversations",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_OpenQuestions_Projects_ProjectId",
table: "OpenQuestions",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Tags_Projects_ProjectId",
table: "Tags",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RenameSettingToLocations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Setting",
table: "Chapters");
migrationBuilder.CreateTable(
name: "Locations",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
NovelId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Locations", x => x.Id);
table.ForeignKey(
name: "FK_Locations_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChapterLocations",
columns: table => new
{
ChaptersId = table.Column<Guid>(type: "TEXT", nullable: false),
LocationsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChapterLocations", x => new { x.ChaptersId, x.LocationsId });
table.ForeignKey(
name: "FK_ChapterLocations_Chapters_ChaptersId",
column: x => x.ChaptersId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChapterLocations_Locations_LocationsId",
column: x => x.LocationsId,
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChapterLocations_LocationsId",
table: "ChapterLocations",
column: "LocationsId");
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChapterLocations");
migrationBuilder.DropTable(
name: "Locations");
migrationBuilder.AddColumn<string>(
name: "Setting",
table: "Chapters",
type: "TEXT",
nullable: true);
}
}
}
@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class CollapseCharacterMotivationAndConflict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE Characters SET Want = CASE
WHEN Want IS NULL OR Want = '' THEN Need
WHEN Need IS NULL OR Need = '' THEN Want
ELSE Want || char(10) || char(10) || Need
END;
""");
migrationBuilder.Sql(
"""
UPDATE Characters SET InternalConflict = CASE
WHEN InternalConflict IS NULL OR InternalConflict = '' THEN ExternalConflict
WHEN ExternalConflict IS NULL OR ExternalConflict = '' THEN InternalConflict
ELSE InternalConflict || char(10) || char(10) || ExternalConflict
END;
""");
migrationBuilder.DropColumn(
name: "ArcSummary",
table: "Characters");
migrationBuilder.DropColumn(
name: "ExternalConflict",
table: "Characters");
migrationBuilder.DropColumn(
name: "Need",
table: "Characters");
migrationBuilder.RenameColumn(
name: "Want",
table: "Characters",
newName: "Motivation");
migrationBuilder.RenameColumn(
name: "InternalConflict",
table: "Characters",
newName: "Conflict");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Motivation",
table: "Characters",
newName: "Want");
migrationBuilder.RenameColumn(
name: "Conflict",
table: "Characters",
newName: "InternalConflict");
migrationBuilder.AddColumn<string>(
name: "ArcSummary",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalConflict",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Need",
table: "Characters",
type: "TEXT",
nullable: true);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ActivityEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ActivityEvents",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
NovelId = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
OccurredAt = table.Column<long>(type: "INTEGER", nullable: false),
DayKey = table.Column<int>(type: "INTEGER", nullable: false),
EntityKind = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Action = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
EntityId = table.Column<Guid>(type: "TEXT", nullable: false),
WordDelta = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ActivityEvents", x => x.Id);
table.ForeignKey(
name: "FK_ActivityEvents_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ActivityEvents_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ActivityEvents_NovelId_DayKey",
table: "ActivityEvents",
columns: new[] { "NovelId", "DayKey" });
migrationBuilder.CreateIndex(
name: "IX_ActivityEvents_UserId_DayKey",
table: "ActivityEvents",
columns: new[] { "UserId", "DayKey" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ActivityEvents");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddChapterKind : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Kind",
table: "Chapters",
type: "TEXT",
maxLength: 32,
nullable: false,
defaultValue: "Body");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "Chapters");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations");
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Locations",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Characters",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Chapters",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true,
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Locations");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Characters");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Chapters");
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true);
}
}
}
@@ -32,6 +32,21 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("BeatCharacters", (string)null); b.ToTable("BeatCharacters", (string)null);
}); });
modelBuilder.Entity("BeatCharacterArcStage", b =>
{
b.Property<Guid>("ArcStagesId")
.HasColumnType("TEXT");
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.HasKey("ArcStagesId", "BeatsId");
b.HasIndex("BeatsId");
b.ToTable("ArcStageBeats", (string)null);
});
modelBuilder.Entity("BeatTag", b => modelBuilder.Entity("BeatTag", b =>
{ {
b.Property<Guid>("BeatsId") b.Property<Guid>("BeatsId")
@@ -47,6 +62,21 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("BeatTags", (string)null); b.ToTable("BeatTags", (string)null);
}); });
modelBuilder.Entity("ChapterLocation", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("LocationsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "LocationsId");
b.HasIndex("LocationsId");
b.ToTable("ChapterLocations", (string)null);
});
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
{ {
b.Property<Guid>("ChaptersId") b.Property<Guid>("ChaptersId")
@@ -139,6 +169,49 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null); b.ToTable("AspNetUserTokens", (string)null);
}); });
modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<int>("DayKey")
.HasColumnType("INTEGER");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT");
b.Property<string>("EntityKind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<long>("OccurredAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
b.Property<int>("WordDelta")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("NovelId", "DayKey");
b.HasIndex("UserId", "DayKey");
b.ToTable("ActivityEvents");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -148,7 +221,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Title") b.Property<string>("Title")
@@ -161,7 +234,7 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.ToTable("Conversations"); b.ToTable("Conversations");
}); });
@@ -246,21 +319,26 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("DeletedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<int>("Number") b.Property<int>("Number")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Prose") b.Property<string>("Prose")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
@@ -285,7 +363,7 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId", "Number"); b.HasIndex("NovelId", "Number");
b.ToTable("Chapters"); b.ToTable("Chapters");
}); });
@@ -306,17 +384,17 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Appearance") b.Property<string>("Appearance")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ArcSummary") b.Property<string>("Backstory")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Backstory") b.Property<string>("Conflict")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("ExternalConflict") b.Property<long?>("DeletedAt")
.HasColumnType("TEXT"); .HasColumnType("INTEGER");
b.Property<string>("IdentityNote") b.Property<string>("IdentityNote")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -326,7 +404,7 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("InternalConflict") b.Property<string>("Motivation")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name") b.Property<string>("Name")
@@ -334,10 +412,10 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Need") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Notes") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Occupation") b.Property<string>("Occupation")
@@ -346,9 +424,6 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Personality") b.Property<string>("Personality")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns") b.Property<string>("Pronouns")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -369,12 +444,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Voice") b.Property<string>("Voice")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.HasIndex("RevealedInChapterId"); b.HasIndex("RevealedInChapterId");
@@ -398,7 +470,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Description") b.Property<string>("Result")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
@@ -576,7 +648,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid?>("ProjectId") b.Property<Guid?>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid?>("RequestedByUserId") b.Property<Guid?>("RequestedByUserId")
@@ -605,7 +677,36 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("ImportJobs"); b.ToTable("ImportJobs");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Locations.Location", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long?>("DeletedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NovelId", "Name")
.IsUnique()
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Locations");
});
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -652,7 +753,7 @@ namespace Novelly.Api.Data.Migrations
b.HasIndex("OwnerId"); b.HasIndex("OwnerId");
b.ToTable("Projects"); b.ToTable("Novels");
}); });
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
@@ -673,7 +774,7 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Detail") b.Property<string>("Detail")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Question") b.Property<string>("Question")
@@ -696,7 +797,7 @@ namespace Novelly.Api.Data.Migrations
b.HasIndex("CharacterId"); b.HasIndex("CharacterId");
b.HasIndex("ProjectId"); b.HasIndex("NovelId");
b.ToTable("OpenQuestions"); b.ToTable("OpenQuestions");
}); });
@@ -719,17 +820,50 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ProjectId") b.Property<Guid>("NovelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ProjectId", "Name") b.HasIndex("NovelId", "Name")
.IsUnique(); .IsUnique();
b.ToTable("Tags"); b.ToTable("Tags");
}); });
modelBuilder.Entity("Novelly.Api.Users.NovelMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("GrantedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("GrantedByUserId")
.HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.Property<string>("NovelRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("NovelId", "UserId")
.IsUnique();
b.ToTable("NovelMembers");
});
modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b => modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -808,39 +942,6 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("AspNetUsers", (string)null); b.ToTable("AspNetUsers", (string)null);
}); });
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("GrantedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("GrantedByUserId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("ProjectRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("ProjectId", "UserId")
.IsUnique();
b.ToTable("ProjectMembers");
});
modelBuilder.Entity("BeatCharacter", b => modelBuilder.Entity("BeatCharacter", b =>
{ {
b.HasOne("Novelly.Api.Beats.Beat", null) b.HasOne("Novelly.Api.Beats.Beat", null)
@@ -856,6 +957,21 @@ namespace Novelly.Api.Data.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("BeatCharacterArcStage", b =>
{
b.HasOne("Novelly.Api.Characters.CharacterArcStage", null)
.WithMany()
.HasForeignKey("ArcStagesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Beats.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("BeatTag", b => modelBuilder.Entity("BeatTag", b =>
{ {
b.HasOne("Novelly.Api.Beats.Beat", null) b.HasOne("Novelly.Api.Beats.Beat", null)
@@ -871,6 +987,21 @@ namespace Novelly.Api.Data.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("ChapterLocation", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", null)
.WithMany()
.HasForeignKey("ChaptersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Locations.Location", null)
.WithMany()
.HasForeignKey("LocationsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
{ {
b.HasOne("Novelly.Api.Chapters.Chapter", null) b.HasOne("Novelly.Api.Chapters.Chapter", null)
@@ -928,15 +1059,33 @@ namespace Novelly.Api.Data.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Conversations") .WithMany()
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.HasOne("Novelly.Api.Users.NovellyUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Novel");
b.Navigation("User");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Conversations")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
@@ -963,20 +1112,20 @@ namespace Novelly.Api.Data.Migrations
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Chapters") .WithMany("Chapters")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Characters.Character", b => modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Characters") .WithMany("Characters")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -990,7 +1139,7 @@ namespace Novelly.Api.Data.Migrations
.HasForeignKey("SameCharacterAsId") .HasForeignKey("SameCharacterAsId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.Navigation("Project"); b.Navigation("Novel");
b.Navigation("RevealedInChapter"); b.Navigation("RevealedInChapter");
@@ -1034,7 +1183,18 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("RelatedCharacter"); b.Navigation("RelatedCharacter");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Locations.Location", b =>
{
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany()
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") b.HasOne("Novelly.Api.Users.NovellyUser", "Owner")
.WithMany() .WithMany()
@@ -1056,9 +1216,9 @@ namespace Novelly.Api.Data.Migrations
.HasForeignKey("CharacterId") .HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany() .WithMany()
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -1066,25 +1226,25 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Character"); b.Navigation("Character");
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Tags.Tag", b => modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Tags") .WithMany("Tags")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
}); });
modelBuilder.Entity("Novelly.Api.Users.ProjectMember", b => modelBuilder.Entity("Novelly.Api.Users.NovelMember", b =>
{ {
b.HasOne("Novelly.Api.Projects.Project", "Project") b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany("Members") .WithMany("Members")
.HasForeignKey("ProjectId") .HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -1094,7 +1254,7 @@ namespace Novelly.Api.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Project"); b.Navigation("Novel");
b.Navigation("User"); b.Navigation("User");
}); });
@@ -1118,7 +1278,7 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("Relationships"); b.Navigation("Relationships");
}); });
modelBuilder.Entity("Novelly.Api.Projects.Project", b => modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.Navigation("Chapters"); b.Navigation("Chapters");
+11 -5
View File
@@ -1,13 +1,15 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Agent; using Novelly.Api.Agent;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -18,19 +20,21 @@ internal class UtcTicksConverter() : ValueConverter<DateTimeOffset, long>(value
public class NovelDbContext(DbContextOptions<NovelDbContext> options) : IdentityUserContext<NovellyUser, Guid>(options), INovelDbContext public class NovelDbContext(DbContextOptions<NovelDbContext> options) : IdentityUserContext<NovellyUser, Guid>(options), INovelDbContext
{ {
public DbSet<Project> Projects => Set<Project>(); public DbSet<Novel> Novels => Set<Novel>();
public DbSet<Character> Characters => Set<Character>(); public DbSet<Character> Characters => Set<Character>();
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>(); public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>(); public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>();
public DbSet<Beat> Beats => Set<Beat>(); public DbSet<Beat> Beats => Set<Beat>();
public DbSet<Tag> Tags => Set<Tag>(); public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Location> Locations => Set<Location>();
public DbSet<Chapter> Chapters => Set<Chapter>(); public DbSet<Chapter> Chapters => Set<Chapter>();
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>(); public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
public DbSet<AgentConversation> Conversations => Set<AgentConversation>(); public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>(); public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
public DbSet<ImportJob> ImportJobs => Set<ImportJob>(); public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
public DbSet<Genre> Genres => Set<Genre>(); public DbSet<Genre> Genres => Set<Genre>();
public DbSet<ProjectMember> ProjectMembers => Set<ProjectMember>(); public DbSet<NovelMember> NovelMembers => Set<NovelMember>();
public DbSet<ActivityEvent> ActivityEvents => Set<ActivityEvent>();
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken); Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken);
@@ -45,12 +49,13 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
public interface INovelDbContext public interface INovelDbContext
{ {
DbSet<Project> Projects { get; } DbSet<Novel> Novels { get; }
DbSet<Character> Characters { get; } DbSet<Character> Characters { get; }
DbSet<CharacterRelationship> CharacterRelationships { get; } DbSet<CharacterRelationship> CharacterRelationships { get; }
DbSet<CharacterArcStage> CharacterArcStages { get; } DbSet<CharacterArcStage> CharacterArcStages { get; }
DbSet<Beat> Beats { get; } DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; } DbSet<Tag> Tags { get; }
DbSet<Location> Locations { get; }
DbSet<Chapter> Chapters { get; } DbSet<Chapter> Chapters { get; }
DbSet<OpenQuestion> OpenQuestions { get; } DbSet<OpenQuestion> OpenQuestions { get; }
DbSet<AgentConversation> Conversations { get; } DbSet<AgentConversation> Conversations { get; }
@@ -58,7 +63,8 @@ public interface INovelDbContext
DbSet<ImportJob> ImportJobs { get; } DbSet<ImportJob> ImportJobs { get; }
DbSet<Genre> Genres { get; } DbSet<Genre> Genres { get; }
DbSet<NovellyUser> Users { get; } DbSet<NovellyUser> Users { get; }
DbSet<ProjectMember> ProjectMembers { get; } DbSet<NovelMember> NovelMembers { get; }
DbSet<ActivityEvent> ActivityEvents { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }
+5
View File
@@ -9,6 +9,11 @@ RUN dotnet publish src/Novelly.Api/Novelly.Api.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
# curl is used by the docker-compose healthcheck; the base image ships neither curl nor
# wget, so the healthcheck silently fails as "unhealthy" without it.
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY --from=build /app . COPY --from=build /app .
ENV ASPNETCORE_URLS=http://0.0.0.0:8080 ENV ASPNETCORE_URLS=http://0.0.0.0:8080
+78 -14
View File
@@ -3,7 +3,7 @@ using Novelly.Api.Agent;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public record ImportRunResult(bool Completed, Guid? ProjectId, int ChaptersCompleted, string? Message); public record ImportRunResult(bool Completed, Guid? NovelId, int ChaptersCompleted, string? Message);
public class ImportAgentService( public class ImportAgentService(
IAgentModelClient model, IAgentModelClient model,
@@ -14,16 +14,18 @@ public class ImportAgentService(
private readonly AgentOptions _options = options.Value; private readonly AgentOptions _options = options.Value;
public async Task<ImportRunResult> RunAsync( public async Task<ImportRunResult> RunAsync(
string sourceRoot, Guid? existingProjectId, int chaptersTotal, CancellationToken ct = default) string sourceRoot, Guid? existingNovelId, int chaptersTotal, CancellationToken ct = default)
{ {
logger.LogInformation( logger.LogInformation(
"Running import for {SourceRoot}, existing project {ExistingProjectId}, {ChaptersTotal} chapters total", "Running import for {SourceRoot}, existing novel {ExistingNovelId}, {ChaptersTotal} chapters total",
sourceRoot, existingProjectId, chaptersTotal); sourceRoot, existingNovelId, chaptersTotal);
toolset.Initialize(sourceRoot, existingProjectId); toolset.Initialize(sourceRoot, existingNovelId);
var startingLedger = toolset.ReadLedgerOrNull(); var startingLedger = toolset.ReadLedgerOrNull();
var systemPrompt = BuildSystemPrompt(sourceRoot); var systemPrompt = ImportPaths.IsSingleFileSource(sourceRoot)
? BuildSingleFileSystemPrompt(sourceRoot)
: BuildSystemPrompt(sourceRoot);
var transcript = new List<AgentChatMessage> var transcript = new List<AgentChatMessage>
{ {
@@ -46,7 +48,7 @@ public class ImportAgentService(
logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1); logger.LogInformation("Import for {SourceRoot} completed after {Turns} turns", sourceRoot, turn + 1);
return new ImportRunResult( return new ImportRunResult(
Completed: true, Completed: true,
toolset.ProjectId, toolset.NovelId,
ledger?.CompletedChapters?.Count ?? 0, ledger?.CompletedChapters?.Count ?? 0,
null); null);
} }
@@ -60,7 +62,7 @@ public class ImportAgentService(
return new ImportRunResult( return new ImportRunResult(
Completed: false, Completed: false,
toolset.ProjectId, toolset.NovelId,
finalLedger?.CompletedChapters?.Count ?? 0, finalLedger?.CompletedChapters?.Count ?? 0,
"Reached the safety limit for this run without finishing. Starting the import " "Reached the safety limit for this run without finishing. Starting the import "
+ "again for the same folder will resume from the ledger."); + "again for the same folder will resume from the ledger.");
@@ -105,14 +107,17 @@ public class ImportAgentService(
private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot); private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot);
private static string BuildSingleFileSystemPrompt(string sourceRoot) =>
SingleFileSystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot);
private const string SystemPromptTemplate = """ private const string SystemPromptTemplate = """
You import a novel outline that already exists as markdown files on disk into this You import a novel outline that already exists as markdown files on disk into this
app's project data. You are running unattended nobody will read your replies or app's novel data. You are running unattended nobody will read your replies or
answer questions mid-run, so make the judgment calls the instructions below call answer questions mid-run, so make the judgment calls the instructions below call
for yourself and record anything genuinely ambiguous rather than stalling on it. for yourself and record anything genuinely ambiguous rather than stalling on it.
Your tools give you exactly two things: read-only access to files under the import Your tools give you exactly two things: read-only access to files under the import
source folder, and application tools that create the project's chapters, characters, source folder, and application tools that create the novel's chapters, characters,
beats and arcs the same ones the writer's own UI uses. You cannot write or edit beats and arcs the same ones the writer's own UI uses. You cannot write or edit
anything on disk except the resume ledger, and you cannot read anything outside the anything on disk except the resume ledger, and you cannot read anything outside the
source folder. source folder.
@@ -143,10 +148,10 @@ public class ImportAgentService(
```json ```json
{{ {{
"projectId": "guid", "novelId": "guid",
"characters": {{ "Name": "guid", "Alias": "guid" }}, "characters": {{ "Name": "guid", "Alias": "guid" }},
"chapters": {{ "1": "guid" }}, "chapters": {{ "1": "guid" }},
"completedPasses": ["project", "characters"], "completedPasses": ["novel", "characters"],
"completedChapters": [1, 2, 3] "completedChapters": [1, 2, 3]
}} }}
``` ```
@@ -160,9 +165,9 @@ public class ImportAgentService(
Skip a pass whose completion is already recorded. Jump straight to the first Skip a pass whose completion is already recorded. Jump straight to the first
incomplete one. incomplete one.
1. **Project** skip if `completedPasses` has "project". Parse title and author from 1. **Novel** skip if `completedPasses` has "novel". Parse title and author from
`outline.md`'s heading. The paragraph(s) before the chapter table are the blurb `outline.md`'s heading. The paragraph(s) before the chapter table are the blurb
pass them as `notes` to create_project. Record `projectId`, mark "project" done. pass them as `notes` to create_novel. Record `novelId`, mark "novel" done.
2. **Characters (dossiers)** skip if "characters" is complete. For each 2. **Characters (dossiers)** skip if "characters" is complete. For each
`characters/*.md` not already in the ledger's `characters` map: name from the `#` `characters/*.md` not already in the ledger's `characters` map: name from the `#`
heading, occupation from the tagline, appearance/backstory/want from heading, occupation from the tagline, appearance/backstory/want from
@@ -199,4 +204,63 @@ public class ImportAgentService(
- If a tool call fails, stop that item and move on rather than retrying blindly - If a tool call fails, stop that item and move on rather than retrying blindly
the ledger stays at the last successful write either way. the ledger stays at the last successful write either way.
"""; """;
private const string SingleFileSystemPromptTemplate = """
You import a single outline file that already exists as markdown on disk into this
app's novel data. You are running unattended nobody will read your replies or
answer questions mid-run, so make the judgment calls yourself and record anything
genuinely ambiguous rather than stalling on it.
Your tools give you exactly two things: read-only access to the file under the
import source folder, and application tools that create the novel's chapters,
characters, beats and arcs. You cannot write or edit anything on disk except the
resume ledger, and you cannot read anything outside the source folder.
## Source file
The source root `{{SOURCE_ROOT}}` holds exactly one markdown file. Call
list_source_files to find its name, then read_source_file to read it. Decide what
kind of document it is before doing anything else:
- If it reads like a chapter outline (`# Chapter NN`, one or more summary
paragraphs, a beat table `| Beat | Character | What | Why |`) treat it as a single
chapter.
- If it reads like a character dossier (`# Name`, an italic tagline,
`## Appearance`, `## Background`, `## Motivation`) treat it as a single character.
`**Thread:**` (chapter files only) may name one character, several, or a character
plus a qualifier only auto-create an undossiered name from it when it names
exactly one clear proper name.
## The ledger
Before writing anything, call read_ledger. If it returns `{{}}`, this is a fresh
run. Call write_ledger with the full, updated ledger after every successful write.
## Passes
1. **Novel** skip if "novel" is in completedPasses or a novel id was already
supplied. Otherwise create one from whatever title/author information the file
gives, or a sensible placeholder title drawn from the file name if none is
present. Record novelId, mark "novel" done.
2. **The document** skip if already recorded. If it is a chapter: auto-create a
character stub (name only) for any single, unqualified name in the Thread or a
beat's Character column that isn't in the ledger yet, then create_chapter with
title, number (1 unless the file states otherwise), summary, and tags, then
create_beat for each table row with resolved character_ids. If it is a
character: create_character with occupation from the tagline and
appearance/backstory/want from Appearance/Background/Motivation; if it has a
`## Events` section, also update_character(importance: "Main") and add_arc_stage
for each bullet. Mark "characters", "chapters", and "arcs" all done once you've
handled the one document this run only ever has one item to place.
## Constraints
- Never invent plot content or character detail, and never guess which of several
candidate names an ambiguous reference means.
- Never write to disk except via write_ledger.
- Never call a create tool for something the ledger already records.
- If a tool call fails, stop and record what you have the ledger stays at the
last successful write either way.
""";
} }
+31 -28
View File
@@ -4,7 +4,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Projects; using Novelly.Api.Novels;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
@@ -20,7 +20,7 @@ internal record ImportAgentTool(
Func<JsonElement, CancellationToken, Task<object?>> Handler); Func<JsonElement, CancellationToken, Task<object?>> Handler);
public class ImportAgentToolset( public class ImportAgentToolset(
ProjectService projects, NovelService novels,
CharacterService characters, CharacterService characters,
CharacterArcService arcs, CharacterArcService arcs,
ChapterService chapters, ChapterService chapters,
@@ -36,15 +36,15 @@ public class ImportAgentToolset(
private string _sourceRoot = string.Empty; private string _sourceRoot = string.Empty;
private Dictionary<string, ImportAgentTool>? _byName; private Dictionary<string, ImportAgentTool>? _byName;
public Guid? ProjectId { get; private set; } public Guid? NovelId { get; private set; }
public IReadOnlyList<AgentToolDefinition> Definitions => public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; [.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
public void Initialize(string sourceRoot, Guid? existingProjectId) public void Initialize(string sourceRoot, Guid? existingNovelId)
{ {
_sourceRoot = sourceRoot; _sourceRoot = sourceRoot;
ProjectId = existingProjectId; NovelId = existingNovelId;
} }
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot); public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
@@ -99,9 +99,9 @@ public class ImportAgentToolset(
} }
} }
private Guid RequireProjectId() => private Guid RequireNovelId() =>
ProjectId ?? throw new InvalidOperationException( NovelId ?? throw new InvalidOperationException(
"No project exists yet for this import — call create_project first."); "No novel exists yet for this import — call create_novel first.");
private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name); private Dictionary<string, ImportAgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
@@ -187,8 +187,8 @@ public class ImportAgentToolset(
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
"create_project", "create_novel",
"Create the novel project this import populates. Call once, in the first pass.", "Create the novel this import populates. Call once, in the first pass.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "The book's title.", required: true) .Str("title", "The book's title.", required: true)
.Str("author", "Author name, if known.") .Str("author", "Author name, if known.")
@@ -196,18 +196,18 @@ public class ImportAgentToolset(
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var created = await projects.CreateAsync(new CreateProjectRequest( var created = await novels.CreateAsync(new CreateNovelRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
ProjectId = created.Id; NovelId = created.Id;
return created.ToResponse(null); return created.ToResponse(null);
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
"update_project_brief", "update_novel_brief",
"Revise the project's top-level fields. Only the fields you supply change.", "Revise the novel's top-level fields. Only the fields you supply change.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "New title.") .Str("title", "New title.")
.Str("author", "Author name.") .Str("author", "Author name.")
@@ -216,15 +216,15 @@ public class ImportAgentToolset(
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var updated = await projects.UpdateAsync(projectId, new UpdateProjectRequest( var updated = await novels.UpdateAsync(novelId, new UpdateNovelRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
JsonInput.String(input, "genre"), JsonInput.String(input, "genre"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return updated is null return updated is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: updated.ToResponse(null); : updated.ToResponse(null);
}); });
@@ -234,18 +234,18 @@ public class ImportAgentToolset(
CharacterSchema(nameRequired: true).Build(), CharacterSchema(nameRequired: true).Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var created = await characters.CreateAsync(projectId, new CreateCharacterRequest( var created = await characters.CreateAsync(novelId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"), JsonInput.RequiredString(input, "name"),
Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting, Importance: JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
Occupation: JsonInput.String(input, "occupation"), Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"), Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"), Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"), Motivation: JsonInput.String(input, "motivation"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return created is null return created is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: created.ToResponse(); : created.ToResponse();
}); });
@@ -264,7 +264,7 @@ public class ImportAgentToolset(
Occupation: JsonInput.String(input, "occupation"), Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"), Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"), Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"), Motivation: JsonInput.String(input, "motivation"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return updated is null return updated is null
@@ -274,26 +274,29 @@ public class ImportAgentToolset(
yield return new ImportAgentTool( yield return new ImportAgentTool(
"create_chapter", "create_chapter",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.", "Add a chapter. Its number is appended to the end of the manuscript unless you supply one. "
+ "Use 'kind' for a foreword, prologue, afterword, or other unnumbered front/back matter.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "Chapter title.", required: true) .Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.") .Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.")
.Enum("kind", "Front matter, a numbered body chapter, or back matter. Defaults to a body chapter.", System.Enum.GetNames<ChapterKind>())
.Str("summary", "The chapter's prose summary paragraph(s).") .Str("summary", "The chapter's prose summary paragraph(s).")
.Str("notes", "The chapter file's ## Notes section, if present.") .Str("notes", "The chapter file's ## Notes section, if present.")
.StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].") .StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].")
.Build(), .Build(),
async (input, ct) => async (input, ct) =>
{ {
var projectId = RequireProjectId(); var novelId = RequireNovelId();
var created = await chapters.CreateAsync(projectId, new CreateChapterRequest( var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
Notes: JsonInput.String(input, "notes"), Notes: JsonInput.String(input, "notes"),
Tags: JsonInput.Strings(input, "tags")), ct); Tags: JsonInput.Strings(input, "tags")), ct);
return created is null return created is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Novel", novelId)
: created.ToResponse(); : created.ToResponse();
}); });
@@ -359,7 +362,7 @@ public class ImportAgentToolset(
characterId, characterId,
new CreateArcStageRequest( new CreateArcStageRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
Description: JsonInput.String(input, "description"), Result: JsonInput.String(input, "description"),
ChapterId: JsonInput.Guid(input, "chapter_id")), ct); ChapterId: JsonInput.Guid(input, "chapter_id")), ct);
return created is null return created is null
@@ -379,6 +382,6 @@ public class ImportAgentToolset(
.Str("occupation", "The italic tagline under the heading.") .Str("occupation", "The italic tagline under the heading.")
.Str("appearance", "The ## Appearance section.") .Str("appearance", "The ## Appearance section.")
.Str("backstory", "The ## Background section.") .Str("backstory", "The ## Background section.")
.Str("want", "The ## Motivation section.") .Str("motivation", "The ## Motivation section.")
.Str("notes", "The ## Notes section, if present."); .Str("notes", "The ## Notes section, if present.");
} }
@@ -0,0 +1,81 @@
using Microsoft.Extensions.Options;
namespace Novelly.Api.Imports;
public class ImportBrowseService(IOptions<ImportOptions> options, ILogger<ImportBrowseService> logger)
{
private readonly ImportOptions _options = options.Value;
public string? RootPath => _options.RootPath;
public ImportBrowseResponse List(string? relativePath)
{
var root = RequireRoot();
logger.LogInformation("Browsing import root at {RelativePath}", relativePath ?? "");
var target = string.IsNullOrWhiteSpace(relativePath) ? root : ImportPaths.ResolveWithin(root, relativePath);
if (!Directory.Exists(target))
throw new ArgumentException($"'{relativePath}' does not exist or is not a directory.", nameof(relativePath));
var normalizedRelative = Path.GetRelativePath(root, target).Replace(Path.DirectorySeparatorChar, '/');
if (normalizedRelative == ".")
{
normalizedRelative = "";
}
var parent = normalizedRelative == "" ? null : Path.GetRelativePath(root, Path.GetFullPath(Path.Combine(target, ".."))).Replace(Path.DirectorySeparatorChar, '/');
if (parent == ".")
{
parent = "";
}
var entries = Directory.EnumerateFileSystemEntries(target)
.Select(BuildEntry)
.Where(e => e is not null)
.Select(e => e!)
.OrderByDescending(e => e.IsDirectory)
.ThenBy(e => e.Name, StringComparer.Ordinal)
.ToArray();
return new ImportBrowseResponse(normalizedRelative, parent, entries);
ImportBrowseEntry? BuildEntry(string path)
{
var name = Path.GetFileName(path);
if (name.StartsWith('.'))
{
return null;
}
var isDirectory = Directory.Exists(path);
if (!isDirectory && !name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var entryRelative = Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
var markdownCount = isDirectory ? ImportPaths.CountChapterFiles(path) : 0;
var looksImportable = isDirectory
? File.Exists(Path.Combine(path, "outline.md")) || markdownCount > 0
: true;
return new ImportBrowseEntry(name, entryRelative, path, isDirectory, markdownCount, looksImportable);
}
}
private string RequireRoot()
{
if (string.IsNullOrWhiteSpace(_options.RootPath))
throw new InvalidOperationException("No import root is configured (Imports:RootPath).");
var full = Path.GetFullPath(_options.RootPath);
Directory.CreateDirectory(full);
return full;
}
}
public record ImportBrowseEntry(string Name, string RelativePath, string SourceRoot, bool IsDirectory, int MarkdownFileCount, bool LooksImportable);
public record ImportBrowseResponse(string RelativePath, string? ParentRelativePath, IReadOnlyList<ImportBrowseEntry> Entries);
+5 -3
View File
@@ -5,7 +5,7 @@ namespace Novelly.Api.Imports;
public record ImportJobResponse( public record ImportJobResponse(
Guid Id, Guid Id,
string SourceRoot, string SourceRoot,
Guid? ProjectId, Guid? NovelId,
ImportJobStatus Status, ImportJobStatus Status,
string? StatusMessage, string? StatusMessage,
int ChaptersCompleted, int ChaptersCompleted,
@@ -22,7 +22,7 @@ public enum ImportReadiness
public record ImportInspectionResponse( public record ImportInspectionResponse(
ImportReadiness Readiness, ImportReadiness Readiness,
Guid? ProjectId, Guid? NovelId,
int ChaptersCompleted, int ChaptersCompleted,
int ChaptersTotal, int ChaptersTotal,
IReadOnlyList<string> CompletedPasses); IReadOnlyList<string> CompletedPasses);
@@ -57,12 +57,14 @@ public class StartImportRequestValidator : IModelValidator<StartImportRequest>
} }
} }
public record ImportUploadResponse(string SourceRoot, string RelativePath, int MarkdownFileCount);
public static class ImportMapping public static class ImportMapping
{ {
public static ImportJobResponse ToResponse(this ImportJob job) => new( public static ImportJobResponse ToResponse(this ImportJob job) => new(
job.Id, job.Id,
job.SourceRoot, job.SourceRoot,
job.ProjectId, job.NovelId,
job.Status, job.Status,
job.StatusMessage, job.StatusMessage,
job.ChaptersCompleted, job.ChaptersCompleted,
@@ -28,6 +28,24 @@ public static class ImportEndpoints
(await service.GetStatusAsync(id, ct))?.ToResponse().ToApiResult()) (await service.GetStatusAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Poll an import job's progress."); .WithSummary("Poll an import job's progress.");
imports.MapGet("/browse", (string? path, ImportBrowseService browse) =>
Results.Ok(browse.List(path)))
.WithSummary("List entries under the configured import root, for the source picker.");
imports.MapPost("/upload", (IFormFile file, ImportService service) =>
{
if (!file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Only .zip files can be uploaded.", nameof(file));
if (file.Length == 0)
throw new ArgumentException("The uploaded file is empty.", nameof(file));
using var stream = file.OpenReadStream();
return Results.Ok(service.UploadZip(stream, file.FileName));
})
.DisableAntiforgery()
.WithSummary("Upload a zip of an outline folder and stage it under the configured import root.");
return app; return app;
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ public class ImportJob
public string SourceRoot { get; init; } = string.Empty; public string SourceRoot { get; init; } = string.Empty;
public Guid? ProjectId { get; set; } public Guid? NovelId { get; set; }
public Guid? RequestedByUserId { get; init; } public Guid? RequestedByUserId { get; init; }
+3 -3
View File
@@ -53,11 +53,11 @@ public class ImportJobRunner(
try try
{ {
var existingProjectId = ImportPaths.ReadLedger(job.SourceRoot)?.ProjectId; var existingNovelId = ImportPaths.ReadLedger(job.SourceRoot)?.NovelId;
var result = await agent.RunAsync(job.SourceRoot, existingProjectId, job.ChaptersTotal, ct); var result = await agent.RunAsync(job.SourceRoot, existingNovelId, job.ChaptersTotal, ct);
job.ProjectId = result.ProjectId; job.NovelId = result.NovelId;
job.ChaptersCompleted = result.ChaptersCompleted; job.ChaptersCompleted = result.ChaptersCompleted;
job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused; job.Status = result.Completed ? ImportJobStatus.Completed : ImportJobStatus.Paused;
job.StatusMessage = result.Message; job.StatusMessage = result.Message;
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Imports;
public class ImportOptions
{
public const string SectionName = "Imports";
public string? RootPath { get; set; }
}
+38 -5
View File
@@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public record ImportLedger( public record ImportLedger(
Guid? ProjectId, Guid? NovelId,
Dictionary<string, Guid>? Characters, Dictionary<string, Guid>? Characters,
Dictionary<string, Guid>? Chapters, Dictionary<string, Guid>? Chapters,
List<string>? CompletedPasses, List<string>? CompletedPasses,
@@ -13,6 +13,7 @@ public record ImportLedger(
internal static class ImportPaths internal static class ImportPaths
{ {
private const string LedgerFileName = ".novelly-import.json"; private const string LedgerFileName = ".novelly-import.json";
public const string StagingFolderName = ".novelly-staging";
private static readonly JsonSerializerOptions LedgerOptions = new() private static readonly JsonSerializerOptions LedgerOptions = new()
{ {
@@ -20,7 +21,7 @@ internal static class ImportPaths
WriteIndented = true WriteIndented = true
}; };
public static string ResolveRoot(string sourceRoot) public static string ResolveRoot(string sourceRoot, string? importRoot = null)
{ {
if (string.IsNullOrWhiteSpace(sourceRoot)) if (string.IsNullOrWhiteSpace(sourceRoot))
throw new ArgumentException("'Source Root' must not be empty.", nameof(sourceRoot)); throw new ArgumentException("'Source Root' must not be empty.", nameof(sourceRoot));
@@ -38,25 +39,57 @@ internal static class ImportPaths
if (!Directory.Exists(full)) if (!Directory.Exists(full))
throw new ArgumentException($"'{full}' does not exist or is not a directory.", nameof(sourceRoot)); throw new ArgumentException($"'{full}' does not exist or is not a directory.", nameof(sourceRoot));
EnsureWithinImportRoot(importRoot, full, sourceRoot);
return full; return full;
} }
public static void EnsureWithinImportRoot(string? importRoot, string candidate, string originalInput)
{
if (importRoot is not null && !IsWithin(importRoot, candidate))
throw new ArgumentException($"'{originalInput}' is outside the configured import root.", nameof(originalInput));
}
public static bool IsSingleFileSource(string root)
{
if (Directory.EnumerateDirectories(root).Any())
{
return false;
}
return Directory.EnumerateFiles(root, "*.md", SearchOption.TopDirectoryOnly).Count() == 1;
}
public static string ResolveWithin(string root, string relativePath) public static string ResolveWithin(string root, string relativePath)
{ {
if (string.IsNullOrWhiteSpace(relativePath)) if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentException("Path must not be empty."); throw new ArgumentException("Path must not be empty.");
var combined = Path.GetFullPath(Path.Combine(root, relativePath)); var combined = Path.GetFullPath(Path.Combine(root, relativePath));
var relativeToRoot = Path.GetRelativePath(root, combined);
if (relativeToRoot.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativeToRoot)) if (!IsWithin(root, combined))
throw new ArgumentException($"'{relativePath}' escapes the import source folder."); throw new ArgumentException($"'{relativePath}' escapes the import source folder.");
return combined; return combined;
} }
private static bool IsWithin(string root, string candidate)
{
var relativeToRoot = Path.GetRelativePath(root, candidate);
return relativeToRoot == "." || !relativeToRoot.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativeToRoot);
}
public static string LedgerPath(string root) => Path.Combine(root, LedgerFileName); public static string LedgerPath(string root) => Path.Combine(root, LedgerFileName);
public static string StagingRoot(string importRoot) => Path.Combine(importRoot, StagingFolderName);
public static string SanitizeForFolderName(string value)
{
var sanitized = new string(value.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray());
sanitized = sanitized.Trim('-', '_');
return string.IsNullOrEmpty(sanitized) ? "import" : sanitized[..Math.Min(sanitized.Length, 60)];
}
public static ImportLedger? ReadLedger(string root) public static ImportLedger? ReadLedger(string root)
{ {
var path = LedgerPath(root); var path = LedgerPath(root);
@@ -100,7 +133,7 @@ internal static class ImportPaths
} }
var passes = ledger.CompletedPasses ?? []; var passes = ledger.CompletedPasses ?? [];
var requiredPasses = new[] { "project", "characters", "chapters", "arcs" }; var requiredPasses = new[] { "novel", "characters", "chapters", "arcs" };
var chaptersDone = ledger.CompletedChapters?.Count ?? 0; var chaptersDone = ledger.CompletedChapters?.Count ?? 0;
return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal); return requiredPasses.All(passes.Contains) && (chaptersTotal == 0 || chaptersDone >= chaptersTotal);
+67 -8
View File
@@ -1,22 +1,47 @@
using System.Threading.Channels; using System.Threading.Channels;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Novels;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Imports; namespace Novelly.Api.Imports;
public class ImportService( public class ImportService(
INovelDbContext db, INovelDbContext db,
ProjectService projects, NovelService novels,
Channel<Guid> queue, Channel<Guid> queue,
INovelUserContext userContext, INovelUserContext userContext,
IOptions<ImportOptions> importOptions,
ImportZipExtractor zipExtractor,
ILogger<ImportService> logger, ILogger<ImportService> logger,
IModelValidator<InspectImportRequest> inspectValidator, IModelValidator<InspectImportRequest> inspectValidator,
IModelValidator<StartImportRequest> startValidator) IModelValidator<StartImportRequest> startValidator)
{ {
private readonly string? _importRoot = importOptions.Value.RootPath is { } root ? Path.GetFullPath(root) : null;
public ImportUploadResponse UploadZip(Stream zipStream, string fileName)
{
if (_importRoot is null)
throw new InvalidOperationException("No import root is configured (Imports:RootPath).");
logger.LogInformation("Uploading import zip {FileName}", fileName);
Directory.CreateDirectory(_importRoot);
var stagingDir = Path.Combine(
ImportPaths.StagingRoot(_importRoot),
$"zip-{ImportPaths.SanitizeForFolderName(Path.GetFileNameWithoutExtension(fileName))}-{Guid.NewGuid():N}");
zipExtractor.Extract(zipStream, stagingDir);
var markdownCount = Directory.EnumerateFiles(stagingDir, "*.md", SearchOption.AllDirectories).Count();
var relativePath = Path.GetRelativePath(_importRoot, stagingDir).Replace(Path.DirectorySeparatorChar, '/');
return new ImportUploadResponse(stagingDir, relativePath, markdownCount);
}
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default) public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
{ {
Guard.Null(request, nameof(request)); Guard.Null(request, nameof(request));
@@ -24,7 +49,7 @@ public class ImportService(
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot); logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
var root = ImportPaths.ResolveRoot(request.SourceRoot); var root = ResolveSourceRoot(request.SourceRoot);
var ledger = ImportPaths.ReadLedger(root); var ledger = ImportPaths.ReadLedger(root);
var total = ImportPaths.CountChapterFiles(root); var total = ImportPaths.CountChapterFiles(root);
@@ -37,7 +62,7 @@ public class ImportService(
var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable; var readiness = ImportPaths.IsComplete(ledger, total) ? ImportReadiness.Complete : ImportReadiness.Resumable;
return Task.FromResult(new ImportInspectionResponse( return Task.FromResult(new ImportInspectionResponse(
readiness, ledger.ProjectId, chaptersDone, total, ledger.CompletedPasses ?? [])); readiness, ledger.NovelId, chaptersDone, total, ledger.CompletedPasses ?? []));
} }
public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default) public async Task<ImportJob> StartOrResumeAsync(StartImportRequest request, CancellationToken ct = default)
@@ -48,16 +73,16 @@ public class ImportService(
logger.LogInformation( logger.LogInformation(
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart); "Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
var root = ImportPaths.ResolveRoot(request.SourceRoot); var root = ResolveSourceRoot(request.SourceRoot);
if (request.ForceRestart) if (request.ForceRestart)
{ {
var ledger = ImportPaths.ReadLedger(root); var ledger = ImportPaths.ReadLedger(root);
if (ledger?.ProjectId is { } existingProjectId) if (ledger?.NovelId is { } existingNovelId)
{ {
logger.LogWarning( logger.LogWarning(
"Force-restarting import for {SourceRoot}: deleting project {ProjectId}", root, existingProjectId); "Force-restarting import for {SourceRoot}: deleting novel {NovelId}", root, existingNovelId);
await projects.DeleteAsync(existingProjectId, ct); await novels.DeleteAsync(existingNovelId, ct);
} }
ImportPaths.DeleteLedger(root); ImportPaths.DeleteLedger(root);
@@ -88,6 +113,40 @@ public class ImportService(
return job; return job;
} }
private string ResolveSourceRoot(string sourceRoot)
{
string full;
try
{
full = Path.GetFullPath(sourceRoot);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new ArgumentException($"'{sourceRoot}' is not a valid path.", nameof(sourceRoot), ex);
}
if (!File.Exists(full))
{
return ImportPaths.ResolveRoot(sourceRoot, _importRoot);
}
if (!full.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"'{sourceRoot}' is not a markdown file or a directory.", nameof(sourceRoot));
ImportPaths.EnsureWithinImportRoot(_importRoot, full, sourceRoot);
var stagingParent = _importRoot is not null
? ImportPaths.StagingRoot(_importRoot)
: Path.Combine(Path.GetTempPath(), "novelly-import-staging");
var stagingDir = Path.Combine(
stagingParent, $"file-{ImportPaths.SanitizeForFolderName(Path.GetFileNameWithoutExtension(full))}");
Directory.CreateDirectory(stagingDir);
File.Copy(full, Path.Combine(stagingDir, Path.GetFileName(full)), overwrite: true);
return stagingDir;
}
public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default) public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
@@ -0,0 +1,84 @@
using System.IO.Compression;
namespace Novelly.Api.Imports;
public class ImportZipExtractor(ILogger<ImportZipExtractor> logger)
{
private const int MaxEntryCount = 2000;
private const long MaxEntryUncompressedBytes = 10 * 1024 * 1024;
private const long MaxTotalUncompressedBytes = 100 * 1024 * 1024;
private static readonly string[] AllowedFileNames = [".novelly-import.json"];
public void Extract(Stream zipStream, string stagingDirectory)
{
Directory.CreateDirectory(stagingDirectory);
try
{
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
var entries = archive.Entries
.Where(e => !string.IsNullOrEmpty(e.Name))
.Where(e => !e.FullName.StartsWith("__MACOSX/", StringComparison.OrdinalIgnoreCase))
.Where(e => AllowedFileNames.Contains(e.Name) || !e.Name.StartsWith('.'))
.ToArray();
if (entries.Length == 0)
throw new ArgumentException("The zip file is empty.");
if (entries.Length > MaxEntryCount)
throw new ArgumentException($"The zip file has too many entries (max {MaxEntryCount}).");
var stripPrefix = FindCommonTopLevelDirectory(entries);
var totalBytes = 0L;
foreach (var entry in entries)
{
var relativePath = stripPrefix is null
? entry.FullName
: entry.FullName[(stripPrefix.Length + 1)..];
if (relativePath.Length == 0) continue;
if (!relativePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) && !AllowedFileNames.Contains(entry.Name))
throw new ArgumentException($"'{entry.FullName}' is not a markdown file. Only .md files (and .novelly-import.json) are allowed.");
if (entry.Length > MaxEntryUncompressedBytes)
throw new ArgumentException($"'{entry.FullName}' is too large (max {MaxEntryUncompressedBytes / (1024 * 1024)} MB per file).");
totalBytes += entry.Length;
if (totalBytes > MaxTotalUncompressedBytes)
throw new ArgumentException($"The zip file is too large uncompressed (max {MaxTotalUncompressedBytes / (1024 * 1024)} MB).");
var destination = ImportPaths.ResolveWithin(stagingDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
using var entryStream = entry.Open();
using var fileStream = File.Create(destination);
entryStream.CopyTo(fileStream);
}
logger.LogInformation("Extracted import zip with {EntryCount} entries into staging folder", entries.Length);
}
catch
{
if (Directory.Exists(stagingDirectory))
Directory.Delete(stagingDirectory, recursive: true);
throw;
}
}
private static string? FindCommonTopLevelDirectory(IReadOnlyCollection<ZipArchiveEntry> entries)
{
var topLevelSegments = entries
.Select(e => e.FullName.Split('/', '\\')[0])
.Distinct()
.ToArray();
return topLevelSegments.Length == 1 && entries.All(e => e.FullName.Contains('/') || e.FullName.Contains('\\'))
? topLevelSegments[0]
: null;
}
}
+36
View File
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Novels;
namespace Novelly.Api.Locations;
public class Location : ISoftDeletable
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; }
public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty;
public List<Chapter> Chapters { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
}
public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location>
{
public void Configure(EntityTypeBuilder<Location> entity)
{
entity.Property(l => l.Name).IsRequired().HasMaxLength(120);
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique().HasFilter("\"DeletedAt\" IS NULL");
entity.HasQueryFilter(l => l.DeletedAt == null);
entity.HasMany(l => l.Chapters).WithMany(c => c.Locations)
.UsingEntity(join => join.ToTable("ChapterLocations"));
}
}
@@ -0,0 +1,56 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public record LocationResponse(Guid Id, string Name);
public record LocationSummaryResponse(Guid Id, string Name, int ChapterCount);
public record CreateLocationRequest(string Name);
public class CreateLocationRequestValidator : IModelValidator<CreateLocationRequest>
{
public ValidationResult Validate(CreateLocationRequest model)
{
var result = new ValidationResult();
result.AddRequiredTextErrors("Name", "Name", model.Name, 120);
return result;
}
}
public record UpdateLocationRequest(string? Name = null);
public class UpdateLocationRequestValidator : IModelValidator<UpdateLocationRequest>
{
public ValidationResult Validate(UpdateLocationRequest model)
{
var result = new ValidationResult();
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a location", 120);
return result;
}
}
public record LocationReferencesResponse(LocationResponse Location, IReadOnlyList<LocatedChapterResponse> Chapters);
public record LocatedChapterResponse(Guid Id, int Number, ChapterKind Kind, int? DisplayNumber, string Title, string? Summary);
public static class LocationMapping
{
public static LocationResponse ToResponse(this Location l) => new(l.Id, l.Name);
public static LocationReferencesResponse ToReferencesResponse(this Location location, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
location.ToResponse(),
[.. location.Chapters
.OrderBy(c => c.Number)
.Select(c => new LocatedChapterResponse(
c.Id, c.Number, c.Kind,
displayNumbers is not null && displayNumbers.TryGetValue(c.Id, out var n) ? n : null,
c.Title, c.Summary))]);
public static string Normalise(string name) => name.Trim();
}
@@ -0,0 +1,61 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public static class LocationEndpoints
{
public static IEndpointRouteBuilder MapLocationEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, LocationService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List a novel's locations with usage counts.");
novelScoped.MapPost("/", async (
Guid novelId, CreateLocationRequest request, LocationService service, CancellationToken ct) =>
{
var location = await service.CreateAsync(novelId, request, ct);
if (location is null)
{
return Results.NotFound();
}
var created = location.ToResponse();
return Results.Created($"/api/locations/{created.Id}", created);
})
.WithSummary("Create a location. Locations are also created on demand when applied by name.");
var locations = app.MapGroup("/api/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
{
var location = await service.GetReferencesAsync(id, ct);
if (location is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForNovelAsync(location.NovelId, ct);
return location.ToReferencesResponse(displayNumbers).ToApiResult();
})
.WithSummary("Cross-reference: every chapter set at this location.");
locations.MapPatch("/{id:guid}", async (
Guid id, UpdateLocationRequest request, LocationService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Rename a location.");
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Move a location to the trash. Whatever carried it is left alone.");
return app;
}
}
@@ -0,0 +1,189 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Locations;
public class LocationService(
INovelDbContext db,
NovelAccessService access,
ActivityLog activity,
ILogger<LocationService> logger,
IModelValidator<CreateLocationRequest> createValidator,
IModelValidator<UpdateLocationRequest> updateValidator)
{
public async Task<IReadOnlyList<LocationSummaryResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing locations for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Locations
.Where(l => l.NovelId == novelId)
.OrderBy(l => l.Name)
.Select(l => new LocationSummaryResponse(l.Id, l.Name, l.Chapters.Count))
.ToListAsync(ct);
}
public async Task<Location?> GetReferencesAsync(Guid locationId, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Getting references for location {LocationId}", locationId);
var location = await db.Locations
.Include(l => l.Chapters)
.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return location;
}
await access.RequireAsync(location.NovelId, NovelPermission.Read, ct);
return location;
}
public async Task<Location?> CreateAsync(Guid novelId, CreateLocationRequest request, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating location {Name} for novel {NovelId}", request.Name, novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected location creation: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var name = LocationMapping.Normalise(request.Name);
var existing = await FindByNameAsync(novelId, name, ct);
if (existing is not null)
{
logger.LogWarning("Rejected location creation for novel {NovelId}: '{Name}' already exists", novelId, existing.Name);
throw new InvalidOperationException($"The novel already has a location called '{existing.Name}'.");
}
var location = new Location { NovelId = novelId, Name = name };
db.Locations.Add(location);
activity.Record(novelId, ActivityEntityKind.Location, ActivityAction.Created, location.Id);
await db.SaveChangesAsync(ct);
return location;
}
public async Task<Location?> UpdateAsync(Guid locationId, UpdateLocationRequest request, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating location {LocationId}", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return null;
}
await access.RequireAsync(location.NovelId, NovelPermission.Write, ct);
if (request.Name is not null)
{
var name = LocationMapping.Normalise(request.Name);
var clash = await FindByNameAsync(location.NovelId, name, ct);
if (clash is not null && clash.Id != location.Id)
{
logger.LogWarning("Rejected update for location {LocationId}: '{Name}' already exists as {ClashLocationId}", locationId, clash.Name, clash.Id);
throw new InvalidOperationException($"The novel already has a location called '{clash.Name}'.");
}
location.Name = name;
}
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Updated, location.Id);
await db.SaveChangesAsync(ct);
return location;
}
public async Task<bool> DeleteAsync(Guid locationId, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Moving location {LocationId} to trash", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return false;
}
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
location.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id);
await db.SaveChangesAsync(ct);
return true;
}
internal async Task<List<Location>> ResolveAsync(
Guid novelId, IReadOnlyList<string> names, CancellationToken ct)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(names, nameof(names));
logger.LogDebug("Resolving {Count} location names for novel {NovelId}", names.Count, novelId);
var wanted = names
.Select(LocationMapping.Normalise)
.Where(n => !string.IsNullOrWhiteSpace(n))
.DistinctBy(n => n.ToLowerInvariant())
.ToList();
if (wanted.Count == 0)
{
logger.LogDebug("No usable location names for novel {NovelId}", novelId);
return [];
}
var existing = await db.Locations
.Where(l => l.NovelId == novelId)
.ToListAsync(ct);
var resolved = new List<Location>();
foreach (var name in wanted)
{
var match = existing.FirstOrDefault(
l => string.Equals(l.Name, name, StringComparison.OrdinalIgnoreCase));
if (match is null)
{
match = new Location { NovelId = novelId, Name = name };
db.Locations.Add(match);
existing.Add(match);
}
resolved.Add(match);
}
logger.LogDebug("Resolved {Count} locations for novel {NovelId}", resolved.Count, novelId);
return resolved;
}
private async Task<Location?> FindByNameAsync(Guid novelId, string name, CancellationToken ct) =>
await db.Locations.FirstOrDefaultAsync(
l => l.NovelId == novelId && EF.Functions.Like(l.Name, name), ct);
}
+10
View File
@@ -0,0 +1,10 @@
namespace Novelly.Api.Mcp;
public static class McpEndpoints
{
public static IEndpointRouteBuilder MapNovelMcp(this IEndpointRouteBuilder app)
{
app.MapMcp("/mcp");
return app;
}
}
+109
View File
@@ -0,0 +1,109 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ModelContextProtocol.Protocol;
using Novelly.Api.Agent;
namespace Novelly.Api.Mcp;
public static class NovelMcpTools
{
private const string NovelIdProperty = "novelId";
public static IReadOnlyList<Tool> Describe(IReadOnlyList<AgentToolDefinition> definitions) =>
[.. definitions.Select(definition => new Tool
{
Name = definition.Name,
Description = definition.Description,
InputSchema = definition.RequiresNovelId ? WithNovelId(definition.InputSchema) : definition.InputSchema
})];
public static async Task<CallToolResult> CallAsync(
NovelAgentToolset toolset,
IReadOnlyList<AgentToolDefinition> definitions,
CallToolRequestParams parameters,
CancellationToken ct)
{
var definition = definitions.FirstOrDefault(d => d.Name == parameters.Name);
if (definition is null)
{
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = $"No such tool: '{parameters.Name}'." }]
};
}
var arguments = ToJsonElement(parameters.Arguments);
Guid novelId;
if (definition.RequiresNovelId)
{
try
{
novelId = JsonInput.RequiredGuid(arguments, NovelIdProperty);
}
catch (ArgumentException ex)
{
return new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = ex.Message }] };
}
}
else
{
novelId = Guid.Empty;
}
var result = await toolset.ExecuteAsync(parameters.Name, novelId, arguments, ct);
return new CallToolResult
{
IsError = result.IsError,
Content = [new TextContentBlock { Text = result.Content }]
};
}
private static JsonElement ToJsonElement(IDictionary<string, JsonElement>? arguments)
{
if (arguments is null)
{
return JsonSerializer.Deserialize<JsonElement>("{}");
}
var obj = new JsonObject();
foreach (var (key, value) in arguments)
{
obj[key] = JsonNode.Parse(value.GetRawText());
}
return JsonSerializer.Deserialize<JsonElement>(obj.ToJsonString());
}
private static JsonElement WithNovelId(JsonElement schema)
{
var node = JsonNode.Parse(schema.GetRawText())!.AsObject();
var properties = new JsonObject
{
[NovelIdProperty] = new JsonObject
{
["type"] = "string",
["description"] = "The novel's id."
}
};
if (node["properties"] is JsonObject existingProperties)
{
foreach (var (key, value) in existingProperties.ToList())
{
existingProperties.Remove(key);
properties[key] = value;
}
}
node["properties"] = properties;
var required = node["required"] as JsonArray ?? [];
required.Insert(0, NovelIdProperty);
node["required"] = required;
return JsonSerializer.Deserialize<JsonElement>(node.ToJsonString());
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Novelly.ServiceDefaults\Novelly.ServiceDefaults.csproj" /> <ProjectReference Include="..\Novelly.ServiceDefaults\Novelly.ServiceDefaults.csproj" />
@@ -14,6 +14,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" /> <PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
@@ -24,6 +25,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>fb455630-f631-46bb-9c60-0deaf01c593e</UserSecretsId>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -6,9 +6,9 @@ using Novelly.Api.Characters;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public class Project public class Novel
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -24,7 +24,7 @@ public class Project
public int? TargetWordCount { get; set; } public int? TargetWordCount { get; set; }
public ProjectPhase Phase { get; set; } = ProjectPhase.Brainstorming; public NovelPhase Phase { get; set; } = NovelPhase.Brainstorming;
public Guid? OwnerId { get; set; } public Guid? OwnerId { get; set; }
public NovellyUser? Owner { get; set; } public NovellyUser? Owner { get; set; }
@@ -36,25 +36,25 @@ public class Project
public List<Chapter> Chapters { get; set; } = []; public List<Chapter> Chapters { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
public List<AgentConversation> Conversations { get; set; } = []; public List<AgentConversation> Conversations { get; set; } = [];
public List<ProjectMember> Members { get; set; } = []; public List<NovelMember> Members { get; set; } = [];
} }
public class ProjectEntityTypeConfiguration : IEntityTypeConfiguration<Project> public class NovelEntityTypeConfiguration : IEntityTypeConfiguration<Novel>
{ {
public void Configure(EntityTypeBuilder<Project> entity) public void Configure(EntityTypeBuilder<Novel> entity)
{ {
entity.Property(p => p.Title).IsRequired().HasMaxLength(300); entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
entity.Property(p => p.Phase).HasConversion<string>().HasMaxLength(32); entity.Property(p => p.Phase).HasConversion<string>().HasMaxLength(32);
entity.HasMany(p => p.Characters).WithOne(c => c.Project!) entity.HasMany(p => p.Characters).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!) entity.HasMany(p => p.Chapters).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Tags).WithOne(t => t.Project!) entity.HasMany(p => p.Tags).WithOne(t => t.Novel!)
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(t => t.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!) entity.HasMany(p => p.Conversations).WithOne(c => c.Novel!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Members).WithOne(m => m.Project!) entity.HasMany(p => p.Members).WithOne(m => m.Novel!)
.HasForeignKey(m => m.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(m => m.NovelId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.Owner).WithMany() entity.HasOne(p => p.Owner).WithMany()
.HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(p => p.OwnerId).OnDelete(DeleteBehavior.Restrict);
} }
@@ -1,21 +1,21 @@
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public record ProjectSummaryResponse( public record NovelSummaryResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Author, string? Author,
string? Genre, string? Genre,
string? Logline, string? Logline,
int? TargetWordCount, int? TargetWordCount,
ProjectPhase Phase, NovelPhase Phase,
int CharacterCount, int CharacterCount,
int ChapterCount, int ChapterCount,
int WordCount, int WordCount,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record ProjectResponse( public record NovelResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Author, string? Author,
@@ -24,13 +24,13 @@ public record ProjectResponse(
string? Synopsis, string? Synopsis,
string? Notes, string? Notes,
int? TargetWordCount, int? TargetWordCount,
ProjectPhase Phase, NovelPhase Phase,
Guid? OwnerId, Guid? OwnerId,
string? MyRole, string? MyRole,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record CreateProjectRequest( public record CreateNovelRequest(
string Title, string Title,
string? Author = null, string? Author = null,
string? Genre = null, string? Genre = null,
@@ -39,20 +39,20 @@ public record CreateProjectRequest(
string? Notes = null, string? Notes = null,
int? TargetWordCount = null); int? TargetWordCount = null);
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest> public class CreateNovelRequestValidator : IModelValidator<CreateNovelRequest>
{ {
public ValidationResult Validate(CreateProjectRequest model) public ValidationResult Validate(CreateNovelRequest model)
{ {
var result = new ValidationResult(); var result = new ValidationResult();
ProjectValidation.Title(model.Title, result); NovelValidation.Title(model.Title, result);
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result); NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result; return result;
} }
} }
public record UpdateProjectRequest( public record UpdateNovelRequest(
string? Title = null, string? Title = null,
string? Author = null, string? Author = null,
string? Genre = null, string? Genre = null,
@@ -60,28 +60,26 @@ public record UpdateProjectRequest(
string? Synopsis = null, string? Synopsis = null,
string? Notes = null, string? Notes = null,
int? TargetWordCount = null, int? TargetWordCount = null,
ProjectPhase? Phase = null); NovelPhase? Phase = null);
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest> public class UpdateNovelRequestValidator : IModelValidator<UpdateNovelRequest>
{ {
public ValidationResult Validate(UpdateProjectRequest model) public ValidationResult Validate(UpdateNovelRequest model)
{ {
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a project", 200); result.AddUnclearableTextErrors("Title", "Title", model.Title, "a novel", 200);
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result); NovelValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result; return result;
} }
} }
file static class ProjectValidation file static class NovelValidation
{ {
public static void Title(string title, ValidationResult result) => public static void Title(string title, ValidationResult result) => result.AddRequiredTextErrors("Title", "Title", title, 200);
result.AddRequiredTextErrors("Title", "Title", title, 200);
public static void OptionalFields( public static void OptionalFields(string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
{ {
if (author is { Length: > 200 }) if (author is { Length: > 200 })
result.AddError("Author", "'Author' must be 200 characters or fewer."); result.AddError("Author", "'Author' must be 200 characters or fewer.");
@@ -103,9 +101,9 @@ file static class ProjectValidation
} }
} }
public static class ProjectMapping public static class NovelMapping
{ {
public static ProjectResponse ToResponse(this Project p, string? myRole) => new( public static NovelResponse ToResponse(this Novel p, string? myRole) => new(
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes, p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt); p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt);
} }
+57
View File
@@ -0,0 +1,57 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Novels;
public static class NovelEndpoints
{
public static IEndpointRouteBuilder MapNovelEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/novels").WithTags("Novels")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (NovelService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novels.");
group.MapGet("/{id:guid}", async (Guid id, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.GetAsync(id, ct);
if (novel is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(novel, ct);
return Results.Ok(novel.ToResponse(myRole));
})
.WithSummary("Read a novel's brief.");
group.MapPost("/", async (CreateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.CreateAsync(request, ct);
var myRole = await access.GetMyRoleAsync(novel, ct);
var created = novel.ToResponse(myRole);
return Results.Created($"/api/novels/{created.Id}", created);
})
.WithSummary("Create a novel.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateNovelRequest request, NovelService service, NovelAccessService access, CancellationToken ct) =>
{
var novel = await service.UpdateAsync(id, request, ct);
if (novel is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(novel, ct);
return Results.Ok(novel.ToResponse(myRole));
})
.WithSummary("Update a novel's brief.");
group.MapDelete("/{id:guid}", async (Guid id, NovelService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a novel and everything in it.");
return app;
}
}
@@ -1,6 +1,6 @@
namespace Novelly.Api.Projects; namespace Novelly.Api.Novels;
public enum ProjectPhase public enum NovelPhase
{ {
Brainstorming, Brainstorming,
Outlining, Outlining,
+137
View File
@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Novels;
public class NovelService(
INovelDbContext db,
NovelAccessService access,
INovelUserContext userContext,
ActivityLog activity,
ILogger<NovelService> logger,
IModelValidator<CreateNovelRequest> createValidator,
IModelValidator<UpdateNovelRequest> updateValidator)
{
public async Task<IReadOnlyList<NovelSummaryResponse>> ListAsync(CancellationToken ct = default)
{
logger.LogInformation("Listing novels");
return await access.VisibleNovels()
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new NovelSummaryResponse(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Phase,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
}
public async Task<Novel?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return null;
await access.RequireAsync(id, NovelPermission.Read, ct);
return novel;
}
public async Task<Novel> CreateAsync(CreateNovelRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
access.RequireCanCreateNovel();
logger.LogInformation("Creating novel {Title}", request.Title);
var novel = new Novel
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount,
OwnerId = userContext.UserId
};
db.Novels.Add(novel);
activity.Record(novel.Id, ActivityEntityKind.Novel, ActivityAction.Created, novel.Id);
await db.SaveChangesAsync(ct);
return novel;
}
public async Task<Novel?> UpdateAsync(Guid id, UpdateNovelRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return null;
await access.RequireAsync(id, NovelPermission.Write, ct);
novel.Title = Patch.Apply(novel.Title, request.Title) ?? novel.Title;
novel.Author = Patch.Apply(novel.Author, request.Author);
novel.Genre = Patch.Apply(novel.Genre, request.Genre);
novel.Logline = Patch.Apply(novel.Logline, request.Logline);
novel.Synopsis = Patch.Apply(novel.Synopsis, request.Synopsis);
novel.Notes = Patch.Apply(novel.Notes, request.Notes);
novel.TargetWordCount = request.TargetWordCount ?? novel.TargetWordCount;
novel.Phase = request.Phase ?? novel.Phase;
novel.UpdatedAt = DateTimeOffset.UtcNow;
activity.Record(novel.Id, ActivityEntityKind.Novel, ActivityAction.Updated, novel.Id);
await db.SaveChangesAsync(ct);
return novel;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting novel {NovelId}", id);
var novel = await FindAsync(id, ct);
if (novel is null) return false;
await access.RequireAsync(id, NovelPermission.DeleteContent, ct);
db.Novels.Remove(novel);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Novel?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding novel {NovelId}", id);
var novel = await db.Novels.FirstOrDefaultAsync(p => p.Id == id, ct);
if (novel is null)
{
logger.LogWarning("Novel {NovelId} not found", id);
return novel;
}
logger.LogDebug("Found novel {NovelId}", id);
return novel;
}
}
+38 -4
View File
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Diagnostics;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Agent; using Novelly.Api.Agent;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
@@ -10,9 +11,12 @@ using Novelly.Api.Common;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Locations;
using Novelly.Api.Mcp;
using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users; using Novelly.Api.Users;
using Serilog; using Serilog;
@@ -40,13 +44,38 @@ builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
.AllowAnyMethod() .AllowAnyMethod()
.AllowCredentials())); .AllowCredentials()));
var migrateOnly = args.Contains("--migrate-only");
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<NovelDbContext>(); var db = scope.ServiceProvider.GetRequiredService<NovelDbContext>();
try
{
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
}
catch (Exception ex)
{
app.Logger.LogCritical(ex, "Database migration failed on startup");
Environment.Exit(1);
}
if (migrateOnly)
{
app.Logger.LogInformation("Migration complete, exiting ({MigrateOnlyFlag})", "--migrate-only");
Environment.Exit(0);
}
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger); await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
await ActivityBackfill.RunAsync(db, app.Logger);
var importRoot = builder.Configuration.GetSection(ImportOptions.SectionName)[nameof(ImportOptions.RootPath)];
if (!string.IsNullOrWhiteSpace(importRoot))
{
Directory.CreateDirectory(importRoot);
}
} }
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
@@ -84,21 +113,26 @@ if (app.Environment.IsDevelopment())
} }
app.MapDefaultEndpoints(); app.MapDefaultEndpoints();
app.MapNovelMcp();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous(); app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
app.MapUiSettingsEndpoints();
app.MapUserEndpoints(); app.MapUserEndpoints();
app.MapProjectMemberEndpoints(); app.MapNovelMemberEndpoints();
app.MapProjectEndpoints() app.MapNovelEndpoints()
.MapCharacterEndpoints() .MapCharacterEndpoints()
.MapChapterEndpoints() .MapChapterEndpoints()
.MapBeatEndpoints() .MapBeatEndpoints()
.MapTagEndpoints() .MapTagEndpoints()
.MapLocationEndpoints()
.MapGenreEndpoints() .MapGenreEndpoints()
.MapOpenQuestionEndpoints() .MapOpenQuestionEndpoints()
.MapAgentEndpoints() .MapAgentEndpoints()
.MapImportEndpoints(); .MapImportEndpoints()
.MapActivityEndpoints()
.MapTrashEndpoints();
app.Run(); app.Run();
@@ -1,57 +0,0 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Projects;
public static class ProjectEndpoints
{
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects").WithTags("Projects")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novel projects.");
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.GetAsync(id, ct);
if (project is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(project, ct);
return Results.Ok(project.ToResponse(myRole));
})
.WithSummary("Read a project's brief.");
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.CreateAsync(request, ct);
var myRole = await access.GetMyRoleAsync(project, ct);
var created = project.ToResponse(myRole);
return Results.Created($"/api/projects/{created.Id}", created);
})
.WithSummary("Create a novel project.");
group.MapPatch("/{id:guid}", async (
Guid id, UpdateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
{
var project = await service.UpdateAsync(id, request, ct);
if (project is null)
return Results.NotFound();
var myRole = await access.GetMyRoleAsync(project, ct);
return Results.Ok(project.ToResponse(myRole));
})
.WithSummary("Update a project's brief.");
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a project and everything in it.");
return app;
}
}

Some files were not shown because too many files have changed in this diff Show More