From 19f86b4b60b128c1fbb422b46b35d0dcb33fe432 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:55:25 +0900 Subject: [PATCH 1/2] Journal: postcard composer saves the text an export gives back, and reports photo errors The composer stored title, text, folder, tags and captions as typed, while the portable-file schema sanitizes them on export and import. A control character, a bidi override or a leading formula character came back changed from the first export or sync, and a tag that sanitizes to nothing ("-") made every export fail validation. Picking photos also lost the old composer's toasts: an unreadable image failed silently and photos past the per-postcard limit were dropped without a word. --- .../src/features/journal/StoryComposer.tsx | 38 ++++++-- .../tests/unit/storyComposer.spec.tsx | 91 +++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 apps/postcards/tests/unit/storyComposer.spec.tsx diff --git a/apps/postcards/src/features/journal/StoryComposer.tsx b/apps/postcards/src/features/journal/StoryComposer.tsx index e6f54b0..9c96f54 100644 --- a/apps/postcards/src/features/journal/StoryComposer.tsx +++ b/apps/postcards/src/features/journal/StoryComposer.tsx @@ -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"; @@ -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( @@ -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(""); @@ -247,6 +254,12 @@ 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, { @@ -254,15 +267,26 @@ export function StoryComposer({ storyId, onClose }: { storyId: string | null; on 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(); diff --git a/apps/postcards/tests/unit/storyComposer.spec.tsx b/apps/postcards/tests/unit/storyComposer.spec.tsx new file mode 100644 index 0000000..f4519cc --- /dev/null +++ b/apps/postcards/tests/unit/storyComposer.spec.tsx @@ -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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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); + }); +}); From 717533a6993aea2d8af6b5128f2f09bf67208e33 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:08:42 +0900 Subject: [PATCH 2/2] Backup: a stored tag that sanitizes away no longer blocks export and sync A postcard saved by the composer before it cleaned tags can hold one such as "-" or a lone bidi mark. The schema rejects a tag that sanitizes to nothing, so buildFile's self-validation threw and every backup and sync of that device failed. Drop such tags before validating, as empty photo arrays already are. --- .../src/features/backup/exportJson.ts | 13 ++++++++- apps/postcards/tests/unit/backup.spec.ts | 27 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/postcards/src/features/backup/exportJson.ts b/apps/postcards/src/features/backup/exportJson.ts index f3afaf9..a9d70ef 100644 --- a/apps/postcards/src/features/backup/exportJson.ts +++ b/apps/postcards/src/features/backup/exportJson.ts @@ -10,6 +10,8 @@ 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(rec: T): T | Omit { @@ -17,6 +19,15 @@ function dropEmptyPhotos(rec: T): T | Omit 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. */ @@ -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, }; diff --git a/apps/postcards/tests/unit/backup.spec.ts b/apps/postcards/tests/unit/backup.spec.ts index bcf1be2..b54c046 100644 --- a/apps/postcards/tests/unit/backup.spec.ts +++ b/apps/postcards/tests/unit/backup.spec.ts @@ -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 { @@ -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); + }); +});