Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7712e971a8 | ||
|
|
4491e22573 | ||
|
|
18734acaad | ||
|
|
a73c5ea002 |
No files matched your search
@@ -4,6 +4,10 @@ Current stage:
|
|||||||
|
|
||||||
- Build 0 complete: Electron boots directly into local 5etools wiki.
|
- Build 0 complete: Electron boots directly into local 5etools wiki.
|
||||||
- Stage 1 in progress: React shell navigation + light visual reskin pass, while preserving wiki behavior.
|
- Stage 1 in progress: React shell navigation + light visual reskin pass, while preserving wiki behavior.
|
||||||
|
- Character Creator milestone 1 added:
|
||||||
|
- Create new character
|
||||||
|
- Load character from JSON
|
||||||
|
- View/edit saved characters
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -79,7 +83,10 @@ This builds a macOS arm64 package and writes output to `release/`.
|
|||||||
- The app starts an internal local HTTP server for wiki content.
|
- The app starts an internal local HTTP server for wiki content.
|
||||||
- This avoids `file://` loading limitations while keeping content fully local.
|
- This avoids `file://` loading limitations while keeping content fully local.
|
||||||
- Electron launch scripts clear `ELECTRON_RUN_AS_NODE` automatically, so app boot is stable even if that variable is set in your shell environment.
|
- Electron launch scripts clear `ELECTRON_RUN_AS_NODE` automatically, so app boot is stable even if that variable is set in your shell environment.
|
||||||
- SQLite is intentionally deferred until post-Build 0 to avoid native module/toolchain issues while the wiki bootstrap is stabilized.
|
- Character files are stored as JSON under the app user-data directory:
|
||||||
|
- `<userData>/characters/<character-id>.json`
|
||||||
|
- one file per character
|
||||||
|
- SQLite is intentionally deferred until a later character-system migration phase.
|
||||||
- Build identity is set to:
|
- Build identity is set to:
|
||||||
- Product name: `5ET-D`
|
- Product name: `5ET-D`
|
||||||
- App ID: `com.notBoris.5et-desktop`
|
- App ID: `com.notBoris.5et-desktop`
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,796 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type CharacterEntityType = "class" | "subclass" | "background" | "species" | "item" | "feat" | "spell";
|
||||||
|
export type AdvancementType = "milestone" | "xp";
|
||||||
|
export type HpMode = "average" | "maximum" | "rolled";
|
||||||
|
export type AbilityScoreMethod = "standardArray" | "pointBuy" | "manual" | "rolled";
|
||||||
|
export type AbilityKey = "str" | "dex" | "con" | "int" | "wis" | "cha";
|
||||||
|
|
||||||
|
export interface CharacterEntityRef {
|
||||||
|
entityType: CharacterEntityType;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
hash: string;
|
||||||
|
entityRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterClassSelection {
|
||||||
|
class: CharacterEntityRef;
|
||||||
|
subclass: CharacterEntityRef | null;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterProfile {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
alignment: string;
|
||||||
|
faith: string;
|
||||||
|
lifestyle: string;
|
||||||
|
age: string;
|
||||||
|
gender: string;
|
||||||
|
height: string;
|
||||||
|
weight: string;
|
||||||
|
eyes: string;
|
||||||
|
skin: string;
|
||||||
|
hair: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterPreferencesStep {
|
||||||
|
sourceBuckets: string[];
|
||||||
|
useOptionalClassFeatures: boolean;
|
||||||
|
customizeOrigin: boolean;
|
||||||
|
advancementType: AdvancementType;
|
||||||
|
hpMode: HpMode;
|
||||||
|
usePrerequisitesFeats: boolean;
|
||||||
|
usePrerequisitesMulticlass: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterAbilityScoresStep {
|
||||||
|
method: AbilityScoreMethod;
|
||||||
|
scores: Record<AbilityKey, number>;
|
||||||
|
rolledValues: [number, number, number, number, number, number];
|
||||||
|
assignments: Record<AbilityKey, number>;
|
||||||
|
backgroundBonusModeIndex: number;
|
||||||
|
backgroundBonusSelections: AbilityKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterFeatsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterSkillsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterSpellsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterEquipmentStep {
|
||||||
|
startingEquipmentText: string;
|
||||||
|
inventoryText: string;
|
||||||
|
possessionsText: string;
|
||||||
|
currency: Record<"cp" | "sp" | "ep" | "gp" | "pp", number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterAboutStep {
|
||||||
|
personality: string;
|
||||||
|
ideals: string;
|
||||||
|
bonds: string;
|
||||||
|
flaws: string;
|
||||||
|
backstory: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterSteps {
|
||||||
|
preferences: CharacterPreferencesStep;
|
||||||
|
abilityScores: CharacterAbilityScoresStep;
|
||||||
|
feats: CharacterFeatsStep;
|
||||||
|
skills: CharacterSkillsStep;
|
||||||
|
spells: CharacterSpellsStep;
|
||||||
|
equipment: CharacterEquipmentStep;
|
||||||
|
about: CharacterAboutStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterSelections {
|
||||||
|
class: CharacterEntityRef | null;
|
||||||
|
subclass: CharacterEntityRef | null;
|
||||||
|
classes: CharacterClassSelection[];
|
||||||
|
background: CharacterEntityRef | null;
|
||||||
|
species: CharacterEntityRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterDocument {
|
||||||
|
schema: "5etd.character";
|
||||||
|
version: 1;
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
profile: CharacterProfile;
|
||||||
|
selections: CharacterSelections;
|
||||||
|
steps: CharacterSteps;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
className: string | null;
|
||||||
|
backgroundName: string | null;
|
||||||
|
speciesName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterUpsertInput {
|
||||||
|
id?: string;
|
||||||
|
profile?: Partial<CharacterProfile>;
|
||||||
|
selections?: Partial<CharacterSelections>;
|
||||||
|
steps?: Partial<CharacterSteps>;
|
||||||
|
notes?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHARACTER_SCHEMA = "5etd.character";
|
||||||
|
const CHARACTER_VERSION = 1;
|
||||||
|
const CHARACTERS_DIRNAME = "characters";
|
||||||
|
const ABILITY_KEYS: readonly AbilityKey[] = ["str", "dex", "con", "int", "wis", "cha"];
|
||||||
|
|
||||||
|
const DEFAULT_PROFILE: CharacterProfile = {
|
||||||
|
name: "Unnamed Character",
|
||||||
|
level: 1,
|
||||||
|
alignment: "",
|
||||||
|
faith: "",
|
||||||
|
lifestyle: "",
|
||||||
|
age: "",
|
||||||
|
gender: "",
|
||||||
|
height: "",
|
||||||
|
weight: "",
|
||||||
|
eyes: "",
|
||||||
|
skin: "",
|
||||||
|
hair: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_STEPS: CharacterSteps = {
|
||||||
|
preferences: {
|
||||||
|
sourceBuckets: ["core2024"],
|
||||||
|
useOptionalClassFeatures: true,
|
||||||
|
customizeOrigin: true,
|
||||||
|
advancementType: "milestone",
|
||||||
|
hpMode: "average",
|
||||||
|
usePrerequisitesFeats: true,
|
||||||
|
usePrerequisitesMulticlass: true
|
||||||
|
},
|
||||||
|
abilityScores: {
|
||||||
|
method: "standardArray",
|
||||||
|
scores: {
|
||||||
|
str: 15,
|
||||||
|
dex: 14,
|
||||||
|
con: 13,
|
||||||
|
int: 12,
|
||||||
|
wis: 10,
|
||||||
|
cha: 8
|
||||||
|
},
|
||||||
|
rolledValues: [15, 14, 13, 12, 10, 8],
|
||||||
|
assignments: {
|
||||||
|
str: 0,
|
||||||
|
dex: 1,
|
||||||
|
con: 2,
|
||||||
|
int: 3,
|
||||||
|
wis: 4,
|
||||||
|
cha: 5
|
||||||
|
},
|
||||||
|
backgroundBonusModeIndex: 0,
|
||||||
|
backgroundBonusSelections: []
|
||||||
|
},
|
||||||
|
feats: {
|
||||||
|
selected: [],
|
||||||
|
notes: ""
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
selected: [],
|
||||||
|
notes: ""
|
||||||
|
},
|
||||||
|
spells: {
|
||||||
|
selected: [],
|
||||||
|
notes: ""
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
startingEquipmentText: "",
|
||||||
|
inventoryText: "",
|
||||||
|
possessionsText: "",
|
||||||
|
currency: {
|
||||||
|
cp: 0,
|
||||||
|
sp: 0,
|
||||||
|
ep: 0,
|
||||||
|
gp: 0,
|
||||||
|
pp: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
personality: "",
|
||||||
|
ideals: "",
|
||||||
|
bonds: "",
|
||||||
|
flaws: "",
|
||||||
|
backstory: "",
|
||||||
|
notes: ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SELECTIONS: CharacterSelections = {
|
||||||
|
class: null,
|
||||||
|
subclass: null,
|
||||||
|
classes: [],
|
||||||
|
background: null,
|
||||||
|
species: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toString(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoolean(value: unknown, fallback = false): boolean {
|
||||||
|
return typeof value === "boolean" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInteger(value: unknown, fallback: number, min: number, max: number): number {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounded = Math.trunc(value);
|
||||||
|
return Math.min(max, Math.max(min, rounded));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDate(value: unknown, fallback: string): string {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSourceBuckets(value: unknown, fallback: string[]): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = value
|
||||||
|
.map((it) => (typeof it === "string" ? it.trim() : ""))
|
||||||
|
.filter((it) => Boolean(it));
|
||||||
|
|
||||||
|
return cleaned.length ? Array.from(new Set(cleaned)) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
value
|
||||||
|
.map((it) => (typeof it === "string" ? it.trim() : ""))
|
||||||
|
.filter((it) => Boolean(it))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAbilityKeys(value: unknown, max = 6): AbilityKey[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: AbilityKey[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowered = item.trim().toLowerCase();
|
||||||
|
if (!lowered || !ABILITY_KEYS.includes(lowered as AbilityKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = lowered as AbilityKey;
|
||||||
|
if (!out.includes(key)) {
|
||||||
|
out.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.length >= max) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeToken(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntityRef(entityType: CharacterEntityType, source: string, hash: string): string {
|
||||||
|
return `${entityType}|${source}|${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntityRefParts(entityRef: string): { source: string; hash: string } | null {
|
||||||
|
const parts = entityRef.split("|");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = parts[1]?.trim() || "";
|
||||||
|
const hash = parts[2]?.trim() || "";
|
||||||
|
if (!source || !hash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source, hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntityRef(value: unknown, expectedType: CharacterEntityType): CharacterEntityRef | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = toString(value.name).trim();
|
||||||
|
const entityRefRaw = toString(value.entityRef).trim();
|
||||||
|
|
||||||
|
let source = toString(value.source).trim().toUpperCase();
|
||||||
|
let hash = toString(value.hash).trim();
|
||||||
|
|
||||||
|
if ((!source || !hash) && entityRefRaw) {
|
||||||
|
const parsed = parseEntityRefParts(entityRefRaw);
|
||||||
|
if (parsed) {
|
||||||
|
source ||= parsed.source.toUpperCase();
|
||||||
|
hash ||= parsed.hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hash && name && source) {
|
||||||
|
hash = `${sanitizeToken(name)}_${sanitizeToken(source)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !source || !hash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entityType: expectedType,
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
hash,
|
||||||
|
entityRef: buildEntityRef(expectedType, source, hash)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClassSelection(value: unknown): CharacterClassSelection | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classRef = normalizeEntityRef(value.class, "class");
|
||||||
|
if (!classRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subclassRef = normalizeEntityRef(value.subclass, "subclass");
|
||||||
|
return {
|
||||||
|
class: classRef,
|
||||||
|
subclass: subclassRef,
|
||||||
|
level: toInteger(value.level, 1, 1, 20)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalClassLevel(classes: CharacterClassSelection[]): number {
|
||||||
|
const total = classes.reduce((sum, cls) => sum + cls.level, 0);
|
||||||
|
return Math.max(1, Math.min(20, total));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClassSelections(
|
||||||
|
value: unknown,
|
||||||
|
fallbackClass: CharacterEntityRef | null,
|
||||||
|
fallbackSubclass: CharacterEntityRef | null,
|
||||||
|
fallbackLevel: number
|
||||||
|
): CharacterClassSelection[] {
|
||||||
|
const out: CharacterClassSelection[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const raw of value) {
|
||||||
|
const normalized = normalizeClassSelection(raw);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(normalized.class.entityRef)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(normalized.class.entityRef);
|
||||||
|
out.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length && fallbackClass) {
|
||||||
|
out.push({
|
||||||
|
class: fallbackClass,
|
||||||
|
subclass: fallbackSubclass,
|
||||||
|
level: Math.max(1, Math.min(20, fallbackLevel))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfile(value: unknown): CharacterProfile {
|
||||||
|
const profile = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
name: toString(profile.name, DEFAULT_PROFILE.name).trim() || DEFAULT_PROFILE.name,
|
||||||
|
level: toInteger(profile.level, DEFAULT_PROFILE.level, 1, 20),
|
||||||
|
alignment: toString(profile.alignment).trim(),
|
||||||
|
faith: toString(profile.faith).trim(),
|
||||||
|
lifestyle: toString(profile.lifestyle).trim(),
|
||||||
|
age: toString(profile.age).trim(),
|
||||||
|
gender: toString(profile.gender).trim(),
|
||||||
|
height: toString(profile.height).trim(),
|
||||||
|
weight: toString(profile.weight).trim(),
|
||||||
|
eyes: toString(profile.eyes).trim(),
|
||||||
|
skin: toString(profile.skin).trim(),
|
||||||
|
hair: toString(profile.hair).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePreferences(value: unknown): CharacterPreferencesStep {
|
||||||
|
const pref = isRecord(value) ? value : {};
|
||||||
|
|
||||||
|
const advancementType = toString(pref.advancementType, DEFAULT_STEPS.preferences.advancementType);
|
||||||
|
const hpMode = toString(pref.hpMode, DEFAULT_STEPS.preferences.hpMode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceBuckets: toSourceBuckets(pref.sourceBuckets, DEFAULT_STEPS.preferences.sourceBuckets),
|
||||||
|
useOptionalClassFeatures: toBoolean(pref.useOptionalClassFeatures, DEFAULT_STEPS.preferences.useOptionalClassFeatures),
|
||||||
|
customizeOrigin: toBoolean(pref.customizeOrigin, DEFAULT_STEPS.preferences.customizeOrigin),
|
||||||
|
advancementType: advancementType === "xp" ? "xp" : "milestone",
|
||||||
|
hpMode: hpMode === "maximum" || hpMode === "rolled" ? hpMode : "average",
|
||||||
|
usePrerequisitesFeats: toBoolean(pref.usePrerequisitesFeats, DEFAULT_STEPS.preferences.usePrerequisitesFeats),
|
||||||
|
usePrerequisitesMulticlass: toBoolean(
|
||||||
|
pref.usePrerequisitesMulticlass,
|
||||||
|
DEFAULT_STEPS.preferences.usePrerequisitesMulticlass
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAbilityScores(value: unknown): CharacterAbilityScoresStep {
|
||||||
|
const ability = isRecord(value) ? value : {};
|
||||||
|
const methodRaw = toString(ability.method, DEFAULT_STEPS.abilityScores.method);
|
||||||
|
const method: AbilityScoreMethod =
|
||||||
|
methodRaw === "pointBuy" || methodRaw === "manual" || methodRaw === "rolled" ? methodRaw : "standardArray";
|
||||||
|
|
||||||
|
const scoresRaw = isRecord(ability.scores) ? ability.scores : {};
|
||||||
|
|
||||||
|
const rolledRaw = Array.isArray(ability.rolledValues)
|
||||||
|
? ability.rolledValues
|
||||||
|
.map((it) => toInteger(it, 0, 1, 30))
|
||||||
|
.filter((it) => Number.isInteger(it) && it > 0)
|
||||||
|
.slice(0, 6)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const rolledValues: [number, number, number, number, number, number] =
|
||||||
|
rolledRaw.length === 6
|
||||||
|
? ([
|
||||||
|
rolledRaw[0],
|
||||||
|
rolledRaw[1],
|
||||||
|
rolledRaw[2],
|
||||||
|
rolledRaw[3],
|
||||||
|
rolledRaw[4],
|
||||||
|
rolledRaw[5]
|
||||||
|
] as [number, number, number, number, number, number])
|
||||||
|
: [...DEFAULT_STEPS.abilityScores.rolledValues];
|
||||||
|
|
||||||
|
const assignmentsRaw = isRecord(ability.assignments) ? ability.assignments : {};
|
||||||
|
const assignments: Record<AbilityKey, number> = {
|
||||||
|
str: toInteger(assignmentsRaw.str, DEFAULT_STEPS.abilityScores.assignments.str, 0, 5),
|
||||||
|
dex: toInteger(assignmentsRaw.dex, DEFAULT_STEPS.abilityScores.assignments.dex, 0, 5),
|
||||||
|
con: toInteger(assignmentsRaw.con, DEFAULT_STEPS.abilityScores.assignments.con, 0, 5),
|
||||||
|
int: toInteger(assignmentsRaw.int, DEFAULT_STEPS.abilityScores.assignments.int, 0, 5),
|
||||||
|
wis: toInteger(assignmentsRaw.wis, DEFAULT_STEPS.abilityScores.assignments.wis, 0, 5),
|
||||||
|
cha: toInteger(assignmentsRaw.cha, DEFAULT_STEPS.abilityScores.assignments.cha, 0, 5)
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
scores: {
|
||||||
|
str: toInteger(scoresRaw.str, DEFAULT_STEPS.abilityScores.scores.str, 1, 30),
|
||||||
|
dex: toInteger(scoresRaw.dex, DEFAULT_STEPS.abilityScores.scores.dex, 1, 30),
|
||||||
|
con: toInteger(scoresRaw.con, DEFAULT_STEPS.abilityScores.scores.con, 1, 30),
|
||||||
|
int: toInteger(scoresRaw.int, DEFAULT_STEPS.abilityScores.scores.int, 1, 30),
|
||||||
|
wis: toInteger(scoresRaw.wis, DEFAULT_STEPS.abilityScores.scores.wis, 1, 30),
|
||||||
|
cha: toInteger(scoresRaw.cha, DEFAULT_STEPS.abilityScores.scores.cha, 1, 30)
|
||||||
|
},
|
||||||
|
rolledValues,
|
||||||
|
assignments,
|
||||||
|
backgroundBonusModeIndex: toInteger(ability.backgroundBonusModeIndex, DEFAULT_STEPS.abilityScores.backgroundBonusModeIndex, 0, 10),
|
||||||
|
backgroundBonusSelections: toAbilityKeys(ability.backgroundBonusSelections, 6)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFeats(value: unknown): CharacterFeatsStep {
|
||||||
|
const step = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
selected: toStringArray(step.selected),
|
||||||
|
notes: toString(step.notes).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkills(value: unknown): CharacterSkillsStep {
|
||||||
|
const step = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
selected: toStringArray(step.selected),
|
||||||
|
notes: toString(step.notes).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpells(value: unknown): CharacterSpellsStep {
|
||||||
|
const step = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
selected: toStringArray(step.selected),
|
||||||
|
notes: toString(step.notes).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEquipment(value: unknown): CharacterEquipmentStep {
|
||||||
|
const equipment = isRecord(value) ? value : {};
|
||||||
|
const currencyRaw = isRecord(equipment.currency) ? equipment.currency : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
startingEquipmentText: toString(equipment.startingEquipmentText).trim(),
|
||||||
|
inventoryText: toString(equipment.inventoryText).trim(),
|
||||||
|
possessionsText: toString(equipment.possessionsText).trim(),
|
||||||
|
currency: {
|
||||||
|
cp: toInteger(currencyRaw.cp, DEFAULT_STEPS.equipment.currency.cp, 0, 999999),
|
||||||
|
sp: toInteger(currencyRaw.sp, DEFAULT_STEPS.equipment.currency.sp, 0, 999999),
|
||||||
|
ep: toInteger(currencyRaw.ep, DEFAULT_STEPS.equipment.currency.ep, 0, 999999),
|
||||||
|
gp: toInteger(currencyRaw.gp, DEFAULT_STEPS.equipment.currency.gp, 0, 999999),
|
||||||
|
pp: toInteger(currencyRaw.pp, DEFAULT_STEPS.equipment.currency.pp, 0, 999999)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAbout(value: unknown): CharacterAboutStep {
|
||||||
|
const about = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
personality: toString(about.personality).trim(),
|
||||||
|
ideals: toString(about.ideals).trim(),
|
||||||
|
bonds: toString(about.bonds).trim(),
|
||||||
|
flaws: toString(about.flaws).trim(),
|
||||||
|
backstory: toString(about.backstory).trim(),
|
||||||
|
notes: toString(about.notes).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSelections(value: unknown, fallbackLevel: number): CharacterSelections {
|
||||||
|
const selections = isRecord(value) ? value : {};
|
||||||
|
const primaryClass = normalizeEntityRef(selections.class, "class");
|
||||||
|
const primarySubclass = normalizeEntityRef(selections.subclass, "subclass");
|
||||||
|
const classes = normalizeClassSelections(selections.classes, primaryClass, primarySubclass, fallbackLevel);
|
||||||
|
const firstClass = classes[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
class: firstClass?.class ?? primaryClass,
|
||||||
|
subclass: firstClass?.subclass ?? primarySubclass,
|
||||||
|
classes,
|
||||||
|
background: normalizeEntityRef(selections.background, "background"),
|
||||||
|
species: normalizeEntityRef(selections.species, "species")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSteps(value: unknown): CharacterSteps {
|
||||||
|
const steps = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
preferences: normalizePreferences(steps.preferences),
|
||||||
|
abilityScores: normalizeAbilityScores(steps.abilityScores),
|
||||||
|
feats: normalizeFeats(steps.feats),
|
||||||
|
skills: normalizeSkills(steps.skills),
|
||||||
|
spells: normalizeSpells(steps.spells),
|
||||||
|
equipment: normalizeEquipment(steps.equipment),
|
||||||
|
about: normalizeAbout(steps.about)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCharacterRecord(raw: unknown): CharacterDocument {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const root = isRecord(raw) ? raw : {};
|
||||||
|
|
||||||
|
const profile = normalizeProfile(root.profile);
|
||||||
|
const selections = normalizeSelections(root.selections, profile.level);
|
||||||
|
const steps = normalizeSteps(root.steps);
|
||||||
|
const normalizedProfile: CharacterProfile = {
|
||||||
|
...profile,
|
||||||
|
level: selections.classes.length ? getTotalClassLevel(selections.classes) : profile.level
|
||||||
|
};
|
||||||
|
|
||||||
|
const idCandidate = toString(root.id).trim();
|
||||||
|
const id = idCandidate || randomUUID();
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema: CHARACTER_SCHEMA,
|
||||||
|
version: CHARACTER_VERSION,
|
||||||
|
id,
|
||||||
|
createdAt: toIsoDate(root.createdAt, nowIso),
|
||||||
|
updatedAt: toIsoDate(root.updatedAt, nowIso),
|
||||||
|
profile: normalizedProfile,
|
||||||
|
selections,
|
||||||
|
steps,
|
||||||
|
notes: toString(root.notes).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCharacterFilePath(charactersDir: string, id: string): string {
|
||||||
|
return path.join(charactersDir, `${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCharactersDir(charactersDir: string): void {
|
||||||
|
fs.mkdirSync(charactersDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCharacterFile(filePath: string): CharacterDocument {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return normalizeCharacterRecord(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCharacterFile(charactersDir: string, character: CharacterDocument): void {
|
||||||
|
ensureCharactersDir(charactersDir);
|
||||||
|
const filePath = getCharacterFilePath(charactersDir, character.id);
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(character, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCharactersDirectory(userDataDir: string): string {
|
||||||
|
return path.join(userDataDir, CHARACTERS_DIRNAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultCharacter(): CharacterDocument {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
schema: CHARACTER_SCHEMA,
|
||||||
|
version: CHARACTER_VERSION,
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
profile: { ...DEFAULT_PROFILE },
|
||||||
|
selections: { ...DEFAULT_SELECTIONS },
|
||||||
|
steps: {
|
||||||
|
preferences: { ...DEFAULT_STEPS.preferences },
|
||||||
|
abilityScores: {
|
||||||
|
...DEFAULT_STEPS.abilityScores,
|
||||||
|
scores: { ...DEFAULT_STEPS.abilityScores.scores },
|
||||||
|
rolledValues: [...DEFAULT_STEPS.abilityScores.rolledValues] as [number, number, number, number, number, number],
|
||||||
|
assignments: { ...DEFAULT_STEPS.abilityScores.assignments },
|
||||||
|
backgroundBonusSelections: [...DEFAULT_STEPS.abilityScores.backgroundBonusSelections]
|
||||||
|
},
|
||||||
|
feats: {
|
||||||
|
...DEFAULT_STEPS.feats,
|
||||||
|
selected: [...DEFAULT_STEPS.feats.selected]
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
...DEFAULT_STEPS.skills,
|
||||||
|
selected: [...DEFAULT_STEPS.skills.selected]
|
||||||
|
},
|
||||||
|
spells: {
|
||||||
|
...DEFAULT_STEPS.spells,
|
||||||
|
selected: [...DEFAULT_STEPS.spells.selected]
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
...DEFAULT_STEPS.equipment,
|
||||||
|
currency: { ...DEFAULT_STEPS.equipment.currency }
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
...DEFAULT_STEPS.about
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notes: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCharacters(charactersDir: string): CharacterSummary[] {
|
||||||
|
if (!fs.existsSync(charactersDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(charactersDir, { withFileTypes: true });
|
||||||
|
const characters: CharacterSummary[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(charactersDir, entry.name);
|
||||||
|
try {
|
||||||
|
const character = readCharacterFile(filePath);
|
||||||
|
characters.push({
|
||||||
|
id: character.id,
|
||||||
|
name: character.profile.name,
|
||||||
|
level: character.profile.level,
|
||||||
|
className: character.selections.classes[0]?.class.name ?? character.selections.class?.name ?? null,
|
||||||
|
backgroundName: character.selections.background?.name ?? null,
|
||||||
|
speciesName: character.selections.species?.name ?? null,
|
||||||
|
createdAt: character.createdAt,
|
||||||
|
updatedAt: character.updatedAt
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return characters.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCharacterById(charactersDir: string, id: string): CharacterDocument | null {
|
||||||
|
const filePath = getCharacterFilePath(charactersDir, id);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readCharacterFile(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCharacter(charactersDir: string, input: CharacterUpsertInput): CharacterDocument {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
|
const existing = input.id ? getCharacterById(charactersDir, input.id) : null;
|
||||||
|
const normalized = normalizeCharacterRecord({
|
||||||
|
...input,
|
||||||
|
id: input.id || existing?.id || randomUUID(),
|
||||||
|
createdAt: existing?.createdAt ?? input.createdAt ?? nowIso,
|
||||||
|
updatedAt: nowIso
|
||||||
|
});
|
||||||
|
|
||||||
|
writeCharacterFile(charactersDir, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importCharacterFromFile(charactersDir: string, sourceFilePath: string): CharacterDocument {
|
||||||
|
const raw = fs.readFileSync(sourceFilePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
const normalized = normalizeCharacterRecord(parsed);
|
||||||
|
|
||||||
|
const existing = getCharacterById(charactersDir, normalized.id);
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const persisted: CharacterDocument = {
|
||||||
|
...normalized,
|
||||||
|
createdAt: existing?.createdAt ?? normalized.createdAt ?? nowIso,
|
||||||
|
updatedAt: nowIso
|
||||||
|
};
|
||||||
|
|
||||||
|
writeCharacterFile(charactersDir, persisted);
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCharacter(charactersDir: string, id: string): boolean {
|
||||||
|
const filePath = getCharacterFilePath(charactersDir, id);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(filePath, { force: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
+196
-1
@@ -1,7 +1,15 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { app, BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron";
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
dialog,
|
||||||
|
ipcMain,
|
||||||
|
Menu,
|
||||||
|
type MenuItemConstructorOptions,
|
||||||
|
type OpenDialogOptions
|
||||||
|
} from "electron";
|
||||||
import {
|
import {
|
||||||
clearDesktopAppConfig,
|
clearDesktopAppConfig,
|
||||||
loadDesktopAppConfig,
|
loadDesktopAppConfig,
|
||||||
@@ -9,6 +17,42 @@ import {
|
|||||||
type DesktopAppConfig
|
type DesktopAppConfig
|
||||||
} from "./appConfig";
|
} from "./appConfig";
|
||||||
import { DEFAULT_EXTERNAL_IMAGE_ROOT, findWikiRootDir, startWikiServer, type WikiServer } from "./wikiServer";
|
import { DEFAULT_EXTERNAL_IMAGE_ROOT, findWikiRootDir, startWikiServer, type WikiServer } from "./wikiServer";
|
||||||
|
import { clearCharacterSelectionCache, getCharacterSelectionDetail, getCharacterSelectionOptions } from "./characterData";
|
||||||
|
import {
|
||||||
|
deleteCharacter,
|
||||||
|
getCharacterById,
|
||||||
|
getCharactersDirectory,
|
||||||
|
importCharacterFromFile,
|
||||||
|
listCharacters,
|
||||||
|
saveCharacter,
|
||||||
|
type CharacterDocument,
|
||||||
|
type CharacterSummary,
|
||||||
|
type CharacterUpsertInput
|
||||||
|
} from "./characterStore";
|
||||||
|
|
||||||
|
// Hardware acceleration is enabled by default.
|
||||||
|
// Set ELECTRON_DISABLE_HARDWARE_ACCELERATION=1 to opt out for troubleshooting.
|
||||||
|
if (process.env.ELECTRON_DISABLE_HARDWARE_ACCELERATION === "1") {
|
||||||
|
app.disableHardwareAcceleration();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep hardware acceleration enabled, but allow backend tuning on Apple Silicon.
|
||||||
|
// Electron/Chromium can emit mailbox errors on some macOS GPU driver paths.
|
||||||
|
// Using ANGLE "gl" is often more stable while still GPU-accelerated.
|
||||||
|
if (process.platform === "darwin" && process.arch === "arm64") {
|
||||||
|
const requestedAngleBackend = (process.env.ELECTRON_ANGLE_BACKEND || "").trim().toLowerCase();
|
||||||
|
const angleBackend =
|
||||||
|
requestedAngleBackend === "metal" || requestedAngleBackend === "gl" || requestedAngleBackend === "default"
|
||||||
|
? requestedAngleBackend
|
||||||
|
: "gl";
|
||||||
|
|
||||||
|
if (angleBackend !== "default") {
|
||||||
|
app.commandLine.appendSwitch("use-angle", angleBackend);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the validating command decoder for stability over passthrough on some drivers.
|
||||||
|
app.commandLine.appendSwitch("use-cmd-decoder", "validating");
|
||||||
|
}
|
||||||
|
|
||||||
const DEV_RENDERER_URL = process.env.VITE_DEV_SERVER_URL;
|
const DEV_RENDERER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||||
const DEFAULT_SOURCE_REPO_URL = "https://github.com/5etools-mirror-3/5etools-src.git";
|
const DEFAULT_SOURCE_REPO_URL = "https://github.com/5etools-mirror-3/5etools-src.git";
|
||||||
@@ -39,12 +83,55 @@ interface SetupRunResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CharacterSelectionOptionSets = ReturnType<typeof getCharacterSelectionOptions>;
|
||||||
|
type CharacterSelectionDetail = NonNullable<ReturnType<typeof getCharacterSelectionDetail>>;
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let wikiServer: WikiServer | null = null;
|
let wikiServer: WikiServer | null = null;
|
||||||
let wikiServerRootDir: string | null = null;
|
let wikiServerRootDir: string | null = null;
|
||||||
let wikiServerExternalImageRoot: string | null = null;
|
let wikiServerExternalImageRoot: string | null = null;
|
||||||
let isSetupInProgress = false;
|
let isSetupInProgress = false;
|
||||||
|
|
||||||
|
function setApplicationMenu(): void {
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
|
||||||
|
const template: MenuItemConstructorOptions[] = isMac
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: app.name,
|
||||||
|
submenu: [{ role: "about" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "pasteAndMatchStyle" }, { role: "delete" }, { role: "selectAll" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "View",
|
||||||
|
submenu: [{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Window",
|
||||||
|
submenu: [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
label: "File",
|
||||||
|
submenu: [{ role: "quit" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "delete" }, { role: "selectAll" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "View",
|
||||||
|
submenu: [{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||||
|
}
|
||||||
|
|
||||||
function isDevMode(): boolean {
|
function isDevMode(): boolean {
|
||||||
return !app.isPackaged;
|
return !app.isPackaged;
|
||||||
}
|
}
|
||||||
@@ -136,6 +223,29 @@ function normalizeRequiredZipPath(raw: string): string {
|
|||||||
return absolutePath;
|
return absolutePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRequiredJsonPath(raw: string, label: string): string {
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${label} path is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(value);
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
throw new Error(`${label} path does not exist: ${absolutePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(absolutePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throw new Error(`${label} path is not a file: ${absolutePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.extname(absolutePath).toLowerCase() !== ".json") {
|
||||||
|
throw new Error(`${label} file must be a .json file.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
function isWikiRootDirectory(dir: string): boolean {
|
function isWikiRootDirectory(dir: string): boolean {
|
||||||
return fs.existsSync(path.join(dir, "5etools.html")) && fs.existsSync(path.join(dir, "data"));
|
return fs.existsSync(path.join(dir, "5etools.html")) && fs.existsSync(path.join(dir, "data"));
|
||||||
}
|
}
|
||||||
@@ -329,6 +439,7 @@ async function prepareSourceFromSetup(input: SetupRunInput): Promise<{ rootDir:
|
|||||||
imageBaseUrl,
|
imageBaseUrl,
|
||||||
downloadImages
|
downloadImages
|
||||||
});
|
});
|
||||||
|
clearCharacterSelectionCache();
|
||||||
|
|
||||||
return { rootDir, externalImageRoot: imageBaseUrl };
|
return { rootDir, externalImageRoot: imageBaseUrl };
|
||||||
}
|
}
|
||||||
@@ -451,6 +562,7 @@ function clearOnboardingData(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearDesktopAppConfig();
|
clearDesktopAppConfig();
|
||||||
|
clearCharacterSelectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveConfiguredSource(config: DesktopAppConfig): string | null {
|
function resolveConfiguredSource(config: DesktopAppConfig): string | null {
|
||||||
@@ -476,6 +588,28 @@ async function pTryStartWikiFromLocalConfig(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCharactersDir(): string {
|
||||||
|
return getCharactersDirectory(app.getPath("userData"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCharacterOptions(): CharacterSelectionOptionSets {
|
||||||
|
const rootDir = wikiServerRootDir ?? resolveConfiguredSource(loadDesktopAppConfig());
|
||||||
|
if (!rootDir) {
|
||||||
|
throw new Error("Cannot load character options: 5etools source is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCharacterSelectionOptions(rootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCharacterOptionDetail(entityRef: string): CharacterSelectionDetail | null {
|
||||||
|
const rootDir = wikiServerRootDir ?? resolveConfiguredSource(loadDesktopAppConfig());
|
||||||
|
if (!rootDir) {
|
||||||
|
throw new Error("Cannot load character option details: 5etools source is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCharacterSelectionDetail(rootDir, entityRef);
|
||||||
|
}
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
function registerIpcHandlers(): void {
|
||||||
ipcMain.handle("wiki:get-url", async () => {
|
ipcMain.handle("wiki:get-url", async () => {
|
||||||
if (!wikiServer) {
|
if (!wikiServer) {
|
||||||
@@ -540,10 +674,71 @@ function registerIpcHandlers(): void {
|
|||||||
isSetupInProgress = false;
|
isSetupInProgress = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("characters:list", async (): Promise<CharacterSummary[]> => {
|
||||||
|
return listCharacters(getCharactersDir());
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("characters:get", async (_event, id: string): Promise<CharacterDocument | null> => {
|
||||||
|
const sanitizedId = String(id || "").trim();
|
||||||
|
if (!sanitizedId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCharacterById(getCharactersDir(), sanitizedId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("characters:save", async (_event, input: CharacterUpsertInput): Promise<CharacterDocument> => {
|
||||||
|
return saveCharacter(getCharactersDir(), input);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("characters:delete", async (_event, id: string): Promise<boolean> => {
|
||||||
|
const sanitizedId = String(id || "").trim();
|
||||||
|
if (!sanitizedId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteCharacter(getCharactersDir(), sanitizedId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("characters:get-options", async (): Promise<CharacterSelectionOptionSets> => {
|
||||||
|
return getCharacterOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("characters:get-option-detail", async (_event, entityRefRaw: string): Promise<CharacterSelectionDetail | null> => {
|
||||||
|
const entityRef = String(entityRefRaw || "").trim();
|
||||||
|
if (!entityRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCharacterOptionDetail(entityRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("characters:pick-json", async (event): Promise<string | null> => {
|
||||||
|
const owner = BrowserWindow.fromWebContents(event.sender) || mainWindow;
|
||||||
|
const dialogOptions: OpenDialogOptions = {
|
||||||
|
title: "Select Character JSON",
|
||||||
|
properties: ["openFile"],
|
||||||
|
filters: [{ name: "JSON files", extensions: ["json"] }]
|
||||||
|
};
|
||||||
|
const result = owner ? await dialog.showOpenDialog(owner, dialogOptions) : await dialog.showOpenDialog(dialogOptions);
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.filePaths[0] || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("characters:import-json", async (_event, inputPath: string): Promise<CharacterDocument> => {
|
||||||
|
const sourcePath = normalizeRequiredJsonPath(String(inputPath || ""), "Character JSON");
|
||||||
|
return importCharacterFromFile(getCharactersDir(), sourcePath);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
|
setApplicationMenu();
|
||||||
|
|
||||||
const hasLocalSource = await pTryStartWikiFromLocalConfig();
|
const hasLocalSource = await pTryStartWikiFromLocalConfig();
|
||||||
await openAppWindow(!hasLocalSource);
|
await openAppWindow(!hasLocalSource);
|
||||||
|
|||||||
+413
-27
@@ -39,13 +39,26 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
".xml": "application/xml; charset=utf-8"
|
".xml": "application/xml; charset=utf-8"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CORS_HEADERS: Record<string, string> = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type"
|
||||||
|
};
|
||||||
|
|
||||||
|
function withCorsHeaders(headers: Record<string, string>): Record<string, string> {
|
||||||
|
return {
|
||||||
|
...CORS_HEADERS,
|
||||||
|
...headers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
||||||
return `
|
return `
|
||||||
(() => {
|
(() => {
|
||||||
${externalImageRoot ? `globalThis.DEPLOYED_IMG_ROOT = ${JSON.stringify(externalImageRoot)};` : ""}
|
${externalImageRoot ? `globalThis.DEPLOYED_IMG_ROOT = ${JSON.stringify(externalImageRoot)};` : ""}
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.add("desktop-reskin");
|
root.classList.add("desktop-reskin", "desktop-app-navbar-mode");
|
||||||
|
|
||||||
${
|
${
|
||||||
SHOULD_FORCE_NIGHT_THEME
|
SHOULD_FORCE_NIGHT_THEME
|
||||||
@@ -69,6 +82,15 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
|||||||
console.info("[5ET-D]", content);
|
console.info("[5ET-D]", content);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const postToDesktop = (type, payload) => {
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage({ type, ...(payload || {}) }, "*");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const requestOnboardingReset = () => {
|
const requestOnboardingReset = () => {
|
||||||
const message = [
|
const message = [
|
||||||
"Reset onboarding and remove local 5etools data?",
|
"Reset onboarding and remove local 5etools data?",
|
||||||
@@ -89,20 +111,6 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
|||||||
doToastInfo("Reset onboarding is unavailable in this context.");
|
doToastInfo("Reset onboarding is unavailable in this context.");
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("message", (event) => {
|
|
||||||
const data = event?.data;
|
|
||||||
if (!data || typeof data !== "object") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.type !== "5etd:reset-onboarding-result" || data.ok !== false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reason = typeof data.error === "string" && data.error ? data.error : "unknown error";
|
|
||||||
doToastInfo(\`Reset onboarding failed: \${reason}\`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeDropdown = (li, toggle) => {
|
const closeDropdown = (li, toggle) => {
|
||||||
if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") {
|
if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") {
|
||||||
NavBar._closeDropdownElement(li);
|
NavBar._closeDropdownElement(li);
|
||||||
@@ -175,9 +183,21 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
|||||||
menu.append(menuItem);
|
menu.append(menuItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
addMenuItem("New", () => doToastInfo("Characters: New is coming in a later build."));
|
addMenuItem("New", () => {
|
||||||
addMenuItem("Load from JSON", () => doToastInfo("Characters: Load from JSON is coming in a later build."));
|
if (!postToDesktop("5etd:characters:new")) {
|
||||||
addMenuItem("My Characters", () => doToastInfo("Characters: My Characters is coming in a later build."));
|
doToastInfo("Characters menu is unavailable in this context.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addMenuItem("Load from JSON", () => {
|
||||||
|
if (!postToDesktop("5etd:characters:load-json")) {
|
||||||
|
doToastInfo("Characters menu is unavailable in this context.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addMenuItem("My Characters", () => {
|
||||||
|
if (!postToDesktop("5etd:characters:list")) {
|
||||||
|
doToastInfo("Characters menu is unavailable in this context.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
li.append(toggle, menu);
|
li.append(toggle, menu);
|
||||||
|
|
||||||
@@ -329,9 +349,348 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
|||||||
const applyDesktopMenuExtensions = () => {
|
const applyDesktopMenuExtensions = () => {
|
||||||
addCharactersMenu();
|
addCharactersMenu();
|
||||||
addResetOnboardingMenu();
|
addResetOnboardingMenu();
|
||||||
scheduleAutoFocusSearch();
|
bindNativeNavbarSync();
|
||||||
|
queueNativeNavbarModelPost({ isForce: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NAVBAR_ACTION_ATTR = "data-5etd-nav-action-id";
|
||||||
|
|
||||||
|
const cleanNavLabel = (value) => String(value || "").replace(/\\s+/g, " ").replace(/[▾▼►]+/g, "").trim();
|
||||||
|
|
||||||
|
const getNavPrimaryText = (ele) => {
|
||||||
|
for (const node of Array.from(ele.childNodes || [])) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = cleanNavLabel(node.textContent || "");
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanNavLabel(ele.textContent || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNavEntryMeta = (ele) => {
|
||||||
|
if (!ele) return {};
|
||||||
|
|
||||||
|
const out = {};
|
||||||
|
const dateEle = ele.querySelector(":scope > .page__nav-date");
|
||||||
|
if (dateEle) {
|
||||||
|
const datePrefix = cleanNavLabel(dateEle.textContent || "");
|
||||||
|
if (datePrefix) out.datePrefix = datePrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source");
|
||||||
|
if (sourceEle instanceof HTMLElement) {
|
||||||
|
const computed = getComputedStyle(sourceEle);
|
||||||
|
const sourceColor = String(computed.backgroundColor || computed.borderLeftColor || "").trim();
|
||||||
|
if (
|
||||||
|
sourceColor &&
|
||||||
|
sourceColor !== "transparent" &&
|
||||||
|
!/^rgba?\\(\\s*0\\s*,\\s*0\\s*,\\s*0(?:\\s*,\\s*0)?\\s*\\)$/i.test(sourceColor)
|
||||||
|
) {
|
||||||
|
out.sourceColor = sourceColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActionLikeHref = (hrefRaw) => {
|
||||||
|
const href = String(hrefRaw || "").trim().toLowerCase();
|
||||||
|
return !href || href === "#" || href.startsWith("javascript:");
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNavbarMenuList = (list, idPrefix) => {
|
||||||
|
const entries = [];
|
||||||
|
const liElements = Array.from(list?.children || []).filter((it) => it instanceof HTMLLIElement);
|
||||||
|
|
||||||
|
liElements.forEach((li, index) => {
|
||||||
|
const entryId = \`\${idPrefix}-\${index}\`;
|
||||||
|
const submenuList = li.querySelector(":scope > ul")
|
||||||
|
|| (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null);
|
||||||
|
const submenuChildren = submenuList ? parseNavbarMenuList(submenuList, \`\${entryId}-submenu\`) : [];
|
||||||
|
|
||||||
|
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
|
||||||
|
entries.push({ id: \`\${entryId}-divider\`, type: "divider" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (li.classList.contains("nav2-list__label")) {
|
||||||
|
const label = cleanNavLabel(li.textContent || "");
|
||||||
|
if (label) entries.push({ id: \`\${entryId}-label\`, type: "label", label });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (li.classList.contains("dropdown--navbar")) {
|
||||||
|
const toggle = li.querySelector(":scope > a");
|
||||||
|
const label = toggle ? getNavPrimaryText(toggle) : "";
|
||||||
|
if (!label) return;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: \`\${entryId}-submenu\`,
|
||||||
|
type: "submenu",
|
||||||
|
label,
|
||||||
|
...getNavEntryMeta(toggle),
|
||||||
|
children: submenuChildren
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (li.classList.contains("nav2-accord__wrp")) {
|
||||||
|
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
|
||||||
|
const label = headPrimary ? getNavPrimaryText(headPrimary) : "";
|
||||||
|
if (!label || !submenuChildren.length) return;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: \`\${entryId}-accordion\`,
|
||||||
|
type: "submenu",
|
||||||
|
label,
|
||||||
|
...getNavEntryMeta(headPrimary),
|
||||||
|
children: submenuChildren
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = li.querySelector(":scope > a");
|
||||||
|
if (link) {
|
||||||
|
const label = getNavPrimaryText(link);
|
||||||
|
const href = String(link.getAttribute("href") || "").trim();
|
||||||
|
if (submenuChildren.length) {
|
||||||
|
if (isActionLikeHref(href)) {
|
||||||
|
const actionId = \`\${entryId}-submenu-action\`;
|
||||||
|
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||||
|
entries.push({
|
||||||
|
id: \`\${entryId}-submenu-link\`,
|
||||||
|
type: "submenu",
|
||||||
|
label: label || "Section",
|
||||||
|
...getNavEntryMeta(link),
|
||||||
|
actionId,
|
||||||
|
children: submenuChildren
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: \`\${entryId}-submenu-link\`,
|
||||||
|
type: "submenu",
|
||||||
|
label: label || href,
|
||||||
|
...getNavEntryMeta(link),
|
||||||
|
href,
|
||||||
|
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href),
|
||||||
|
children: submenuChildren
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActionLikeHref(href)) {
|
||||||
|
const actionId = \`\${entryId}-action\`;
|
||||||
|
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||||
|
entries.push({ id: \`\${entryId}-action\`, type: "action", label: label || "Action", actionId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: \`\${entryId}-link\`,
|
||||||
|
type: "link",
|
||||||
|
label: label || href,
|
||||||
|
...getNavEntryMeta(link),
|
||||||
|
href,
|
||||||
|
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it) => it instanceof HTMLSpanElement);
|
||||||
|
if (submenuChildren.length && spans.length) {
|
||||||
|
const first = spans[0];
|
||||||
|
const label = cleanNavLabel(first.textContent || first.title || "");
|
||||||
|
const actionId = \`\${entryId}-submenu-span\`;
|
||||||
|
first.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||||
|
entries.push({
|
||||||
|
id: \`\${entryId}-submenu-span\`,
|
||||||
|
type: "submenu",
|
||||||
|
label: label || "Section",
|
||||||
|
...getNavEntryMeta(first),
|
||||||
|
actionId,
|
||||||
|
children: submenuChildren
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spans.forEach((span, spanIndex) => {
|
||||||
|
const label = cleanNavLabel(span.textContent || span.title || "");
|
||||||
|
const actionId = \`\${entryId}-span-\${spanIndex}\`;
|
||||||
|
span.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||||
|
entries.push({
|
||||||
|
id: \`\${entryId}-span-\${spanIndex}\`,
|
||||||
|
type: "action",
|
||||||
|
label: label || "Action",
|
||||||
|
...getNavEntryMeta(span),
|
||||||
|
actionId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNativeNavbarGroups = () => {
|
||||||
|
const navbar = document.getElementById("navbar");
|
||||||
|
if (!navbar) return [];
|
||||||
|
|
||||||
|
const groups = [];
|
||||||
|
const rootItems = Array.from(navbar.children).filter((it) => it instanceof HTMLLIElement);
|
||||||
|
|
||||||
|
rootItems.forEach((li, index) => {
|
||||||
|
if (
|
||||||
|
li.classList.contains("omni__wrp-input") ||
|
||||||
|
li.classList.contains("desktop-nav-characters")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (li.classList.contains("dropdown--navbar")) {
|
||||||
|
const toggle = li.querySelector(":scope > a");
|
||||||
|
const menuList = li.querySelector(":scope > ul");
|
||||||
|
const label = toggle ? getNavPrimaryText(toggle) : "";
|
||||||
|
if (!label) return;
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
id: \`root-\${index}-dropdown\`,
|
||||||
|
kind: "dropdown",
|
||||||
|
label,
|
||||||
|
items: menuList ? parseNavbarMenuList(menuList, \`root-\${index}-list\`) : []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = li.querySelector(":scope > a");
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
const label = getNavPrimaryText(link);
|
||||||
|
const href = String(link.getAttribute("href") || "").trim();
|
||||||
|
if (!label) return;
|
||||||
|
|
||||||
|
if (isActionLikeHref(href)) {
|
||||||
|
const actionId = \`root-\${index}-action\`;
|
||||||
|
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||||
|
groups.push({
|
||||||
|
id: \`root-\${index}-action\`,
|
||||||
|
kind: "action",
|
||||||
|
label,
|
||||||
|
actionId
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
id: \`root-\${index}-link\`,
|
||||||
|
kind: "link",
|
||||||
|
label,
|
||||||
|
href,
|
||||||
|
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
|
let navbarModelLastSignature = "";
|
||||||
|
let isNavbarModelPostQueued = false;
|
||||||
|
|
||||||
|
const postNativeNavbarModel = ({ isForce = false } = {}) => {
|
||||||
|
if (!window.parent || window.parent === window) return;
|
||||||
|
const groups = buildNativeNavbarGroups();
|
||||||
|
let nextSignature = "";
|
||||||
|
try {
|
||||||
|
nextSignature = JSON.stringify(groups);
|
||||||
|
} catch {
|
||||||
|
nextSignature = \`groups:\${groups.length}:\${Date.now()}\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isForce && nextSignature === navbarModelLastSignature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navbarModelLastSignature = nextSignature;
|
||||||
|
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: "5etd:native-navbar-data",
|
||||||
|
groups
|
||||||
|
},
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueNativeNavbarModelPost = ({ isForce = false } = {}) => {
|
||||||
|
if (isForce) {
|
||||||
|
isNavbarModelPostQueued = false;
|
||||||
|
postNativeNavbarModel({ isForce: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNavbarModelPostQueued) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isNavbarModelPostQueued = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
isNavbarModelPostQueued = false;
|
||||||
|
postNativeNavbarModel();
|
||||||
|
}, 80);
|
||||||
|
};
|
||||||
|
|
||||||
|
let navbarSyncObserver = null;
|
||||||
|
const bindNativeNavbarSync = () => {
|
||||||
|
const navbar = document.getElementById("navbar");
|
||||||
|
if (!navbar || navbar.dataset.desktopNavSyncBound === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navbar.dataset.desktopNavSyncBound = "1";
|
||||||
|
navbarSyncObserver?.disconnect?.();
|
||||||
|
navbarSyncObserver = new MutationObserver(() => {
|
||||||
|
queueNativeNavbarModelPost();
|
||||||
|
});
|
||||||
|
navbarSyncObserver.observe(navbar, { childList: true, subtree: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const runNativeNavbarAction = (actionId) => {
|
||||||
|
const id = String(actionId || "").trim();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const navbar = document.getElementById("navbar");
|
||||||
|
if (!navbar) return;
|
||||||
|
|
||||||
|
const actionEle = navbar.querySelector(\`[\${NAVBAR_ACTION_ATTR}="\${id}"]\`);
|
||||||
|
if (!(actionEle instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionEle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
||||||
|
actionEle.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
const data = event?.data;
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "5etd:reset-onboarding-result" && data.ok === false) {
|
||||||
|
const reason = typeof data.error === "string" && data.error ? data.error : "unknown error";
|
||||||
|
doToastInfo(\`Reset onboarding failed: \${reason}\`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "5etd:native-navbar-request") {
|
||||||
|
bindNativeNavbarSync();
|
||||||
|
queueNativeNavbarModelPost({ isForce: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "5etd:native-navbar-run-action") {
|
||||||
|
runNativeNavbarAction(data.actionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", applyDesktopMenuExtensions);
|
window.addEventListener("DOMContentLoaded", applyDesktopMenuExtensions);
|
||||||
window.addEventListener("load", applyDesktopMenuExtensions);
|
window.addEventListener("load", applyDesktopMenuExtensions);
|
||||||
})();
|
})();
|
||||||
@@ -367,7 +726,7 @@ html.desktop-reskin .page__header {
|
|||||||
border: 1px solid rgba(109, 164, 223, 0.32) !important;
|
border: 1px solid rgba(109, 164, 223, 0.32) !important;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: linear-gradient(115deg, rgba(17, 29, 47, 0.98), rgba(24, 38, 58, 0.96) 64%, rgba(87, 58, 32, 0.88));
|
background: linear-gradient(115deg, rgba(17, 29, 47, 0.98), rgba(24, 38, 58, 0.96) 64%, rgba(87, 58, 32, 0.88));
|
||||||
backdrop-filter: blur(14px);
|
// backdrop-filter: blur(14px);
|
||||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,6 +1360,27 @@ html.desktop-reskin .modal__inner {
|
|||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* App-managed top bar mode: keep 5etools content, hide native header/nav chrome */
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode body {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode .viewport-wrapper {
|
||||||
|
inset: 0 !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 100vh !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode header.page__header,
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode .page__header,
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode #navigation,
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode nav.page__nav,
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode #navigation.page__nav,
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode .page__nav,
|
||||||
|
html.desktop-reskin.desktop-app-navbar-mode .page__disp-download-progress {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export interface WikiServer {
|
export interface WikiServer {
|
||||||
@@ -1127,10 +1507,10 @@ async function pTryServeExternalImage(
|
|||||||
const responseBuffer = Buffer.from(await response.arrayBuffer());
|
const responseBuffer = Buffer.from(await response.arrayBuffer());
|
||||||
const contentType = response.headers.get("content-type") ?? getMimeType(externalImageUrl.pathname);
|
const contentType = response.headers.get("content-type") ?? getMimeType(externalImageUrl.pathname);
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, withCorsHeaders({
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=3600"
|
"Cache-Control": "public, max-age=3600"
|
||||||
});
|
}));
|
||||||
res.end(responseBuffer);
|
res.end(responseBuffer);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1184,13 +1564,19 @@ export async function startWikiServer(options: StartWikiServerOptions = {}): Pro
|
|||||||
|
|
||||||
return await new Promise<WikiServer>((resolve, reject) => {
|
return await new Promise<WikiServer>((resolve, reject) => {
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = req.url ?? "/";
|
const url = req.url ?? "/";
|
||||||
|
|
||||||
let filePath: string;
|
let filePath: string;
|
||||||
try {
|
try {
|
||||||
filePath = resolveRequestPath(rootDir, url);
|
filePath = resolveRequestPath(rootDir, url);
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
res.writeHead(403, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
|
||||||
res.end("Forbidden");
|
res.end("Forbidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1203,13 +1589,13 @@ export async function startWikiServer(options: StartWikiServerOptions = {}): Pro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
res.writeHead(404, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
|
||||||
res.end("Not Found");
|
res.end("Not Found");
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
res.writeHead(500, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
|
||||||
res.end("Internal Server Error");
|
res.end("Internal Server Error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1218,10 +1604,10 @@ export async function startWikiServer(options: StartWikiServerOptions = {}): Pro
|
|||||||
const transformedBody =
|
const transformedBody =
|
||||||
extension === ".html" && SHOULD_INJECT_RESKIN ? injectDesktopReskin(data.toString("utf8"), reskinBootstrap) : data;
|
extension === ".html" && SHOULD_INJECT_RESKIN ? injectDesktopReskin(data.toString("utf8"), reskinBootstrap) : data;
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, withCorsHeaders({
|
||||||
"Content-Type": getMimeType(filePath),
|
"Content-Type": getMimeType(filePath),
|
||||||
"Cache-Control": "no-store"
|
"Cache-Control": "no-store"
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (typeof transformedBody === "string") {
|
if (typeof transformedBody === "string") {
|
||||||
res.end(transformedBody, "utf8");
|
res.end(transformedBody, "utf8");
|
||||||
|
|||||||
+182
-1
@@ -22,10 +22,191 @@ interface SetupRunResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CharacterEntityType = "class" | "background" | "species";
|
||||||
|
type CharacterSourceBucket = "homebrew" | "core2024" | "expanded2024" | "core2014" | "expanded2014";
|
||||||
|
|
||||||
|
interface CharacterSelectionOption {
|
||||||
|
entityType: CharacterEntityType;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
hash: string;
|
||||||
|
entityRef: string;
|
||||||
|
details: string;
|
||||||
|
sourceName: string;
|
||||||
|
sourceGroup: string | null;
|
||||||
|
sourcePublished: string | null;
|
||||||
|
sourceBucket: CharacterSourceBucket;
|
||||||
|
sourceEdition: "2014" | "2024" | null;
|
||||||
|
imagePath: string | null;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterFeatureOption {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
hash: string;
|
||||||
|
entityRef: string;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterFeatureCard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
level: number | null;
|
||||||
|
summary: string;
|
||||||
|
options?: CharacterFeatureOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSelectionDetail extends CharacterSelectionOption {
|
||||||
|
featureCards: CharacterFeatureCard[];
|
||||||
|
rawJson: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSelectionOptionSets {
|
||||||
|
classes: CharacterSelectionOption[];
|
||||||
|
backgrounds: CharacterSelectionOption[];
|
||||||
|
species: CharacterSelectionOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterEntityRef {
|
||||||
|
entityType: "class" | "subclass" | "background" | "species" | "item" | "feat" | "spell";
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
hash: string;
|
||||||
|
entityRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterClassSelection {
|
||||||
|
class: CharacterEntityRef;
|
||||||
|
subclass: CharacterEntityRef | null;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterProfile {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
alignment: string;
|
||||||
|
faith: string;
|
||||||
|
lifestyle: string;
|
||||||
|
age: string;
|
||||||
|
gender: string;
|
||||||
|
height: string;
|
||||||
|
weight: string;
|
||||||
|
eyes: string;
|
||||||
|
skin: string;
|
||||||
|
hair: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterPreferencesStep {
|
||||||
|
sourceBuckets: string[];
|
||||||
|
useOptionalClassFeatures: boolean;
|
||||||
|
customizeOrigin: boolean;
|
||||||
|
advancementType: "milestone" | "xp";
|
||||||
|
hpMode: "average" | "maximum" | "rolled";
|
||||||
|
usePrerequisitesFeats: boolean;
|
||||||
|
usePrerequisitesMulticlass: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterAbilityScoresStep {
|
||||||
|
method: "standardArray" | "pointBuy" | "manual" | "rolled";
|
||||||
|
scores: Record<"str" | "dex" | "con" | "int" | "wis" | "cha", number>;
|
||||||
|
rolledValues: [number, number, number, number, number, number];
|
||||||
|
assignments: Record<"str" | "dex" | "con" | "int" | "wis" | "cha", number>;
|
||||||
|
backgroundBonusModeIndex: number;
|
||||||
|
backgroundBonusSelections: Array<"str" | "dex" | "con" | "int" | "wis" | "cha">;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterFeatsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSkillsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSpellsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterEquipmentStep {
|
||||||
|
startingEquipmentText: string;
|
||||||
|
inventoryText: string;
|
||||||
|
possessionsText: string;
|
||||||
|
currency: Record<"cp" | "sp" | "ep" | "gp" | "pp", number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterAboutStep {
|
||||||
|
personality: string;
|
||||||
|
ideals: string;
|
||||||
|
bonds: string;
|
||||||
|
flaws: string;
|
||||||
|
backstory: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterDocument {
|
||||||
|
schema: "5etd.character";
|
||||||
|
version: 1;
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
profile: CharacterProfile;
|
||||||
|
selections: {
|
||||||
|
class: CharacterEntityRef | null;
|
||||||
|
subclass: CharacterEntityRef | null;
|
||||||
|
classes: CharacterClassSelection[];
|
||||||
|
background: CharacterEntityRef | null;
|
||||||
|
species: CharacterEntityRef | null;
|
||||||
|
};
|
||||||
|
steps: {
|
||||||
|
preferences: CharacterPreferencesStep;
|
||||||
|
abilityScores: CharacterAbilityScoresStep;
|
||||||
|
feats: CharacterFeatsStep;
|
||||||
|
skills: CharacterSkillsStep;
|
||||||
|
spells: CharacterSpellsStep;
|
||||||
|
equipment: CharacterEquipmentStep;
|
||||||
|
about: CharacterAboutStep;
|
||||||
|
};
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
className: string | null;
|
||||||
|
backgroundName: string | null;
|
||||||
|
speciesName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterUpsertInput {
|
||||||
|
id?: string;
|
||||||
|
profile?: Partial<CharacterProfile>;
|
||||||
|
selections?: Partial<CharacterDocument["selections"]>;
|
||||||
|
steps?: Partial<CharacterDocument["steps"]>;
|
||||||
|
notes?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("desktopApi", {
|
contextBridge.exposeInMainWorld("desktopApi", {
|
||||||
getWikiUrl: () => ipcRenderer.invoke("wiki:get-url") as Promise<string>,
|
getWikiUrl: () => ipcRenderer.invoke("wiki:get-url") as Promise<string>,
|
||||||
getSetupState: () => ipcRenderer.invoke("setup:get-state") as Promise<SetupState>,
|
getSetupState: () => ipcRenderer.invoke("setup:get-state") as Promise<SetupState>,
|
||||||
pickSourceZip: () => ipcRenderer.invoke("setup:pick-source-zip") as Promise<string | null>,
|
pickSourceZip: () => ipcRenderer.invoke("setup:pick-source-zip") as Promise<string | null>,
|
||||||
runSetup: (input: SetupRunInput) => ipcRenderer.invoke("setup:run", input) as Promise<SetupRunResult>,
|
runSetup: (input: SetupRunInput) => ipcRenderer.invoke("setup:run", input) as Promise<SetupRunResult>,
|
||||||
resetOnboarding: () => ipcRenderer.invoke("setup:reset-onboarding") as Promise<SetupRunResult>
|
resetOnboarding: () => ipcRenderer.invoke("setup:reset-onboarding") as Promise<SetupRunResult>,
|
||||||
|
listCharacters: () => ipcRenderer.invoke("characters:list") as Promise<CharacterSummary[]>,
|
||||||
|
getCharacter: (id: string) => ipcRenderer.invoke("characters:get", id) as Promise<CharacterDocument | null>,
|
||||||
|
saveCharacter: (input: CharacterUpsertInput) => ipcRenderer.invoke("characters:save", input) as Promise<CharacterDocument>,
|
||||||
|
deleteCharacter: (id: string) => ipcRenderer.invoke("characters:delete", id) as Promise<boolean>,
|
||||||
|
getCharacterOptions: () => ipcRenderer.invoke("characters:get-options") as Promise<CharacterSelectionOptionSets>,
|
||||||
|
getCharacterOptionDetail: (entityRef: string) =>
|
||||||
|
ipcRenderer.invoke("characters:get-option-detail", entityRef) as Promise<CharacterSelectionDetail | null>,
|
||||||
|
pickCharacterJson: () => ipcRenderer.invoke("characters:pick-json") as Promise<string | null>,
|
||||||
|
importCharacterJson: (filePath: string) =>
|
||||||
|
ipcRenderer.invoke("characters:import-json", filePath) as Promise<CharacterDocument>
|
||||||
});
|
});
|
||||||
+7095
-54
File diff suppressed because it is too large.
Load diff
+2195
-90
File diff suppressed because it is too large.
Load diff
Vendored
+179
@@ -6,6 +6,14 @@ interface DesktopApi {
|
|||||||
pickSourceZip: () => Promise<string | null>;
|
pickSourceZip: () => Promise<string | null>;
|
||||||
runSetup: (input: SetupRunInput) => Promise<SetupRunResult>;
|
runSetup: (input: SetupRunInput) => Promise<SetupRunResult>;
|
||||||
resetOnboarding: () => Promise<SetupRunResult>;
|
resetOnboarding: () => Promise<SetupRunResult>;
|
||||||
|
listCharacters: () => Promise<CharacterSummary[]>;
|
||||||
|
getCharacter: (id: string) => Promise<CharacterDocument | null>;
|
||||||
|
saveCharacter: (input: CharacterUpsertInput) => Promise<CharacterDocument>;
|
||||||
|
deleteCharacter: (id: string) => Promise<boolean>;
|
||||||
|
getCharacterOptions: () => Promise<CharacterSelectionOptionSets>;
|
||||||
|
getCharacterOptionDetail: (entityRef: string) => Promise<CharacterSelectionDetail | null>;
|
||||||
|
pickCharacterJson: () => Promise<string | null>;
|
||||||
|
importCharacterJson: (filePath: string) => Promise<CharacterDocument>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetupState {
|
interface SetupState {
|
||||||
@@ -30,6 +38,177 @@ interface SetupRunResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CharacterEntityType = "class" | "background" | "species";
|
||||||
|
type CharacterSourceBucket = "homebrew" | "core2024" | "expanded2024" | "core2014" | "expanded2014";
|
||||||
|
|
||||||
|
interface CharacterSelectionOption {
|
||||||
|
entityType: CharacterEntityType;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
hash: string;
|
||||||
|
entityRef: string;
|
||||||
|
details: string;
|
||||||
|
sourceName: string;
|
||||||
|
sourceGroup: string | null;
|
||||||
|
sourcePublished: string | null;
|
||||||
|
sourceBucket: CharacterSourceBucket;
|
||||||
|
sourceEdition: "2014" | "2024" | null;
|
||||||
|
imagePath: string | null;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterFeatureOption {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
hash: string;
|
||||||
|
entityRef: string;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterFeatureCard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
level: number | null;
|
||||||
|
summary: string;
|
||||||
|
options?: CharacterFeatureOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSelectionDetail extends CharacterSelectionOption {
|
||||||
|
featureCards: CharacterFeatureCard[];
|
||||||
|
rawJson: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSelectionOptionSets {
|
||||||
|
classes: CharacterSelectionOption[];
|
||||||
|
backgrounds: CharacterSelectionOption[];
|
||||||
|
species: CharacterSelectionOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterEntityRef {
|
||||||
|
entityType: "class" | "subclass" | "background" | "species" | "item" | "feat" | "spell";
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
hash: string;
|
||||||
|
entityRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterClassSelection {
|
||||||
|
class: CharacterEntityRef;
|
||||||
|
subclass: CharacterEntityRef | null;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterProfile {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
alignment: string;
|
||||||
|
faith: string;
|
||||||
|
lifestyle: string;
|
||||||
|
age: string;
|
||||||
|
gender: string;
|
||||||
|
height: string;
|
||||||
|
weight: string;
|
||||||
|
eyes: string;
|
||||||
|
skin: string;
|
||||||
|
hair: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterPreferencesStep {
|
||||||
|
sourceBuckets: string[];
|
||||||
|
useOptionalClassFeatures: boolean;
|
||||||
|
customizeOrigin: boolean;
|
||||||
|
advancementType: "milestone" | "xp";
|
||||||
|
hpMode: "average" | "maximum" | "rolled";
|
||||||
|
usePrerequisitesFeats: boolean;
|
||||||
|
usePrerequisitesMulticlass: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterAbilityScoresStep {
|
||||||
|
method: "standardArray" | "pointBuy" | "manual" | "rolled";
|
||||||
|
scores: Record<"str" | "dex" | "con" | "int" | "wis" | "cha", number>;
|
||||||
|
rolledValues: [number, number, number, number, number, number];
|
||||||
|
assignments: Record<"str" | "dex" | "con" | "int" | "wis" | "cha", number>;
|
||||||
|
backgroundBonusModeIndex: number;
|
||||||
|
backgroundBonusSelections: Array<"str" | "dex" | "con" | "int" | "wis" | "cha">;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterFeatsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSkillsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSpellsStep {
|
||||||
|
selected: string[];
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterEquipmentStep {
|
||||||
|
startingEquipmentText: string;
|
||||||
|
inventoryText: string;
|
||||||
|
possessionsText: string;
|
||||||
|
currency: Record<"cp" | "sp" | "ep" | "gp" | "pp", number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterAboutStep {
|
||||||
|
personality: string;
|
||||||
|
ideals: string;
|
||||||
|
bonds: string;
|
||||||
|
flaws: string;
|
||||||
|
backstory: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterDocument {
|
||||||
|
schema: "5etd.character";
|
||||||
|
version: 1;
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
profile: CharacterProfile;
|
||||||
|
selections: {
|
||||||
|
class: CharacterEntityRef | null;
|
||||||
|
subclass: CharacterEntityRef | null;
|
||||||
|
classes: CharacterClassSelection[];
|
||||||
|
background: CharacterEntityRef | null;
|
||||||
|
species: CharacterEntityRef | null;
|
||||||
|
};
|
||||||
|
steps: {
|
||||||
|
preferences: CharacterPreferencesStep;
|
||||||
|
abilityScores: CharacterAbilityScoresStep;
|
||||||
|
feats: CharacterFeatsStep;
|
||||||
|
skills: CharacterSkillsStep;
|
||||||
|
spells: CharacterSpellsStep;
|
||||||
|
equipment: CharacterEquipmentStep;
|
||||||
|
about: CharacterAboutStep;
|
||||||
|
};
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
className: string | null;
|
||||||
|
backgroundName: string | null;
|
||||||
|
speciesName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterUpsertInput {
|
||||||
|
id?: string;
|
||||||
|
profile?: Partial<CharacterProfile>;
|
||||||
|
selections?: Partial<CharacterDocument["selections"]>;
|
||||||
|
steps?: Partial<CharacterDocument["steps"]>;
|
||||||
|
notes?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
desktopApi: DesktopApi;
|
desktopApi: DesktopApi;
|
||||||
}
|
}
|
||||||
+71
-21
@@ -1,40 +1,90 @@
|
|||||||
export interface WikiNavItem {
|
export interface WikiTopbarItem {
|
||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path: string;
|
||||||
description: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WikiNavSection {
|
export interface WikiTopbarMenu {
|
||||||
title: string;
|
label: string;
|
||||||
items: WikiNavItem[];
|
items: WikiTopbarItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WIKI_NAV_SECTIONS: WikiNavSection[] = [
|
export const WIKI_TOPBAR_MENUS: WikiTopbarMenu[] = [
|
||||||
{
|
{
|
||||||
title: "Core",
|
label: "Home",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Home", path: "/5etools.html", description: "Main dashboard and tools" },
|
{ label: "Dashboard", path: "/5etools.html" },
|
||||||
{ label: "Search", path: "/search.html", description: "Global content search" },
|
{ label: "Quick Reference", path: "/quickreference.html" },
|
||||||
{ label: "Rules", path: "/variantrules.html", description: "System and optional rules" },
|
{ label: "Search", path: "/search.html" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Character",
|
label: "Rules",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Classes", path: "/classes.html", description: "Class and subclass details" },
|
{ label: "Variant Rules", path: "/variantrules.html" },
|
||||||
{ label: "Backgrounds", path: "/backgrounds.html", description: "Origin and backstory options" },
|
{ label: "Actions", path: "/actions.html" },
|
||||||
{ label: "Species", path: "/races.html", description: "Species and lineages" },
|
{ label: "Conditions", path: "/conditionsdiseases.html" },
|
||||||
{ label: "Feats", path: "/feats.html", description: "Character feat selection" },
|
{ label: "Tables", path: "/tables.html" }
|
||||||
{ label: "Optional Features", path: "/optionalfeatures.html", description: "Expanded build options" }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Compendium",
|
label: "Player",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Spells", path: "/spells.html", description: "Spell catalog and filters" },
|
{ label: "Species", path: "/races.html" },
|
||||||
{ label: "Items", path: "/items.html", description: "Equipment and magic items" },
|
{ label: "Classes", path: "/classes.html" },
|
||||||
{ label: "Bestiary", path: "/bestiary.html", description: "Creatures and stat blocks" },
|
{ label: "Backgrounds", path: "/backgrounds.html" },
|
||||||
{ label: "Tables", path: "/tables.html", description: "Reference tables and charts" }
|
{ label: "Feats", path: "/feats.html" },
|
||||||
|
{ label: "Optional Features", path: "/optionalfeatures.html" },
|
||||||
|
{ label: "Spells", path: "/spells.html" },
|
||||||
|
{ label: "Items", path: "/items.html" },
|
||||||
|
{ label: "Stat Generator", path: "/statgen.html" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dungeon Master",
|
||||||
|
items: [
|
||||||
|
{ label: "Bestiary", path: "/bestiary.html" },
|
||||||
|
{ label: "DM Screen", path: "/dmscreen.html" },
|
||||||
|
{ label: "Encounter Builder", path: "/encountergen.html" },
|
||||||
|
{ label: "Maps", path: "/maps.html" },
|
||||||
|
{ label: "Traps & Hazards", path: "/trapshazards.html" },
|
||||||
|
{ label: "Deities", path: "/deities.html" },
|
||||||
|
{ label: "Objects", path: "/objects.html" },
|
||||||
|
{ label: "Vehicles", path: "/vehicles.html" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "References",
|
||||||
|
items: [
|
||||||
|
{ label: "Adventures", path: "/adventures.html" },
|
||||||
|
{ label: "Books", path: "/books.html" },
|
||||||
|
{ label: "Rewards", path: "/rewards.html" },
|
||||||
|
{ label: "Psionics", path: "/psionics.html" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Utilities",
|
||||||
|
items: [
|
||||||
|
{ label: "Name Generator", path: "/names.html" },
|
||||||
|
{ label: "Loot Generator", path: "/lootgen.html" },
|
||||||
|
{ label: "CR Calculator", path: "/crcalculator.html" },
|
||||||
|
{ label: "Converter", path: "/converter.html" },
|
||||||
|
{ label: "Decks", path: "/decks.html" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
items: [
|
||||||
|
{ label: "Manage Homebrew", path: "/managebrew.html" },
|
||||||
|
{ label: "Manage Prerelease", path: "/manageprerelease.html" },
|
||||||
|
{ label: "Blocklist", path: "/blocklist.html" },
|
||||||
|
{ label: "Changelog", path: "/changelog.html" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Help",
|
||||||
|
items: [
|
||||||
|
{ label: "Privacy Policy", path: "/privacy-policy.html" },
|
||||||
|
{ label: "Plutonium", path: "/plutonium.html" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
Reference in new issue
Block a user