Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion apps/postcards/src/features/backup/exportJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,24 @@ import {
type Visit,
} from "../../lib/schema/models";
import { getReferenceData } from "../../lib/reference/referenceData";
import { MAX_TAG_LEN } from "../../lib/schema/helpers";
import { sanitizeText } from "../../lib/schema/sanitize";

/** Drop an empty `photos` array so a photo-less record stays lean in the file. */
function dropEmptyPhotos<T extends { photos?: unknown[] }>(rec: T): T | Omit<T, "photos"> {
const { photos, ...rest } = rec;
return photos && photos.length ? { ...rest, photos } : rest;
}

/** Drop a stored tag that sanitizes to nothing ("-", a lone bidi mark): the schema
* rejects it, which would block every backup and sync of a device holding one. */
function dropBlankTags(story: Story): Story {
if (!story.tags) return story;
const { tags, ...rest } = story;
const kept = tags.filter((t) => sanitizeText(t, MAX_TAG_LEN));
return kept.length ? { ...rest, tags: kept } : rest;
}

/** Build the canonical portable file object from the current visits + trips + stories.
* `tombstones` is written only for device sync; a plain backup passes none, so the
* exported file stays free of an empty `tombstones` key. */
Expand All @@ -40,7 +51,7 @@ export function buildFile(
// Drop empty `photos` arrays so a photo-less export stays lean and readable.
visits: visits.map(dropEmptyPhotos),
trips,
stories: stories.map(dropEmptyPhotos),
stories: stories.map((s) => dropEmptyPhotos(dropBlankTags(s))),
...(tombstones.length ? { tombstones } : {}),
referenceSources,
};
Expand Down
38 changes: 31 additions & 7 deletions apps/postcards/src/features/journal/StoryComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { useStories } from "../../lib/store/useStories";
import { useVisits } from "../../lib/store/useVisits";
import { useTrips } from "../../lib/store/useTrips";
import { useUi } from "../../lib/store/useUi";
import { placeKey, MAX_PHOTOS_PER_STORY, MAX_TAGS_PER_STORY } from "../../lib/schema/helpers";
import { useToast } from "../../lib/store/useToast";
import { placeKey, MAX_PHOTOS_PER_STORY, MAX_TAGS_PER_STORY, MAX_TAG_LEN } from "../../lib/schema/helpers";
import { sanitizeText } from "../../lib/schema/sanitize";
import type { Photo, PlaceRef } from "../../lib/schema/models";
import { fileToPostcard } from "../../lib/image/downscale";
Expand Down Expand Up @@ -67,6 +68,7 @@ export function StoryComposer({ storyId, onClose }: { storyId: string | null; on
const stories = useStories((s) => s.stories);
const addStory = useStories((s) => s.addStory);
const updateStory = useStories((s) => s.updateStory);
const showToast = useToast((s) => s.show);
const visits = useVisits((s) => s.visits);

const existing = useMemo(
Expand Down Expand Up @@ -227,13 +229,18 @@ export function StoryComposer({ storyId, onClose }: { storyId: string | null; on
added.push({ src: await fileToPostcard(file), caption: null });
}
setPhotos((prev) => [...prev, ...added]);
if (files.length > room) showToast(t("journal.toast.addedRoom", { count: room }));
} catch {
showToast(t("journal.toast.readImgErr"));
} finally {
setBusy(false);
}
}

function addTag(raw: string): void {
const tag = raw.trim();
// The same cleaning the portable file applies, so a tag exports as shown; one
// that cleans away ("-", a lone bidi mark) would make the export invalid.
const tag = sanitizeText(raw, MAX_TAG_LEN);
if (!tag) return;
setTags((prev) => (prev.includes(tag) || prev.length >= MAX_TAGS_PER_STORY ? prev : [...prev, tag]));
setTagInput("");
Expand All @@ -247,22 +254,39 @@ export function StoryComposer({ storyId, onClose }: { storyId: string | null; on
const tripLink = tripId || undefined;
const end = endDate && endDate > date ? endDate : undefined;
const extras = extraPlaces.length ? extraPlaces : undefined;
// Store the SANITIZED text (the transform the portable-file schema applies), so
// an export/import or a sync gives back exactly what was saved.
const cleanTitle = sanitizeText(title, 200);
const cleanText = sanitizeText(text, 8000);
const cleanFolder = sanitizeText(folder, 80);
const cleanPhotos = photos.map((p) => ({ ...p, caption: sanitizeText(p.caption ?? "", 300) || null }));
if (storyId && existing) {
// `place: undefined` with the key present tells the store to CLEAR a removed place.
await updateStory(storyId, {
place: place ?? undefined,
extraPlaces: extras ?? [],
date,
endDate: end,
title,
text,
folder,
photos,
title: cleanTitle,
text: cleanText,
folder: cleanFolder,
photos: cleanPhotos,
tags,
tripId: tripLink,
});
} else {
await addStory({ place, extraPlaces: extras, date, endDate: end, title, text, folder, photos, tags, tripId: tripLink });
await addStory({
place,
extraPlaces: extras,
date,
endDate: end,
title: cleanTitle,
text: cleanText,
folder: cleanFolder,
photos: cleanPhotos,
tags,
tripId: tripLink,
});
}
clearDraft();
if (keepOpen && !storyId) resetToNew();
Expand Down
27 changes: 26 additions & 1 deletion apps/postcards/tests/unit/backup.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { serializeFile } from "../../src/features/backup/exportJson";
import { importFile } from "../../src/features/backup/importJson";
import { normalizeVisitPhotos, type Visit } from "../../src/lib/schema/models";
import { normalizeVisitPhotos, type Story, type Visit } from "../../src/lib/schema/models";

function visit(): Visit {
return {
Expand Down Expand Up @@ -231,3 +231,28 @@ describe("import security (SC-008, Constitution VI)", () => {
if (result.ok) expect(result.visits[0]!.note).toBe("IMPORTXML(evil)");
});
});

describe("postcard tags", () => {
// A composer that stored tags as typed could save one that sanitizes to nothing
// ("-", a lone bidi mark); the file would then fail its own validation.
const story = (tags: string[]): Story => ({
storyId: "s1",
date: "2026-05-02",
title: "Lyon",
text: "",
tags,
addedAt: "2026-05-02T10:00:00.000Z",
});

it("still exports and syncs a postcard holding a tag that sanitizes away, without that tag", () => {
const result = importFile(serializeFile([], [], [story(["sunny", "-", "\u202e"])]));
expect(result.ok).toBe(true);
if (result.ok) expect(result.stories[0]!.tags).toEqual(["sunny"]);
});

it("drops the tags key when no tag survives", () => {
const result = importFile(serializeFile([], [], [story(["-"])]));
expect(result.ok).toBe(true);
if (result.ok) expect("tags" in result.stories[0]!).toBe(false);
});
});
91 changes: 91 additions & 0 deletions apps/postcards/tests/unit/storyComposer.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";

const downscale = vi.hoisted(() => ({ fileToPostcard: vi.fn() }));
vi.mock("../../src/lib/image/downscale", () => downscale);

import { StoryComposer } from "../../src/features/journal/StoryComposer";
import { useStories } from "../../src/lib/store/useStories";
import { useToast } from "../../src/lib/store/useToast";
import { MAX_PHOTOS_PER_STORY } from "../../src/lib/schema/helpers";
import { serializeFile } from "../../src/features/backup/exportJson";
import { importFile } from "../../src/features/backup/importJson";

const JPEG = "data:image/jpeg;base64,AAAA";
const RLO = "‮"; // bidi override
const NUL = "\u0000";

function pickFiles(container: HTMLElement, n: number): void {
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const files = Array.from({ length: n }, (_, i) => new File(["x"], `p${i}.jpg`, { type: "image/jpeg" }));
fireEvent.change(input, { target: { files } });
}

function roundTrip() {
const result = importFile(serializeFile([], [], useStories.getState().stories));
if (!result.ok) throw new Error(result.error);
return result.stories;
}

beforeEach(() => {
localStorage.clear();
useStories.setState({ stories: [], loaded: true });
useToast.setState({ toast: null });
downscale.fileToPostcard.mockReset();
downscale.fileToPostcard.mockResolvedValue(JPEG);
});

describe("StoryComposer save", () => {
it("stores the text an export and import give back unchanged", async () => {
render(<StoryComposer storyId={null} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Title (optional)"), { target: { value: `=Lyon${RLO} trip` } });
fireEvent.change(document.getElementById("story-text")!, { target: { value: `Rain${NUL} all day ` } });
fireEvent.change(document.getElementById("story-folder")!, { target: { value: ` ${RLO}Spring ` } });
fireEvent.click(screen.getByRole("button", { name: "Save postcard" }));
await waitFor(() => expect(useStories.getState().stories).toHaveLength(1));

const stored = useStories.getState().stories[0]!;
expect(stored.title).toBe("Lyon trip");
expect(stored.text).toBe("Rain all day");
expect(stored.folder).toBe("Spring");
expect(roundTrip()).toEqual([stored]);
});

it("stores tags and captions as the schema cleans them, and a tag that cleans away is not kept", async () => {
const { container } = render(<StoryComposer storyId={null} onClose={() => {}} />);
const tagInput = screen.getByPlaceholderText("Add a tag and press Enter…");
for (const tag of [`${RLO}sunny`, "-", "+beach"]) {
fireEvent.change(tagInput, { target: { value: tag } });
fireEvent.keyDown(tagInput, { key: "Enter" });
}
pickFiles(container, 2);
await screen.findByLabelText("Caption for photo 2");
fireEvent.change(screen.getByLabelText("Caption for photo 1"), { target: { value: `@the${RLO} view` } });
fireEvent.change(screen.getByLabelText("Caption for photo 2"), { target: { value: " " } });
fireEvent.click(screen.getByRole("button", { name: "Save postcard" }));
await waitFor(() => expect(useStories.getState().stories).toHaveLength(1));

const stored = useStories.getState().stories[0]!;
expect(stored.tags).toEqual(["sunny", "beach"]);
expect(stored.photos?.map((p) => p.caption)).toEqual(["the view", null]);
expect(roundTrip()).toEqual([stored]);
});
});

describe("StoryComposer photos", () => {
it("says so when an image cannot be read", async () => {
downscale.fileToPostcard.mockRejectedValue(new Error("decode failed"));
const { container } = render(<StoryComposer storyId={null} onClose={() => {}} />);
pickFiles(container, 1);
await waitFor(() => expect(useToast.getState().toast?.message).toBe("Couldn't read that image."));
});

it("says how many fit when more photos are picked than the postcard holds", async () => {
const { container } = render(<StoryComposer storyId={null} onClose={() => {}} />);
pickFiles(container, MAX_PHOTOS_PER_STORY + 3);
await waitFor(() =>
expect(useToast.getState().toast?.message).toBe(`Added ${MAX_PHOTOS_PER_STORY} — the story is now full.`),
);
expect(downscale.fileToPostcard).toHaveBeenCalledTimes(MAX_PHOTOS_PER_STORY);
});
});
Loading