How codeplug projects are saved and restored in the browser.
Tracking: codeplug-tool#9
Codeplug state should survive page reloads without re-importing. Data stays in the browser only — never in the repository.
A versioned projects envelope at a single LocalStorage key:
{
"version": 1,
"activeProjectId": "…",
"projects": [
{
"id": "…",
"name": "Channels",
"createdAt": "2026-06-18T…",
"updatedAt": "2026-06-18T…",
"codeplug": { "channels": [], "zones": [], "…": "…" }
}
]
}Each codeplug object is a full Codeplug (channels, zones, stubs, meta).
| Key | Purpose |
|---|---|
mm9pdy-codeplug-tool.codeplug |
Projects envelope (CODEPLUG_STORAGE_KEY) |
mm9pdy-codeplug-tool.channel-map.mapboxToken |
Mapbox token — separate, map-only |
mm9pdy-codeplug-tool.channel-map.tileProvider |
Tile provider preference — separate |
mm9pdy-codeplug-tool.list.*.{projectId} |
Entity list filters/sort prefs — see UI list table state |
Legacy (migrated on read): channels-list-columns, channels-list-columns-schema.
| Constant | Value | Gates |
|---|---|---|
CODEPLUG_STORAGE_VERSION |
1 |
On-disk envelope shape |
CODEPLUG_SCHEMA_VERSION |
17 |
Inner Codeplug model shape (meta.schemaVersion); older codeplugs migrate on load |
Unknown or future envelope version → boot with an empty project set (no crash).
On the first load of a persisted v6 codeplug, wire names are resolved to id FKs once; subsequent loads at v7+ trust persisted model fields and do not re-resolve from provenance:
Channel.contactName→contactRef(EntityRef | null); wire string preserved inmeta.imported.contactWireNameChannel.rxGroupListName→rxGroupListId; wire string inmeta.imported.rxGroupListWireNameRxGroupListprovenancememberWireNames→memberRefswhen talk groups/contacts are available andmemberRefsis still empty- Dangling legacy wire names become
nullrefs (not errors)
At schema v7 and above, migrateCodeplug normalises shape only — operator edits to contactRef, rxGroupListId, and memberRefs survive reload. See import-export fidelity contract — Provenance is not re-applied on load.
Code: migrateCodeplug · fixture in codeplugStorage.test.ts.
| Action | LocalStorage |
|---|---|
| Import on home (new project) | Save envelope |
| Import on Import & export (active project) | Save |
| Switch active project | Save |
| Delete project | Save (or remove key if last project) |
| Empty active codeplug (project kept) | Save |
| Empty project set | Key removed |
| Corrupt JSON on load | Key removed; boot empty |
| Partially invalid projects | Invalid entries filtered; activeProjectId fixed up |
Code: src/state/codeplugStorage.ts, wired from src/state/codeplugStore.tsx.
saveProjectsToStorage throws StorageQuotaError when setItem hits QuotaExceededError. The store surfaces a dismissible yellow Alert in import UI; in-memory state is not blocked — the user can keep working but may lose data on reload.
- Typical browser limit: ~5 MB per origin (varies by browser and other keys on the same origin).
- A medium codeplug (hundreds of channels, dozens of zones) is usually well under 1 MB as JSON.
- Multiple projects share the same quota. Very large codeplugs or many projects may need #32 (IndexedDB / OPFS).
Google Drive (#17) provides explicit open/save of native YAML and CPS files. The working edit store remains this LocalStorage envelope — cloud is an optional interchange layer alongside file download. See cloud-storage.
- All project data is browser-local only.
- Never commit operator CSV exports or LocalStorage dumps to the repo.
- Use
sample-exports/(gitignored) for local testing.
npm run dev→ import a codeplug on the home page → open/channels.- Hard refresh (Cmd+R) — projects and active selection restored.
- Import a second codeplug from home — both listed after refresh.
- Delete a project — confirm dialog; refresh — deletion persists.
- DevTools → Application → Local Storage — confirm
mm9pdy-codeplug-tool.codeplugenvelope.
- Codeplug projects — wrapper model and CRUD
- Data model —
Codeplugcontents - Import / export — imported format → codeplug at the store boundary