Files
gpx.studio/website/src/lib/db.ts

304 lines
11 KiB
TypeScript
Raw Normal View History

2024-05-02 19:51:08 +02:00
import Dexie, { liveQuery } from 'dexie';
import { GPXFile } from 'gpx';
2024-05-03 17:37:34 +02:00
import { type FreezedObject, type Patch, produceWithPatches, applyPatches } from 'structurajs';
2024-05-02 19:51:08 +02:00
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
import { fileOrder, selectedFiles } from './stores';
2024-05-04 15:10:30 +02:00
import { mode } from 'mode-watcher';
2024-05-05 18:59:09 +02:00
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays } from './assets/layers';
2024-05-02 19:51:08 +02:00
class Database extends Dexie {
2024-05-03 15:59:34 +02:00
fileids!: Dexie.Table<string, string>;
2024-05-02 19:51:08 +02:00
files!: Dexie.Table<FreezedObject<GPXFile>, string>;
patches!: Dexie.Table<{ patch: Patch[], inversePatch: Patch[] }, number>;
settings!: Dexie.Table<any, string>;
constructor() {
2024-05-03 15:59:34 +02:00
super("Database", {
cache: 'immutable'
});
2024-05-02 19:51:08 +02:00
this.version(1).stores({
2024-05-03 15:59:34 +02:00
fileids: ',&fileid',
files: '',
patches: ',patch',
settings: ''
2024-05-02 19:51:08 +02:00
});
2024-05-03 15:59:34 +02:00
this.files.add
2024-05-02 19:51:08 +02:00
}
}
const db = new Database();
2024-05-05 18:59:09 +02:00
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
function dexieSettingStore(setting: string, initial: any): Writable<any> {
let store = writable(initial);
liveQuery(() => db.settings.get(setting)).subscribe(value => {
if (value !== undefined) {
store.set(value);
}
});
return {
subscribe: store.subscribe,
set: (value: any) => db.settings.put(value, setting),
update: (callback: (value: any) => any) => {
let newValue = callback(get(store));
db.settings.put(newValue, setting);
}
};
}
export const settings = {
distanceUnits: dexieSettingStore('distanceUnits', 'metric'),
velocityUnits: dexieSettingStore('velocityUnits', 'speed'),
temperatureUnits: dexieSettingStore('temperatureUnits', 'celsius'),
mode: dexieSettingStore('mode', (() => {
let currentMode: string | undefined = get(mode);
if (currentMode === undefined) {
currentMode = 'system';
}
return currentMode;
})()),
routing: dexieSettingStore('routing', true),
routingProfile: dexieSettingStore('routingProfile', 'bike'),
privateRoads: dexieSettingStore('privateRoads', false),
currentBasemap: dexieSettingStore('currentBasemap', defaultBasemap),
previousBasemap: dexieSettingStore('previousBasemap', defaultBasemap),
selectedBasemapTree: dexieSettingStore('selectedBasemapTree', defaultBasemapTree),
currentOverlays: dexieSettingStore('currentOverlays', defaultOverlays),
previousOverlays: dexieSettingStore('previousOverlays', defaultOverlays),
selectedOverlayTree: dexieSettingStore('selectedOverlayTree', defaultOverlayTree),
};
2024-05-04 14:27:12 +02:00
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
function dexieStore<T>(querier: () => T | Promise<T>, initial?: T): Readable<T> {
let store = writable<T>(initial);
2024-05-03 15:59:34 +02:00
liveQuery(querier).subscribe(value => {
if (value !== undefined) {
2024-05-04 14:27:12 +02:00
store.set(value);
2024-05-03 15:59:34 +02:00
}
});
2024-05-02 19:51:08 +02:00
return {
2024-05-04 14:27:12 +02:00
subscribe: store.subscribe,
2024-05-03 15:59:34 +02:00
};
}
2024-05-04 14:27:12 +02:00
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, also takes care of the conversion to a GPXFile object
function dexieGPXFileStore(querier: () => FreezedObject<GPXFile> | undefined | Promise<FreezedObject<GPXFile> | undefined>): Readable<GPXFile> {
let store = writable<GPXFile>(undefined);
2024-05-03 15:59:34 +02:00
liveQuery(querier).subscribe(value => {
if (value !== undefined) {
2024-05-04 14:27:12 +02:00
let gpx = new GPXFile(value);
fileState.set(gpx._data.id, gpx);
store.set(gpx);
2024-05-02 19:51:08 +02:00
}
2024-05-03 15:59:34 +02:00
});
return {
2024-05-04 14:27:12 +02:00
subscribe: store.subscribe
2024-05-03 15:59:34 +02:00
};
}
2024-05-04 14:27:12 +02:00
// Add/update the files to the database
function updateDbFiles(files: (FreezedObject<GPXFile> | undefined)[], add: boolean = false) {
2024-05-03 15:59:34 +02:00
let filteredFiles = files.filter(file => file !== undefined) as FreezedObject<GPXFile>[];
let fileIds = filteredFiles.map(file => file._data.id);
if (add) {
return db.transaction('rw', db.fileids, db.files, async () => {
await db.fileids.bulkAdd(fileIds, fileIds);
await db.files.bulkAdd(filteredFiles, fileIds);
});
} else {
return db.files.bulkPut(filteredFiles, fileIds);
2024-05-02 19:51:08 +02:00
}
}
2024-05-04 14:27:12 +02:00
// Delete the files with the given ids from the database
function deleteDbFiles(fileIds: string[]) {
2024-05-03 15:59:34 +02:00
return db.transaction('rw', db.fileids, db.files, async () => {
await db.fileids.bulkDelete(fileIds);
await db.files.bulkDelete(fileIds);
});
}
2024-05-04 14:27:12 +02:00
// Commit the changes to the file state to the database
2024-05-03 15:59:34 +02:00
function commitFileStateChange(newFileState: ReadonlyMap<string, FreezedObject<GPXFile>>, patch: Patch[]) {
if (newFileState.size > fileState.size) {
2024-05-04 14:27:12 +02:00
return updateDbFiles(getChangedFileIds(patch).map((fileId) => newFileState.get(fileId)), true);
2024-05-03 15:59:34 +02:00
} else if (newFileState.size === fileState.size) {
2024-05-04 14:27:12 +02:00
return updateDbFiles(getChangedFileIds(patch).map((fileId) => newFileState.get(fileId)));
2024-05-03 15:59:34 +02:00
} else {
2024-05-04 14:27:12 +02:00
return deleteDbFiles(getChangedFileIds(patch));
2024-05-03 15:59:34 +02:00
}
2024-05-02 19:51:08 +02:00
}
2024-05-03 17:37:34 +02:00
export const fileObservers: Writable<Map<string, Readable<GPXFile | undefined>>> = writable(new Map());
const fileState: Map<string, GPXFile> = new Map(); // Used to generate patches
2024-05-02 19:51:08 +02:00
2024-05-04 14:27:12 +02:00
// Observe the file ids in the database, and maintain a map of file observers for the corresponding files
2024-05-03 15:59:34 +02:00
liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => {
2024-05-02 19:51:08 +02:00
// Find new files to observe
2024-05-03 15:59:34 +02:00
let newFiles = dbFileIds.filter(id => !get(fileObservers).has(id));
2024-05-02 19:51:08 +02:00
// Find deleted files to stop observing
2024-05-03 15:59:34 +02:00
let deletedFiles = Array.from(get(fileObservers).keys()).filter(id => !dbFileIds.find(fileId => fileId === id));
2024-05-02 19:51:08 +02:00
// Update the store
if (newFiles.length > 0 || deletedFiles.length > 0) {
fileObservers.update($files => {
newFiles.forEach(id => {
2024-05-04 14:27:12 +02:00
$files.set(id, dexieGPXFileStore(() => db.files.get(id)));
2024-05-02 19:51:08 +02:00
});
deletedFiles.forEach(id => {
$files.delete(id);
fileState.delete(id);
});
return $files;
});
}
});
2024-05-03 15:59:34 +02:00
const patchIndex: Readable<number> = dexieStore(() => db.settings.get('patchIndex'), -1);
2024-05-04 11:00:56 +02:00
const patchCount: Readable<number> = dexieStore(() => db.patches.count(), 0);
2024-05-03 15:59:34 +02:00
export const canUndo: Readable<boolean> = derived(patchIndex, ($patchIndex) => $patchIndex >= 0);
2024-05-04 11:00:56 +02:00
export const canRedo: Readable<boolean> = derived([patchIndex, patchCount], ([$patchIndex, $patchCount]) => $patchIndex < $patchCount - 1);
2024-05-02 19:51:08 +02:00
2024-05-04 14:27:12 +02:00
// Helper function to apply a callback to the global file state
function applyGlobal(callback: (files: Map<string, GPXFile>) => void) {
2024-05-02 19:51:08 +02:00
const [newFileState, patch, inversePatch] = produceWithPatches(fileState, callback);
2024-05-04 14:27:12 +02:00
storePatches(patch, inversePatch);
2024-05-02 19:51:08 +02:00
2024-05-03 15:59:34 +02:00
return commitFileStateChange(newFileState, patch);
2024-05-02 19:51:08 +02:00
}
2024-05-04 14:27:12 +02:00
// Helper function to apply a callback to multiple files
2024-05-02 19:51:08 +02:00
function applyToFiles(fileIds: string[], callback: (file: GPXFile) => void) {
const [newFileState, patch, inversePatch] = produceWithPatches(fileState, (draft) => {
fileIds.forEach((fileId) => {
2024-05-03 15:59:34 +02:00
let file = draft.get(fileId);
if (file) {
callback(file);
}
2024-05-02 19:51:08 +02:00
});
});
2024-05-04 14:27:12 +02:00
storePatches(patch, inversePatch);
2024-05-02 19:51:08 +02:00
2024-05-03 15:59:34 +02:00
return commitFileStateChange(newFileState, patch);
2024-05-02 19:51:08 +02:00
}
2024-05-04 14:27:12 +02:00
// Store the new patches in the database
async function storePatches(patch: Patch[], inversePatch: Patch[]) {
2024-05-03 15:59:34 +02:00
if (get(patchIndex) !== undefined) {
db.patches.where(':id').above(get(patchIndex)).delete();
}
db.transaction('rw', db.patches, db.settings, async () => {
await db.patches.put({
patch,
inversePatch
}, get(patchIndex) + 1);
await db.settings.put(get(patchIndex) + 1, 'patchIndex');
2024-05-02 19:51:08 +02:00
});
}
2024-05-04 14:27:12 +02:00
// Apply a patch to the file state
2024-05-02 19:51:08 +02:00
function applyPatch(patch: Patch[]) {
let newFileState = applyPatches(fileState, patch);
2024-05-03 15:59:34 +02:00
return commitFileStateChange(newFileState, patch);
}
2024-05-04 14:27:12 +02:00
// Get the file ids of the files that have changed in the patch
2024-05-03 15:59:34 +02:00
function getChangedFileIds(patch: Patch[]) {
let changedFileIds = [];
2024-05-02 19:51:08 +02:00
for (let p of patch) {
let fileId = p.p?.toString();
if (fileId) {
2024-05-03 15:59:34 +02:00
changedFileIds.push(fileId);
2024-05-02 19:51:08 +02:00
}
}
2024-05-03 15:59:34 +02:00
return changedFileIds;
2024-05-02 19:51:08 +02:00
}
2024-05-04 14:27:12 +02:00
// Generate unique file ids, different from the ones in the database
2024-05-03 15:59:34 +02:00
function getFileIds(n: number) {
let ids = [];
for (let index = 0; ids.length < n; index++) {
2024-05-02 19:51:08 +02:00
let id = `gpx-${index}`;
if (!get(fileObservers).has(id)) {
2024-05-03 15:59:34 +02:00
ids.push(id);
2024-05-02 19:51:08 +02:00
}
}
2024-05-03 15:59:34 +02:00
return ids;
2024-05-02 19:51:08 +02:00
}
2024-05-04 14:27:12 +02:00
// Helper functions for file operations
2024-05-02 19:51:08 +02:00
export const dbUtils = {
add: (file: GPXFile) => {
2024-05-03 15:59:34 +02:00
file._data.id = getFileIds(1)[0];
return applyGlobal((draft) => {
2024-05-02 19:51:08 +02:00
draft.set(file._data.id, file);
});
},
addMultiple: (files: GPXFile[]) => {
2024-05-03 15:59:34 +02:00
return applyGlobal((draft) => {
let ids = getFileIds(files.length);
files.forEach((file, index) => {
file._data.id = ids[index];
2024-05-02 19:51:08 +02:00
draft.set(file._data.id, file);
});
});
},
applyToFile: (id: string, callback: (file: GPXFile) => void) => {
applyToFiles([id], callback);
},
applyToSelectedFiles: (callback: (file: GPXFile) => void) => {
applyToFiles(get(fileOrder).filter(fileId => get(selectedFiles).has(fileId)), callback);
},
duplicateSelectedFiles: () => {
applyGlobal((draft) => {
2024-05-03 15:59:34 +02:00
let ids = getFileIds(get(fileOrder).length);
get(fileOrder).forEach((fileId, index) => {
2024-05-02 19:51:08 +02:00
if (get(selectedFiles).has(fileId)) {
let file = draft.get(fileId);
if (file) {
let clone = file.clone();
2024-05-03 15:59:34 +02:00
clone._data.id = ids[index];
2024-05-02 19:51:08 +02:00
draft.set(clone._data.id, clone);
}
}
});
});
},
deleteSelectedFiles: () => {
applyGlobal((draft) => {
get(selectedFiles).forEach((fileId) => {
draft.delete(fileId);
});
});
},
deleteAllFiles: () => {
applyGlobal((draft) => {
draft.clear();
});
},
2024-05-04 14:27:12 +02:00
// undo-redo
undo: () => {
if (get(canUndo)) {
let index = get(patchIndex);
db.patches.get(index).then(patch => {
if (patch) {
applyPatch(patch.inversePatch);
db.settings.put(index - 1, 'patchIndex');
}
});
}
},
redo: () => {
if (get(canRedo)) {
let index = get(patchIndex) + 1;
db.patches.get(index).then(patch => {
if (patch) {
applyPatch(patch.patch);
db.settings.put(index, 'patchIndex');
}
});
}
}
2024-05-05 18:59:09 +02:00
}