1.1.1: fullbak
This commit is contained in:
1 parent
d59201100b
commit
01c13981c4
6 files changed
+443
No files matched your search
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
|
PhArchive,
|
||||||
|
PhArrowsClockwise,
|
||||||
PhFileArrowUp,
|
PhFileArrowUp,
|
||||||
PhFolderOpen,
|
PhFolderOpen,
|
||||||
PhPlus,
|
PhPlus,
|
||||||
@@ -9,9 +11,16 @@ import {
|
|||||||
import ruwioLogo from '@/assets/logo.png'
|
import ruwioLogo from '@/assets/logo.png'
|
||||||
import {
|
import {
|
||||||
deleteStoredProject,
|
deleteStoredProject,
|
||||||
|
listAllProjects,
|
||||||
listRecentProjects,
|
listRecentProjects,
|
||||||
|
saveProjectToStore,
|
||||||
type StoredProject,
|
type StoredProject,
|
||||||
} from '@/lib/projectStore'
|
} from '@/lib/projectStore'
|
||||||
|
import {
|
||||||
|
downloadBackup,
|
||||||
|
pickBackupFile,
|
||||||
|
readBackupFile,
|
||||||
|
} from '@/lib/backupFile'
|
||||||
import { confirmDialog, Modal } from '@/components/ui'
|
import { confirmDialog, Modal } from '@/components/ui'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
@@ -149,6 +158,90 @@ function importFile() {
|
|||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backupBusy = ref(false)
|
||||||
|
const backupError = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** Dump every project in IDB into a single .ruwbak file. No-ops
|
||||||
|
* with a friendly toast when the store is empty — users expect a
|
||||||
|
* signal, not a mystery zero-byte file. */
|
||||||
|
async function backupAll() {
|
||||||
|
if (backupBusy.value) return
|
||||||
|
backupBusy.value = true
|
||||||
|
backupError.value = null
|
||||||
|
try {
|
||||||
|
const all = await listAllProjects()
|
||||||
|
const records = all
|
||||||
|
.filter((r) => !!r.project)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
savedAt: r.savedAt,
|
||||||
|
thumbnail: r.thumbnail,
|
||||||
|
project: r.project!,
|
||||||
|
}))
|
||||||
|
if (!records.length) {
|
||||||
|
backupError.value = t('welcome.backupEmpty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downloadBackup(records)
|
||||||
|
} catch (err) {
|
||||||
|
backupError.value =
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
backupBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick a .ruwbak file and write every project it contains into
|
||||||
|
* IDB, preserving ids so the next save overwrites cleanly. The
|
||||||
|
* existing recents list is NOT cleared — users typically want to
|
||||||
|
* merge rather than wipe, and we want the opportunity to refuse
|
||||||
|
* a confusing clobber. */
|
||||||
|
async function restoreBackup() {
|
||||||
|
if (backupBusy.value) return
|
||||||
|
const file = await pickBackupFile()
|
||||||
|
if (!file) return
|
||||||
|
backupBusy.value = true
|
||||||
|
backupError.value = null
|
||||||
|
try {
|
||||||
|
const records = await readBackupFile(file)
|
||||||
|
if (records.length === 0) {
|
||||||
|
backupError.value = t('welcome.backupEmpty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check for id collisions so the user knows they'll be
|
||||||
|
// overwriting existing projects before we start writing.
|
||||||
|
const existing = await listAllProjects()
|
||||||
|
const existingIds = new Set(existing.map((r) => r.id))
|
||||||
|
const overlaps = records.filter((r) => existingIds.has(r.id)).length
|
||||||
|
if (overlaps > 0) {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t('welcome.restoreConfirmTitle'),
|
||||||
|
message: t('welcome.restoreConfirmMessage', {
|
||||||
|
total: records.length,
|
||||||
|
overlap: overlaps,
|
||||||
|
}),
|
||||||
|
confirmLabel: t('welcome.restoreConfirmButton'),
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
for (const rec of records) {
|
||||||
|
await saveProjectToStore(rec.project, {
|
||||||
|
id: rec.id,
|
||||||
|
name: rec.name,
|
||||||
|
thumbnail: rec.thumbnail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
backupError.value =
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
backupBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
@@ -219,6 +312,7 @@ watch(
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="backupError" class="backup-error">{{ backupError }}</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button ref="newBtnRef" class="ftr-btn" @click="startFresh">
|
<button ref="newBtnRef" class="ftr-btn" @click="startFresh">
|
||||||
<PhPlus :size="13" weight="bold" />
|
<PhPlus :size="13" weight="bold" />
|
||||||
@@ -228,6 +322,25 @@ watch(
|
|||||||
<PhFileArrowUp :size="13" />
|
<PhFileArrowUp :size="13" />
|
||||||
{{ t('welcome.importFile') }}
|
{{ t('welcome.importFile') }}
|
||||||
</button>
|
</button>
|
||||||
|
<span class="ftr-sep"></span>
|
||||||
|
<button
|
||||||
|
class="ftr-btn"
|
||||||
|
:disabled="backupBusy"
|
||||||
|
v-tooltip="t('welcome.backupAllHelp')"
|
||||||
|
@click="backupAll"
|
||||||
|
>
|
||||||
|
<PhArchive :size="13" />
|
||||||
|
{{ t('welcome.backupAll') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ftr-btn"
|
||||||
|
:disabled="backupBusy"
|
||||||
|
v-tooltip="t('welcome.restoreBackupHelp')"
|
||||||
|
@click="restoreBackup"
|
||||||
|
>
|
||||||
|
<PhArrowsClockwise :size="13" />
|
||||||
|
{{ t('welcome.restoreBackup') }}
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -389,4 +502,22 @@ watch(
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ftr-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ftr-sep {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-error {
|
||||||
|
margin: 0 12px 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1943,6 +1943,102 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('ruwbak — multi-project backup roundtrip', () => {
|
||||||
|
// A .ruwbak is a zip holding one manifest.json + one packaged
|
||||||
|
// .ruw per project. Covers the "back up everything in browser
|
||||||
|
// storage" / "restore a backup" flow exposed from the welcome
|
||||||
|
// dialog.
|
||||||
|
|
||||||
|
test('pack + unpack preserves each project with its metadata', async () => {
|
||||||
|
const { packBackup, unpackBackup } = await import('@/lib/backupFile')
|
||||||
|
const a = makeFreshStore()
|
||||||
|
a.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#000' })
|
||||||
|
const projectA = JSON.parse(JSON.stringify(a.projectForSave))
|
||||||
|
const b = makeFreshStore()
|
||||||
|
b.addAsset({ kind: 'rect', name: 'B', width: 20, height: 20, fill: '#fff' })
|
||||||
|
const projectB = JSON.parse(JSON.stringify(b.projectForSave))
|
||||||
|
const bundle = packBackup([
|
||||||
|
{ id: 'id-a', name: 'Alpha', savedAt: 1, project: projectA },
|
||||||
|
{ id: 'id-b', name: 'Beta', savedAt: 2, project: projectB },
|
||||||
|
])
|
||||||
|
const out = unpackBackup(bundle)
|
||||||
|
expect(out.length).toBe(2)
|
||||||
|
expect(out[0].id).toBe('id-a')
|
||||||
|
expect(out[0].name).toBe('Alpha')
|
||||||
|
expect(out[0].savedAt).toBe(1)
|
||||||
|
expect(out[0].project.assets.some((x) => x.name === 'A')).toBe(true)
|
||||||
|
expect(out[1].id).toBe('id-b')
|
||||||
|
expect(out[1].project.assets.some((x) => x.name === 'B')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('backup survives asset data through the inner .ruw bundles', async () => {
|
||||||
|
const { packBackup, unpackBackup } = await import('@/lib/backupFile')
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const imgSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAAC0lEQVQI12NgAAIAAAUAAeImBZsAAAAASUVORK5CYII='
|
||||||
|
s.addAsset({
|
||||||
|
kind: 'image', name: 'pixel', src: imgSrc, width: 1, height: 1,
|
||||||
|
})
|
||||||
|
const project = JSON.parse(JSON.stringify(s.projectForSave))
|
||||||
|
const bundle = packBackup([
|
||||||
|
{ id: 'only', name: 'Sole', savedAt: 0, project },
|
||||||
|
])
|
||||||
|
const [rec] = unpackBackup(bundle)
|
||||||
|
const img = rec.project.assets.find((a) => a.name === 'pixel')! as {
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
// Image round-trips all the way: through inner .ruw asset
|
||||||
|
// extraction + rehydration via the outer backup zip.
|
||||||
|
expect(img.src).toBe(imgSrc)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unpackBackup rejects non-zip + wrong magic + missing files', async () => {
|
||||||
|
const { unpackBackup, packBackup } = await import('@/lib/backupFile')
|
||||||
|
// Non-zip input.
|
||||||
|
let err: unknown
|
||||||
|
try { unpackBackup(new Uint8Array([0x7b, 0x7d])) } catch (e) { err = e }
|
||||||
|
expect((err as Error).message).toContain('ZIP')
|
||||||
|
// Wrong-magic manifest.
|
||||||
|
const { zipStore } = await import('@/lib/zipUtil')
|
||||||
|
const badManifest = zipStore([{
|
||||||
|
name: 'manifest.json',
|
||||||
|
data: new TextEncoder().encode(JSON.stringify({ magic: 'nope', version: 1, projects: [] })),
|
||||||
|
}])
|
||||||
|
err = null
|
||||||
|
try { unpackBackup(badManifest) } catch (e) { err = e }
|
||||||
|
expect((err as Error).message.toLowerCase()).toContain('magic')
|
||||||
|
// Missing referenced project file.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const goodBundle = packBackup([
|
||||||
|
{ id: 'missing', name: 'Lost', savedAt: 1, project: JSON.parse(JSON.stringify(s.projectForSave)) },
|
||||||
|
])
|
||||||
|
// Strip the inner .ruw to simulate corruption.
|
||||||
|
const { unzipStore } = await import('@/lib/zipUtil')
|
||||||
|
const entries = unzipStore(goodBundle).filter(
|
||||||
|
(e) => !e.name.startsWith('projects/'),
|
||||||
|
)
|
||||||
|
const corrupt = zipStore(entries)
|
||||||
|
err = null
|
||||||
|
try { unpackBackup(corrupt) } catch (e) { err = e }
|
||||||
|
expect((err as Error).message.toLowerCase()).toContain('missing')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicate ids in the input get unique file paths inside the zip', async () => {
|
||||||
|
const { packBackup } = await import('@/lib/backupFile')
|
||||||
|
const { unzipStore } = await import('@/lib/zipUtil')
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const project = JSON.parse(JSON.stringify(s.projectForSave))
|
||||||
|
const bundle = packBackup([
|
||||||
|
{ id: 'dup', name: 'A', savedAt: 1, project },
|
||||||
|
{ id: 'dup', name: 'B', savedAt: 2, project },
|
||||||
|
])
|
||||||
|
const files = unzipStore(bundle).map((e) => e.name).filter((n) => n.startsWith('projects/'))
|
||||||
|
// Two distinct project files, one suffixed to avoid collision.
|
||||||
|
expect(new Set(files).size).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('editor state — save / load roundtrip', () => {
|
suite('editor state — save / load roundtrip', () => {
|
||||||
// The store's editor block carries canvas zoom/pan, overlay
|
// The store's editor block carries canvas zoom/pan, overlay
|
||||||
// toggles and timeline display settings. It rides along with the
|
// toggles and timeline display settings. It rides along with the
|
||||||
@@ -2842,6 +2938,29 @@ export function registerAllTests(): void {
|
|||||||
const back = await getStoredProject('definitely-missing-id-42')
|
const back = await getStoredProject('definitely-missing-id-42')
|
||||||
expect(back).toBeNull()
|
expect(back).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('listAllProjects returns every record — no 8-recents cap', async () => {
|
||||||
|
// Backup flow needs the full dump, not just the top 8 that
|
||||||
|
// the welcome dialog shows.
|
||||||
|
const { listAllProjects } = await import('@/lib/projectStore')
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const ids: string[] = []
|
||||||
|
const prefix = `test-backup-${Date.now()}-`
|
||||||
|
for (let i = 0; i < 10; i++) ids.push(`${prefix}${i}`)
|
||||||
|
try {
|
||||||
|
for (const id of ids) {
|
||||||
|
await saveProjectToStore(s.project, { id, name: id })
|
||||||
|
await tick(1)
|
||||||
|
}
|
||||||
|
const all = await listAllProjects()
|
||||||
|
// At least our 10 show up, even though listRecentProjects
|
||||||
|
// would only return 8.
|
||||||
|
const seen = ids.filter((id) => all.some((r) => r.id === id))
|
||||||
|
expect(seen.length).toBe(10)
|
||||||
|
} finally {
|
||||||
|
for (const id of ids) await deleteStoredProject(id).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
suite('ui — WelcomeDialog', () => {
|
suite('ui — WelcomeDialog', () => {
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ export const en = {
|
|||||||
message: '"{name}" will be permanently removed from browser storage.',
|
message: '"{name}" will be permanently removed from browser storage.',
|
||||||
confirm: 'Delete',
|
confirm: 'Delete',
|
||||||
},
|
},
|
||||||
|
backupAll: 'Backup all…',
|
||||||
|
backupAllHelp: 'Save every project in browser storage into a single .ruwbak file.',
|
||||||
|
backupEmpty: 'No saved projects yet — nothing to back up.',
|
||||||
|
restoreBackup: 'Restore backup…',
|
||||||
|
restoreBackupHelp: 'Load every project from a .ruwbak file into browser storage.',
|
||||||
|
restoreConfirmTitle: 'Restore backup?',
|
||||||
|
restoreConfirmMessage: 'Writing {total} projects — {overlap} of them would overwrite projects already in browser storage with the same id.',
|
||||||
|
restoreConfirmButton: 'Overwrite and restore',
|
||||||
},
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Multi-project backup bundle. Packs every StoredProject in IDB into
|
||||||
|
* one zip with an outer manifest and each project re-serialised as
|
||||||
|
* its own inner .ruw bundle:
|
||||||
|
*
|
||||||
|
* backup.zip
|
||||||
|
* manifest.json — { magic: 'ruwio-backup', version: 1,
|
||||||
|
* projects: [{ id, name, savedAt,
|
||||||
|
* thumbnail?, file: 'projects/<id>.ruw' }] }
|
||||||
|
* projects/<id>.ruw — normal single-project bundle
|
||||||
|
* (reusable standalone if the user
|
||||||
|
* extracts it).
|
||||||
|
*
|
||||||
|
* Nested zips cost a bit of overhead but buy a clean boundary: each
|
||||||
|
* inner .ruw stays a valid round-trippable project that the welcome
|
||||||
|
* dialog / file picker can import on its own. Extracting the backup
|
||||||
|
* gives the user a tidy folder of independent projects rather than
|
||||||
|
* one bucket of tangled assets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Project } from '@/types/scene'
|
||||||
|
import { looksLikeZip, unzipStore, zipStore, type ZipEntry } from './zipUtil'
|
||||||
|
import { packProject, unpackProject } from './ruwFile'
|
||||||
|
|
||||||
|
const BACKUP_MAGIC = 'ruwio-backup'
|
||||||
|
const BACKUP_VERSION = 1
|
||||||
|
const PROJECTS_DIR = 'projects/'
|
||||||
|
|
||||||
|
export interface BackupRecord {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
savedAt: number
|
||||||
|
thumbnail?: string
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupManifestEntry {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
savedAt: number
|
||||||
|
thumbnail?: string
|
||||||
|
file: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupManifest {
|
||||||
|
magic: typeof BACKUP_MAGIC
|
||||||
|
version: number
|
||||||
|
savedAt: string
|
||||||
|
projects: BackupManifestEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pack every provided record into a single backup zip. Returns the
|
||||||
|
* raw bytes; callers download via `downloadBackup`. */
|
||||||
|
export function packBackup(records: BackupRecord[]): Uint8Array {
|
||||||
|
const entries: ZipEntry[] = []
|
||||||
|
const manifest: BackupManifest = {
|
||||||
|
magic: BACKUP_MAGIC,
|
||||||
|
version: BACKUP_VERSION,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
projects: [],
|
||||||
|
}
|
||||||
|
const used = new Set<string>()
|
||||||
|
for (const rec of records) {
|
||||||
|
// Guard against id collisions (shouldn't happen from IDB but we
|
||||||
|
// accept arbitrary input) — suffix duplicates so zip paths stay
|
||||||
|
// unique.
|
||||||
|
let fileId = rec.id
|
||||||
|
let i = 2
|
||||||
|
while (used.has(fileId)) {
|
||||||
|
fileId = `${rec.id}-${i++}`
|
||||||
|
}
|
||||||
|
used.add(fileId)
|
||||||
|
const file = `${PROJECTS_DIR}${fileId}.ruw`
|
||||||
|
entries.push({ name: file, data: packProject(rec.project) })
|
||||||
|
manifest.projects.push({
|
||||||
|
id: rec.id,
|
||||||
|
name: rec.name,
|
||||||
|
savedAt: rec.savedAt,
|
||||||
|
thumbnail: rec.thumbnail,
|
||||||
|
file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
entries.unshift({
|
||||||
|
name: 'manifest.json',
|
||||||
|
data: new TextEncoder().encode(JSON.stringify(manifest, null, 2)),
|
||||||
|
})
|
||||||
|
return zipStore(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverse of `packBackup`. Throws if the file isn't a RUWIO backup
|
||||||
|
* or an entry referenced by the manifest is missing / corrupt. */
|
||||||
|
export function unpackBackup(bytes: Uint8Array): BackupRecord[] {
|
||||||
|
if (!looksLikeZip(bytes)) {
|
||||||
|
throw new Error('Not a RUWIO backup (missing ZIP signature).')
|
||||||
|
}
|
||||||
|
const entries = unzipStore(bytes)
|
||||||
|
const byName = new Map<string, Uint8Array>()
|
||||||
|
for (const e of entries) byName.set(e.name, e.data)
|
||||||
|
const manifestBytes = byName.get('manifest.json')
|
||||||
|
if (!manifestBytes) {
|
||||||
|
throw new Error('Backup is missing manifest.json.')
|
||||||
|
}
|
||||||
|
let manifest: BackupManifest
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as BackupManifest
|
||||||
|
} catch {
|
||||||
|
throw new Error('Backup manifest is not valid JSON.')
|
||||||
|
}
|
||||||
|
if (manifest.magic !== BACKUP_MAGIC) {
|
||||||
|
throw new Error('Manifest magic does not match — is this a RUWIO backup?')
|
||||||
|
}
|
||||||
|
if (!Array.isArray(manifest.projects)) {
|
||||||
|
throw new Error('Backup manifest is missing the projects list.')
|
||||||
|
}
|
||||||
|
const out: BackupRecord[] = []
|
||||||
|
for (const entry of manifest.projects) {
|
||||||
|
const file = byName.get(entry.file)
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`Backup is missing project file "${entry.file}".`)
|
||||||
|
}
|
||||||
|
const project = unpackProject(file)
|
||||||
|
out.push({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
savedAt: entry.savedAt,
|
||||||
|
thumbnail: entry.thumbnail,
|
||||||
|
project,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBackup(
|
||||||
|
records: BackupRecord[],
|
||||||
|
filename = `ruwio-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.ruwbak`,
|
||||||
|
): void {
|
||||||
|
const bytes = packBackup(records)
|
||||||
|
const copy = new Uint8Array(bytes.byteLength)
|
||||||
|
copy.set(bytes)
|
||||||
|
const blob = new Blob([copy], { type: 'application/zip' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBackupFile(file: File): Promise<BackupRecord[]> {
|
||||||
|
const buf = new Uint8Array(await file.arrayBuffer())
|
||||||
|
return unpackBackup(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File-picker tuned for `.ruwbak` plus a zip fallback. */
|
||||||
|
export function pickBackupFile(): Promise<File | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = '.ruwbak,application/zip'
|
||||||
|
input.onchange = () => resolve(input.files?.[0] ?? null)
|
||||||
|
input.oncancel = () => resolve(null)
|
||||||
|
input.click()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -76,6 +76,21 @@ export async function listRecentProjects(): Promise<StoredProject[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full IDB dump — every stored project, newest first, no recents
|
||||||
|
* cap. Used by the "backup all" flow; the welcome list sticks to
|
||||||
|
* `listRecentProjects` so the UI stays short. */
|
||||||
|
export async function listAllProjects(): Promise<StoredProject[]> {
|
||||||
|
try {
|
||||||
|
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
|
||||||
|
return (all ?? [])
|
||||||
|
.slice()
|
||||||
|
.map(migrate)
|
||||||
|
.sort((a, b) => b.savedAt - a.savedAt)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface SaveOptions {
|
export interface SaveOptions {
|
||||||
id?: string
|
id?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
|
||||||
|
server: {
|
||||||
|
port: 5200,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
|||||||
Reference in new issue
Block a user