Portrait upload was broken: it wrote to Data/worlds/<world>/uploads (per-world, missing the Data/ segment) and did not URL-encode. Fixed to the verified convention -- copy to the GLOBAL Data/uploads/<world>/ dir (unencoded on disk), flag stored URL-encoded as uploads/<world>/<encodeURIComponent(file)>. Extracted a reusable uploadImageAsset helper. Body ![[image]] embeds were dropped by mdToHtml. Added push-layer pre-processing (processBodyImages in push.ts): upload co-located files, rewrite the embed to , drop non-local refs gracefully. mdToHtml renders  -> <img>. parseBody now returns a preface (preamble content besides the tagline) so a loose image between the tagline and the first ## section is no longer dropped before conversion. scripts/ helpers for the live-relay path (no offline CC export needed): - seed-by-name: link refined notes to Foundry by name -> uuid, inject foundry block - import-missing: create Foundry entries with no vault note as new pushable notes - gen-cc-dir: generate a cc dir from a journal snapshot so the dashboard ui can run - resync: push every body-changed note and baseline its foundry.contentHash so re-runs only catch new edits (idempotent, re-runnable) Verified end-to-end against the dev Foundry world: portraits + body images served by Foundry, bidirectional round-trip faithful, push is no-clobber (diff keys only name + flags.campaign-codex). Co-Authored-By: Claude <noreply@anthropic.com>
141 lines
5.8 KiB
TypeScript
141 lines
5.8 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { rm, mkdir, writeFile, access } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { uploadPortrait, findPortraitFile, portraitBasename } from "../src/foundry/assets.js";
|
|
|
|
const TMP = "/tmp/test-assets-out";
|
|
const WORLD = "mardonar";
|
|
|
|
async function mk(p: string): Promise<void> { await mkdir(p, { recursive: true }); }
|
|
|
|
beforeEach(async () => { await rm(TMP, { recursive: true, force: true }); await mk(TMP); });
|
|
afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
|
|
|
|
describe("portraitBasename", () => {
|
|
it("strips [[ ]] wrappers", () => {
|
|
expect(portraitBasename("[[Fenris_portrait.png]]")).toBe("Fenris_portrait.png");
|
|
expect(portraitBasename("Fenris_portrait.png")).toBe("Fenris_portrait.png");
|
|
expect(portraitBasename("")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("findPortraitFile", () => {
|
|
it("finds a co-located sibling by exact name", async () => {
|
|
const noteDir = join(TMP, "notes");
|
|
await mk(noteDir);
|
|
const notePath = join(noteDir, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
await writeFile(join(noteDir, "Fenris_portrait.png"), "png-bytes");
|
|
expect(await findPortraitFile(notePath, "[[Fenris_portrait.png]]")).toBe(join(noteDir, "Fenris_portrait.png"));
|
|
});
|
|
|
|
it("tries common extensions when the field has none", async () => {
|
|
const noteDir = join(TMP, "notes");
|
|
await mk(noteDir);
|
|
const notePath = join(noteDir, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
await writeFile(join(noteDir, "Fenris.webp"), "webp");
|
|
expect(await findPortraitFile(notePath, "Fenris")).toBe(join(noteDir, "Fenris.webp"));
|
|
});
|
|
|
|
it("returns null when no sibling matches", async () => {
|
|
const notePath = join(TMP, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
expect(await findPortraitFile(notePath, "[[Missing.png]]")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("uploadPortrait", () => {
|
|
it("keeps the existing image when the basename matches (no write)", async () => {
|
|
const noteDir = join(TMP, "notes");
|
|
await mk(noteDir);
|
|
const notePath = join(noteDir, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
const res = await uploadPortrait({
|
|
notePath, portraitField: "[[Fenris_portrait.png]]",
|
|
foundryDataDir: join(TMP, "data"), world: WORLD,
|
|
existingImage: "uploads/mardonar/Fenris_portrait.png",
|
|
});
|
|
expect(res.wrote).toBe(false);
|
|
expect(res.servedPath).toBe("uploads/mardonar/Fenris_portrait.png");
|
|
// Nothing written to the assets dir.
|
|
await expect(access(join(TMP, "data"))).rejects.toThrow();
|
|
});
|
|
|
|
it("uploads a new portrait into the global Data/uploads/<world>/ dir (URL-encoded served path)", async () => {
|
|
const noteDir = join(TMP, "notes");
|
|
await mk(noteDir);
|
|
const notePath = join(noteDir, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
await writeFile(join(noteDir, "NewPortrait.png"), "png-bytes");
|
|
const dataDir = join(TMP, "data");
|
|
const res = await uploadPortrait({
|
|
notePath, portraitField: "[[NewPortrait.png]]",
|
|
foundryDataDir: dataDir, world: WORLD,
|
|
existingImage: "uploads/mardonar/old.webp",
|
|
});
|
|
expect(res.wrote).toBe(true);
|
|
expect(res.servedPath).toBe("uploads/mardonar/NewPortrait.png");
|
|
// File lands in the GLOBAL uploads dir: <dataDir>/Data/uploads/<world>/<file> (unencoded on disk).
|
|
const disk = join(dataDir, "Data", "uploads", WORLD, "NewPortrait.png");
|
|
await access(disk); // throws if missing
|
|
});
|
|
|
|
it("URL-encodes spaces in the served path (disk file stays unencoded)", async () => {
|
|
const noteDir = join(TMP, "notes");
|
|
await mk(noteDir);
|
|
const notePath = join(noteDir, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
await writeFile(join(noteDir, "Kara Fairhaven_port.png"), "png-bytes");
|
|
const dataDir = join(TMP, "data");
|
|
const res = await uploadPortrait({
|
|
notePath, portraitField: "[[Kara Fairhaven_port.png]]",
|
|
foundryDataDir: dataDir, world: WORLD, existingImage: null,
|
|
});
|
|
expect(res.wrote).toBe(true);
|
|
expect(res.servedPath).toBe("uploads/mardonar/Kara%20Fairhaven_port.png");
|
|
// Disk file is unencoded (space, not %20).
|
|
await access(join(dataDir, "Data", "uploads", WORLD, "Kara Fairhaven_port.png"));
|
|
});
|
|
|
|
it("defaults to uploads/<world>/ when there is no existing image", async () => {
|
|
const noteDir = join(TMP, "notes");
|
|
await mk(noteDir);
|
|
const notePath = join(noteDir, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
await writeFile(join(noteDir, "Fenris_portrait.png"), "png-bytes");
|
|
const dataDir = join(TMP, "data");
|
|
const res = await uploadPortrait({
|
|
notePath, portraitField: "[[Fenris_portrait.png]]",
|
|
foundryDataDir: dataDir, world: WORLD, existingImage: null,
|
|
});
|
|
expect(res.wrote).toBe(true);
|
|
expect(res.servedPath).toBe(`uploads/${WORLD}/Fenris_portrait.png`);
|
|
});
|
|
|
|
it("returns the existing image when the portrait file is missing", async () => {
|
|
const noteDir = join(TMP, "notes");
|
|
await mk(noteDir);
|
|
const notePath = join(noteDir, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
const res = await uploadPortrait({
|
|
notePath, portraitField: "[[Ghost.png]]",
|
|
foundryDataDir: join(TMP, "data"), world: WORLD,
|
|
existingImage: "uploads/mardonar/old.webp",
|
|
});
|
|
expect(res.wrote).toBe(false);
|
|
expect(res.servedPath).toBe("uploads/mardonar/old.webp");
|
|
expect(res.note).toContain("not found");
|
|
});
|
|
|
|
it("no portrait field -> keeps existing, no write", async () => {
|
|
const notePath = join(TMP, "Fenris.md");
|
|
await writeFile(notePath, "x");
|
|
const res = await uploadPortrait({
|
|
notePath, portraitField: null,
|
|
foundryDataDir: join(TMP, "data"), world: WORLD, existingImage: "uploads/mardonar/old.webp",
|
|
});
|
|
expect(res.wrote).toBe(false);
|
|
expect(res.servedPath).toBe("uploads/mardonar/old.webp");
|
|
});
|
|
}); |