feat(entity): Chunk #4 data layer + entity UI (characters & world-building)
Data layer: - Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests - Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks - Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests - StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity; implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search - WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind - @mention autocomplete extension + picker in the editor package Entity UI (apps/web/src/features/entities/): - Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef - Tabbed directory with fuzzy search + entity cards (EntitiesView) - Create/edit/delete drawer (EntityEditor) reusing shared fields - Relationship editor + force-directed SVG relationship map - Entity engine stylesheet; wired into nav Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract. CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
@@ -13,6 +13,7 @@ import type {
|
||||
import type { StorageAdapter } from "./storage.js";
|
||||
import { createUlid } from "./ids.js";
|
||||
import { nowIso } from "./time.js";
|
||||
import { fuzzySort } from "./fuzzy.js";
|
||||
|
||||
interface EntityRec {
|
||||
key: string;
|
||||
@@ -49,6 +50,35 @@ const entityKey = (projectId: string, kind: string, id: string): string =>
|
||||
const kindOf = (e: Entity): string =>
|
||||
"fields" in e ? "world" : "body" in e ? "note" : "character";
|
||||
|
||||
/**
|
||||
* Concatenate every human-readable field of an entity into a single searchable
|
||||
* string (name + field values). Used by the fuzzy search index.
|
||||
*/
|
||||
function entitySearchText(e: Entity): string {
|
||||
if ("fields" in e) {
|
||||
// WorldItem
|
||||
const parts = [e.name, e.kind];
|
||||
for (const v of Object.values(e.fields)) if (typeof v === "string") parts.push(v);
|
||||
return parts.join(" ");
|
||||
}
|
||||
if ("body" in e) {
|
||||
// Note
|
||||
return [e.body, ...e.tags].join(" ");
|
||||
}
|
||||
// Character
|
||||
return [
|
||||
e.name,
|
||||
e.role,
|
||||
e.bio,
|
||||
e.appearance,
|
||||
e.voice,
|
||||
e.relationships,
|
||||
e.arc,
|
||||
e.status,
|
||||
...e.tags,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The local (offline-first) `StorageAdapter`, backed by IndexedDB via Dexie.
|
||||
* Implements the exact `StorageAdapter` contract so the app can run fully
|
||||
@@ -136,6 +166,30 @@ export const storageLocal: StorageAdapter = {
|
||||
await db.entities.put({ key: entityKey(e.projectId, kind, e.id), projectId: e.projectId, kind, data: e });
|
||||
},
|
||||
|
||||
async listEntities(projectId: string, kind?: string): Promise<Entity[]> {
|
||||
const rows = await db.entities
|
||||
.where("projectId")
|
||||
.equals(projectId)
|
||||
.toArray();
|
||||
const filtered = kind ? rows.filter((r) => r.kind === kind) : rows;
|
||||
return filtered.map((r) => r.data);
|
||||
},
|
||||
|
||||
async getEntity(projectId: string, kind: string, id: string): Promise<Entity | null> {
|
||||
const row = await db.entities.get(entityKey(projectId, kind, id));
|
||||
return row ? row.data : null;
|
||||
},
|
||||
|
||||
async deleteEntity(projectId: string, kind: string, id: string): Promise<void> {
|
||||
await db.entities.delete(entityKey(projectId, kind, id));
|
||||
},
|
||||
|
||||
async searchEntities(projectId: string, q: string): Promise<Entity[]> {
|
||||
const all = await this.listEntities(projectId);
|
||||
if (!q) return all;
|
||||
return fuzzySort(all, q, entitySearchText);
|
||||
},
|
||||
|
||||
async saveBoard(b: Board): Promise<void> {
|
||||
await db.boards.put(b);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user