-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Project import, improved home sceen, use Zod with small schema changes
- Loading branch information
Showing
13 changed files
with
400 additions
and
95 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
Oops, something went wrong.