diff --git a/docs/architecture.md b/docs/architecture.md index d68f8fe..9d6f6d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,14 +48,17 @@ Coverage). Всё остальное в `core/**` — чистые функци Страница исполняет ровно один шаг: загрузил файл → применила инструмент → скачала результат. Отсутствуют: -- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для - него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг, - а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из - полей двух инструментов — отдельная работа, описанная как фаза 11 - `docs/roadmap.md`. Под неё зарезервированы - `components/display/StepCard.svelte` (сейчас импортируется только витриной - `/kit`) и ключи `chain.*`, `errors.badPipelineShape`, - `errors.pipelineVersion`, `errors.noSteps`. +- **Пайплайн / цепочки шагов** — реализованы: модуль шагов + `web/src/lib/pipeline.svelte.ts` (createChain/parseChain/serializeSteps), + именованные цепочки — `web/src/lib/chains.svelte.ts` (localStorage + `chains:named`). Страница инструмента всегда открывается чистой; при первом + структурном изменении (добавлен шаг, заменён первый инструмент) цепочка + автоматически становится именованной (имя — дата/время) и маршрут бесшовно + меняется на `/pipeline?id=` (передача исходника через handoff-стор, + replaceState). `/chains` — список цепочек (переименовать/удалить/открыть/ + создать). Легаси-автосейв `pipeline:` снят без миграции (ключи просто + перестают читаться). Экспортный JSON-формат не устоялся и может меняться до + фазы 11 `docs/roadmap.md`. - **Разделения страниц, которые делят один инструмент** — сводит все convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока `registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а diff --git a/docs/backlog.md b/docs/backlog.md index e14f1b3..c7abebd 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -18,16 +18,6 @@ - [ ] посмотреть реализацию flip animation svelte для анимации переключения режима aligned\normal - [ ] переделать визуал toggle компонента (которым режим переключается) -- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M. - Именованные цепочки в localStorage (список, создать/переименовать/ - удалить), быстрое переключение. Формат экспортного JSON расширить - опциональным полем имени; обратной совместимости со старыми выгрузками не - делаем — формат не устоялся: он появится вместе с фазой 11 - `docs/roadmap.md` и до её старта может измениться. Плюс отдельная страница - со списком всех цепочек: удалить, переименовать, добавить новую, - «использовать» (переход на страницу инструмента с этой цепочкой). На - странице — упрощённый вид: только список инструментов цепочки, без - реального выполнения и параметров. - [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив картинок (распаковка в браузере), прогон текущего или выбранного сохранённого chain по каждому файлу, сборка результата обратно в zip для diff --git a/web/src/lib/chains.svelte.ts b/web/src/lib/chains.svelte.ts new file mode 100644 index 0000000..acecf21 --- /dev/null +++ b/web/src/lib/chains.svelte.ts @@ -0,0 +1,153 @@ +import { formatStamp } from "./core/datefmt"; +import type { OutputMime } from "./core/io"; +import type { PixelImage } from "./core/types"; +import { t } from "./i18n/t"; +import { + CHAIN_VERSION, + parseChain, + serializeSteps, + type ChainStep, +} from "./pipeline.svelte"; + +export interface NamedChain { + id: string; + name: string; + updatedAt: number; + steps: ChainStep[]; +} + +const STORE_KEY = "chains:named"; + +function storage(): Storage | null { + return typeof localStorage === "undefined" ? null : localStorage; +} + +export function autoChainName(date = new Date()): string { + return t("savedChains.autoName", { + stamp: formatStamp(date, "YYYY-MM-DD hh:mm"), + }); +} + +/** + * Lenient per entry: one broken chain must not kill the rest of the list. + * Steps reuse the same serialized shape and validation as parseChain. + */ +export function parseNamedChains(data: unknown): NamedChain[] { + if ( + !data || + typeof data !== "object" || + (data as { version?: unknown }).version !== CHAIN_VERSION || + !Array.isArray((data as { chains?: unknown }).chains) + ) { + return []; + } + const out: NamedChain[] = []; + for (const raw of (data as { chains: unknown[] }).chains) { + if (!raw || typeof raw !== "object") continue; + const { id, name, updatedAt, steps } = raw as { + id?: unknown; + name?: unknown; + updatedAt?: unknown; + steps?: unknown; + }; + if (typeof id !== "string" || typeof name !== "string") continue; + const parsed = parseChain({ version: CHAIN_VERSION, steps }); + if (!parsed) continue; + out.push({ + id, + name, + updatedAt: + typeof updatedAt === "number" && Number.isFinite(updatedAt) + ? updatedAt + : 0, + steps: parsed, + }); + } + return out; +} + +export function serializeNamedChains( + chains: NamedChain[], +): Record { + return { + version: CHAIN_VERSION, + chains: chains.map((c) => ({ + id: c.id, + name: c.name, + updatedAt: c.updatedAt, + steps: serializeSteps(c.steps), + })), + }; +} + +function readAll(): NamedChain[] { + const raw = storage()?.getItem(STORE_KEY); + if (!raw) return []; + try { + return parseNamedChains(JSON.parse(raw)); + } catch { + return []; + } +} + +function writeAll(chains: NamedChain[]): void { + storage()?.setItem(STORE_KEY, JSON.stringify(serializeNamedChains(chains))); +} + +/** Newest first. */ +export function listChains(): NamedChain[] { + return readAll().sort((a, b) => b.updatedAt - a.updatedAt); +} + +export function getChain(id: string): NamedChain | undefined { + return readAll().find((c) => c.id === id); +} + +export function createNamedChain(name: string, steps: ChainStep[]): NamedChain { + const chain: NamedChain = { + id: crypto.randomUUID(), + name: name.trim() || autoChainName(), + updatedAt: Date.now(), + steps, + }; + writeAll([...readAll(), chain]); + return chain; +} + +export function updateChainSteps(id: string, steps: ChainStep[]): void { + writeAll( + readAll().map((c) => + c.id === id ? { ...c, steps, updatedAt: Date.now() } : c, + ), + ); +} + +export function renameChain(id: string, name: string): void { + const trimmed = name.trim(); + if (!trimmed) return; + writeAll(readAll().map((c) => (c.id === id ? { ...c, name: trimmed } : c))); +} + +export function deleteChain(id: string): void { + writeAll(readAll().filter((c) => c.id !== id)); +} + +/** State that cannot persist across the tool -> pipeline route swap. */ +export interface ChainHandoff { + source: PixelImage | null; + textSource: string; + format: OutputMime; + limitKb: number | undefined; +} + +const handoffs = new Map(); + +export function stashHandoff(id: string, handoff: ChainHandoff): void { + handoffs.set(id, handoff); +} + +export function takeHandoff(id: string): ChainHandoff | undefined { + const handoff = handoffs.get(id); + handoffs.delete(id); + return handoff; +} diff --git a/web/src/lib/chains.test.ts b/web/src/lib/chains.test.ts new file mode 100644 index 0000000..2083148 --- /dev/null +++ b/web/src/lib/chains.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + autoChainName, + parseNamedChains, + serializeNamedChains, +} from "./chains.svelte"; +import { + CHAIN_VERSION, + createChain, + isStructuralDefault, +} from "./pipeline.svelte"; +import { getPageBySlug } from "./registry"; + +const cropPage = getPageBySlug("crop-png")!; + +describe("named chains: parse/serialize round-trip", () => { + const chain = { + id: "c1", + name: "My chain", + updatedAt: 123, + steps: createChain(cropPage), + }; + + it("round-trips a valid chain", () => { + const data = serializeNamedChains([chain]); + const parsed = parseNamedChains(JSON.parse(JSON.stringify(data))); + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatchObject({ id: "c1", name: "My chain" }); + expect(parsed[0].steps[0].id).toBe("crop"); + }); + + it("drops a broken entry, keeps the rest", () => { + const good = JSON.parse(JSON.stringify(serializeNamedChains([chain]))) as { + chains: unknown[]; + }; + const data = { + version: CHAIN_VERSION, + chains: [ + good.chains[0], + { id: "bad", name: "Broken", steps: [{ id: "nope" }] }, + { nope: true }, + ], + }; + const parsed = parseNamedChains(data); + expect(parsed.map((c) => c.id)).toEqual(["c1"]); + }); + + it.each([ + ["junk", "just a string"], + ["no version", { chains: [] }], + ["other version", { version: 99, chains: [] }], + ])("invalid store -> empty list (%s)", (_name, data) => { + expect(parseNamedChains(data)).toEqual([]); + }); +}); + +describe("isStructuralDefault", () => { + it("true for the untouched page chain", () => { + expect(isStructuralDefault(cropPage, createChain(cropPage))).toBe(true); + }); + + it("false when a step is added or the first tool is swapped", () => { + const grown = [...createChain(cropPage), ...createChain(cropPage)]; + expect(isStructuralDefault(cropPage, grown)).toBe(false); + const swapped = createChain(cropPage); + swapped[0] = { ...swapped[0], id: "resize" }; + expect(isStructuralDefault(cropPage, swapped)).toBe(false); + }); +}); + +describe("autoChainName", () => { + it("embeds the formatted stamp", () => { + const name = autoChainName(new Date(2026, 9, 8, 14, 35)); + expect(name).toContain("2026-10-08 14:35"); + }); +}); diff --git a/web/src/lib/components/layout/TopNav.svelte b/web/src/lib/components/layout/TopNav.svelte index efabbc9..22dabcb 100644 --- a/web/src/lib/components/layout/TopNav.svelte +++ b/web/src/lib/components/layout/TopNav.svelte @@ -6,6 +6,7 @@ diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index 88eddfc..675f999 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -1,5 +1,9 @@ {#if schema} @@ -490,19 +544,34 @@

{pageTitle(page)} - + {#if !chainId} + + {/if}

-

{pageDescription(page)}

+ {#if pageDescription(page)} +

{pageDescription(page)}

+ {/if}
+ {#if !chainId} +