Compare commits

..
4 Commits
10 changed files with 12337 additions and 175 deletions

No files matched your search

+8 -1
View File
@@ -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
+796
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
}); });
+7075 -34
View File
File diff suppressed because it is too large. Load diff
+2195 -90
View File
File diff suppressed because it is too large. Load diff
+179
View File
@@ -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
View File
@@ -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" }
] ]
} }
]; ];