Files
negotium/src/lib/backup.js
T
Aculix Technologies e6dd24a046 feat: installable offline app, plus export and import
Two things that were deliberately left out of 2.0.0.

Export writes every stored day to a JSON file. Import reads one back, and
only ever adds: a task whose id is already there is left alone, so
importing the same file twice does nothing and importing into a list
you're using can't lose work. That is also why it needs no confirmation
dialog. The trade is that import restores rather than reverts.

Both sit in a quiet line under the task list rather than the header, since
they get used about twice a year and the header is what you look at all
day.

For the PWA half, the service worker is about fifty lines with no
dependency, because the strategy falls out of how Vite builds. Documents
go network first and fall back to cache, so a deploy is picked up as soon
as you're online and nobody ends up stuck on an old build. Fingerprinted
assets go cache first and are kept, since their names change when their
contents do. No build-time asset manifest needed.

Icons are SVG in the manifest, which stays sharp at any size and costs
about a kilobyte, plus one 180px PNG because iOS wants a raster
apple-touch-icon. Two theme-color metas so the phone status bar follows
the theme, and safe-area padding on the header, without which the header
sits under the clock once installed on an iPhone.

Verified against the production build: worker registers and claims the
page, shell and assets land in cache, and with the server stopped the app
still loads, adds a task and persists it.
2026-08-16 02:16:31 +05:30

124 lines
3.3 KiB
JavaScript

import { isKey } from './dates.js'
const APP = 'negotium'
const FORMAT_VERSION = 1
/** Everything currently stored, as a plain object ready to serialize. */
export function buildExport(storage, now = new Date()) {
const days = {}
for (const dateKey of storage.listTaskKeys().sort()) {
const tasks = storage.loadTasks(dateKey)
if (tasks.length > 0) days[dateKey] = tasks
}
return {
app: APP,
version: FORMAT_VERSION,
exportedAt: now.toISOString(),
days,
}
}
export function serialize(data) {
return JSON.stringify(data, null, 2)
}
/** Keeps only the fields we know about, so an edited file can't smuggle
* anything unexpected into storage. Returns null if the task is unusable. */
function cleanTask(raw) {
if (!raw || typeof raw !== 'object') return null
const hasId = typeof raw.id === 'string' || typeof raw.id === 'number'
const text = typeof raw.text === 'string' ? raw.text.trim() : ''
if (!hasId || !text) return null
return {
id: String(raw.id),
text,
completed: Boolean(raw.completed),
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : Date.now(),
}
}
/**
* Validates a file's contents without touching storage.
*
* Anything malformed is skipped rather than failing the whole import: one bad
* row in a hand-edited file shouldn't cost someone the other two hundred.
*/
export function parseImport(text) {
let raw
try {
raw = JSON.parse(text)
} catch {
return { ok: false, error: "That file isn't a valid JSON file." }
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { ok: false, error: "That file doesn't look like a Negotium export." }
}
if (raw.app !== APP || !raw.days || typeof raw.days !== 'object' || Array.isArray(raw.days)) {
return { ok: false, error: "That file doesn't look like a Negotium export." }
}
const days = {}
let taskCount = 0
let skipped = 0
for (const [dateKey, value] of Object.entries(raw.days)) {
if (!isKey(dateKey) || !Array.isArray(value)) continue
const tasks = []
for (const entry of value) {
const task = cleanTask(entry)
if (task) tasks.push(task)
else skipped += 1
}
if (tasks.length > 0) {
days[dateKey] = tasks
taskCount += tasks.length
}
}
return { ok: true, days, taskCount, skipped }
}
/**
* Adds parsed tasks to storage. Additive by design: a task whose id is already
* present is left alone, so importing the same file twice changes nothing and
* importing into a live list can't lose work. The trade is that import cannot
* be used to roll back to an earlier state.
*/
export function mergeImport(storage, parsed) {
if (!parsed?.ok) return { imported: 0, duplicates: 0, days: 0 }
let imported = 0
let duplicates = 0
let days = 0
for (const [dateKey, incoming] of Object.entries(parsed.days)) {
const existing = storage.loadTasks(dateKey)
const seen = new Set(existing.map(task => String(task.id)))
const additions = []
for (const task of incoming) {
if (seen.has(task.id)) duplicates += 1
else {
additions.push(task)
seen.add(task.id)
}
}
if (additions.length > 0) {
storage.saveTasks(dateKey, [...existing, ...additions])
imported += additions.length
days += 1
}
}
return { imported, duplicates, days }
}