Changes all documents to have an explicit type

This commit is contained in:
2025-06-27 21:58:58 -07:00
parent 611eaca5b6
commit c00eb1d965
29 changed files with 309 additions and 363 deletions

View File

@@ -0,0 +1,46 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)"
]
}, collection)
// add field
collection.fields.addAt(3, new Field({
"hidden": false,
"id": "select2363381545",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"location",
"monster",
"npc",
"scene",
"secret",
"session",
"treasure"
]
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": []
}, collection)
// remove field
collection.fields.removeById("select2363381545")
return app.save(collection)
})

View File

@@ -0,0 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)",
"CREATE INDEX `idx_KtpMErDe1C` ON `documents` (`campaign`)"
]
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3332084752")
// update collection data
unmarshal({
"indexes": [
"CREATE INDEX `idx_gxNj5R3hxv` ON `documents` (`type`)"
]
}, collection)
return app.save(collection)
})

View File

@@ -0,0 +1,55 @@
const DocType = [
"location",
"monster",
"npc",
"scene",
"secret",
"session",
"treasure",
];
function parseJsonB(data) {
if (typeof data === "string") {
return JSON.parse(data);
} else if (data instanceof Array) {
return JSON.parse(String.fromCharCode.apply(String, data));
}
throw new Error("Unsupported data type for JSON parsing");
}
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let documents = app.findAllRecords("documents");
console.log("Records to parse: ", documents.length);
documents: for (const doc of documents) {
if (!doc) continue;
let data = parseJsonB(doc.get("data"));
if (data[""]) {
data = data[""];
}
for (const t of DocType) {
if (data[t]) {
doc.set("type", t);
doc.set("data", data[t]);
app.save(doc);
continue documents;
}
}
throw new Error(`Unrecognized data: ${JSON.stringify(data)}`);
}
},
(app) => {
// add down queries...
let documents = app.findAllRecords("documents");
for (const doc of documents) {
if (!doc) continue;
doc.set("data", { [doc.get("type")]: doc.get("data") });
app.save(doc);
}
},
);

View File

@@ -1,13 +1,4 @@
import {
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
type AnyDocument,
} from "@/lib/types";
import { type AnyDocument } from "@/lib/types";
import { LocationEditForm } from "./location/LocationEditForm";
import { MonsterEditForm } from "./monsters/MonsterEditForm";
import { NpcEditForm } from "./npc/NpcEditForm";
@@ -16,41 +7,24 @@ import { SecretEditForm } from "./secret/SecretEditForm";
import { SessionEditForm } from "./session/SessionEditForm";
import { TreasureEditForm } from "./treasure/TreasureEditForm";
function assertUnreachable(_x: never): never {
throw new Error("DocumentForm switch is not exhaustive");
}
/**
* Renders a form for any document type depending on the relationship.
*/
export const DocumentEditForm = ({ document }: { document: AnyDocument }) => {
if (isLocation(document)) {
return <LocationEditForm location={document} />;
switch (document.type) {
case "location":
return <LocationEditForm location={document} />;
case "monster":
return <MonsterEditForm monster={document} />;
case "npc":
return <NpcEditForm npc={document} />;
case "scene":
return <SceneEditForm scene={document} />;
case "secret":
return <SecretEditForm secret={document} />;
case "session":
return <SessionEditForm session={document} />;
case "treasure":
return <TreasureEditForm treasure={document} />;
}
if (isMonster(document)) {
return <MonsterEditForm monster={document} />;
}
if (isNpc(document)) {
return <NpcEditForm npc={document} />;
}
if (isScene(document)) {
return <SceneEditForm scene={document} />;
}
if (isSecret(document)) {
return <SecretEditForm secret={document} />;
}
if (isSession(document)) {
return <SessionEditForm session={document} />;
}
if (isTreasure(document)) {
return <TreasureEditForm treasure={document} />;
}
return assertUnreachable(document);
};

View File

@@ -1,64 +1,33 @@
// DocumentRow.tsx
// Generic row component for displaying any document type.
import {
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
type Document,
} from "@/lib/types";
import { type AnyDocument } from "@/lib/types";
import { LocationPrintRow } from "./location/LocationPrintRow";
import { MonsterPrintRow } from "./monsters/MonsterPrintRow";
import { TreasurePrintRow } from "./treasure/TreasurePrintRow";
import { SecretPrintRow } from "./secret/SecretPrintRow";
import { NpcPrintRow } from "./npc/NpcPrintRow";
import { ScenePrintRow } from "./scene/ScenePrintRow";
import { SecretPrintRow } from "./secret/SecretPrintRow";
import { SessionPrintRow } from "./session/SessionPrintRow";
import { TreasurePrintRow } from "./treasure/TreasurePrintRow";
/**
* Renders a row for any document type. Prioritizes Session, then Secret, then falls back to ID and creation time.
* If rendering a SecretRow, uses the provided session prop if available.
*/
export const DocumentPrintRow = ({ document }: { document: Document }) => {
if (isLocation(document)) {
return <LocationPrintRow location={document} />;
export const DocumentPrintRow = ({ document }: { document: AnyDocument }) => {
switch (document.type) {
case "location":
return <LocationPrintRow location={document} />;
case "monster":
return <MonsterPrintRow monster={document} />;
case "npc":
return <NpcPrintRow npc={document} />;
case "scene":
return <ScenePrintRow scene={document} />;
case "secret":
return <SecretPrintRow secret={document} />;
case "session":
return <SessionPrintRow session={document} />;
case "treasure":
return <TreasurePrintRow treasure={document} />;
}
if (isMonster(document)) {
return <MonsterPrintRow monster={document} />;
}
if (isNpc(document)) {
return <NpcPrintRow npc={document} />;
}
if (isSession(document)) {
return <SessionPrintRow session={document} />;
}
if (isSecret(document)) {
return <SecretPrintRow secret={document} />;
}
if (isScene(document)) {
return <ScenePrintRow scene={document} />;
}
if (isTreasure(document)) {
return <TreasurePrintRow treasure={document} />;
}
// Fallback: show ID and creation time
return (
<div>
<div className="font-semibold text-lg text-slate-300">
Unrecognized Document
</div>
<div className="text-slate-400 text-sm">ID: {document.id}</div>
<div className="text-slate-400 text-sm">Created: {document.created}</div>
</div>
);
};

View File

@@ -1,17 +1,7 @@
// DocumentRow.tsx
// Generic row component for displaying any document type.
import { SecretToggleRow } from "@/components/documents/secret/SecretToggleRow";
import {
isLocation,
isMonster,
isNpc,
isScene,
isSecret,
isSession,
isTreasure,
type Document,
type Session,
} from "@/lib/types";
import { type AnyDocument, type Session } from "@/lib/types";
import { BasicRow } from "./BasicRow";
import { TreasureToggleRow } from "./treasure/TreasureToggleRow";
@@ -23,63 +13,47 @@ export const DocumentRow = ({
document,
session,
}: {
document: Document;
document: AnyDocument;
session?: Session;
}) => {
if (isLocation(document)) {
return (
<BasicRow
doc={document}
title={document.data.location.name}
description={document.data.location.description}
/>
);
}
switch (document.type) {
case "location":
return (
<BasicRow
doc={document}
title={document.data.name}
description={document.data.description}
/>
);
if (isMonster(document)) {
return <BasicRow doc={document} title={document.data.monster.name} />;
}
case "monster":
return <BasicRow doc={document} title={document.data.name} />;
if (isNpc(document)) {
return (
<BasicRow
doc={document}
title={document.data.npc.name}
description={document.data.npc.description}
/>
);
}
case "npc":
return (
<BasicRow
doc={document}
title={document.data.name}
description={document.data.description}
/>
);
if (isSession(document)) {
return (
<BasicRow
doc={document}
title={document.created}
description={document.data.session.strongStart}
/>
);
}
case "session":
return (
<BasicRow
doc={document}
title={document.created}
description={document.data.strongStart}
/>
);
if (isSecret(document)) {
return <SecretToggleRow secret={document} session={session} />;
}
case "secret":
return <SecretToggleRow secret={document} session={session} />;
if (isScene(document)) {
return <BasicRow doc={document} title={document.data.scene.text} />;
}
case "scene":
return <BasicRow doc={document} title={document.data.text} />;
if (isTreasure(document)) {
return <TreasureToggleRow treasure={document} session={session} />;
case "treasure":
return <TreasureToggleRow treasure={document} session={session} />;
}
// Fallback: show ID and creation time
return (
<div>
<div className="font-semibold text-lg text-slate-300">
Unrecognized Document
</div>
<div className="text-slate-400 text-sm">ID: {document.id}</div>
<div className="text-slate-400 text-sm">Created: {document.created}</div>
</div>
);
};

View File

@@ -10,10 +10,7 @@ export const LocationEditForm = ({ location }: { location: Location }) => {
await pb.collection("documents").update(location.id, {
data: {
...location.data,
location: {
...location.data.location,
name,
},
name,
},
});
}
@@ -22,10 +19,7 @@ export const LocationEditForm = ({ location }: { location: Location }) => {
await pb.collection("documents").update(location.id, {
data: {
...location.data,
location: {
...location.data.location,
description,
},
description,
},
});
}
@@ -34,11 +28,11 @@ export const LocationEditForm = ({ location }: { location: Location }) => {
<div className="">
<AutoSaveTextarea
multiline={false}
value={location.data.location.name}
value={location.data.name}
onSave={saveLocationName}
/>
<AutoSaveTextarea
value={location.data.location.description}
value={location.data.description}
onSave={saveLocationDescription}
/>
</div>

View File

@@ -6,8 +6,8 @@ import type { Location } from "@/lib/types";
export const LocationPrintRow = ({ location }: { location: Location }) => {
return (
<li>
<h4>{location.data.location.name}</h4>
<p>{location.data.location.description}</p>
<h4>{location.data.name}</h4>
<p>{location.data.description}</p>
</li>
);
};

View File

@@ -25,11 +25,10 @@ export const NewLocationForm = ({
try {
const locationDoc: Location = await pb.collection("documents").create({
campaign,
type: "location",
data: {
location: {
name,
description,
},
name,
description,
},
});
setName("");

View File

@@ -10,10 +10,7 @@ export const MonsterEditForm = ({ monster }: { monster: Monster }) => {
await pb.collection("documents").update(monster.id, {
data: {
...monster.data,
monster: {
...monster.data.monster,
name,
},
name,
},
});
}
@@ -22,7 +19,7 @@ export const MonsterEditForm = ({ monster }: { monster: Monster }) => {
<div className="">
<AutoSaveTextarea
multiline={false}
value={monster.data.monster.name}
value={monster.data.name}
onSave={saveMonsterName}
/>
</div>

View File

@@ -4,5 +4,5 @@ import type { Monster } from "@/lib/types";
* Renders an editable monster row
*/
export const MonsterPrintRow = ({ monster }: { monster: Monster }) => {
return <li>{monster.data.monster.name}</li>;
return <li>{monster.data..name}</li>;
};

View File

@@ -25,11 +25,10 @@ export const NewMonsterForm = ({
try {
const monsterDoc: Monster = await pb.collection("documents").create({
campaign,
type: "monster",
data: {
monster: {
name,
description,
},
name,
description,
},
});
setName("");

View File

@@ -25,11 +25,10 @@ export const NewNpcForm = ({
try {
const npcDoc: Npc = await pb.collection("documents").create({
campaign,
type: "npc",
data: {
npc: {
name,
description,
},
name,
description,
},
});
setName("");

View File

@@ -10,10 +10,7 @@ export const NpcEditForm = ({ npc }: { npc: Npc }) => {
await pb.collection("documents").update(npc.id, {
data: {
...npc.data,
npc: {
...npc.data.npc,
name,
},
name,
},
});
}
@@ -22,10 +19,7 @@ export const NpcEditForm = ({ npc }: { npc: Npc }) => {
await pb.collection("documents").update(npc.id, {
data: {
...npc.data,
npc: {
...npc.data.npc,
description,
},
description,
},
});
}
@@ -34,11 +28,11 @@ export const NpcEditForm = ({ npc }: { npc: Npc }) => {
<div className="">
<AutoSaveTextarea
multiline={false}
value={npc.data.npc.name}
value={npc.data.name}
onSave={saveNpcName}
/>
<AutoSaveTextarea
value={npc.data.npc.description}
value={npc.data.description}
onSave={saveNpcDescription}
/>
</div>

View File

@@ -6,8 +6,8 @@ import type { Npc } from "@/lib/types";
export const NpcPrintRow = ({ npc }: { npc: Npc }) => {
return (
<li className="">
<h4>{npc.data.npc.name}</h4>
<p>{npc.data.npc.description}</p>
<h4>{npc.data.name}</h4>
<p>{npc.data.description}</p>
</li>
);
};

View File

@@ -26,10 +26,9 @@ export const NewSceneForm = ({
try {
const sceneDoc: Scene = await pb.collection("documents").create({
campaign,
type: "scene",
data: {
scene: {
text,
},
text,
},
});
setText("");

View File

@@ -13,9 +13,7 @@ export const SceneEditForm = ({ scene }: { scene: Scene }) => {
await pb.collection("documents").update(scene.id, {
data: {
...scene.data,
scene: {
text,
},
text,
},
});
queryClient.invalidateQueries({
@@ -25,7 +23,7 @@ export const SceneEditForm = ({ scene }: { scene: Scene }) => {
return (
<div className="">
<AutoSaveTextarea value={scene.data.scene.text} onSave={saveScene} />
<AutoSaveTextarea value={scene.data.text} onSave={saveScene} />
</div>
);
};

View File

@@ -4,5 +4,5 @@ import type { Scene } from "@/lib/types";
* Renders an editable scene row
*/
export const ScenePrintRow = ({ scene }: { scene: Scene }) => {
return <li className="">{scene.data.scene.text}</li>;
return <li className="">{scene.data.text}</li>;
};

View File

@@ -26,11 +26,10 @@ export const NewSecretForm = ({
try {
const secretDoc: Secret = await pb.collection("documents").create({
campaign,
type: "secret",
data: {
secret: {
text: newSecret,
discovered: false,
},
text: newSecret,
discovered: false,
},
});
setNewSecret("");

View File

@@ -28,10 +28,7 @@ export const SecretEditForm = ({
await pb.collection("documents").update(secret.id, {
data: {
...secret.data,
secret: {
...(secret.data as any).secret,
discovered: newChecked,
},
discovered: newChecked,
},
});
if (session || !newChecked) {
@@ -62,10 +59,7 @@ export const SecretEditForm = ({
await pb.collection("documents").update(secret.id, {
data: {
...secret.data,
secret: {
...secret.data.secret,
text,
},
text,
},
});
}
@@ -82,7 +76,7 @@ export const SecretEditForm = ({
/>
<AutoSaveTextarea
multiline={false}
value={secret.data.secret.text}
value={secret.data.text}
onSave={saveText}
/>
</div>

View File

@@ -68,11 +68,7 @@ export const SecretToggleRow = ({
aria-label="Discovered"
disabled={loading}
/>
<span>
{(secret.data as any)?.secret?.text || (
<span className="italic text-slate-400">(No secret text)</span>
)}
</span>
<span>{secret.data.text}</span>
</div>
);
};

View File

@@ -7,10 +7,7 @@ export const SessionEditForm = ({ session }: { session: Session }) => {
await pb.collection("documents").update(session.id, {
data: {
...session.data,
session: {
...session.data.session,
strongStart,
},
strongStart,
},
});
}
@@ -19,7 +16,7 @@ export const SessionEditForm = ({ session }: { session: Session }) => {
<form>
<h3 className="text-lg font-bold mb-4 text-slate-100">Strong Start</h3>
<AutoSaveTextarea
value={session.data.session.strongStart}
value={session.data.strongStart}
onSave={saveStrongStart}
placeholder="Enter a strong start for this session..."
aria-label="Strong Start"

View File

@@ -4,7 +4,7 @@ export const SessionPrintRow = ({ session }: { session: Session }) => {
return (
<div>
<h3 className="text-lg font-bold text-slate-600">StrongStart</h3>
<div className="">{session.data.session.strongStart}</div>
<div className="">{session.data.strongStart}</div>
</div>
);
};

View File

@@ -12,7 +12,7 @@ export const SessionRow = ({ session }: { session: Session }) => {
>
<FormattedDate date={session.created} />
</Link>
<div className="">{session.data.session.strongStart}</div>
<div className="">{session.data.strongStart}</div>
</div>
);
};

View File

@@ -26,11 +26,10 @@ export const NewTreasureForm = ({
try {
const treasureDoc: Treasure = await pb.collection("documents").create({
campaign,
type: "treasure",
data: {
treasure: {
text: newTreasure,
discovered: false,
},
text: newTreasure,
discovered: false,
},
});
setNewTreasure("");

View File

@@ -62,10 +62,7 @@ export const TreasureEditForm = ({
await pb.collection("documents").update(treasure.id, {
data: {
...treasure.data,
treasure: {
...treasure.data.treasure,
text,
},
text,
},
});
}
@@ -82,7 +79,7 @@ export const TreasureEditForm = ({
/>
<AutoSaveTextarea
multiline={false}
value={treasure.data.treasure.text}
value={treasure.data.text}
onSave={saveText}
/>
</div>

View File

@@ -68,11 +68,7 @@ export const TreasureToggleRow = ({
aria-label="Discovered"
disabled={loading}
/>
<span>
{(treasure.data as any)?.treasure?.text || (
<span className="italic text-slate-400">(No treasure text)</span>
)}
</span>
<span>{treasure.data.text}</span>
</div>
);
};

View File

@@ -41,21 +41,6 @@ export type Relationship = RecordModel & {
* Documents
******************************************/
export type DocumentData<K extends string, V> = {
data: Record<K, V>;
};
export type Document = RecordModel & {
id: DocumentId;
campaign: CampaignId;
data: {
[K in DocumentType]?: unknown;
};
// These two are not in Pocketbase's types, but they seem to always be present
created: ISO8601Date;
updated: ISO8601Date;
};
export type DocumentType =
| "location"
| "monster"
@@ -65,6 +50,21 @@ export type DocumentType =
| "session"
| "treasure";
export type DocumentData<Type extends DocumentType, Data> = {
type: Type;
data: Data;
};
export type Document<Type extends DocumentType, Data> = RecordModel & {
id: DocumentId;
campaign: CampaignId;
type: Type;
data: Data;
// These two are not in Pocketbase's types, but they seem to always be present
created: ISO8601Date;
updated: ISO8601Date;
};
export type AnyDocument =
| Location
| Monster
@@ -75,122 +75,71 @@ export type AnyDocument =
| Treasure;
export function getDocumentType(doc: AnyDocument): DocumentType {
if (isLocation(doc)) {
return "location";
} else if (isMonster(doc)) {
return "monster";
} else if (isNpc(doc)) {
return "npc";
} else if (isScene(doc)) {
return "scene";
} else if (isSecret(doc)) {
return "secret";
} else if (isSession(doc)) {
return "session";
} else if (isTreasure(doc)) {
return "treasure";
}
throw new Error(`Document type not found: ${JSON.stringify(doc)}`);
return doc.type;
}
/** Locations **/
export type Location = Document &
DocumentData<
"location",
{
name: string;
description: string;
}
>;
export function isLocation(doc: Document): doc is Location {
return Object.hasOwn(doc.data, "location");
}
export type Location = Document<
"location",
{
name: string;
description: string;
}
>;
/** Monsters **/
export type Monster = Document &
DocumentData<
"monster",
{
name: string;
}
>;
export function isMonster(doc: Document): doc is Monster {
return Object.hasOwn(doc.data, "monster");
}
export type Monster = Document<
"monster",
{
name: string;
}
>;
/** NPCs **/
export type Npc = Document &
DocumentData<
"npc",
{
name: string;
description: string;
}
>;
export function isNpc(doc: Document): doc is Npc {
return Object.hasOwn(doc.data, "npc");
}
export type Npc = Document<
"npc",
{
name: string;
description: string;
}
>;
/** Session **/
export type Session = Document &
DocumentData<
"session",
{
strongStart: string;
}
>;
export function isSession(doc: Document): doc is Session {
return Object.hasOwn(doc.data, "session");
}
export type Session = Document<
"session",
{
strongStart: string;
}
>;
/** Scene **/
export type Scene = Document &
DocumentData<
"scene",
{
text: string;
}
>;
export function isScene(doc: Document): doc is Scene {
return Object.hasOwn(doc.data, "scene");
}
export type Scene = Document<
"scene",
{
text: string;
}
>;
/** Secret **/
export type Secret = Document &
DocumentData<
"secret",
{
text: string;
discovered: boolean;
}
>;
export function isSecret(doc: Document): doc is Secret {
return Object.hasOwn(doc.data, "secret");
}
export type Secret = Document<
"secret",
{
text: string;
discovered: boolean;
}
>;
/** Treasure **/
export type Treasure = Document &
DocumentData<
"treasure",
{
text: string;
discovered: boolean;
}
>;
export function isTreasure(doc: Document): doc is Treasure {
return Object.hasOwn(doc.data, "treasure");
}
export type Treasure = Document<
"treasure",
{
text: string;
discovered: boolean;
}
>;

View File

@@ -27,12 +27,10 @@ function RouteComponent() {
.collection("campaigns")
.getOne(params.campaignId);
// Fetch all documents for this campaign
const docs = await pb.collection("documents").getFullList({
filter: `campaign = "${params.campaignId}"`,
const sessions = await pb.collection("documents").getFullList({
filter: `campaign = "${params.campaignId}" && type = 'session'`,
sort: "-created",
});
// Filter to only those with data.session
const sessions = docs.filter((doc: any) => doc.data && doc.data.session);
return {
campaign,
sessions,
@@ -45,7 +43,7 @@ function RouteComponent() {
const prevSession = await pb
.collection("documents")
.getFirstListItem(
`campaign = "${campaign.id}" && json_extract(data, '$.session') IS NOT NULL`,
`campaign = "${campaign.id}" && json_extract(data, '$.session') != null`,
{
sort: "-created",
},
@@ -58,10 +56,9 @@ function RouteComponent() {
const newSession = await pb.collection("documents").create({
campaign: campaign.id,
type: "session",
data: {
session: {
strongStart: "",
},
strongStart: "",
},
});