Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Next-generation in vivo electrophysiology planning and automation tool.

# Usage & Documentation

[Launch the web app](https://pinpoint.allenneuraldynamics.org/v5/#/). Documentation is coming soon.
[Launch the web app](https://pinpoint.allenneuraldynamics.org/v5/#/). Documentation is coming soon.

<!--For instructions, desktop releases, and tutorials please see the [documentation](https://virtualbrainlab.org/pinpoint/installation_and_use.html).-->

Expand Down
14 changes: 13 additions & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
pre-commit:
jobs:
- name: format
run: npm run format
run: pnpm format
stage_fixed: true

- name: lint
run: pnpm exec oxlint {staged_files}
glob: "*.{js,ts,mjs,cjs,vue}"

pre-push:
jobs:
- name: typecheck
run: pnpm typecheck

- name: test
run: pnpm vitest run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dbirman Testing takes a really long time (tens of seconds on a really strong PC)... are you sure you want to be running that every time you want to push? Typecheck on TS v6 is also really slow (maybe 10 seconds), and TS v7 (which fixes this) support won't come for Vue until v7.1. That's why I stripped out these two functions from lefthook and instead put them in GitHub Actions as required checks before PR merges.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"test": "vitest",
"coverage": "vitest run --coverage",
"testui": "vitest --ui",
"postinstall": "quasar prepare --silent",
"postinstall": "quasar prepare --silent && lefthook install",
"local-atlas": "pnpx serve -C ~/.brainglobe/",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
Expand Down
7 changes: 6 additions & 1 deletion quasar.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export default defineConfig(ctx => {
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: ["i18n.boot", "appearance.boot", "babylon-runtime.service.boot"],
boot: [
"i18n.boot",
"appearance.boot",
"babylon-runtime.service.boot",
"sync.boot"
],

// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
// Order matters: tokens define the custom properties that theme.sass reads.
Expand Down
13 changes: 13 additions & 0 deletions src/boot/sync.boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineBoot } from "#q-app";
import { useExperimentSync } from "@/features/sync";

/**
* Start mirroring experiments to the sync server and adopt any live ORCID
* session left by a login redirect.
*/
export default defineBoot(({ store }) => {
const { restoreSession, watchCurrentExperiment } = useExperimentSync(store);

watchCurrentExperiment();
void restoreSession();
});
22 changes: 22 additions & 0 deletions src/features/experiment/api/experiment-file.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
zipSync
} from "fflate";
import type { Experiment } from "../models/experiment.model";
import type { ExperimentAuthor } from "../models/experiment-author.model";
import type { VisibleStructure } from "../models/visible-structure.model";
import { getExperimentModelIds } from "./experiment.api";
import { isAtlas } from "@/features/atlas";
Expand Down Expand Up @@ -165,6 +166,8 @@ function isExperiment(value: unknown): value is Experiment {
const {
id,
version,
updatedAt,
author,
name,
atlas,
referenceCoordinate,
Expand All @@ -179,6 +182,10 @@ function isExperiment(value: unknown): value is Experiment {

if (typeof id !== "string") return false;
if (typeof version !== "string") return false;
if (typeof updatedAt !== "string" || Number.isNaN(Date.parse(updatedAt))) {
return false;
}
if (!isExperimentAuthorOrNull(author)) return false;
if (typeof name !== "string") return false;
if (!isAtlas(atlas)) return false;
if (!isFiniteTriple(referenceCoordinate)) return false;
Expand Down Expand Up @@ -239,6 +246,21 @@ function isExperiment(value: unknown): value is Experiment {
);
}

/**
* Check that a value is an `ExperimentAuthor`, or null for an unclaimed experiment.
* @param value Value to check.
*/
function isExperimentAuthorOrNull(
value: unknown
): value is ExperimentAuthor | null {
return (
value === null ||
(isRecord(value) &&
typeof value.orcid === "string" &&
typeof value.name === "string")
);
}

/**
* Check that a value has the shape of a `VisibleStructure`.
* @param value Value to check.
Expand Down
30 changes: 30 additions & 0 deletions src/features/experiment/api/experiment-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,36 @@ describe("zipExperiment / unzipExperiment", () => {
expect(unzipExperiment(zipRawExperiment(rest))).toBeNull();
});

it("returns null when updatedAt is missing", () => {
const { updatedAt: _updatedAt, ...rest } = makeFullExperiment();
expect(unzipExperiment(zipRawExperiment(rest))).toBeNull();
});

it("returns null when updatedAt is not a parseable timestamp", () => {
const experiment = { ...makeFullExperiment(), updatedAt: "not a date" };
expect(unzipExperiment(zipRawExperiment(experiment))).toBeNull();
});

it("returns null when author is missing", () => {
const { author: _author, ...rest } = makeFullExperiment();
expect(unzipExperiment(zipRawExperiment(rest))).toBeNull();
});

it("returns null when author is missing its orcid", () => {
const experiment = { ...makeFullExperiment(), author: { name: "Alice" } };
expect(unzipExperiment(zipRawExperiment(experiment))).toBeNull();
});

it("round-trips an authored experiment", () => {
const experiment = {
...makeFullExperiment(),
author: { orcid: "0000-0001-2345-6789", name: "Alice" }
};
expect(unzipExperiment(zipRawExperiment(experiment))?.experiment).toEqual(
experiment
);
});

it("returns null when name is missing", () => {
const { name: _name, ...rest } = makeFullExperiment();
expect(unzipExperiment(zipRawExperiment(rest))).toBeNull();
Expand Down
2 changes: 2 additions & 0 deletions src/features/experiment/api/experiment.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export function buildExperiment(
return {
id: crypto.randomUUID(),
version: import.meta.env.APP_VERSION,
updatedAt: new Date().toISOString(),
author: null,
name,
atlas,
referenceCoordinate,
Expand Down
6 changes: 6 additions & 0 deletions src/features/experiment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ export { default as RecentExperimentsDialog } from "./components/RecentExperimen
export { default as RecentExperimentsList } from "./components/RecentExperimentsList.vue";
export { default as ExperimentPropertiesDialog } from "./components/ExperimentPropertiesDialog.vue";
export { useExperimentFile } from "./composable/useExperimentFile";
export {
unzipExperiment,
zipExperiment,
type SceneModelFile
} from "./api/experiment-file.api";
export type { Experiment } from "./models/experiment.model";
export type { ExperimentAuthor } from "./models/experiment-author.model";
export type { CameraPose } from "./models/camera-pose.model";
export type { VisibleStructure } from "./models/visible-structure.model";
export {
Expand Down
8 changes: 8 additions & 0 deletions src/features/experiment/models/experiment-author.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** The ORCID account an experiment belongs to. */
export interface ExperimentAuthor {
/** Author's ORCID iD, e.g. `0000-0001-2345-6789`. */
orcid: string;

/** Author's display name as reported by ORCID. */
name: string;
}
7 changes: 7 additions & 0 deletions src/features/experiment/models/experiment.model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Atlas } from "@/features/atlas";
import type { ExperimentAuthor } from "./experiment-author.model";
import type { CoordinateSystem } from "@/features/coordinate-system";
import type { CameraPose } from "./camera-pose.model";
import type { VisibleStructure } from "./visible-structure.model";
Expand All @@ -12,6 +13,12 @@ export interface Experiment {
// Semantic version of Pinpoint.
version: string;

/** ISO-8601 timestamp of the experiment's last edit, used to resolve syncs. */
updatedAt: string;

/** ORCID account this experiment belongs to, or null while unclaimed. */
author: ExperimentAuthor | null;

// Can be anything since ID is unique.
name: string;
atlas: Atlas;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe("PreferencesDialog", () => {
wrappers.unmountAll();
});

it("renders the title and the five tabs", async () => {
it("renders the title and the six tabs", async () => {
const wrapper = await mountDialog();

expect(document.body.textContent).toContain(t.title);
Expand All @@ -59,6 +59,7 @@ describe("PreferencesDialog", () => {
t.scene,
t.probe,
t.export,
t.sync,
t.reset
]);
});
Expand Down
3 changes: 3 additions & 0 deletions src/features/preferences/components/PreferencesDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ScenePreferences from "./ScenePreferences.vue";
import ProbePreferences from "./ProbePreferences.vue";
import ExportPreferences from "./ExportPreferences.vue";
import ResetPreferences from "./ResetPreferences.vue";
import { SyncPreferences } from "@/features/sync";
import type { PreferencesTab } from "../models/preferences-dialog.model";

defineEmits([...useDialogPluginComponent.emits]);
Expand All @@ -24,6 +25,7 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
<q-tab name="scene" :label="$t('preferences.scene')" />
<q-tab name="probe" :label="$t('preferences.probe')" />
<q-tab name="export" :label="$t('preferences.export')" />
<q-tab name="sync" :label="$t('preferences.sync')" />
<q-tab name="reset" :label="$t('preferences.reset')" />
</q-tabs>
<q-separator />
Expand All @@ -34,6 +36,7 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
</q-tab-panel>
<q-tab-panel name="probe"><ProbePreferences /></q-tab-panel>
<q-tab-panel name="export"><ExportPreferences /></q-tab-panel>
<q-tab-panel name="sync"><SyncPreferences /></q-tab-panel>
<q-tab-panel name="reset"><ResetPreferences /></q-tab-panel>
</q-tab-panels>
</q-card-section>
Expand Down
8 changes: 7 additions & 1 deletion src/features/preferences/models/preferences-dialog.model.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
/** Which tab the preferences dialog opens on. */
export type PreferencesTab = "general" | "scene" | "probe" | "export" | "reset";
export type PreferencesTab =
| "general"
| "scene"
| "probe"
| "export"
| "sync"
| "reset";
16 changes: 15 additions & 1 deletion src/features/splash/components/SplashDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
useExperimentFile
} from "@/features/experiment";
import { useRecentExperimentsStore } from "@/stores/recent-experiments.store";
import { SyncRecentExperimentsList, useExperimentSync } from "@/features/sync";
import { useSyncStore } from "@/stores/sync.store";

const appVersion = import.meta.env.APP_VERSION;
const BASE_URL = import.meta.env.BASE_URL;
Expand All @@ -16,6 +18,8 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
const $q = useQuasar();
const { openExperiment, onOpened } = useExperimentFile();
const recentExperimentStore = useRecentExperimentsStore();
const syncStore = useSyncStore();
const { signIn } = useExperimentSync();

onOpened(onDialogOK);
</script>
Expand Down Expand Up @@ -78,12 +82,22 @@ onOpened(onDialogOK);
href="https://virtualbrainlab.org/index.html"
icon="web"
/>
<q-btn
v-if="!syncStore.isSignedIn"
icon="sync"
:label="$t('sync.signIn')"
@click="signIn()"
/>
</div>
</div>
</q-card-section>

<q-card-section v-if="recentExperimentStore.recents.length > 0">
<RecentExperimentsList @opened="onDialogOK" />
<SyncRecentExperimentsList
v-if="syncStore.isSignedIn"
@opened="onDialogOK"
/>
<RecentExperimentsList v-else @opened="onDialogOK" />
</q-card-section>
</q-card>
</q-dialog>
Expand Down
57 changes: 57 additions & 0 deletions src/features/sync/api/experiment-archive.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
getExperimentModelIds,
type Experiment,
type SceneModelFile,
unzipExperiment,
zipExperiment
} from "@/features/experiment";
import { getSceneModel, putSceneModel } from "@/features/scene";

/** An experiment recovered from a synced archive. */
export interface RestoredArchive {
experiment: Experiment;
}

/**
* Zip an experiment together with every scene model file it references that is
* still held locally.
* @param experiment Experiment to archive.
*/
export async function buildExperimentArchive(
experiment: Experiment
): Promise<Uint8Array> {
const models = new Map<string, SceneModelFile>();
for (const id of getExperimentModelIds(experiment)) {
const modelFile = await getSceneModel(id);
if (!modelFile) continue;
models.set(id, {
fileName: modelFile.name,
bytes: new Uint8Array(await modelFile.arrayBuffer())
});
}

return zipExperiment(experiment, models);
}

/**
* Read an experiment archive back, writing its model files to local storage,
* or null when the bytes aren't a well-formed archive.
* @param archiveBytes Zipped experiment archive.
*/
export async function restoreExperimentArchive(
archiveBytes: Uint8Array
): Promise<RestoredArchive | null> {
let archive: ReturnType<typeof unzipExperiment>;
try {
archive = unzipExperiment(archiveBytes);
} catch {
return null;
}
if (!archive) return null;

for (const [id, { fileName, bytes }] of archive.models) {
await putSceneModel(id, new File([bytes.slice()], fileName));
}

return { experiment: archive.experiment };
}
Loading