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:
@@ -6,20 +6,23 @@
|
||||
* kind. Characters show a relationship map in the drawer.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Entity } from "@pubscript/core";
|
||||
import { fuzzySort } from "@pubscript/core";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import type { ConsistencyIssue, Entity } from "@pubscript/core";
|
||||
import { fuzzySort, findInconsistencies } from "@pubscript/core";
|
||||
import { Button, Input, Select } from "@pubscript/ui";
|
||||
import { useProjectStore } from "../../store/project-store.js";
|
||||
import { EntityEditor } from "./EntityEditor.js";
|
||||
import { entityLabel } from "./RelationshipEditor.js";
|
||||
|
||||
type TabId = "all" | "people" | "places" | "notes";
|
||||
type TabId = "all" | "people" | "places" | "notes" | "consistency";
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
/** Filter predicate over entities, or null for "all". */
|
||||
match: (e: Entity) => boolean;
|
||||
/** When true, the tab shows a special view instead of entity cards. */
|
||||
special?: boolean;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
@@ -27,6 +30,7 @@ const TABS: Tab[] = [
|
||||
{ id: "people", label: "People", match: (e) => "relationships" in e },
|
||||
{ id: "places", label: "Places", match: (e) => "fields" in e },
|
||||
{ id: "notes", label: "Notes", match: (e) => "body" in e },
|
||||
{ id: "consistency", label: "Consistency", match: () => true, special: true },
|
||||
];
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
@@ -49,6 +53,8 @@ function searchText(e: Entity): string {
|
||||
}
|
||||
|
||||
export function EntitiesView() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
const createEntity = useProjectStore((s) => s.createEntity);
|
||||
const updateEntity = useProjectStore((s) => s.updateEntity);
|
||||
@@ -60,6 +66,24 @@ export function EntitiesView() {
|
||||
const [draft, setDraft] = React.useState<Entity | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// Run every consistency check over the current entities and chapters.
|
||||
const chapters = useProjectStore((s) => s.chapters);
|
||||
const issues = React.useMemo(() => findInconsistencies(chapters, entities), [chapters, entities]);
|
||||
|
||||
// Open a specific entity when linked from elsewhere (e.g. the writing-view
|
||||
// context rail: `/characters?entity=<id>`).
|
||||
React.useEffect(() => {
|
||||
const id = searchParams.get("entity");
|
||||
if (!id) return;
|
||||
const e = entities.find((x) => x.id === id);
|
||||
if (e) {
|
||||
setDraft(e);
|
||||
setOpen(true);
|
||||
}
|
||||
// Clear the param so a reload doesn't re-open the drawer.
|
||||
navigate("/characters", { replace: true });
|
||||
}, [searchParams, entities, navigate]);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
|
||||
return query ? fuzzySort(filtered, query, searchText) : filtered;
|
||||
@@ -121,7 +145,12 @@ export function EntitiesView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
{tab === "consistency" ? (
|
||||
<ConsistencyPanel issues={issues} onOpenEntity={(id) => {
|
||||
const e = entities.find((x) => x.id === id);
|
||||
if (e) { setDraft(e); setOpen(true); }
|
||||
}} />
|
||||
) : visible.length === 0 ? (
|
||||
<p className="ps-muted">No entities match.</p>
|
||||
) : (
|
||||
<div className="ps-entity-grid">
|
||||
@@ -184,3 +213,94 @@ function tagsFor(e: Entity): string[] {
|
||||
if ("body" in e) return e.tags;
|
||||
return e.tags;
|
||||
}
|
||||
|
||||
interface ConsistencyPanelProps {
|
||||
issues: ConsistencyIssue[];
|
||||
onOpenEntity: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistency tab — shows the combined output of `findInconsistencies()` as a
|
||||
* list of warnings. Each issue names the affected entity, which can be opened
|
||||
* in the editor drawer.
|
||||
*/
|
||||
function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="ps-consistency-empty">
|
||||
<div className="ps-consistency-empty-icon">✓</div>
|
||||
<p className="ps-muted">No inconsistencies found. Your entity registry and chapters are consistent.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ps-consistency-panel">
|
||||
<div className="ps-consistency-header">
|
||||
<span className="ps-consistency-count">{issues.length} issue{issues.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<ul className="ps-consistency-list">
|
||||
{issues.map((issue, i) => (
|
||||
<li key={`${issue.kind}-${i}`} className="ps-consistency-item">
|
||||
<span className="ps-consistency-icon">{iconFor(issue)}</span>
|
||||
<div className="ps-consistency-body">
|
||||
<div className="ps-consistency-title">{titleFor(issue)}</div>
|
||||
<div className="ps-consistency-detail">{detailFor(issue)}</div>
|
||||
</div>
|
||||
{(issue.kind === "orphan-mention" || issue.kind === "dead-character-mentioned") && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-consistency-open"
|
||||
onClick={() => onOpenEntity(issue.entityId)}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Severity icon for an issue. */
|
||||
function iconFor(issue: ConsistencyIssue): string {
|
||||
switch (issue.kind) {
|
||||
case "orphan-mention":
|
||||
return "⚠";
|
||||
case "dead-character-mentioned":
|
||||
return "💀";
|
||||
case "relationship-asymmetry":
|
||||
return "↔";
|
||||
default:
|
||||
return "•";
|
||||
}
|
||||
}
|
||||
|
||||
/** Primary label for an issue. */
|
||||
function titleFor(issue: ConsistencyIssue): string {
|
||||
switch (issue.kind) {
|
||||
case "orphan-mention":
|
||||
return `Orphaned mention in “${issue.chapterTitle}”`;
|
||||
case "dead-character-mentioned":
|
||||
return `Dead character mentioned in “${issue.chapterTitle}”`;
|
||||
case "relationship-asymmetry":
|
||||
return `Unreciprocated ${issue.relationship} relationship`;
|
||||
default:
|
||||
return "Consistency issue";
|
||||
}
|
||||
}
|
||||
|
||||
/** Secondary explanation for an issue. */
|
||||
function detailFor(issue: ConsistencyIssue): string {
|
||||
switch (issue.kind) {
|
||||
case "orphan-mention":
|
||||
return `A mention references an entity that no longer exists.`;
|
||||
case "dead-character-mentioned":
|
||||
return `${issue.name ?? "A character"} is marked dead but still appears in the chapter.`;
|
||||
case "relationship-asymmetry":
|
||||
return `${issue.fromName} lists ${issue.toName} as “${issue.relationship}”, but the feeling isn't recorded on the other side.`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user