diff --git a/docs/tips/fast-setup.md b/docs/tips/fast-setup.md index e570ed5e..031e922f 100644 --- a/docs/tips/fast-setup.md +++ b/docs/tips/fast-setup.md @@ -68,6 +68,49 @@ Once you confirm your choices: Fast Setup records the last successfully stored remote position as it saves documents. A transient connection interruption is retried automatically from that position. The operation reports completion only after the captured remote state has been stored successfully. -If an error while decrypting data, reading the remote response, or writing to the local database stops the operation, LiveSync does not run the Vault scan or finalise the reconstructed local database. It retains the saved position, the selected data-processing method, and the fetch flag. File watching and database reflection also remain suspended so that a partly reconstructed database cannot be applied to the Vault or combined with new local changes. +If an error while decrypting data, reading the remote response, or writing to the local database stops the operation, LiveSync does not run the Vault scan or finalise the reconstructed local database. It retains the saved position and the fetch flag. Any valid stored choices remain available for confirmation on restart. File watching and database reflection also remain suspended so that a partly reconstructed database cannot be applied to the Vault or combined with new local changes. Review the first specific error in **Show log**, correct its cause, then restart Obsidian to retry from the retained state. While you intend to continue the operation, do not remove the fetch flag or manually resume the Scram switches. If the same error remains after a restart, leave LiveSync suspended, preserve the available data, and [collect a report](../troubleshooting.md#collect-a-report). + + +## Remembered choices in the fetch flag + +Fast Setup stores its choices in a `json` code block inside `flag_fetch.md` (or +`redflag3.md` when that is the only fetch flag). The file remains Markdown and +can be opened and edited in Obsidian. Explanatory text outside the state block +is preserved when the choices are updated. The entire flag file is temporary: +it is removed when the operation completes or is cancelled. + +After an interruption, a restart shows the current remote and the saved +choices. We can continue with those choices, choose again, or cancel the +scheduled Fetch. Saved choices are never applied without confirmation, even +when the previous Fetch completed but its flag could not be removed. + +Starting a new Join or scheduling another Fetch replaces `flag_fetch.md` with +an empty flag and asks for new choices. If both filenames exist, `flag_fetch.md` +takes precedence, including when it is empty; completion removes both aliases. + +The generated block has `type: "livesync-fast-setup"`, `version: 1`, and the two +stable English choice values (`stage1` and `stage2`). It stores no connection +credentials. Only recognised combinations of the existing choices are accepted. +The confirmation applies the choices to the currently selected remote, +including after changing P2P connection settings. + +An empty flag starts the normal choice dialogues. If a non-empty flag has no +readable supported selection, the plug-in asks whether to choose new options +and continue. This includes malformed JSON, unsupported versions, duplicate +state blocks, and incomplete Markdown fences. Continuing leaves the file +contents unchanged and uses the new choices only for that attempt. Another +restart asks again; the plug-in does not repair the Markdown or append a +replacement block. Completing or cancelling the Fetch removes the entire flag, +including any explanatory text. + +A storage read or write failure stops the operation; it is not treated as an +empty flag. If flag deletion fails at the end, file watching and database +reflection remain suspended until the failure is resolved. A remaining flag +will ask for confirmation again on restart. + +Older versions stored choices separately in device-local storage. Those values +are no longer reused because they may have survived a completed Fetch. After +updating during an interrupted operation, confirm the choices once more. This +change does not itself remove downloaded data or the Fast Fetch checkpoint. diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 2b7588c7..4e203afd 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -398,6 +398,15 @@ export const liveSyncProvisionalEnglishMessages = { "Save report": "Save report", "The inspection could not finish. Close this dialogue and try again after database startup.": "The inspection could not finish. Close this dialogue and try again after database startup.", + "Resume scheduled fetch?": "Resume scheduled fetch?", + "Apply these saved choices to the current remote?\n\nCurrent remote: ${REMOTE}\n\n- ${STAGE1}\n- ${STAGE2}\n\nThe fetch flag may remain after an interruption or an incomplete cleanup. Continue only if this fetch is still intended.": + "Apply these saved choices to the current remote?\n\nCurrent remote: ${REMOTE}\n\n- ${STAGE1}\n- ${STAGE2}\n\nThe fetch flag may remain after an interruption or an incomplete cleanup. Continue only if this fetch is still intended.", + "Continue with these choices": "Continue with these choices", + "Choose again": "Choose again", + "Saved fetch choices could not be read": "Saved fetch choices could not be read", + "The fetch flag does not contain one supported selection. Choose new options and continue with the current remote?\n\nCurrent remote: ${REMOTE}\n\nThe flag contents will remain unchanged. The new choices apply only to this attempt; another restart will ask again. Completing or cancelling the fetch removes the flag.": + "The fetch flag does not contain one supported selection. Choose new options and continue with the current remote?\n\nCurrent remote: ${REMOTE}\n\nThe flag contents will remain unchanged. The new choices apply only to this attempt; another restart will ask again. Completing or cancelling the fetch removes the flag.", + "Choose new options and continue": "Choose new options and continue", } as const; export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages; diff --git a/src/serviceFeatures/redFlag.fetchState.ts b/src/serviceFeatures/redFlag.fetchState.ts new file mode 100644 index 00000000..acc3f018 --- /dev/null +++ b/src/serviceFeatures/redFlag.fetchState.ts @@ -0,0 +1,109 @@ +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings"; +import { + REMOTE_COUCHDB, + REMOTE_MINIO, + REMOTE_P2P, +} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import { + FlagFilesHumanReadable, + FlagFilesOriginal, +} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const"; +import { readFetchFlagState, writeFetchFlagState, type FetchFlagState } from "./redFlag.fetchStateMarkdown"; + +type FetchStateHost = NecessaryServices; + +function endpointDescription(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); + } catch { + return ""; + } +} + +/** Describe the current destination for confirmation, without connection credentials. */ +export function describeFetchRemote(settings: ObsidianLiveSyncSettings): string { + switch (settings.remoteType) { + case REMOTE_COUCHDB: + return ["CouchDB", endpointDescription(settings.couchDB_URI), settings.couchDB_DBNAME].join(" / "); + case REMOTE_MINIO: + return [ + "Object Storage", + endpointDescription(settings.endpoint), + settings.region, + settings.bucket, + settings.bucketPrefix, + ] + .filter(Boolean) + .join(" / "); + case REMOTE_P2P: + return [ + "P2P", + settings.P2P_AppID, + settings.P2P_roomID, + ...settings.P2P_relays.split(",").map((relay) => endpointDescription(relay.trim())), + ] + .filter(Boolean) + .join(" / "); + default: + throw new Error("The selected remote type cannot use Fast Setup."); + } +} + +async function readFetchFlag(host: FetchStateHost) { + const storage = host.serviceModules.storageAccess; + // A newly scheduled fetch writes the human-readable flag, even if an older alias remains. + for (const name of [FlagFilesHumanReadable.FETCH_ALL, FlagFilesOriginal.FETCH_ALL]) { + const path = storage.normalisePath(name); + if (await storage.isExists(path)) { + return { path, markdown: await storage.readFileText(path) }; + } + } + throw new Error("The scheduled fetch flag is missing."); +} + +async function writeVerified(host: FetchStateHost, path: string, markdown: string) { + const storage = host.serviceModules.storageAccess; + if (!(await storage.writeFileAuto(path, markdown)) || (await storage.readFileText(path)) !== markdown) { + throw new Error("The Fast Setup choices could not be saved in the fetch flag."); + } +} + +export async function readFetchSelection(host: FetchStateHost): Promise { + const { markdown } = await readFetchFlag(host); + if (!markdown.trim()) return "empty"; + return readFetchFlagState(markdown) ?? "unreadable"; +} + +export async function saveFetchSelection(host: FetchStateHost, stage1: string, stage2: string): Promise { + const { path, markdown } = await readFetchFlag(host); + const state: FetchFlagState = { + type: "livesync-fast-setup", + version: 1, + stage1, + stage2, + }; + const updated = writeFetchFlagState(markdown, state); + if (updated !== markdown) await writeVerified(host, path, updated); +} + +export async function clearFetchSelection(host: FetchStateHost): Promise { + const { path, markdown } = await readFetchFlag(host); + const cleared = writeFetchFlagState(markdown, undefined); + if (cleared !== markdown) await writeVerified(host, path, cleared); +} + +/** Remove both aliases, retaining the authoritative flag until the other alias is gone. */ +export async function deleteFetchFlags(host: NecessaryServices): Promise { + const storage = host.serviceModules.storageAccess; + for (const name of [FlagFilesOriginal.FETCH_ALL, FlagFilesHumanReadable.FETCH_ALL]) { + const path = storage.normalisePath(name); + if (await storage.isExists(path)) await storage.delete(path, true); + if (await storage.isExists(path)) throw new Error(`Could not remove ${name}.`); + } +} diff --git a/src/serviceFeatures/redFlag.fetchState.unit.spec.ts b/src/serviceFeatures/redFlag.fetchState.unit.spec.ts new file mode 100644 index 00000000..9414f767 --- /dev/null +++ b/src/serviceFeatures/redFlag.fetchState.unit.spec.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings"; +import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import { + FlagFilesHumanReadable, + FlagFilesOriginal, +} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const"; +import { + clearFetchSelection, + deleteFetchFlags, + describeFetchRemote, + readFetchSelection, + saveFetchSelection, +} from "./redFlag.fetchState"; +import { readFetchFlagState, writeFetchFlagState } from "./redFlag.fetchStateMarkdown"; +import { + askSimpleFetchMode, + SIMPLE_FETCH_STAGE1_REMOTE_WINS, + SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE, +} from "./redFlag.simpleFetch"; + +const modern = FlagFilesHumanReadable.FETCH_ALL; +const legacy = FlagFilesOriginal.FETCH_ALL; +const stage1 = SIMPLE_FETCH_STAGE1_REMOTE_WINS; +const stage2 = SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE; + +function fixture() { + const files = new Map([[modern, ""]]); + const settings = { ...DEFAULT_SETTINGS, couchDB_URI: "https://example.invalid", couchDB_DBNAME: "vault" }; + const storage = { + normalisePath: (path: string) => path, + isExists: vi.fn(async (path: string) => files.has(path)), + readFileText: vi.fn(async (path: string) => { + if (!files.has(path)) throw new Error("missing file"); + return files.get(path)!; + }), + writeFileAuto: vi.fn(async (path: string, value: string) => { + files.set(path, value); + return true; + }), + delete: vi.fn(async (path: string) => { + files.delete(path); + }), + }; + const confirm = vi.fn(async (_title: string, _message: string, buttons: string[]) => buttons[1]); + const host = { + services: { setting: { currentSettings: () => settings }, UI: { confirm: { confirmWithMessage: confirm } } }, + serviceModules: { storageAccess: storage }, + } as any; + return { host, files, settings, storage, confirm }; +} + +describe("Fetch flag state", () => { + it("preserves Markdown and reuses the selection independently of the database suffix", async () => { + const { host, files, settings } = fixture(); + await saveFetchSelection(host, stage1, stage2); + const prose = "# My recovery notes\n\nKeep this explanation.\n\n"; + files.set(modern, prose + files.get(modern)!); + await saveFetchSelection(host, stage1, stage2); + expect(files.get(modern)).toContain(prose); + settings.additionalSuffixOfDatabaseName = "changed-by-rebuild"; + await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 }); + await clearFetchSelection(host); + expect(files.get(modern)).toContain(prose); + expect(readFetchFlagState(files.get(modern)!)).toBeUndefined(); + }); + + it("reads and updates the legacy filename when it is the only flag", async () => { + const { host, files } = fixture(); + files.delete(modern); + files.set(legacy, ""); + await saveFetchSelection(host, stage1, stage2); + await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 }); + expect(files.has(modern)).toBe(false); + await deleteFetchFlags(host); + expect(files.size).toBe(0); + }); + + it("treats a new empty canonical flag as a new operation even when a legacy selection remains", async () => { + const { host, files, confirm } = fixture(); + await saveFetchSelection(host, stage1, stage2); + files.set(legacy, files.get(modern)!); + files.set(modern, ""); + await askSimpleFetchMode(host); + expect(confirm).toHaveBeenCalledTimes(2); + await deleteFetchFlags(host); + expect(files.size).toBe(0); + }); + + it("does not fall back to the legacy flag when reading the canonical flag fails", async () => { + const { host, files, storage } = fixture(); + files.set(legacy, ""); + storage.readFileText.mockRejectedValueOnce(new Error("read failed")); + await expect(readFetchSelection(host)).rejects.toThrow("read failed"); + expect(storage.readFileText).toHaveBeenCalledOnce(); + }); + + it("does not create an unsolicited fetch flag when none exists", async () => { + const { host, files, storage } = fixture(); + files.clear(); + await expect(saveFetchSelection(host, stage1, stage2)).rejects.toThrow("flag is missing"); + expect(storage.writeFileAuto).not.toHaveBeenCalled(); + }); + + it("rejects a save when the storage silently retains different content", async () => { + const { host, storage } = fixture(); + storage.writeFileAuto.mockResolvedValueOnce(true); + await expect(saveFetchSelection(host, stage1, stage2)).rejects.toThrow("could not be saved"); + }); + + it("retains the authoritative flag when removing the legacy alias fails", async () => { + const { host, files, storage } = fixture(); + files.set(legacy, ""); + storage.delete.mockRejectedValueOnce(new Error("delete failed")); + await expect(deleteFetchFlags(host)).rejects.toThrow("delete failed"); + expect(files.has(modern)).toBe(true); + expect(storage.delete).toHaveBeenCalledOnce(); + }); + + it("detects a deletion which returned without removing the flag", async () => { + const { host, files, storage } = fixture(); + storage.delete.mockResolvedValueOnce(undefined); + await expect(deleteFetchFlags(host)).rejects.toThrow("Could not remove"); + expect(files.has(modern)).toBe(true); + }); + + it.each([ + { stage1: "unknown", stage2 }, + { stage1, stage2: "Delete local files if deleted on remote" }, + { stage1: "Use the detailed flow", stage2 }, + ])("asks again for an invalid combination: %j", async (choices) => { + const { host, files, storage, confirm } = fixture(); + files.set( + modern, + writeFetchFlagState("", { + type: "livesync-fast-setup", + version: 1, + ...choices, + }) + ); + const original = files.get(modern); + confirm.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]); + await askSimpleFetchMode(host); + expect(confirm).toHaveBeenCalledTimes(3); + expect(files.get(modern)).toBe(original); + expect(storage.writeFileAuto).not.toHaveBeenCalled(); + }); + + it.each([ + { remoteType: "" as const, couchDB_DBNAME: "different-vault" }, + { remoteType: REMOTE_MINIO, endpoint: "https://s3.invalid", bucket: "other-bucket" }, + { remoteType: REMOTE_P2P, P2P_roomID: "other-room" }, + ])("requires explicit confirmation for the currently selected remote: %j", async (remote) => { + const { host, settings, confirm } = fixture(); + await saveFetchSelection(host, stage1, stage2); + Object.assign(settings, remote); + await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 }); + confirm.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]); + await askSimpleFetchMode(host); + expect(confirm).toHaveBeenCalledOnce(); + expect(confirm.mock.calls[0][1]).toContain("Current remote:"); + }); +}); + +describe("Fetch destination description", () => { + it("omits URL credentials, query parameters, fragments, and connection secrets", () => { + const { settings } = fixture(); + Object.assign(settings, { + couchDB_URI: "https://alice:secret@example.invalid/path?token=secret#secret", + couchDB_USER: "alice", + couchDB_PASSWORD: "secret", + jwtKey: "secret", + passphrase: "secret", + additionalSuffixOfDatabaseName: "another-device", + }); + expect(describeFetchRemote(settings)).toBe("CouchDB / https://example.invalid/path / vault"); + }); + + it("shows Object Storage prefixes and P2P rooms without relay credentials", () => { + const { settings } = fixture(); + Object.assign(settings, { + remoteType: REMOTE_MINIO, + endpoint: "https://s3.invalid", + region: "region", + bucket: "vault", + bucketPrefix: "one/", + }); + expect(describeFetchRemote(settings)).toContain("vault / one/"); + Object.assign(settings, { + remoteType: REMOTE_P2P, + P2P_roomID: "group", + P2P_passphrase: "secret", + P2P_relays: "wss://alice:secret@relay.invalid?token=secret,wss://other.invalid", + }); + const description = describeFetchRemote(settings); + expect(description).toContain("group"); + expect(description).toContain("wss://relay.invalid"); + expect(description).toContain("wss://other.invalid"); + expect(description).not.toContain("secret"); + expect(description).not.toContain("alice"); + }); +}); diff --git a/src/serviceFeatures/redFlag.fetchStateMarkdown.ts b/src/serviceFeatures/redFlag.fetchStateMarkdown.ts new file mode 100644 index 00000000..5d7e9400 --- /dev/null +++ b/src/serviceFeatures/redFlag.fetchStateMarkdown.ts @@ -0,0 +1,131 @@ +import MarkdownIt from "markdown-it"; + +export interface FetchFlagState { + type: "livesync-fast-setup"; + version: 1; + stage1: string; + stage2: string; +} + +const STATE_TYPE = "livesync-fast-setup"; +const markdownParser = new MarkdownIt({ html: true }); + +type MarkdownStateBlock = { + start: number; + end: number; + state: FetchFlagState | undefined; +}; + +function parseFetchFlagState(value: unknown): FetchFlagState | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const record = value as Record; + if ( + record.type !== STATE_TYPE || + record.version !== 1 || + typeof record.stage1 !== "string" || + typeof record.stage2 !== "string" + ) { + return undefined; + } + return { + type: STATE_TYPE, + version: 1, + stage1: record.stage1, + stage2: record.stage2, + }; +} + +function getLineStartOffsets(markdown: string): number[] { + const starts = [0]; + for (let index = 0; index < markdown.length; index++) { + if (markdown[index] === "\r") { + if (markdown[index + 1] === "\n") index++; + starts.push(index + 1); + } else if (markdown[index] === "\n") { + starts.push(index + 1); + } + } + return starts; +} + +function isFenceClosed(markdown: string, map: [number, number] | null, markup: string): boolean { + if (!map) return false; + const closingLine = markdown.split(/\r\n|\n|\r/)[map[1] - 1] ?? ""; + const closing = /^ {0,3}(`+|~+)[\t ]*$/.exec(closingLine); + return closing !== null && closing[1][0] === markup[0] && closing[1].length >= markup.length; +} + +function getStateBlocks(markdown: string): MarkdownStateBlock[] | undefined { + const tokens = markdownParser.parse(markdown, {}); + const topLevelFences = tokens.filter((token) => token.type === "fence" && token.level === 0); + if (topLevelFences.some((token) => !isFenceClosed(markdown, token.map, token.markup))) return undefined; + + const starts = getLineStartOffsets(markdown); + const blocks: MarkdownStateBlock[] = []; + for (const token of topLevelFences) { + if (token.info.trim() !== "json") continue; + let parsed: unknown; + try { + parsed = JSON.parse(token.content); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue; + const record = parsed as Record; + if (record.type !== STATE_TYPE) continue; + + if (!token.map) return undefined; + const start = starts[token.map[0]]; + const end = starts[token.map[1]] ?? markdown.length; + if (start === undefined || end < start) return undefined; + blocks.push({ start, end, state: parseFetchFlagState(parsed) }); + } + return blocks; +} + +function getLineEnding(markdown: string): string { + return markdown.match(/\r\n|\n|\r/)?.[0] ?? "\n"; +} + +function formatStateBlock(state: FetchFlagState, lineEnding: string): string { + const json = JSON.stringify( + { + type: state.type, + version: state.version, + stage1: state.stage1, + stage2: state.stage2, + }, + null, + 2 + ); + return `\`\`\`json${lineEnding}${json}${lineEnding}\`\`\``; +} + +function createFastSetupNote(state: FetchFlagState): string { + return [ + "# Fast Setup", + "", + "This file keeps the Fast Setup choices while the fetch is incomplete. It is removed when the fetch completes.", + "", + formatStateBlock(state, "\n"), + ].join("\n"); +} + +export function readFetchFlagState(markdown: string): FetchFlagState | undefined { + const blocks = getStateBlocks(markdown); + if (!blocks || blocks.length !== 1) return undefined; + return blocks[0].state; +} + +export function writeFetchFlagState(markdown: string, state: FetchFlagState | undefined): string { + if (markdown.trim().length === 0) return state ? createFastSetupNote(state) : markdown; + + const blocks = getStateBlocks(markdown); + if (!blocks || blocks.length !== 1 || !blocks[0].state) return markdown; + + const block = blocks[0]; + const originalBlock = markdown.slice(block.start, block.end); + const trailingLineEnding = originalBlock.match(/(?:\r\n|\n|\r)$/)?.[0] ?? ""; + const replacement = state ? formatStateBlock(state, getLineEnding(markdown)) + trailingLineEnding : ""; + return markdown.slice(0, block.start) + replacement + markdown.slice(block.end); +} diff --git a/src/serviceFeatures/redFlag.fetchStateMarkdown.unit.spec.ts b/src/serviceFeatures/redFlag.fetchStateMarkdown.unit.spec.ts new file mode 100644 index 00000000..05beee10 --- /dev/null +++ b/src/serviceFeatures/redFlag.fetchStateMarkdown.unit.spec.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { readFetchFlagState, writeFetchFlagState, type FetchFlagState } from "./redFlag.fetchStateMarkdown"; + +const state: FetchFlagState = { + type: "livesync-fast-setup", + version: 1, + stage1: "remote-only", + stage2: "keep-local", +}; + +function stateBlock(value: unknown = state): string { + return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``; +} + +describe("Fast Setup Markdown state codec", () => { + it("reads a valid top-level JSON state fence", () => { + expect(readFetchFlagState(stateBlock())).toEqual(state); + }); + + it("ignores a legacy remote field", () => { + expect(readFetchFlagState(stateBlock({ ...state, remote: "old-remote-hash" }))).toEqual(state); + }); + + it("creates a Fast Setup note only for an empty or whitespace-only document", () => { + const markdown = writeFetchFlagState("", state); + + expect(markdown).toContain("# Fast Setup"); + expect(markdown).toContain("This file keeps the Fast Setup choices while the fetch is incomplete."); + expect(markdown).toContain("It is removed when the fetch completes."); + expect(readFetchFlagState(markdown)).toEqual(state); + + const whitespace = " \n\t"; + expect(readFetchFlagState(writeFetchFlagState(whitespace, state))).toEqual(state); + expect(writeFetchFlagState(whitespace, undefined)).toBe(whitespace); + }); + + it("updates one readable state while preserving prose and unrelated code fences", () => { + const unrelatedFence = '```json\n{\n "type": "example"\n}\n```'; + const markdown = `Opening note.\n\n${unrelatedFence}\n\n${stateBlock()}\n\nClosing note.`; + const updated = { ...state, stage1: "new-choice" }; + + const result = writeFetchFlagState(markdown, updated); + + expect(result).toContain("Opening note."); + expect(result).toContain(unrelatedFence); + expect(result).toContain("Closing note."); + expect(readFetchFlagState(result)).toEqual(updated); + }); + + it("clears one readable state while preserving surrounding prose", () => { + const markdown = `Before.\n${stateBlock()}\nAfter.`; + + expect(writeFetchFlagState(markdown, undefined)).toBe("Before.\nAfter."); + }); + + it("does not read or write nested code examples", () => { + const example = [ + "> Example state:", + "> ```json", + `> ${JSON.stringify(state)}`, + "> ```", + "", + "````markdown", + "```json", + JSON.stringify(state), + "```", + "````", + ].join("\n"); + + expect(readFetchFlagState(example)).toBeUndefined(); + expect(writeFetchFlagState(example, state)).toBe(example); + expect(writeFetchFlagState(example, undefined)).toBe(example); + }); + + it("does not change plain prose, malformed JSON, or an unsupported version", () => { + const malformed = '```json\n{"type":"livesync-fast-setup",\n```'; + const unsupportedVersion = stateBlock({ ...state, version: 2 }); + + for (const markdown of ["Ordinary prose only.", malformed, unsupportedVersion]) { + expect(readFetchFlagState(markdown)).toBeUndefined(); + expect(writeFetchFlagState(markdown, state)).toBe(markdown); + expect(writeFetchFlagState(markdown, undefined)).toBe(markdown); + } + }); + + it("does not change duplicate state candidates", () => { + const markdown = `Start.\n\n${stateBlock()}\n\n${stateBlock({ ...state, stage1: "second" })}\n\nEnd.`; + expect(readFetchFlagState(markdown)).toBeUndefined(); + expect(writeFetchFlagState(markdown, state)).toBe(markdown); + expect(writeFetchFlagState(markdown, undefined)).toBe(markdown); + }); + + it("does not repair malformed marker blocks", () => { + const malformed = [ + "Before.", + "", + "```json", + "{", + ' "type": "livesync-fast-setup",', + ' "version": 1,', + ' "remote":', + "}", + "```", + "", + "After.", + ].join("\n"); + + expect(readFetchFlagState(malformed)).toBeUndefined(); + expect(writeFetchFlagState(malformed, state)).toBe(malformed); + expect(writeFetchFlagState(malformed, undefined)).toBe(malformed); + }); + + it("does not write state into malformed Markdown without a readable state block", () => { + const markdown = "Opening note.\n\n```text\nThis note remains inside an unclosed fence."; + + expect(readFetchFlagState(markdown)).toBeUndefined(); + expect(writeFetchFlagState(markdown, state)).toBe(markdown); + }); + + it("does not write when a state block is inside an unclosed fence", () => { + const markdown = `Opening note.\n\n\`\`\`json\n${JSON.stringify(state)}`; + + expect(readFetchFlagState(markdown)).toBeUndefined(); + expect(writeFetchFlagState(markdown, state)).toBe(markdown); + }); + + it("does not write into unclosed raw HTML comments and blocks", () => { + const markdownExamples = [ + "Opening note.\n\n