diff --git a/package-lock.json b/package-lock.json index 480d2d2f9..ca0536949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "govuk-svelte": "github:acteng/govuk-svelte", "humanize-string": "^3.0.0", "js-cookie": "^3.0.5", + "jszip": "^3.10.1", "maplibre-gl": "^4.7.1", "scheme-sketcher-lib": "github:acteng/scheme-sketcher-lib", "svelte": "^4.2.10", @@ -1665,6 +1666,11 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2097,6 +2103,11 @@ } ] }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/immutable": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.0.tgz", @@ -2131,8 +2142,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "4.1.3", @@ -2196,6 +2206,11 @@ "@types/estree": "*" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/isexe": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", @@ -2259,6 +2274,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/just-compare": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/just-compare/-/just-compare-2.3.0.tgz", @@ -2291,6 +2322,14 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -2693,6 +2732,11 @@ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", @@ -2731,6 +2775,20 @@ "quickselect": "^2.0.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2877,6 +2935,11 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/sander": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", @@ -3110,6 +3173,11 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/sorcery": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", @@ -3138,6 +3206,14 @@ "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.1.1.tgz", "integrity": "sha512-9FaQ18FF0+sZc/ieEeXHt+Jw2eSpUgUtTLDYB/HXKWvhYVyOc7h1hzkn5MMO3GPib9MmXG1go8+OsBBzs/NMww==" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3485,6 +3561,11 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/package.json b/package.json index f20e29d1d..ac35ef5c1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "govuk-svelte": "github:acteng/govuk-svelte", "humanize-string": "^3.0.0", "js-cookie": "^3.0.5", + "jszip": "^3.10.1", "maplibre-gl": "^4.7.1", "scheme-sketcher-lib": "github:acteng/scheme-sketcher-lib", "svelte": "^4.2.10", diff --git a/src/lib/common/files.ts b/src/lib/common/files.ts index 139e0da5b..0fd9ea5c4 100644 --- a/src/lib/common/files.ts +++ b/src/lib/common/files.ts @@ -1,4 +1,5 @@ import { findSmallestAuthority, type AuthorityBoundaries } from "boundaries"; +import JSZip from "jszip"; import type { Schema, Schemes } from "types"; // Returns the local storage key for a file @@ -130,6 +131,37 @@ export function exportFile(authority: string, filename: string, gj: Schemes) { ); } +export function checkThenExportAll() { + let today = getDateString(); + let lastBackup = + window.localStorage.getItem("sketches-last-backup-prompt") ?? ""; + if (today != lastBackup) { + if ( + window.confirm(`Would you like to download a backup copy of your files?`) + ) { + exportAll(); + } + } + window.localStorage.setItem("sketches-last-backup-prompt", getDateString()); +} + +// Downloads all sketches as .zip +async function exportAll() { + let name = `scheme_sketch_backup_${getDateString()}`; + let zip = new JSZip(); + let folder = zip.folder(name)!; + + for (let key of getFileKeys()) { + folder.file( + `${key.replaceAll("/", "-")}.json`, + window.localStorage.getItem(key)!, + ); + } + + let bytes = await zip.generateAsync({ type: "arraybuffer" }); + downloadBinaryFile(bytes, `${name}.zip`); +} + export function detectSchema(gj: any): Schema { // Blindly assume the input is valid, and let the try/catch handle otherwise try { @@ -207,3 +239,37 @@ function stripPrefix(value: string, prefix: string): string { function stripSuffix(value: string, suffix: string): string { return value.endsWith(suffix) ? value.slice(0, -suffix.length) : value; } + +function getFileKeys(): string[] { + let results: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + let key = window.localStorage.key(i)!; + if (key.startsWith("sketch")) { + results.push(key); + } + } + return results; +} + +function getDateString(): string { + let today = new Date(); + let day = today.getDate().toString().padStart(2, "0"); + let month = (today.getMonth() + 1).toString().padStart(2, "0"); + return `${day}_${month}_${today.getFullYear()}`; +} + +function downloadBinaryFile(bytes: ArrayBuffer, filename: string) { + let blob = new Blob([bytes], { type: "application/octet-stream" }); + let url = URL.createObjectURL(blob); + + let link = document.createElement("a"); + link.href = url; + link.download = filename; + link.style.display = "none"; + + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/src/pages/ChooseArea.svelte b/src/pages/ChooseArea.svelte index c444daa80..12ea80953 100644 --- a/src/pages/ChooseArea.svelte +++ b/src/pages/ChooseArea.svelte @@ -21,6 +21,7 @@ Popup, } from "lib/common"; import { + checkThenExportAll, countFilesPerAuthority, importFile, importOldFiles, @@ -63,6 +64,7 @@ } filesPerAuthority = countFilesPerAuthority(); + checkThenExportAll(); }); function onClick(e: CustomEvent) { diff --git a/src/pages/ManageFiles.svelte b/src/pages/ManageFiles.svelte index 2eb686c05..a639f25f6 100644 --- a/src/pages/ManageFiles.svelte +++ b/src/pages/ManageFiles.svelte @@ -17,6 +17,7 @@ Header, } from "lib/common"; import { + checkThenExportAll, downloadGeneratedFile, exportFile, getEditUrl, @@ -52,6 +53,8 @@ ) { window.location.href = `choose_area.html?schema=${$schemaStore}&error=Authority name not found: ${authority}`; } + + checkThenExportAll(); }); function renameFile(filename: string) { diff --git a/src/pages/SketchSchemes.svelte b/src/pages/SketchSchemes.svelte index eb302b2b8..f91929d11 100644 --- a/src/pages/SketchSchemes.svelte +++ b/src/pages/SketchSchemes.svelte @@ -13,7 +13,7 @@ MapLibreMap, } from "lib/common"; import ExternalLink from "lib/common/ExternalLink.svelte"; - import { getKey } from "lib/common/files"; + import { checkThenExportAll, getKey } from "lib/common/files"; import { cfg } from "lib/sketch/config"; import FileManagement from "lib/sketch/FileManagement.svelte"; import { map as sketchMapStore } from "scheme-sketcher-lib/config"; @@ -61,6 +61,7 @@ initAll(); boundaryGeojson = await loadAuthorityBoundary(); + checkThenExportAll(); }); async function loadAuthorityBoundary(): Promise { diff --git a/tests/shared.ts b/tests/shared.ts index 7258315fa..31d760e6e 100644 --- a/tests/shared.ts +++ b/tests/shared.ts @@ -6,10 +6,18 @@ export async function resetSketch( page: Page, schema: string = "v1", ): Promise { + page.on("dialog", (dialog) => { + if (dialog.message() === "What do you want to name your new file?" + || dialog.message().includes("Really delete")) { + dialog.accept(filename); + } else { + dialog.dismiss(); + } + }); await page.goto(`/files.html?authority=LAD_Adur&schema=${schema}`); let filename = uuidv4(); - page.on("dialog", (dialog) => dialog.accept(filename)); + await page.getByRole("button", { name: "Create new file" }).click(); await expect(page).toHaveURL(/.*scheme.html\?authority=LAD_Adur/);