diff --git a/client/package.json b/client/package.json index 3fbac0f4f..da9b2d1d5 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "vue-media-annotator", - "version": "1.3.1", + "version": "1.3.2", "author": { "name": "Kitware, Inc.", "email": "viame-web@kitware.com" @@ -16,7 +16,7 @@ "build:cli": "tsc -p tsconfig.cli.json", "lint": "vue-cli-service lint src/ viame-web-common/ platform/", "lint:templates": "vtc --workspace . --srcDir src/", - "test": "vue-cli-service test:unit src/ viame-web-common/", + "test": "vue-cli-service test:unit src/ viame-web-common/ platform/", "serialize": "ts-node --project tsconfig.cli.json platform/desktop/backend/serializers/cli.ts" }, "resolutions": { @@ -55,13 +55,17 @@ }, "devDependencies": { "@types/axios": "^0.14.0", + "@types/body-parser": "^1.19.0", + "@types/cors": "^2.8.9", "@types/csv-parse": "^1.2.2", "@types/d3": "^5.7.2", "@types/electron-devtools-installer": "^2.2.0", + "@types/express": "^4.17.9", "@types/geojson": "^7946.0.7", "@types/jest": "^25.2.3", "@types/lodash": "^4.14.151", "@types/mime-types": "^2.1.0", + "@types/mock-fs": "^4.13.0", "@types/node": "^14.0.5", "@types/pump": "^1.1.0", "@types/range-parser": "^1.2.3", @@ -81,6 +85,8 @@ "babel-eslint": "^10.1.0", "babel-jest": "^26.0.1", "babel-register": "^6.26.0", + "body-parser": "^1.19.0", + "cors": "^2.8.5", "csv-parse": "^4.13.1", "electron": "^10.1.3", "electron-devtools-installer": "^3.1.1", @@ -88,11 +94,13 @@ "eslint-import-resolver-typescript": "^2.2.0", "eslint-plugin-import": "^2.20.2", "eslint-plugin-vue": "^6.2.2", + "express": "^4.17.1", "fs-extra": "^9.0.1", "git-describe": "^4.0.4", "jest": "^26.0.1", "jest-transform-stub": "^2.0.0", "mime-types": "^2.1.27", + "mock-fs": "^4.13.0", "pump": "^3.0.0", "range-parser": "^1.2.1", "request": "^2.88.2", @@ -121,6 +129,7 @@ "moduleNameMapper": { "^vue-media-annotator/(.*)$": "/src/$1", "^viame-web-common/(.*)$": "/viame-web-common/$1", + "^platform/(.*)$": "/platform/$1", "\\.css$": "/test/stub.js" }, "transform": { diff --git a/client/platform/desktop/App.vue b/client/platform/desktop/App.vue index 557983f29..a599479c3 100644 --- a/client/platform/desktop/App.vue +++ b/client/platform/desktop/App.vue @@ -7,7 +7,7 @@ diff --git a/client/platform/desktop/frontend/store/dataset.ts b/client/platform/desktop/frontend/store/dataset.ts new file mode 100644 index 000000000..d3bf2d9aa --- /dev/null +++ b/client/platform/desktop/frontend/store/dataset.ts @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import { uniqBy } from 'lodash'; +import Install, { ref, computed } from '@vue/composition-api'; +import { JsonMeta } from 'platform/desktop/constants'; + +const RecentsKey = 'desktop.recent'; + +// TODO remove this: this won't be necessary in Vue 3 +Vue.use(Install); + +const datasets = ref({} as Record); + +/** + * Return reactive variable that will update + * if properties of the dataset change + * @param id dataset id path + */ +function getDataset(id: string) { + return computed(() => datasets.value[id]); +} + +/** + * Load recent datasets from localstorage + */ +function getRecents(): JsonMeta[] { + const arr = window.localStorage.getItem(RecentsKey); + try { + if (arr) { + const maybeArr = JSON.parse(arr); + if (maybeArr.length) { + return maybeArr; + } + } + } catch (err) { + return []; + } + return []; +} + +/** + * Add ID to recent datasets + * @param id dataset id path + */ +function setRecents(meta: JsonMeta) { + Vue.set(datasets.value, meta.id, meta); + const recents = getRecents(); + recents.splice(0, 0, meta); // verify that it's a valid path + const recentsStrings = uniqBy(recents, ({ id }) => id); + window.localStorage.setItem(RecentsKey, JSON.stringify(recentsStrings)); +} + +export { + datasets, + getDataset, + getRecents, + setRecents, + RecentsKey, +}; diff --git a/client/platform/desktop/frontend/store/index.ts b/client/platform/desktop/frontend/store/index.ts new file mode 100644 index 000000000..e8f239c5e --- /dev/null +++ b/client/platform/desktop/frontend/store/index.ts @@ -0,0 +1,54 @@ +import { Api, Pipe } from 'viame-web-common/apispec'; +import * as api from 'platform/desktop/frontend/api'; + +/* Warning, this import involves node.js code for loadDetections (below) */ +import common from 'platform/desktop/backend/native/common'; + +import { settings } from './settings'; +import { getRecents, setRecents, RecentsKey } from './dataset'; +import { getOrCreateHistory } from './jobs'; + +/* Run forward migrations on any client-side data stores */ +export async function migrate() { + const recents = await getRecents(); + if (recents.length && typeof recents[0] === 'string') { + window.localStorage.setItem(RecentsKey, JSON.stringify([])); + } +} + +/** + * Wrap API with hooks to use the store + */ +export default function wrap(): Api { + async function runPipeline(itemId: string, pipeline: Pipe) { + const job = await api.runPipeline(itemId, pipeline); + getOrCreateHistory(job, job.datasetIds); + } + + async function loadMetadata(datasetId: string) { + const meta = await api.loadMetadata(datasetId); + setRecents(meta); + return meta; + } + + /** + * loadDetections loads JSON data directly from disk into the + * renderer thread. It relies on the node runtime being enabled on the browser. + * + * This is done such that large annotation files do not need to be loaded into memory + * twice, serialized and deserialized twice, and transmitted over the local network. + * + * In a future version, this could me moved to the backend and streamed directly + * to the client using something like https://github.com/uhop/stream-json + */ + async function loadDetections(datasetId: string) { + return common.loadDetections(settings.value, datasetId); + } + + return { + ...api, + loadDetections, + loadMetadata, + runPipeline, + }; +} diff --git a/client/platform/desktop/store/jobs.ts b/client/platform/desktop/frontend/store/jobs.ts similarity index 86% rename from client/platform/desktop/store/jobs.ts rename to client/platform/desktop/frontend/store/jobs.ts index 80c58c4ec..f801d19c2 100644 --- a/client/platform/desktop/store/jobs.ts +++ b/client/platform/desktop/frontend/store/jobs.ts @@ -6,7 +6,7 @@ import Vue from 'vue'; import Install, { ref, Ref, set, computed, } from '@vue/composition-api'; -import { DesktopDataset, DesktopJob, DesktopJobUpdate } from '../constants'; +import { DesktopJob, DesktopJobUpdate } from 'platform/desktop/constants'; // TODO remove this: this won't be necessary in Vue 3 Vue.use(Install); @@ -14,14 +14,14 @@ Vue.use(Install); interface DesktopJobHistory { job: DesktopJob; logs: string[]; - datasets: DesktopDataset[]; + datasets: string[]; } const jobHistory: Ref> = ref({}); const recentHistory = computed(() => Object.values(jobHistory.value)); const runningJobs = computed(() => recentHistory.value.filter((v) => v.job.exitCode === null)); -function getOrCreateHistory(args: DesktopJob, datasets?: DesktopDataset[]): DesktopJobHistory { +function getOrCreateHistory(args: DesktopJob, datasets?: string[]): DesktopJobHistory { let existing = jobHistory.value[args.key]; if (!existing) { set(jobHistory.value, args.key, { diff --git a/client/platform/desktop/store/settings.ts b/client/platform/desktop/frontend/store/settings.ts similarity index 78% rename from client/platform/desktop/store/settings.ts rename to client/platform/desktop/frontend/store/settings.ts index 06fc7c7df..e4bb96cfe 100644 --- a/client/platform/desktop/store/settings.ts +++ b/client/platform/desktop/frontend/store/settings.ts @@ -1,8 +1,7 @@ import Vue from 'vue'; import Install, { ref } from '@vue/composition-api'; import { ipcRenderer } from 'electron'; - -import { Settings } from '../constants'; +import { Settings } from 'platform/desktop/constants'; // TODO remove this: this won't be necessary in Vue 3 Vue.use(Install); @@ -38,17 +37,25 @@ async function init() { if (settingsStr) { const maybeSettings = JSON.parse(settingsStr); if (isSettings(maybeSettings)) { - settingsvalue = maybeSettings; + settingsvalue = { + // Populate from defaults to include any missing properties + ...settingsvalue, + // Overwrite with explicitly persisted settings + ...maybeSettings, + }; } } } catch { // pass } settings.value = settingsvalue; + + ipcRenderer.send('update-settings', settings.value); } async function setSettings(s: Settings) { window.localStorage.setItem(SettingsKey, JSON.stringify(s)); + ipcRenderer.send('update-settings', settings.value); } // Will be initialized on first import diff --git a/client/platform/desktop/main.ts b/client/platform/desktop/main.ts index 4d4a94b2e..8e141b804 100644 --- a/client/platform/desktop/main.ts +++ b/client/platform/desktop/main.ts @@ -7,6 +7,7 @@ import vMousetrap from 'viame-web-common/vue-utilities/v-mousetrap'; import vuetify from './plugins/vuetify'; import router from './router'; +import { migrate } from './frontend/store'; import App from './App.vue'; Vue.config.productionTip = false; @@ -15,13 +16,14 @@ Vue.use(snackbarService(vuetify)); Vue.use(promptService(vuetify)); Vue.use(vMousetrap); - -new Vue({ - vuetify, - router, - provide: { vuetify }, - render: (h) => h(App), -}) - .$mount('#app') - .$snackbarAttach() - .$promptAttach(); +migrate().then(() => { + new Vue({ + vuetify, + router, + provide: { vuetify }, + render: (h) => h(App), + }) + .$mount('#app') + .$snackbarAttach() + .$promptAttach(); +}); diff --git a/client/platform/desktop/router.ts b/client/platform/desktop/router.ts index 9a544aa4c..337979a02 100644 --- a/client/platform/desktop/router.ts +++ b/client/platform/desktop/router.ts @@ -1,10 +1,10 @@ import Vue from 'vue'; import Router from 'vue-router'; -import Jobs from './components/Jobs.vue'; -import Recent from './components/Recent.vue'; -import Settings from './components/Settings.vue'; -import ViewerLoader from './components/ViewerLoader.vue'; +import Jobs from './frontend/components/Jobs.vue'; +import Recent from './frontend/components/Recent.vue'; +import Settings from './frontend/components/Settings.vue'; +import ViewerLoader from './frontend/components/ViewerLoader.vue'; Vue.use(Router); @@ -26,7 +26,7 @@ export default new Router({ component: Jobs, }, { - path: '/viewer/:path', + path: '/viewer/:id', name: 'viewer', component: ViewerLoader, props: true, diff --git a/client/platform/desktop/store/dataset.ts b/client/platform/desktop/store/dataset.ts deleted file mode 100644 index 1105bc90d..000000000 --- a/client/platform/desktop/store/dataset.ts +++ /dev/null @@ -1,70 +0,0 @@ -import path from 'path'; - -import Vue from 'vue'; -import { uniq } from 'lodash'; -import Install, { ref, computed } from '@vue/composition-api'; -import { DesktopDataset } from '../constants'; - -const RecentsKey = 'desktop.recent'; - -// TODO remove this: this won't be necessary in Vue 3 -Vue.use(Install); - -const dsmap = ref({} as Record); - -/** - * Return reactive variable that will update - * if properties of the dataset change - * @param id dataset id path - */ -function getDataset(id: string) { - return computed(() => dsmap.value[id]); -} - -/** - * Load recent datasets from localstorage - */ -function getRecents(): path.ParsedPath[] { - const arr = window.localStorage.getItem(RecentsKey); - let returnVal = [] as path.ParsedPath[]; - try { - if (arr) { - const maybeArr = JSON.parse(arr); - if (maybeArr.length) { - returnVal = maybeArr.map((p: string) => path.parse(p)); - } - } - } catch (err) { - return returnVal; - } - return returnVal; -} - -/** - * Add ID to recent datasets - * @param id dataset id path - */ -function setRecents(id: string) { - const recents = getRecents(); - recents.splice(0, 0, path.parse(id)); // verify that it's a valid path - let recentsStrings = recents.map((r) => path.join(r.dir, r.base)); - recentsStrings = uniq(recentsStrings); - window.localStorage.setItem(RecentsKey, JSON.stringify(recentsStrings)); -} - -/** - * Set properties of in-memory dataset, - * and persist ID to recents - * @param id dataset id path - * @param ds properties - */ -function setDataset(id: string, ds: DesktopDataset) { - Vue.set(dsmap.value, id, ds); - setRecents(id); -} - -export { - getDataset, - setDataset, - getRecents, -}; diff --git a/client/platform/desktop/store/index.ts b/client/platform/desktop/store/index.ts deleted file mode 100644 index 0c0ac41e3..000000000 --- a/client/platform/desktop/store/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Api, Pipe } from 'viame-web-common/apispec'; -import * as api from '../api/main'; - -import { settings } from './settings'; -import { setDataset, getDataset } from './dataset'; -import { getOrCreateHistory } from './jobs'; - -/** - * Wrap API with hooks to use the store - */ -export default function wrap(): Api { - async function loadMetadata(datasetId: string) { - const ds = await api.loadMetadata(datasetId); - setDataset(datasetId, ds); - return ds.meta; - } - - async function getPipelineList() { - return api.getPipelineList(settings.value); - } - - async function runPipeline(itemId: string, pipeline: Pipe) { - const job = await api.runPipeline(itemId, pipeline, settings.value); - const datasets = job.datasetIds.map(((id) => getDataset(id).value)); - getOrCreateHistory(job, datasets); - } - - return { - ...api, - loadMetadata, - getPipelineList, - runPipeline, - }; -} diff --git a/client/platform/web-girder/App.vue b/client/platform/web-girder/App.vue index 93374a475..aedfa4524 100644 --- a/client/platform/web-girder/App.vue +++ b/client/platform/web-girder/App.vue @@ -6,8 +6,8 @@