Skip to content

Commit

Permalink
Project import, improved home sceen, use Zod with small schema changes
Browse files Browse the repository at this point in the history
  • Loading branch information
invpt committed Mar 15, 2024
1 parent c9ca2cd commit 0f3e115
Show file tree
Hide file tree
Showing 13 changed files with 400 additions and 95 deletions.
11 changes: 10 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"solid-icons": "^1.1.0",
"solid-js": "^1.8.15",
"solid-toast": "^0.5.0",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"zod": "^3.22.4"
}
}
137 changes: 76 additions & 61 deletions src/data.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,84 @@
export type AssetType = "narration" | "image" | "tiles";
import { z } from "zod";

export interface ProjectModel {
title: string,
tours: TourModel[],
assets: Record<AssetReference, {
hash: string,
alt: string,
attrib: string,
}>,
}
export const AssetTypeSchema = z.literal("narration").or(z.literal("image")).or(z.literal("tiles"));

export interface TourModel {
type: "driving" | "walking",
id: string,
title: string,
desc: string,
gallery: GalleryModel,
tiles?: AssetReference,
route: Array<StopModel | ControlPointModel>,
pois: PoiModel[],
path: string,
links?: Record<string, {
href: string,
}> | undefined,
}
export const AssetReferenceSchema = z.string();

export interface StopModel {
type: "stop",
id: string,
title: string,
desc: string,
lat: number,
lng: number,
trigger_radius: number,
control: "route" | "path" | "none",
gallery: GalleryModel,
transcript?: AssetReference,
narration?: AssetReference,
links?: Record<string, {
href: string,
}> | undefined,
}
export const GalleryModelSchema = z.array(AssetReferenceSchema);

export interface ControlPointModel {
type: "control",
id: string,
lat: number,
lng: number,
control: "route" | "path",
}
export const LatLngSchema = z.object({
lat: z.number(),
lng: z.number(),
});

export interface PoiModel {
id: string,
lat: number,
lng: number,
title: string,
desc: string,
gallery: GalleryModel,
links?: Record<string, {
href: string,
}> | undefined,
}
export const PoiModelSchema = z.object({
id: z.string(),
title: z.string(),
desc: z.string(),
lat: z.number(),
lng: z.number(),
gallery: GalleryModelSchema,
links: z.record(z.object({ href: z.string() })),
});

export type GalleryModel = AssetReference[];
export const ControlPointModelSchema = z.object({
type: z.literal("control"),
id: z.string(),
lat: z.number(),
lng: z.number(),
control: z.literal("route").or(z.literal("path")).or(z.literal("none")),
});

export type AssetReference = string;
export const StopModelSchema = z.object({
type: z.literal("stop"),
id: z.string(),
title: z.string(),
desc: z.string(),
lat: z.number(),
lng: z.number(),
trigger_radius: z.number(),
control: z.literal("route").or(z.literal("path")).or(z.literal("none")),
gallery: GalleryModelSchema,
transcript: z.optional(AssetReferenceSchema),
narration: z.optional(AssetReferenceSchema),
links: z.record(z.object({ href: z.string() })),
});

export interface LatLng { lat: number, lng: number }
export const TourModelSchema = z.object({
type: z.literal("driving").or(z.literal("walking")),
id: z.string(),
title: z.string(),
desc: z.string(),
gallery: GalleryModelSchema,
tiles: z.optional(AssetReferenceSchema),
route: z.array(z.discriminatedUnion("type", [
StopModelSchema,
ControlPointModelSchema,
])),
pois: z.array(PoiModelSchema),
path: z.string(),
links: z.record(z.object({ href: z.string() })),
});

export const ProjectModelSchema = z.object({
originalId: z.string(),
createDate: z.optional(z.coerce.date()),
modifyDate: z.optional(z.coerce.date()),
title: z.string(),
tours: z.array(TourModelSchema),
assets: z.record(AssetReferenceSchema, z.object({
hash: z.string(),
alt: z.string(),
attrib: z.string(),
})),
});

export type AssetType = z.infer<typeof AssetTypeSchema>;
export type AssetReference = z.infer<typeof AssetReferenceSchema>;
export type GalleryModel = z.infer<typeof GalleryModelSchema>;
export type LatLng = z.infer<typeof LatLngSchema>;
export type PoiModel = z.infer<typeof PoiModelSchema>;
export type ControlPointModel = z.infer<typeof ControlPointModelSchema>;
export type StopModel = z.infer<typeof StopModelSchema>;
export type TourModel = z.infer<typeof TourModelSchema>;
export type ProjectModel = z.infer<typeof ProjectModelSchema>;
27 changes: 26 additions & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,20 @@ export class DB {

/**
* Stores a project in the database.
*
* The createDate and modifyDate fields in the provided project are automatically managed.
* @param project The content of the project.
*/
async storeProject(project: DbProject) {
const db = await this.openDB();

await db.put("projects", project);
const prev = await db.get("projects", project.id);

await db.put("projects", {
...project,
createDate: prev?.createDate ?? new Date(),
modifyDate: new Date(),
});
}

async deleteProject(id: string) {
Expand All @@ -73,6 +81,12 @@ export class DB {
await db.delete("projects", id);
}

async containsAsset(hash: string) {
const db = await this.openDB();

return await db.getKey("assets", hash) !== undefined;
}

async loadAsset(hash: string) {
const db = await this.openDB();

Expand All @@ -82,6 +96,17 @@ export class DB {
async storeAsset(data: Blob) {
const hash = await this.hashBlob(data);

return await this.storeAssetWithHash(hash, data);
}

/**
* Stores an asset assuming it has the given hash. You should only use this method if you are
* reasonably certain that the hash is valid.
* @param hash The precomputed hash of data.
* @param data The asset data to store.
* @returns The precomputed hash.
*/
async storeAssetWithHash(hash: string, data: Blob) {
const db = await this.openDB();

return await db.put("assets", data, hash);
Expand Down
2 changes: 1 addition & 1 deletion src/export.ts → src/export-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const exportProject = async (db: DB, project: string, options: { includeA
const zipUrl = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = zipUrl;
a.download = "TourForge-" + projectContent.title + ".zip";
a.download = "TourForge_" + projectContent.title.replaceAll(" ", "_") + ".zip";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
Expand Down
103 changes: 103 additions & 0 deletions src/import-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import JSZip from "jszip";
import { v4 as uuidv4 } from "uuid";

import type { DbProject, DB } from "./db";
import { type ProjectModel, ProjectModelSchema } from "./data";

export class ImportError extends Error {
}

export type ReplacementAction = "cancel" | "new" | { replace: string };

/**
* Imports a project bundle from a zip file.
*/
export const importBundle = async (db: DB, file: File, chooseReplacement?: (options: DbProject[]) => Promise<ReplacementAction>) => {
let zip: JSZip;
try {
zip = await JSZip.loadAsync(file);
} catch (e) {
throw new ImportError("The chosen file does not appear to be a valid zip file.", { cause: e });
}
const projectJsonFile = zip.file("tourforge.json");
if (projectJsonFile == null) {
throw new ImportError("Malformed project bundle: tourforge.json is missing.");
}
let projectJsonText: string;
try {
projectJsonText = await projectJsonFile.async("text");
} catch (e) {
throw new ImportError("Malformed project bundle: tourforge.json could not be loaded as text.", { cause: e });
}
let projectJson: unknown;
try {
projectJson = JSON.parse(projectJsonText);
} catch (e) {
throw new ImportError("Malformed project bundle: tourforge.json could not pe parsed as JSON.", { cause: e });
}
let project: ProjectModel;
try {
project = ProjectModelSchema.parse(projectJson);
} catch (e) {
throw new ImportError("Malformed project bundle: tourforge.json has an invalid schema.", { cause: e });
}
const assetBlobs: Record<string, Blob> = {};
for (const assetInfo of Object.values(project.assets)) {
const path = assetInfo.hash;
const assetFile = zip.file(path);
if (assetFile == null) {
console.warn("Ignoring missing asset with hash", path);
continue;
}
let assetBlob: Blob;
try {
assetBlob = await assetFile.async("blob");
} catch (e) {
console.warn("Ignoring failed read of asset with hash", path);
continue;
}
assetBlobs[path] = assetBlob;
}

// Figure out if there's another project with the same originalId already.
const existingWithOriginalId: DbProject[] = [];
for (const otherProject of await db.listProjects()) {
if (otherProject.originalId === project.originalId) {
existingWithOriginalId.push(otherProject);
}
}

let replacementAction: ReplacementAction = "new";
if (chooseReplacement != null && existingWithOriginalId.length > 0) {
replacementAction = await chooseReplacement(existingWithOriginalId);
}

let projectId: string;
if (replacementAction === "cancel") {
return;
} else if (replacementAction === "new") {
projectId = uuidv4();
} else {
projectId = replacementAction.replace;
}

const dbProject = {
...project,
id: projectId,
source: { type: "bundle" } as const,
};

await db.storeProject(dbProject);
for (const [hash, blob] of Object.entries(assetBlobs)) {
if (!await db.containsAsset(hash)) {
// We're assuming that the hash used in the project for the asset is correct.
// There's no reason why it shouldn't be unless the bundle we're importing is
// malicious, but very little could be gained from making a malicious bundle.
// This is especially true because we only store the blob if the hash is
// unused in the database.
await db.storeAssetWithHash(hash, blob);
}
}

return dbProject;
};
Loading

0 comments on commit 0f3e115

Please sign in to comment.