feat(entity): Chunk #4 remaining items — consistency UI, context rail, palette search, expanded seed & DATA_MODEL
- Consistency tab in EntitiesView surfacing findInconsistencies() results - ContextRail + MentionPopover wired into WritingView for entity context/hover - CommandPalette indexes entities and chapters for cross-entity search - link field type for entity references in character fields - findDeadCharacterMentions consistency check + tests - Expand seed with a location, note, and a natural Elias Vell mention so the dead-character-after-death case is actually exercised - Author docs/DATA_MODEL.md documenting core schemas, types, storage contract - Rename CharacterMention id attr -> entityId to match core backlinks/consistency
This commit is contained in:
@@ -55,6 +55,13 @@ export function collectMentionedEntityIds(
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Collect the set of entity IDs mentioned in a single document. */
|
||||
export function collectMentionsInDoc(doc: ProseMirrorDoc): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
walkForIds(doc, (entityId) => ids.add(entityId));
|
||||
return ids;
|
||||
}
|
||||
|
||||
function walkForIds(doc: ProseMirrorDoc, onId: (id: string) => void): void {
|
||||
const visit = (content: unknown[]) => {
|
||||
for (const node of content as Record<string, unknown>[]) {
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
findOrphanedMentions,
|
||||
parseRelationships,
|
||||
findRelationshipAsymmetry,
|
||||
findDeadCharacterMentions,
|
||||
findInconsistencies,
|
||||
SYMMETRIC_RELATIONSHIPS,
|
||||
} from "./consistency.js";
|
||||
import { collectMentionsInDoc } from "./backlinks.js";
|
||||
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
|
||||
|
||||
const doc = (entityId: string | null, name = "Mara"): ProseMirrorDoc => ({
|
||||
@@ -152,12 +154,32 @@ describe("findRelationshipAsymmetry", () => {
|
||||
});
|
||||
|
||||
describe("findInconsistencies", () => {
|
||||
it("combines orphan-mention and relationship-asymmetry issues", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
|
||||
it("combines orphan-mention, relationship-asymmetry, and dead-character issues", () => {
|
||||
// Chapter mentions "ghost" (orphan) and "ada" (dead character still alive in prose).
|
||||
const chapters = [
|
||||
{
|
||||
id: "ch1",
|
||||
title: "Ch 1",
|
||||
doc: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Ada met " },
|
||||
{ type: "CharacterMention", attrs: { entityId: "ada", name: "Ada" } },
|
||||
{ type: "text", text: " and a " },
|
||||
{ type: "CharacterMention", attrs: { entityId: "ghost", name: "ghost" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as ProseMirrorDoc,
|
||||
},
|
||||
];
|
||||
const ada: Character = {
|
||||
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
|
||||
relationships: [{ targetId: "maria", kind: "sibling", note: "" }],
|
||||
arc: "", status: "active", tags: [],
|
||||
arc: "", status: "dead", tags: [],
|
||||
};
|
||||
const maria: Character = {
|
||||
id: "maria", projectId: "p", name: "Maria", role: "", bio: "", appearance: "", voice: "",
|
||||
@@ -167,5 +189,36 @@ describe("findInconsistencies", () => {
|
||||
const issues = findInconsistencies(chapters, entities);
|
||||
expect(issues.some((i) => i.kind === "orphan-mention")).toBe(true);
|
||||
expect(issues.some((i) => i.kind === "relationship-asymmetry")).toBe(true);
|
||||
expect(issues.some((i) => i.kind === "dead-character-mentioned")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag a dead character who is not mentioned in any chapter", () => {
|
||||
const chapters = [{ id: "ch1", title: "Ch 1", doc: doc("ghost") }];
|
||||
const ada: Character = {
|
||||
id: "ada", projectId: "p", name: "Ada", role: "", bio: "", appearance: "", voice: "",
|
||||
arc: "", status: "dead", tags: [],
|
||||
relationships: [],
|
||||
};
|
||||
const issues = findDeadCharacterMentions(chapters, [ada]);
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("collects every entity id referenced in a doc", () => {
|
||||
const docWithTwo = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Ada and " },
|
||||
{ type: "CharacterMention", attrs: { entityId: "maria", name: "Maria" } },
|
||||
{ type: "text", text: " met " },
|
||||
{ type: "CharacterMention", attrs: { entityId: "brad", name: "Brad" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as ProseMirrorDoc;
|
||||
const ids = collectMentionsInDoc(docWithTwo);
|
||||
expect([...ids].sort()).toEqual(["brad", "maria"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* See CHUNK #4 step 2.4.
|
||||
*/
|
||||
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
|
||||
import { collectMentionsInDoc } from "./backlinks.js";
|
||||
|
||||
export type RelationshipSymmetry = "symmetric" | "directed";
|
||||
|
||||
@@ -32,7 +33,16 @@ export interface RelationshipIssue {
|
||||
relationship: string;
|
||||
}
|
||||
|
||||
export type ConsistencyIssue = MentionIssue | RelationshipIssue;
|
||||
export interface DeadCharacterIssue {
|
||||
kind: "dead-character-mentioned";
|
||||
chapterId: string;
|
||||
chapterTitle: string;
|
||||
entityId: string;
|
||||
/** The mention text, if any. */
|
||||
name?: string | undefined;
|
||||
}
|
||||
|
||||
export type ConsistencyIssue = MentionIssue | RelationshipIssue | DeadCharacterIssue;
|
||||
|
||||
/**
|
||||
* Find mention nodes that reference an entity id that no longer exists (e.g.
|
||||
@@ -44,25 +54,50 @@ export function findOrphanedMentions(
|
||||
): MentionIssue[] {
|
||||
const issues: MentionIssue[] = [];
|
||||
for (const ch of chapters) {
|
||||
const visit = (content: unknown[], _path: number[]): void => {
|
||||
for (const node of content as Record<string, unknown>[]) {
|
||||
if (!node || typeof node !== "object") continue;
|
||||
if (node.type === "CharacterMention") {
|
||||
const attrs = node.attrs as { entityId?: string; name?: string } | undefined;
|
||||
if (attrs?.entityId && !entityIds.has(attrs.entityId)) {
|
||||
issues.push({
|
||||
kind: "orphan-mention",
|
||||
chapterId: ch.id,
|
||||
chapterTitle: ch.title,
|
||||
entityId: attrs.entityId,
|
||||
name: attrs.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) visit(node.content as unknown[], _path);
|
||||
const mentioned = collectMentionsInDoc(ch.doc);
|
||||
for (const entityId of mentioned) {
|
||||
if (!entityIds.has(entityId)) {
|
||||
issues.push({
|
||||
kind: "orphan-mention",
|
||||
chapterId: ch.id,
|
||||
chapterTitle: ch.title,
|
||||
entityId,
|
||||
});
|
||||
}
|
||||
};
|
||||
visit(ch.doc.content ?? [], []);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find characters who are marked `dead` but are still mentioned (by entity id)
|
||||
* in a chapter body. A dead character should not appear alive in the story.
|
||||
*
|
||||
* Bare-name mentions (no `entityId`) are ignored — a reference to a name that
|
||||
* is not yet an entity is an orphan-mention concern, not a dead-character one.
|
||||
*/
|
||||
export function findDeadCharacterMentions(
|
||||
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
|
||||
entities: Entity[],
|
||||
): DeadCharacterIssue[] {
|
||||
const dead = new Set(
|
||||
entities
|
||||
.filter((e): e is Character => !("fields" in e) && !("body" in e) && e.status === "dead")
|
||||
.map((c) => c.id),
|
||||
);
|
||||
const issues: DeadCharacterIssue[] = [];
|
||||
for (const chapter of chapters) {
|
||||
const mentioned = collectMentionsInDoc(chapter.doc);
|
||||
for (const entityId of mentioned) {
|
||||
if (dead.has(entityId)) {
|
||||
issues.push({
|
||||
kind: "dead-character-mentioned",
|
||||
chapterId: chapter.id,
|
||||
chapterTitle: chapter.title,
|
||||
entityId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -125,6 +160,7 @@ export function findInconsistencies(
|
||||
const issues: ConsistencyIssue[] = [
|
||||
...findOrphanedMentions(chapters, entityIds),
|
||||
...findRelationshipAsymmetry(entities),
|
||||
...findDeadCharacterMentions(chapters, entities),
|
||||
];
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -29,11 +29,12 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
|
||||
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
|
||||
{ key: "home", label: "Home", type: "link", hint: "linked location" },
|
||||
{ key: "bio", label: "Bio", type: "textarea" },
|
||||
{ key: "appearance", label: "Appearance", type: "textarea" },
|
||||
{ key: "voice", label: "Voice", type: "textarea" },
|
||||
{ key: "arc", label: "Arc", type: "textarea" },
|
||||
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
|
||||
{ key: "tags", label: "Tags", type: "list" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -103,7 +103,7 @@ export const MentionAutocomplete = Extension.create({
|
||||
const nodeType = schema.nodes[CHARACTER_MENTION];
|
||||
if (!nodeType) return false;
|
||||
|
||||
const mention = nodeType.create({ id, label });
|
||||
const mention = nodeType.create({ entityId: id, label });
|
||||
const tr = state.tr.deleteRange(atPos, from).insert(atPos, mention);
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CHARACTER_MENTION, colorAttribute } from "../constants.js";
|
||||
|
||||
/**
|
||||
* Character mention — an inline atom linking to a character entity.
|
||||
* Attributes: `id` (character id) and `label` (display name). Inline in HTML
|
||||
* Attributes: `entityId` (character id) and `label` (display name). Inline in HTML
|
||||
* as `<span class="mention mention-character" data-id="...">@Name</span>`.
|
||||
*/
|
||||
export const CharacterMention = Node.create({
|
||||
@@ -14,11 +14,11 @@ export const CharacterMention = Node.create({
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
entityId: {
|
||||
default: null,
|
||||
isRequired: true,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => ({ "data-id": attributes.id }),
|
||||
renderHTML: (attributes) => ({ "data-id": attributes.entityId }),
|
||||
},
|
||||
label: {
|
||||
default: "",
|
||||
@@ -36,9 +36,9 @@ export const CharacterMention = Node.create({
|
||||
renderHTML({ node }) {
|
||||
return [
|
||||
"span",
|
||||
{ class: "mention mention-character", "data-id": node.attrs.id, "data-label": node.attrs.label },
|
||||
{ class: "mention mention-character", "data-id": node.attrs.entityId, "data-label": node.attrs.label },
|
||||
"@",
|
||||
node.attrs.label ?? node.attrs.id,
|
||||
node.attrs.label ?? node.attrs.entityId,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -500,6 +500,16 @@ body {
|
||||
.ps-command-item .ps-command-icon { width: 20px; text-align: center; opacity: 0.8; }
|
||||
.ps-command-item .ps-command-kbd { margin-left: auto; font-size: 12px; color: var(--ps-text-muted); }
|
||||
.ps-command-empty { padding: 24px; text-align: center; color: var(--ps-text-muted); }
|
||||
.ps-command-group-title {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--ps-text-muted); opacity: 0.7;
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
.ps-command-item .ps-command-subtitle {
|
||||
font-size: 12px; color: var(--ps-text-muted); opacity: 0.8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ps-command-item.is-active .ps-command-subtitle { color: var(--ps-accent-contrast); opacity: 0.9; }
|
||||
|
||||
/* ---- Settings ---- */
|
||||
.ps-settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
||||
|
||||
Reference in New Issue
Block a user