// eslint-disable-next-line no-unused-vars import Dexie, { Version } from "dexie"; import shortid from "shortid"; import { v4 as uuid } from "uuid"; import Case from "case"; import chunk from "lodash.chunk"; import blobToBuffer from "./helpers/blobToBuffer"; import { getGridDefaultInset } from "./helpers/grid"; import { createThumbnail, getImageOutline } from "./helpers/image"; import { AddStatesAction, EditStatesAction, RemoveStatesAction, SubtractFogAction, CutFogAction, } from "./actions"; import { getDefaultMaps } from "./maps"; import { getDefaultTokens } from "./tokens"; import { Outline } from "./types/Outline"; import { Group, GroupContainer } from "./types/Group"; import cloneDeep from "lodash.clonedeep"; export type UpgradeEventHandler = (versionNumber: number) => void; type VersionCallback = ( version: Version, onUpgrade?: UpgradeEventHandler ) => void; /** * Mapping of version number to their upgrade function */ export const versions: Record = { // v1.2.0 1(v) { v.stores({ maps: "id, owner", states: "mapId", tokens: "id, owner", user: "key", }); }, // v1.2.1 - Move from blob files to array buffers 2(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(2); const maps = await Dexie.waitFor(tx.table("maps").toArray()); let mapBuffers: Record = {}; for (let map of maps) { mapBuffers[map.id] = await Dexie.waitFor(blobToBuffer(map.file)); } return tx .table("maps") .toCollection() .modify((map) => { map.file = mapBuffers[map.id]; }); }); }, // v1.3.0 - Added new default tokens 3(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(3); return tx .table("states") .toCollection() .modify((state) => { function mapTokenId(id: string) { switch (id) { case "__default-Axes": return "__default-Barbarian"; case "__default-Bird": return "__default-Druid"; case "__default-Book": return "__default-Wizard"; case "__default-Crown": return "__default-Humanoid"; case "__default-Dragon": return "__default-Dragon"; case "__default-Eye": return "__default-Warlock"; case "__default-Fist": return "__default-Monk"; case "__default-Horse": return "__default-Fey"; case "__default-Leaf": return "__default-Druid"; case "__default-Lion": return "__default-Monstrosity"; case "__default-Money": return "__default-Humanoid"; case "__default-Moon": return "__default-Cleric"; case "__default-Potion": return "__default-Sorcerer"; case "__default-Shield": return "__default-Paladin"; case "__default-Skull": return "__default-Undead"; case "__default-Snake": return "__default-Beast"; case "__default-Sun": return "__default-Cleric"; case "__default-Swords": return "__default-Fighter"; case "__default-Tree": return "__default-Plant"; case "__default-Triangle": return "__default-Sorcerer"; default: return "__default-Fighter"; } } for (let stateId in state.tokens) { state.tokens[stateId].tokenId = mapTokenId( state.tokens[stateId].tokenId ); state.tokens[stateId].lastEditedBy = ""; state.tokens[stateId].rotation = 0; } }); }); }, // v1.3.1 - Added show grid option 4(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(4); return tx .table("maps") .toCollection() .modify((map) => { map.showGrid = false; }); }); }, // v1.4.0 - Added fog subtraction 5(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(5); return tx .table("states") .toCollection() .modify((state) => { for (let fogAction of state.fogDrawActions) { if (fogAction.type === "add" || fogAction.type === "edit") { for (let shape of fogAction.shapes) { shape.data.holes = []; } } } }); }); }, // v1.4.2 - Added map resolutions 6(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(6); return tx .table("maps") .toCollection() .modify((map) => { map.resolutions = {}; map.quality = "original"; }); }); }, // v1.5.0 - Fixed default token rogue spelling 7(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(7); return tx .table("states") .toCollection() .modify((state) => { for (let id in state.tokens) { if (state.tokens[id].tokenId === "__default-Rouge") { state.tokens[id].tokenId = "__default-Rogue"; } } }); }); }, // v1.5.0 - Added map snap to grid option 8(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(8); return tx .table("maps") .toCollection() .modify((map) => { map.snapToGrid = true; }); }); }, // v1.5.1 - Added lock, visibility and modified to tokens 9(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(9); return tx .table("states") .toCollection() .modify((state) => { for (let id in state.tokens) { state.tokens[id].lastModifiedBy = state.tokens[id].lastEditedBy; delete state.tokens[id].lastEditedBy; state.tokens[id].lastModified = Date.now(); state.tokens[id].locked = false; state.tokens[id].visible = true; } }); }); }, // v1.5.1 - Added token prop category and remove isVehicle bool 10(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(10); return tx .table("tokens") .toCollection() .modify((token) => { token.category = token.isVehicle ? "vehicle" : "character"; delete token.isVehicle; }); }); }, // v1.5.2 - Added automatic cache invalidation to maps 11(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(11); return tx .table("maps") .toCollection() .modify((map) => { map.lastUsed = map.lastModified; }); }); }, // v1.5.2 - Added automatic cache invalidation to tokens 12(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(12); return tx .table("tokens") .toCollection() .modify((token) => { token.lastUsed = token.lastModified; }); }); }, // v1.6.0 - Added map grouping and grid scale and offset 13(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(13); return tx .table("maps") .toCollection() .modify((map) => { map.group = ""; map.grid = { size: { x: map.gridX, y: map.gridY }, inset: getGridDefaultInset( { size: { x: map.gridX, y: map.gridY }, type: "square" }, map.width, map.height ), type: "square", }; delete map.gridX; delete map.gridY; delete map.gridType; }); }); }, // v1.6.0 - Added token grouping 14(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(14); return tx .table("tokens") .toCollection() .modify((token) => { token.group = ""; }); }); }, // v1.6.1 - Added width and height to tokens 15(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(15); const tokens = await Dexie.waitFor(tx.table("tokens").toArray()); let tokenSizes: Record = {}; for (let token of tokens) { const url = URL.createObjectURL(new Blob([token.file])); let image = new Image(); tokenSizes[token.id] = await Dexie.waitFor( new Promise((resolve) => { image.onload = () => { resolve({ width: image.width, height: image.height }); }; image.src = url; }) ); } return tx .table("tokens") .toCollection() .modify((token) => { token.width = tokenSizes[token.id].width; token.height = tokenSizes[token.id].height; }); }); }, // v1.7.0 - Added note tool 16(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(16); return tx .table("states") .toCollection() .modify((state) => { state.notes = {}; state.editFlags = [...state.editFlags, "notes"]; }); }); }, // 1.7.0 (hotfix) - Optimized fog shape edits to only include needed data 17(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(17); return tx .table("states") .toCollection() .modify((state) => { for (let i = 0; i < state.fogDrawActions.length; i++) { const action = state.fogDrawActions[i]; if (action && action.type === "edit") { for (let j = 0; j < action.shapes.length; j++) { const shape = action.shapes[j]; const temp = { ...shape }; state.fogDrawActions[i].shapes[j] = { id: temp.id, visible: temp.visible, }; } } } }); }); }, // 1.8.0 - Added note text only mode, converted draw and fog representations 18(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(18); return tx .table("states") .toCollection() .modify((state) => { for (let id in state.notes) { state.notes[id].textOnly = false; } state.drawShapes = convertOldActionsToShapes( state.mapDrawActions, state.mapDrawActionIndex ); state.fogShapes = convertOldActionsToShapes( state.fogDrawActions, state.fogDrawActionIndex ); delete state.mapDrawActions; delete state.mapDrawActionIndex; delete state.fogDrawActions; delete state.fogDrawActionIndex; }); }); }, // 1.8.0 - Add thumbnail to maps and add measurement to grid 19(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(19); const userId = (await Dexie.waitFor(tx.table("user").get("userId"))) .value; const maps = await Dexie.waitFor(tx.table("maps").toArray()); const thumbnails: Record = {}; for (let map of maps) { try { if (map.owner === userId) { thumbnails[map.id] = await createDataThumbnail(map); } } catch {} } return tx .table("maps") .toCollection() .modify((map) => { map.thumbnail = thumbnails[map.id]; map.grid.measurement = { type: "chebyshev", scale: "5ft" }; }); }); }, // 1.8.0 - Add thumbnail to tokens 20(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(20); const userId = (await Dexie.waitFor(tx.table("user").get("userId"))) .value; const tokens = await Dexie.waitFor(tx.table("tokens").toArray()); const thumbnails: Record = {}; for (let token of tokens) { try { if (token.owner === userId) { thumbnails[token.id] = await createDataThumbnail(token); } } catch {} } return tx .table("tokens") .toCollection() .modify((token) => { token.thumbnail = thumbnails[token.id]; }); }); }, // 1.8.0 - Upgrade for Dexie.Observable 21(v) { v.stores({}); }, // v1.8.1 - Shorten fog shape ids 22(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(22); return tx .table("states") .toCollection() .modify((state) => { for (let id of Object.keys(state.fogShapes)) { const newId = shortid.generate(); state.fogShapes[newId] = state.fogShapes[id]; state.fogShapes[newId].id = newId; delete state.fogShapes[id]; } }); }); }, // v1.9.0 - Add outlines to tokens 23(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(23); const tokens = await Dexie.waitFor(tx.table("tokens").toArray()); const tokenOutlines = await Dexie.waitFor( Promise.all(tokens.map(createDataOutline)) ); return tx .table("tokens") .toCollection() .modify((token) => { const tokenOutline = tokenOutlines.find((el) => el.id === token.id); if (tokenOutline) { token.outline = tokenOutline.outline; } else { token.outline = { type: "rect", width: token.width, height: token.height, x: 0, y: 0, }; } }); }); }, // v1.9.0 - Move map assets into new table 24(v, onUpgrade) { v.stores({ assets: "id, owner" }).upgrade(async (tx) => { onUpgrade?.(24); const primaryKeys = await Dexie.waitFor( tx.table("maps").toCollection().primaryKeys() ); const keyChunks = chunk(primaryKeys, 4); for (let keys of keyChunks) { let assets = []; let maps = await Dexie.waitFor(tx.table("maps").bulkGet(keys)); while (maps.length > 0) { const map = maps.pop(); assets.push({ id: uuid(), owner: map.owner, file: map.file, width: map.width, height: map.height, mime: "", prevId: map.id, prevType: "map", }); for (let resolution in map.resolutions) { const mapRes = map.resolutions[resolution]; assets.push({ id: uuid(), owner: map.owner, file: mapRes.file, width: mapRes.width, height: mapRes.height, mime: "", prevId: map.id, prevType: "mapResolution", resolution, }); } if (map.thumbnail) { assets.push({ id: uuid(), owner: map.owner, file: map.thumbnail.file, width: map.thumbnail.width, height: map.thumbnail.height, mime: "", prevId: map.id, prevType: "mapThumbnail", }); } } await tx.table("assets").bulkAdd(assets); } }); }, // v1.9.0 - Move token assets into new table 25(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(25); const primaryKeys = await Dexie.waitFor( tx.table("tokens").toCollection().primaryKeys() ); const keyChunks = chunk(primaryKeys, 4); for (let keys of keyChunks) { let assets = []; let tokens = await Dexie.waitFor(tx.table("tokens").bulkGet(keys)); while (tokens.length > 0) { let token = tokens.pop(); assets.push({ id: uuid(), owner: token.owner, file: token.file, width: token.width, height: token.height, mime: "", prevId: token.id, prevType: "token", }); if (token.thumbnail) { assets.push({ id: uuid(), owner: token.owner, file: token.thumbnail.file, width: token.thumbnail.width, height: token.thumbnail.height, mime: "", prevId: token.id, prevType: "tokenThumbnail", }); } } await tx.table("assets").bulkAdd(assets); } }); }, // v1.9.0 - Create foreign keys for assets 26(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(26); let mapUpdates: Record = {}; let tokenUpdates: Record = {}; const primaryKeys = await Dexie.waitFor( tx.table("assets").toCollection().primaryKeys() ); const keyChunks = chunk(primaryKeys, 4); for (let keys of keyChunks) { let assets = await Dexie.waitFor(tx.table("assets").bulkGet(keys)); while (assets.length > 0) { const asset = assets.pop(); const { prevId, id, prevType, resolution } = asset; if (prevType === "token" || prevType === "tokenThumbnail") { if (!(prevId in tokenUpdates)) { tokenUpdates[prevId] = {}; } } else { if (!(prevId in mapUpdates)) { mapUpdates[prevId] = {}; } } if (prevType === "map") { mapUpdates[prevId].file = id; } else if (prevType === "token") { tokenUpdates[prevId].file = id; } else if (prevType === "mapThumbnail") { mapUpdates[prevId].thumbnail = id; } else if (prevType === "tokenThumbnail") { tokenUpdates[prevId].thumbnail = id; } else if (prevType === "mapResolution") { mapUpdates[prevId][resolution] = id; } } } await tx .table("maps") .toCollection() .modify((map) => { if (map.id in mapUpdates) { for (let key in mapUpdates[map.id]) { map[key] = mapUpdates[map.id][key]; } } delete map.resolutions; }); await tx .table("tokens") .toCollection() .modify((token) => { if (token.id in tokenUpdates) { for (let key in tokenUpdates[token.id]) { token[key] = tokenUpdates[token.id][key]; } } }); }); }, // v1.9.0 - Remove asset migration helpers 27(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(27); tx.table("assets").toCollection().modify({ prevId: undefined, prevType: undefined, resolution: undefined, }); }); }, // v1.9.0 - Remap map resolution assets 28(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(28); tx.table("maps") .toCollection() .modify((map) => { const resolutions = ["low", "medium", "high", "ultra"]; map.resolutions = {}; for (let res of resolutions) { if (res in map) { map.resolutions[res] = map[res]; delete map[res]; } } delete map.lastUsed; }); }); }, // v1.9.0 - Move tokens to use more defaults 29(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(29); tx.table("tokens") .toCollection() .modify(async (token) => { token.defaultCategory = token.category; delete token.category; token.defaultLabel = ""; delete token.lastUsed; }); }); }, // v1.9.0 - Move tokens to use more defaults and add token outline to token states 30(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(30); const tokens = await Dexie.waitFor(tx.table("tokens").toArray()); tx.table("states") .toCollection() .modify((state) => { for (let id in state.tokens) { if (!state.tokens[id]?.tokenId) { delete state.tokens[id]; continue; } if (!state.tokens[id].tokenId.startsWith("__default")) { const token = tokens.find( (token) => token.id === state.tokens[id].tokenId ); if (token) { state.tokens[id].category = token.defaultCategory; state.tokens[id].file = token.file; state.tokens[id].type = "file"; state.tokens[id].outline = token.outline; state.tokens[id].width = token.width; state.tokens[id].height = token.height; state.tokens[id].owner = token.owner; } else { state.tokens[id].category = "character"; state.tokens[id].type = "file"; state.tokens[id].file = ""; state.tokens[id].outline = { type: "rect", width: 256, height: 256, x: 0, y: 0, }; state.tokens[id].width = 256; state.tokens[id].height = 256; } } else { state.tokens[id].category = "character"; state.tokens[id].type = "default"; state.tokens[id].key = Case.camel( state.tokens[id].tokenId.slice(10) ); state.tokens[id].outline = { type: "circle", x: 128, y: 128, radius: 128, }; state.tokens[id].width = 256; state.tokens[id].height = 256; } } }); }); }, // v1.9.0 - Remove maps not owned by user as cache is now done on the asset level 31(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(31); const userId = (await Dexie.waitFor(tx.table("user").get("userId"))) ?.value; if (userId) { tx.table("maps").where("owner").notEqual(userId).delete(); } }); }, // v1.9.0 - Remove tokens not owned by user as cache is now done on the asset level 32(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(32); const userId = (await Dexie.waitFor(tx.table("user").get("userId"))) ?.value; if (userId) { tx.table("tokens").where("owner").notEqual(userId).delete(); } }); }, // v1.9.0 - Store default maps and tokens in db 33(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(33); const userId = (await Dexie.waitFor(tx.table("user").get("userId"))) ?.value; if (!userId) { return; } const { maps } = getDefaultMaps(userId); tx.table("maps").bulkAdd(maps); const tokens = getDefaultTokens(userId); tx.table("tokens").bulkAdd(tokens); }); }, // v1.9.0 - Add new group table 34(v, onUpgrade) { v.stores({ groups: "id" }).upgrade(async (tx) => { onUpgrade?.(34); function groupItems(items: { id: string; group: string }[]) { let groups: Group[] = []; let subGroups: Record = {}; for (let item of items) { if (!item.group) { groups.push({ id: item.id, type: "item" }); } else if (item.group in subGroups) { subGroups[item.group].items.push({ id: item.id, type: "item" }); } else { subGroups[item.group] = { id: uuid(), type: "group", name: item.group, items: [{ id: item.id, type: "item" }], }; } } groups.push(...Object.values(subGroups)); return groups; } let maps = await Dexie.waitFor(tx.table("maps").toArray()); maps = maps.sort((a, b) => b.created - a.created); const mapGroupItems = groupItems(maps); tx.table("groups").add({ id: "maps", items: mapGroupItems }); let tokens = await Dexie.waitFor(tx.table("tokens").toArray()); tokens = tokens.sort((a, b) => b.created - a.created); const tokenGroupItems = groupItems(tokens); tx.table("groups").add({ id: "tokens", items: tokenGroupItems }); }); }, // v1.9.0 - Remove map and token group in respective tables 35(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(35); tx.table("maps") .toCollection() .modify((map) => { delete map.group; }); tx.table("tokens") .toCollection() .modify((token) => { delete token.group; }); }); }, 36(v) { v.stores({ _changes: null, _intercomm: null, _syncNodes: null, _uncommittedChanges: null, }); }, // v1.10.0 - Add rotation to notes 37(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(37); tx.table("states") .toCollection() .modify((state) => { for (let id in state.notes) { state.notes[id].rotation = 0; } }); }); }, // v1.10.0 - Delete groups again 38(v, onUpgrade) { v.stores({}).upgrade(async (tx) => { onUpgrade?.(38); tx.table("maps") .toCollection() .modify((map) => { delete map.group; }); tx.table("tokens") .toCollection() .modify((token) => { delete token.group; }); }); }, // v1.10.0 - Rename drawShapes and fogShapes in state 39(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(39); tx.table("states") .toCollection() .modify((state) => { state.drawings = state.drawShapes; state.fogs = state.fogShapes; delete state.drawShapes; delete state.fogShapes; }); }); }, // v1.10.0 (patch 1) - Rename drawShapes and fogShapes in state again (some people's didn't work) 40(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(40); tx.table("states") .toCollection() .modify((state) => { if (state.drawShapes) { state.drawings = cloneDeep(state.drawShapes); delete state.drawShapes; } if (state.fogShapes) { state.fogs = cloneDeep(state.fogShapes); delete state.fogShapes; } }); }); }, // v1.10.0 (patch 2) - Rename drawShapes and fogShapes in state again again (some people's still didn't work) 41(v, onUpgrade) { v.stores({}).upgrade((tx) => { onUpgrade?.(41); return tx .table("states") .toCollection() .modify((state) => { if (state.drawShapes) { state.drawings = cloneDeep(state.drawShapes); delete state.drawShapes; } if (state.fogShapes) { state.fogs = cloneDeep(state.fogShapes); delete state.fogShapes; } }); }); }, }; export const latestVersion = 41; /** * Load versions onto a database up to a specific version number * @param {Dexie} db * @param {number=} upTo version number to load up to, latest version if undefined * @param {UpgradeEventHandler=} onUpgrade */ export function loadVersions( db: Dexie, upTo: number | undefined = latestVersion, onUpgrade: UpgradeEventHandler | undefined ) { for (let versionNumber = 1; versionNumber <= upTo; versionNumber++) { versions[versionNumber](db.version(versionNumber), onUpgrade); } } /** * Convert from the previous representation of actions (1.7.0) to the new representation (1.8.0) * and combine into shapes */ function convertOldActionsToShapes(actions: any[], actionIndex: number) { let newShapes = {}; for (let i = 0; i <= actionIndex; i++) { const action = actions[i]; if (!action) { continue; } let newAction; if (action.shapes) { if (action.type === "add") { newAction = new AddStatesAction(action.shapes); } else if (action.type === "edit") { newAction = new EditStatesAction(action.shapes); } else if (action.type === "remove") { newAction = new RemoveStatesAction(action.shapes); } else if (action.type === "subtract") { newAction = new SubtractFogAction(action.shapes); } else if (action.type === "cut") { newAction = new CutFogAction(action.shapes); } } else if (action.type === "remove" && action.shapeIds) { newAction = new RemoveStatesAction(action.shapeIds); } if (newAction) { newShapes = newAction.execute(newShapes); } } return newShapes; } // Helper to create a thumbnail for a file in a db async function createDataThumbnail(data: any): Promise<{ file: Uint8Array; width: number; height: number; type: "file"; id: "thumbnail"; }> { let url: string; if (data?.resolutions?.low?.file) { url = URL.createObjectURL(new Blob([data.resolutions.low.file])); } else { url = URL.createObjectURL(new Blob([data.file])); } return await Dexie.waitFor( new Promise((resolve) => { let image = new Image(); image.onload = async () => { const thumbnail = await createThumbnail(image, ""); if (thumbnail) { resolve({ file: thumbnail.file, width: thumbnail.width, height: thumbnail.height, type: "file", id: "thumbnail", }); } }; image.src = url; }), 60000 * 10 // 10 minute timeout ); } async function createDataOutline( data: any ): Promise<{ id: string; outline: Outline }> { const url = URL.createObjectURL(new Blob([data.file])); return await Dexie.waitFor( new Promise((resolve) => { let image = new Image(); image.onload = async () => { resolve({ id: data.id, outline: getImageOutline(image) }); }; image.onerror = () => { resolve({ id: data.id, outline: { type: "rect", width: data.width, height: data.height, x: 0, y: 0, }, }); }; image.src = url; }) ); }