Compare commits
6 Commits
bc90a9bca9
...
acce53ce47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acce53ce47 | ||
|
|
72ef29413a | ||
|
|
75ebb39848 | ||
|
|
4c55ff9ffa | ||
|
|
9f71febca0 | ||
|
|
1c571059df |
@@ -1,6 +1,6 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"daisyui": "^5.5.23",
|
||||
"idb": "^8.0.3",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
||||
daisyui:
|
||||
specifier: ^5.5.23
|
||||
version: 5.5.23
|
||||
idb:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
|
||||
@@ -739,6 +742,9 @@ packages:
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
idb@8.0.3:
|
||||
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||
|
||||
is-what@5.5.0:
|
||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1522,6 +1528,8 @@ snapshots:
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
idb@8.0.3: {}
|
||||
|
||||
is-what@5.5.0: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
VueFlow,
|
||||
type XYPosition,
|
||||
} from "@vue-flow/core"
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted } from "vue"
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from "vue"
|
||||
import { useAutosave } from "@/composables/useAutosave"
|
||||
import { parseModel, serializeModel } from "@/model/io"
|
||||
import { project } from "@/model/projection"
|
||||
import { type Sample, SAMPLES } from "@/model/samples"
|
||||
import { canConnect } from "@/model/validation"
|
||||
@@ -62,6 +64,10 @@ const {
|
||||
fitView,
|
||||
} = useVueFlow("meadows")
|
||||
|
||||
// Restore the last document on mount and persist every change (F7). Fit the
|
||||
// view once a restored model has re-projected so it lands framed.
|
||||
useAutosave({ onRestore: () => fitView({ padding: 0.2 }) })
|
||||
|
||||
onNodeDragStart(() => store.beginInteraction())
|
||||
onNodeDragStop(({ nodes: dragged }) => {
|
||||
for (const node of dragged) store.moveNode(node.id, node.position)
|
||||
@@ -180,6 +186,54 @@ async function loadSample(sample: Sample): Promise<void> {
|
||||
fitView({ padding: 0.2 })
|
||||
}
|
||||
|
||||
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
|
||||
|
||||
/** A filesystem-safe stem from the model name, e.g. "Coffee cooling" → "coffee-cooling". */
|
||||
function fileStem(name: string): string {
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return slug || "model"
|
||||
}
|
||||
|
||||
/** Export the current Model as versioned JSON download (F8). */
|
||||
function exportModel(): void {
|
||||
const blob = new Blob([serializeModel(store.model)], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = url
|
||||
anchor.download = `${fileStem(store.model.name)}.json`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** Import a Model from a JSON file: validate, confirm a destructive replace, load (F8). */
|
||||
async function onImportFile(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// Reset first so picking the same file again still fires `change`.
|
||||
input.value = ""
|
||||
if (!file) return
|
||||
|
||||
const result = parseModel(await file.text())
|
||||
if (!result.ok) {
|
||||
window.alert(`Couldn't import “${file.name}”: ${result.error}`)
|
||||
return
|
||||
}
|
||||
// Importing replaces the document and clears undo (T6 / F10×F7), so guard real work.
|
||||
if (
|
||||
store.nodeCount > 0 &&
|
||||
!window.confirm(`Replace the current model with “${result.model.name}”?`)
|
||||
) {
|
||||
return
|
||||
}
|
||||
store.setModel(result.model)
|
||||
await nextTick()
|
||||
fitView({ padding: 0.2 })
|
||||
}
|
||||
|
||||
function isTextEntry(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null
|
||||
return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true
|
||||
@@ -242,6 +296,15 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
class="hidden"
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()">
|
||||
Undo
|
||||
</button>
|
||||
|
||||
83
src/composables/useAutosave.ts
Normal file
83
src/composables/useAutosave.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Autosave (F7) — the binding between the Model store (C5) and the repository
|
||||
* (C9). It restores the last document on mount, then persists every change with
|
||||
* a short debounce so a burst of edits costs one write, well inside the ≤500 ms
|
||||
* budget. The debounce window is the only data-loss risk, so pending edits are
|
||||
* also flushed when the tab is hidden or closed.
|
||||
*
|
||||
* It lives in a composable, not the store: persistence needs component lifecycle
|
||||
* (mount/unmount) and browser globals, which keep the store pure and testable.
|
||||
* The repository is injected (defaulting to IndexedDB) so it stays swappable.
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, onMounted, watch } from "vue"
|
||||
import { createRepository, type ModelRepository } from "@/model/repository"
|
||||
import { useModelStore } from "@/store/model"
|
||||
|
||||
/** Coalesce a burst of edits into one write; comfortably under the 500 ms budget. */
|
||||
const DEBOUNCE_MS = 400
|
||||
|
||||
interface AutosaveOptions {
|
||||
repository?: ModelRepository
|
||||
/** Called once after a saved Model is restored (e.g. to fit the view). */
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
export function useAutosave(options: AutosaveOptions = {}): { flush: () => void } {
|
||||
const repository = options.repository ?? createRepository()
|
||||
const store = useModelStore()
|
||||
// Armed only after the initial load, so restoring a document doesn't echo back
|
||||
// a redundant save of what we just read.
|
||||
let ready = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
if (!ready) return
|
||||
// Plain, proxy-free clone: idb structured-clones on put and would choke on
|
||||
// the Vue reactive proxy (the same reason undo snapshots use JSON).
|
||||
await repository.save(JSON.parse(JSON.stringify(store.model)) as typeof store.model)
|
||||
}
|
||||
|
||||
function schedule(): void {
|
||||
if (timer !== null) clearTimeout(timer)
|
||||
timer = setTimeout(() => void save(), DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (timer !== null) void save()
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
if (document.visibilityState === "hidden") flush()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.model,
|
||||
() => {
|
||||
if (ready) schedule()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = await repository.load()
|
||||
if (saved) store.setModel(saved)
|
||||
// Let the watch tick from setModel pass before arming, so it isn't re-saved.
|
||||
await nextTick()
|
||||
if (saved) options.onRestore?.()
|
||||
ready = true
|
||||
document.addEventListener("visibilitychange", onVisibilityChange)
|
||||
window.addEventListener("beforeunload", flush)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
window.removeEventListener("beforeunload", flush)
|
||||
flush()
|
||||
})
|
||||
|
||||
return { flush }
|
||||
}
|
||||
139
src/model/io.ts
Normal file
139
src/model/io.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Import/Export (C10, F8) — round-trip a Model as versioned JSON.
|
||||
*
|
||||
* Export is just pretty JSON: the Model is contractually plain, serialisable
|
||||
* data (see types.ts), so `serializeModel` is lossless and human-readable.
|
||||
* Import is the trust boundary — a file may be hand-edited, truncated, or from a
|
||||
* future version — so `parseModel` validates shape, types, the schema version,
|
||||
* and referential integrity before handing back a typed Model. It never throws;
|
||||
* it returns a discriminated result so callers can surface a clear message.
|
||||
*
|
||||
* The validator deliberately does not re-implement the full structural guard
|
||||
* (validation.ts): our own exports are valid by construction, and checking that
|
||||
* every reference resolves is enough to keep the projection and loop detector
|
||||
* from tripping over a dangling id.
|
||||
*/
|
||||
import {
|
||||
type InformationLink,
|
||||
type Model,
|
||||
MODEL_VERSION,
|
||||
type ModelNode,
|
||||
type NodeKind,
|
||||
type Polarity,
|
||||
type Position,
|
||||
} from "./types"
|
||||
|
||||
/** Pretty JSON so an exported Model is human-readable and diff-friendly (F8). */
|
||||
export function serializeModel(model: Model): string {
|
||||
return JSON.stringify(model, null, 2)
|
||||
}
|
||||
|
||||
/** The outcome of an import: a validated Model, or a reason it was rejected. */
|
||||
export type ParseResult = { ok: true; model: Model } | { ok: false; error: string }
|
||||
|
||||
const NODE_KINDS: readonly NodeKind[] = ["stock", "flow", "converter", "cloud"]
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function isPosition(value: unknown): value is Position {
|
||||
return isObject(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y)
|
||||
}
|
||||
|
||||
function isPolarity(value: unknown): value is Polarity {
|
||||
return value === "+" || value === "-"
|
||||
}
|
||||
|
||||
/** Validate one node by kind, returning an error string or null when valid. */
|
||||
function nodeError(value: unknown, index: number): string | null {
|
||||
if (!isObject(value)) return `nodes[${index}] is not an object`
|
||||
const at = `nodes[${index}]`
|
||||
if (typeof value.id !== "string" || value.id === "") return `${at}.id must be a non-empty string`
|
||||
if (typeof value.kind !== "string" || !NODE_KINDS.includes(value.kind as NodeKind)) {
|
||||
return `${at}.kind must be one of ${NODE_KINDS.join(", ")}`
|
||||
}
|
||||
if (!isPosition(value.position)) return `${at}.position must be {x, y} numbers`
|
||||
|
||||
const kind = value.kind as NodeKind
|
||||
if (kind !== "cloud" && typeof value.name !== "string") return `${at}.name must be a string`
|
||||
if (kind === "flow") {
|
||||
if (typeof value.source !== "string") return `${at}.source must be a string`
|
||||
if (typeof value.target !== "string") return `${at}.target must be a string`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Validate one Information Link, returning an error string or null when valid. */
|
||||
function linkError(value: unknown, index: number): string | null {
|
||||
if (!isObject(value)) return `infoLinks[${index}] is not an object`
|
||||
const at = `infoLinks[${index}]`
|
||||
if (typeof value.id !== "string" || value.id === "") return `${at}.id must be a non-empty string`
|
||||
if (typeof value.source !== "string") return `${at}.source must be a string`
|
||||
if (typeof value.target !== "string") return `${at}.target must be a string`
|
||||
if (!isPolarity(value.polarity)) return `${at}.polarity must be "+" or "-"`
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate JSON text into a Model. Returns `{ok:false, error}` for
|
||||
* anything that isn't a well-formed, current-version Model with resolvable
|
||||
* references — never throws.
|
||||
*/
|
||||
export function parseModel(text: string): ParseResult {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
return { ok: false, error: "Not valid JSON" }
|
||||
}
|
||||
|
||||
if (!isObject(data)) return { ok: false, error: "Top level must be an object" }
|
||||
if (data.version !== MODEL_VERSION) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unsupported model version ${String(data.version)} (expected ${MODEL_VERSION})`,
|
||||
}
|
||||
}
|
||||
if (typeof data.id !== "string" || data.id === "")
|
||||
return { ok: false, error: "id must be a non-empty string" }
|
||||
if (typeof data.name !== "string") return { ok: false, error: "name must be a string" }
|
||||
if (!Array.isArray(data.nodes)) return { ok: false, error: "nodes must be an array" }
|
||||
if (!Array.isArray(data.infoLinks)) return { ok: false, error: "infoLinks must be an array" }
|
||||
|
||||
for (let i = 0; i < data.nodes.length; i++) {
|
||||
const error = nodeError(data.nodes[i], i)
|
||||
if (error) return { ok: false, error }
|
||||
}
|
||||
for (let i = 0; i < data.infoLinks.length; i++) {
|
||||
const error = linkError(data.infoLinks[i], i)
|
||||
if (error) return { ok: false, error }
|
||||
}
|
||||
|
||||
// References must resolve, or the projection and loop detector would dangle.
|
||||
const nodes = data.nodes as ModelNode[]
|
||||
const ids = new Set(nodes.map((node) => node.id))
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== "flow") continue
|
||||
if (!ids.has(node.source))
|
||||
return { ok: false, error: `flow ${node.id} references missing source ${node.source}` }
|
||||
if (!ids.has(node.target))
|
||||
return { ok: false, error: `flow ${node.id} references missing target ${node.target}` }
|
||||
}
|
||||
const links = data.infoLinks as InformationLink[]
|
||||
for (const link of links) {
|
||||
if (!ids.has(link.source))
|
||||
return { ok: false, error: `link ${link.id} references missing source ${link.source}` }
|
||||
if (!ids.has(link.target))
|
||||
return { ok: false, error: `link ${link.id} references missing target ${link.target}` }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
model: { version: MODEL_VERSION, id: data.id, name: data.name, nodes, infoLinks: links },
|
||||
}
|
||||
}
|
||||
48
src/model/repository.ts
Normal file
48
src/model/repository.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Model repository (C9, F7) — where the working document is persisted locally.
|
||||
*
|
||||
* IndexedDB (via `idb`) sits behind a tiny `ModelRepository` interface so the
|
||||
* storage engine is no longer a hard-to-reverse choice (T4): a future sync
|
||||
* backend (PouchDB, an API) is a new implementation behind the same two methods,
|
||||
* with no caller change. Phase 1 is single-document, so the current Model is
|
||||
* stored under one fixed key; a model list (C12) would key by `model.id` instead.
|
||||
*/
|
||||
import { type IDBPDatabase, openDB } from "idb"
|
||||
import type { Model } from "./types"
|
||||
|
||||
export interface ModelRepository {
|
||||
/** Persist the current Model, overwriting the previous autosave. */
|
||||
save(model: Model): Promise<void>
|
||||
/** Load the autosaved Model, or null if nothing has been saved yet. */
|
||||
load(): Promise<Model | null>
|
||||
}
|
||||
|
||||
const DB_NAME = "meadows"
|
||||
const DB_VERSION = 1
|
||||
const STORE = "models"
|
||||
const CURRENT_KEY = "current"
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase> | null = null
|
||||
|
||||
function db(): Promise<IDBPDatabase> {
|
||||
// Memoise the open so we pay the handshake once, not per save.
|
||||
dbPromise ??= openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains(STORE)) database.createObjectStore(STORE)
|
||||
},
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
/** The default IndexedDB-backed repository used by the app. */
|
||||
export function createRepository(): ModelRepository {
|
||||
return {
|
||||
async save(model: Model): Promise<void> {
|
||||
await (await db()).put(STORE, model, CURRENT_KEY)
|
||||
},
|
||||
async load(): Promise<Model | null> {
|
||||
const model = await (await db()).get(STORE, CURRENT_KEY)
|
||||
return (model as Model | undefined) ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user