From 6dbaa5456ffa942fde65e6acc5c7dc65a614a3c5 Mon Sep 17 00:00:00 2001 From: Dan Birman Date: Wed, 12 Aug 2026 16:36:45 +0200 Subject: [PATCH 1/2] feat: oauth --- deploy/landing/index.html | 5 +- quasar.config.ts | 7 +- src/boot/sync.boot.ts | 13 ++ .../experiment/api/experiment-file.api.ts | 22 +++ .../experiment/api/experiment-file.spec.ts | 30 +++ src/features/experiment/api/experiment.api.ts | 2 + src/features/experiment/index.ts | 6 + .../models/experiment-author.model.ts | 8 + .../experiment/models/experiment.model.ts | 7 + .../components/PreferencesDialog.spec.ts | 3 +- .../components/PreferencesDialog.vue | 3 + .../models/preferences-dialog.model.ts | 8 +- .../splash/components/SplashDialog.vue | 16 +- .../sync/api/experiment-archive.api.ts | 57 ++++++ src/features/sync/api/sync-client.api.ts | 106 ++++++++++ src/features/sync/api/sync-client.spec.ts | 158 +++++++++++++++ src/features/sync/api/sync-session.api.ts | 64 ++++++ src/features/sync/api/sync-session.spec.ts | 187 ++++++++++++++++++ src/features/sync/api/sync.api.ts | 99 ++++++++++ src/features/sync/api/sync.spec.ts | 182 +++++++++++++++++ .../sync/components/SyncPreferences.vue | 35 ++++ .../SyncRecentExperimentsDialog.vue | 40 ++++ .../components/SyncRecentExperimentsList.vue | 138 +++++++++++++ .../sync/composable/useExperimentSync.ts | 151 ++++++++++++++ src/features/sync/index.ts | 15 ++ src/i18n/en-US/index.ts | 15 ++ src/pages/IndexPage.vue | 9 +- src/stores/sync.store.ts | 63 ++++++ 28 files changed, 1443 insertions(+), 6 deletions(-) create mode 100644 src/boot/sync.boot.ts create mode 100644 src/features/experiment/models/experiment-author.model.ts create mode 100644 src/features/sync/api/experiment-archive.api.ts create mode 100644 src/features/sync/api/sync-client.api.ts create mode 100644 src/features/sync/api/sync-client.spec.ts create mode 100644 src/features/sync/api/sync-session.api.ts create mode 100644 src/features/sync/api/sync-session.spec.ts create mode 100644 src/features/sync/api/sync.api.ts create mode 100644 src/features/sync/api/sync.spec.ts create mode 100644 src/features/sync/components/SyncPreferences.vue create mode 100644 src/features/sync/components/SyncRecentExperimentsDialog.vue create mode 100644 src/features/sync/components/SyncRecentExperimentsList.vue create mode 100644 src/features/sync/composable/useExperimentSync.ts create mode 100644 src/features/sync/index.ts create mode 100644 src/stores/sync.store.ts diff --git a/deploy/landing/index.html b/deploy/landing/index.html index fe256f9..e917b7d 100644 --- a/deploy/landing/index.html +++ b/deploy/landing/index.html @@ -157,7 +157,10 @@

Pinpoint

  • Pinpoint V Beta - Help us debug the next generation of stereotaxic planning tools. + Help us debug the next generation of stereotaxic planning + tools.
  • diff --git a/quasar.config.ts b/quasar.config.ts index da87076..c41e0d4 100644 --- a/quasar.config.ts +++ b/quasar.config.ts @@ -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 css: ["app.sass"], diff --git a/src/boot/sync.boot.ts b/src/boot/sync.boot.ts new file mode 100644 index 0000000..70bc104 --- /dev/null +++ b/src/boot/sync.boot.ts @@ -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(); +}); diff --git a/src/features/experiment/api/experiment-file.api.ts b/src/features/experiment/api/experiment-file.api.ts index dab0a95..5287575 100644 --- a/src/features/experiment/api/experiment-file.api.ts +++ b/src/features/experiment/api/experiment-file.api.ts @@ -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"; @@ -161,6 +162,8 @@ function isExperiment(value: unknown): value is Experiment { const { id, version, + updatedAt, + author, name, atlas, referenceCoordinate, @@ -174,6 +177,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; @@ -224,6 +231,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. diff --git a/src/features/experiment/api/experiment-file.spec.ts b/src/features/experiment/api/experiment-file.spec.ts index 067de63..58d9c3a 100644 --- a/src/features/experiment/api/experiment-file.spec.ts +++ b/src/features/experiment/api/experiment-file.spec.ts @@ -153,6 +153,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(); diff --git a/src/features/experiment/api/experiment.api.ts b/src/features/experiment/api/experiment.api.ts index a81d525..b37236a 100644 --- a/src/features/experiment/api/experiment.api.ts +++ b/src/features/experiment/api/experiment.api.ts @@ -45,6 +45,8 @@ export function buildExperiment( return { id: crypto.randomUUID(), version: import.meta.env.APP_VERSION, + updatedAt: new Date().toISOString(), + author: null, name, atlas, referenceCoordinate, diff --git a/src/features/experiment/index.ts b/src/features/experiment/index.ts index 9d9bea3..cfcf8e0 100644 --- a/src/features/experiment/index.ts +++ b/src/features/experiment/index.ts @@ -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 { diff --git a/src/features/experiment/models/experiment-author.model.ts b/src/features/experiment/models/experiment-author.model.ts new file mode 100644 index 0000000..67691dc --- /dev/null +++ b/src/features/experiment/models/experiment-author.model.ts @@ -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; +} diff --git a/src/features/experiment/models/experiment.model.ts b/src/features/experiment/models/experiment.model.ts index afcf853..5ce3fae 100644 --- a/src/features/experiment/models/experiment.model.ts +++ b/src/features/experiment/models/experiment.model.ts @@ -1,4 +1,5 @@ import type { Atlas } from "@/features/atlas"; +import type { ExperimentAuthor } from "./experiment-author.model"; import type { CameraPose } from "./camera-pose.model"; import type { VisibleStructure } from "./visible-structure.model"; import type { Probe, ProbeInterfaceProbe } from "@/features/probe"; @@ -11,6 +12,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; diff --git a/src/features/preferences/components/PreferencesDialog.spec.ts b/src/features/preferences/components/PreferencesDialog.spec.ts index 12d45ea..fd2451e 100644 --- a/src/features/preferences/components/PreferencesDialog.spec.ts +++ b/src/features/preferences/components/PreferencesDialog.spec.ts @@ -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); @@ -59,6 +59,7 @@ describe("PreferencesDialog", () => { t.scene, t.probe, t.export, + t.sync, t.reset ]); }); diff --git a/src/features/preferences/components/PreferencesDialog.vue b/src/features/preferences/components/PreferencesDialog.vue index 7d9df0f..843ac06 100644 --- a/src/features/preferences/components/PreferencesDialog.vue +++ b/src/features/preferences/components/PreferencesDialog.vue @@ -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]); @@ -24,6 +25,7 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent(); + @@ -34,6 +36,7 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent(); + diff --git a/src/features/preferences/models/preferences-dialog.model.ts b/src/features/preferences/models/preferences-dialog.model.ts index 8190b54..dea766c 100644 --- a/src/features/preferences/models/preferences-dialog.model.ts +++ b/src/features/preferences/models/preferences-dialog.model.ts @@ -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"; diff --git a/src/features/splash/components/SplashDialog.vue b/src/features/splash/components/SplashDialog.vue index 68e78fb..68afe6c 100644 --- a/src/features/splash/components/SplashDialog.vue +++ b/src/features/splash/components/SplashDialog.vue @@ -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; @@ -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); @@ -77,12 +81,22 @@ onOpened(onDialogOK); href="https://virtualbrainlab.org/index.html" icon="web" /> + - + + diff --git a/src/features/sync/api/experiment-archive.api.ts b/src/features/sync/api/experiment-archive.api.ts new file mode 100644 index 0000000..0b45bc1 --- /dev/null +++ b/src/features/sync/api/experiment-archive.api.ts @@ -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 { + const models = new Map(); + 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 { + let archive: ReturnType; + 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 }; +} diff --git a/src/features/sync/api/sync-client.api.ts b/src/features/sync/api/sync-client.api.ts new file mode 100644 index 0000000..0c75734 --- /dev/null +++ b/src/features/sync/api/sync-client.api.ts @@ -0,0 +1,106 @@ +import axios from "axios"; +import type { ExperimentAuthor } from "@/features/experiment"; +import { isRecord } from "@/utils/type-guards"; +import { + SYNC_ARCHIVE_CONTENT_TYPE, + SYNC_SERVICE_PATH, + type SyncListing +} from "./sync.api"; + +/** Client for the metadata-viz endpoints backing sync, scoped to the logged-in account. */ +const syncClient = axios.create({ + baseURL: SYNC_SERVICE_PATH, + // The session lives in a cookie set by the metadata-viz service. + withCredentials: true +}); + +/** + * The logged-in ORCID account, or null when there is no live session. + */ +export async function fetchSyncUser(): Promise { + try { + const { data } = await syncClient.get("/auth/me"); + if (!isRecord(data) || typeof data.orcid !== "string") return null; + return { + orcid: data.orcid, + name: typeof data.name === "string" ? data.name : data.orcid + }; + } catch { + return null; + } +} + +/** + * End the logged-in session on the server. + */ +export async function postSyncLogout(): Promise { + try { + await syncClient.post("/auth/logout"); + } catch { + // The local session is dropped either way. + } +} + +/** + * Metadata of every blob the logged-in account holds, or an empty list on failure. + */ +export async function listSyncedArchives(): Promise { + try { + const { data } = await syncClient.get("/pinpoint-get"); + return Array.isArray(data) ? data.filter(isSyncListing) : []; + } catch { + return []; + } +} + +/** + * Upload an experiment archive under the experiment's id, replacing any + * previous copy. Returns whether the upload succeeded. + * @param experimentId Id of the experiment being pushed, used as the blob name. + * @param archiveBytes Zipped experiment archive. + */ +export async function pushSyncedArchive( + experimentId: string, + archiveBytes: Uint8Array +): Promise { + try { + await syncClient.post("/pinpoint-post", archiveBytes, { + params: { name: experimentId }, + headers: { "Content-Type": SYNC_ARCHIVE_CONTENT_TYPE } + }); + return true; + } catch { + return false; + } +} + +/** + * Download an experiment archive by blob name, or null when it can't be fetched. + * @param experimentId Id of the experiment to fetch. + */ +export async function fetchSyncedArchive( + experimentId: string +): Promise { + try { + const { data } = await syncClient.get("/pinpoint-get", { + params: { name: experimentId }, + responseType: "arraybuffer" + }); + return new Uint8Array(data); + } catch { + return null; + } +} + +/** + * Check that a value has the shape of a `SyncListing`. + * @param value Value to check. + */ +function isSyncListing(value: unknown): value is SyncListing { + return ( + isRecord(value) && + typeof value.name === "string" && + typeof value.timestamp === "string" && + typeof value.content_type === "string" + ); +} diff --git a/src/features/sync/api/sync-client.spec.ts b/src/features/sync/api/sync-client.spec.ts new file mode 100644 index 0000000..6603885 --- /dev/null +++ b/src/features/sync/api/sync-client.spec.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fetchSyncedArchive, + fetchSyncUser, + listSyncedArchives, + postSyncLogout, + pushSyncedArchive +} from "./sync-client.api"; + +const { mockCreate, mockGet, mockPost } = vi.hoisted(() => { + const mockGet = vi.fn(); + const mockPost = vi.fn(); + return { + mockGet, + mockPost, + mockCreate: vi.fn(() => ({ get: mockGet, post: mockPost })) + }; +}); + +vi.mock("axios", () => ({ default: { create: mockCreate } })); + +beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); +}); + +describe("the sync client", () => { + it("sends session cookies to the metadata-viz service", () => { + expect(mockCreate).toHaveBeenCalledWith({ + baseURL: "/metadata-viz", + withCredentials: true + }); + }); +}); + +describe("fetchSyncUser", () => { + it("returns the signed-in account", async () => { + mockGet.mockResolvedValue({ + data: { orcid: "0000-0001-2345-6789", name: "Alice" } + }); + + await expect(fetchSyncUser()).resolves.toEqual({ + orcid: "0000-0001-2345-6789", + name: "Alice" + }); + expect(mockGet).toHaveBeenCalledWith("/auth/me"); + }); + + it("falls back to the ORCID iD when the account has no name", async () => { + mockGet.mockResolvedValue({ data: { orcid: "0000-0001-2345-6789" } }); + + await expect(fetchSyncUser()).resolves.toEqual({ + orcid: "0000-0001-2345-6789", + name: "0000-0001-2345-6789" + }); + }); + + it("returns null when nobody is signed in", async () => { + mockGet.mockRejectedValue(new Error("401")); + + await expect(fetchSyncUser()).resolves.toBeNull(); + }); + + it("returns null for a response without an ORCID iD", async () => { + mockGet.mockResolvedValue({ data: { name: "Alice" } }); + + await expect(fetchSyncUser()).resolves.toBeNull(); + }); +}); + +describe("postSyncLogout", () => { + it("ends the session on the server", async () => { + mockPost.mockResolvedValue({}); + + await postSyncLogout(); + + expect(mockPost).toHaveBeenCalledWith("/auth/logout"); + }); + + it("resolves even when the request fails", async () => { + mockPost.mockRejectedValue(new Error("network error")); + + await expect(postSyncLogout()).resolves.toBeUndefined(); + }); +}); + +describe("listSyncedArchives", () => { + it("returns the account's blob metadata", async () => { + const listing = { + name: "abc", + timestamp: "2024-01-01T00:00:00.000Z", + content_type: "application/zip" + }; + mockGet.mockResolvedValue({ data: [listing] }); + + await expect(listSyncedArchives()).resolves.toEqual([listing]); + expect(mockGet).toHaveBeenCalledWith("/pinpoint-get"); + }); + + it("drops entries that aren't well-formed listings", async () => { + mockGet.mockResolvedValue({ data: [{ name: "abc" }, null, 5] }); + + await expect(listSyncedArchives()).resolves.toEqual([]); + }); + + it("returns an empty list when the response isn't an array", async () => { + mockGet.mockResolvedValue({ data: { error: "nope" } }); + + await expect(listSyncedArchives()).resolves.toEqual([]); + }); + + it("returns an empty list when the request fails", async () => { + mockGet.mockRejectedValue(new Error("network error")); + + await expect(listSyncedArchives()).resolves.toEqual([]); + }); +}); + +describe("pushSyncedArchive", () => { + it("posts the archive as a zip under the experiment's id", async () => { + mockPost.mockResolvedValue({}); + const archiveBytes = new Uint8Array([1, 2, 3]); + + await expect(pushSyncedArchive("abc", archiveBytes)).resolves.toBe(true); + expect(mockPost).toHaveBeenCalledWith("/pinpoint-post", archiveBytes, { + params: { name: "abc" }, + headers: { "Content-Type": "application/zip" } + }); + }); + + it("reports failure rather than throwing", async () => { + mockPost.mockRejectedValue(new Error("network error")); + + await expect( + pushSyncedArchive("abc", new Uint8Array([1])) + ).resolves.toBe(false); + }); +}); + +describe("fetchSyncedArchive", () => { + it("returns the archive bytes", async () => { + mockGet.mockResolvedValue({ data: new Uint8Array([1, 2, 3]).buffer }); + + await expect(fetchSyncedArchive("abc")).resolves.toEqual( + new Uint8Array([1, 2, 3]) + ); + expect(mockGet).toHaveBeenCalledWith("/pinpoint-get", { + params: { name: "abc" }, + responseType: "arraybuffer" + }); + }); + + it("returns null when the blob can't be fetched", async () => { + mockGet.mockRejectedValue(new Error("404")); + + await expect(fetchSyncedArchive("abc")).resolves.toBeNull(); + }); +}); diff --git a/src/features/sync/api/sync-session.api.ts b/src/features/sync/api/sync-session.api.ts new file mode 100644 index 0000000..1006fa0 --- /dev/null +++ b/src/features/sync/api/sync-session.api.ts @@ -0,0 +1,64 @@ +import type { Experiment, ExperimentAuthor } from "@/features/experiment"; +import { + claimExperiment, + isAuthoredBy, + planPull, + type SyncListing +} from "./sync.api"; + +/** I/O the pull step needs, injected so the step itself stays testable. */ +export interface PullDependencies { + fetchArchive: (experimentId: string) => Promise; + restoreArchive: ( + archiveBytes: Uint8Array + ) => Promise<{ experiment: Experiment } | null>; +} + +/** + * Claim every unclaimed experiment for the signed-in author and turn sync off + * for the experiments another author left on this computer. + * @param experiments Every experiment held on this computer. + * @param author Signed-in ORCID account. + * @param setSyncEnabled Callback recording an experiment's sync choice. + */ +export function reconcileAuthorship( + experiments: Experiment[], + author: ExperimentAuthor, + setSyncEnabled: (experimentId: string, isEnabled: boolean) => void +) { + for (const experiment of experiments) { + claimExperiment(experiment, author); + if (!isAuthoredBy(experiment, author.orcid)) { + setSyncEnabled(experiment.id, false); + } + } +} + +/** + * Download the server's experiments that this computer is missing or holds an + * older copy of, in listing order. + * @param listings Blob metadata the server reports for the account. + * @param localExperiments Experiments already held on this computer. + * @param dependencies Archive fetch and restore implementations. + */ +export async function pullSyncedExperiments( + listings: SyncListing[], + localExperiments: Experiment[], + dependencies: PullDependencies +): Promise { + const pulled: Experiment[] = []; + + for (const experimentId of planPull(listings, localExperiments)) { + const archiveBytes = await dependencies.fetchArchive(experimentId); + if (!archiveBytes) continue; + + const restored = await dependencies.restoreArchive(archiveBytes); + // A blob whose payload doesn't match its own name would overwrite an + // unrelated experiment, so it is dropped rather than trusted. + if (!restored || restored.experiment.id !== experimentId) continue; + + pulled.push(restored.experiment); + } + + return pulled; +} diff --git a/src/features/sync/api/sync-session.spec.ts b/src/features/sync/api/sync-session.spec.ts new file mode 100644 index 0000000..a7d1824 --- /dev/null +++ b/src/features/sync/api/sync-session.spec.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildExperiment, type Experiment } from "@/features/experiment"; +import { makeAtlas } from "@/test/fixtures"; +import { SYNC_ARCHIVE_CONTENT_TYPE, type SyncListing } from "./sync.api"; +import { + type PullDependencies, + pullSyncedExperiments, + reconcileAuthorship +} from "./sync-session.api"; + +const ALICE = { orcid: "0000-0001-2345-6789", name: "Alice" }; +const BOB = { orcid: "0000-0002-9999-0000", name: "Bob" }; + +/** + * Build an experiment with an explicit author and edit timestamp. + * @param overrides Author and `updatedAt` to apply. + */ +function makeExperiment(overrides: Partial = {}): Experiment { + return { ...buildExperiment("A", makeAtlas(), [0, 0, 0]), ...overrides }; +} + +/** + * Build a synced-archive listing entry. + * @param name Blob name, which is the experiment id. + * @param timestamp ISO-8601 push time. + */ +function makeListing(name: string, timestamp: string): SyncListing { + return { name, timestamp, content_type: SYNC_ARCHIVE_CONTENT_TYPE }; +} + +/** + * Build pull dependencies serving the given experiments by id. + * @param experimentsById Experiments each archive restores to, keyed by blob name. + */ +function makePullDependencies( + experimentsById: Record +): PullDependencies { + const bytesToId = new Map(); + return { + fetchArchive: vi.fn(async (experimentId: string) => { + if (!(experimentId in experimentsById)) return null; + const marker = `archive:${experimentId}`; + bytesToId.set(marker, experimentId); + return new TextEncoder().encode(marker); + }), + restoreArchive: vi.fn(async (archiveBytes: Uint8Array) => { + const experimentId = bytesToId.get( + new TextDecoder().decode(archiveBytes) + ); + const experiment = experimentId + ? experimentsById[experimentId] + : undefined; + return experiment ? { experiment } : null; + }) + }; +} + +describe("reconcileAuthorship", () => { + it("claims every unclaimed experiment for the signed-in author", () => { + const experiment = makeExperiment({ author: null }); + + reconcileAuthorship([experiment], ALICE, vi.fn()); + + expect(experiment.author).toEqual(ALICE); + }); + + it("leaves sync on for the experiments it just claimed", () => { + const experiment = makeExperiment({ author: null }); + const setSyncEnabled = vi.fn(); + + reconcileAuthorship([experiment], ALICE, setSyncEnabled); + + expect(setSyncEnabled).not.toHaveBeenCalled(); + }); + + it("turns sync off for another author's experiment", () => { + const experiment = makeExperiment({ author: BOB }); + const setSyncEnabled = vi.fn(); + + reconcileAuthorship([experiment], ALICE, setSyncEnabled); + + expect(setSyncEnabled).toHaveBeenCalledWith(experiment.id, false); + }); + + it("leaves another author's experiment attributed to them", () => { + const experiment = makeExperiment({ author: BOB }); + + reconcileAuthorship([experiment], ALICE, vi.fn()); + + expect(experiment.author).toEqual(BOB); + }); + + it("leaves the signed-in author's own experiments untouched", () => { + const experiment = makeExperiment({ author: ALICE }); + const setSyncEnabled = vi.fn(); + + reconcileAuthorship([experiment], ALICE, setSyncEnabled); + + expect(setSyncEnabled).not.toHaveBeenCalled(); + expect(experiment.author).toEqual(ALICE); + }); +}); + +describe("pullSyncedExperiments", () => { + it("returns the experiments this computer is missing", async () => { + const remote = makeExperiment({ author: ALICE }); + const dependencies = makePullDependencies({ [remote.id]: remote }); + + const pulled = await pullSyncedExperiments( + [makeListing(remote.id, "2024-01-01T00:00:00.000Z")], + [], + dependencies + ); + + expect(pulled).toEqual([remote]); + }); + + it("does not fetch an experiment the local copy is newer than", async () => { + const local = makeExperiment({ updatedAt: "2024-06-01T00:00:00.000Z" }); + const dependencies = makePullDependencies({ [local.id]: local }); + + const pulled = await pullSyncedExperiments( + [makeListing(local.id, "2024-01-01T00:00:00.000Z")], + [local], + dependencies + ); + + expect(pulled).toEqual([]); + expect(dependencies.fetchArchive).not.toHaveBeenCalled(); + }); + + it("skips a blob that can't be downloaded", async () => { + const dependencies = makePullDependencies({}); + + const pulled = await pullSyncedExperiments( + [makeListing("gone", "2024-01-01T00:00:00.000Z")], + [], + dependencies + ); + + expect(pulled).toEqual([]); + }); + + it("skips an archive that isn't a well-formed experiment", async () => { + const dependencies = makePullDependencies({ broken: null }); + + const pulled = await pullSyncedExperiments( + [makeListing("broken", "2024-01-01T00:00:00.000Z")], + [], + dependencies + ); + + expect(pulled).toEqual([]); + }); + + it("skips an archive whose experiment id does not match its blob name", async () => { + const impostor = makeExperiment({ author: BOB }); + const dependencies = makePullDependencies({ "some-other-id": impostor }); + + const pulled = await pullSyncedExperiments( + [makeListing("some-other-id", "2024-01-01T00:00:00.000Z")], + [], + dependencies + ); + + expect(pulled).toEqual([]); + }); + + it("keeps going after one blob fails", async () => { + const good = makeExperiment({ author: ALICE }); + const dependencies = makePullDependencies({ + missing: null, + [good.id]: good + }); + + const pulled = await pullSyncedExperiments( + [ + makeListing("missing", "2024-01-01T00:00:00.000Z"), + makeListing(good.id, "2024-01-01T00:00:00.000Z") + ], + [], + dependencies + ); + + expect(pulled).toEqual([good]); + }); +}); diff --git a/src/features/sync/api/sync.api.ts b/src/features/sync/api/sync.api.ts new file mode 100644 index 0000000..be066e1 --- /dev/null +++ b/src/features/sync/api/sync.api.ts @@ -0,0 +1,99 @@ +import type { Experiment, ExperimentAuthor } from "@/features/experiment"; + +/** Metadata of one experiment blob held on the sync server. */ +export interface SyncListing { + /** Blob name, which is the experiment's id. */ + name: string; + /** ISO-8601 timestamp of when the blob was last pushed. */ + timestamp: string; + /** MIME type the blob was stored under. */ + content_type: string; +} + +/** Path the metadata-viz service is mounted at, relative to the app's origin. */ +export const SYNC_SERVICE_PATH = "/metadata-viz"; + +/** Content type experiment archives are pushed and served under. */ +export const SYNC_ARCHIVE_CONTENT_TYPE = "application/zip"; + +/** + * Build the ORCID login URL that returns the browser to `returnUrl` afterwards. + * @param returnUrl Absolute URL to come back to once logged in. + */ +export function buildLoginUrl(returnUrl: string): string { + return `${SYNC_SERVICE_PATH}/auth/orcid/login?next=${encodeURIComponent(returnUrl)}`; +} + +/** + * Stamp an experiment as edited now, so a later sync can resolve it by recency. + * @param experiment Experiment to stamp. + */ +export function touchExperiment(experiment: Experiment) { + experiment.updatedAt = new Date().toISOString(); +} + +/** + * Attach an author to an experiment that has none, leaving an already-authored + * experiment untouched. + * @param experiment Experiment to claim. + * @param author Author to attach. + */ +export function claimExperiment( + experiment: Experiment, + author: ExperimentAuthor +) { + if (experiment.author === null) experiment.author = author; +} + +/** + * Is the experiment authored by the given ORCID iD. + * @param experiment Experiment to check. + * @param orcid ORCID iD to check against. + */ +export function isAuthoredBy(experiment: Experiment, orcid: string): boolean { + return experiment.author?.orcid === orcid; +} + +/** + * Split experiments into those authored by `orcid` and those authored by + * somebody else who used this computer. + * @param experiments Experiments to split. + * @param orcid ORCID iD of the logged-in author. + */ +export function partitionExperimentsByAuthor( + experiments: Experiment[], + orcid: string +): { own: Experiment[]; foreign: Experiment[] } { + const own: Experiment[] = []; + const foreign: Experiment[] = []; + for (const experiment of experiments) { + if (isAuthoredBy(experiment, orcid)) own.push(experiment); + else foreign.push(experiment); + } + return { own, foreign }; +} + +/** + * Names of the server's experiment archives worth downloading: those with no + * local copy, and those the server holds a strictly newer copy of. + * @param listings Blob metadata the server reports for the account. + * @param localExperiments Experiments already held on this computer. + */ +export function planPull( + listings: SyncListing[], + localExperiments: Experiment[] +): string[] { + const localUpdatedAt = new Map( + localExperiments.map(({ id, updatedAt }) => [id, Date.parse(updatedAt)]) + ); + + return listings + .filter(({ content_type }) => content_type === SYNC_ARCHIVE_CONTENT_TYPE) + .filter(({ name, timestamp }) => { + const local = localUpdatedAt.get(name); + if (local === undefined || !Number.isFinite(local)) return true; + const remote = Date.parse(timestamp); + return Number.isFinite(remote) && remote > local; + }) + .map(({ name }) => name); +} diff --git a/src/features/sync/api/sync.spec.ts b/src/features/sync/api/sync.spec.ts new file mode 100644 index 0000000..d828ae5 --- /dev/null +++ b/src/features/sync/api/sync.spec.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { buildExperiment, type Experiment } from "@/features/experiment"; +import { makeAtlas } from "@/test/fixtures"; +import { + buildLoginUrl, + claimExperiment, + isAuthoredBy, + partitionExperimentsByAuthor, + planPull, + SYNC_ARCHIVE_CONTENT_TYPE, + type SyncListing, + touchExperiment +} from "./sync.api"; + +const ALICE = { orcid: "0000-0001-2345-6789", name: "Alice" }; +const BOB = { orcid: "0000-0002-9999-0000", name: "Bob" }; + +/** + * Build an experiment with an explicit author and edit timestamp. + * @param overrides Author and `updatedAt` to apply. + */ +function makeExperiment(overrides: Partial = {}): Experiment { + return { ...buildExperiment("A", makeAtlas(), [0, 0, 0]), ...overrides }; +} + +/** + * Build a synced-archive listing entry. + * @param name Blob name, which is the experiment id. + * @param timestamp ISO-8601 push time. + * @param contentType MIME type the blob was stored under. + */ +function makeListing( + name: string, + timestamp: string, + contentType: string = SYNC_ARCHIVE_CONTENT_TYPE +): SyncListing { + return { name, timestamp, content_type: contentType }; +} + +describe("buildLoginUrl", () => { + it("sends the browser back to the given URL after login", () => { + expect(buildLoginUrl("https://example.org/pinpoint-v/#/")).toBe( + "/metadata-viz/auth/orcid/login?next=https%3A%2F%2Fexample.org%2Fpinpoint-v%2F%23%2F" + ); + }); + + it("escapes a return URL's own query string so it survives the round trip", () => { + expect(buildLoginUrl("https://example.org/?a=1&b=2")).toContain( + "next=https%3A%2F%2Fexample.org%2F%3Fa%3D1%26b%3D2" + ); + }); +}); + +describe("touchExperiment", () => { + it("replaces the timestamp with a parseable one at or after the old", () => { + const experiment = makeExperiment({ updatedAt: "2020-01-01T00:00:00.000Z" }); + + touchExperiment(experiment); + + expect(Date.parse(experiment.updatedAt)).toBeGreaterThan( + Date.parse("2020-01-01T00:00:00.000Z") + ); + }); +}); + +describe("claimExperiment", () => { + it("attaches the author to an unclaimed experiment", () => { + const experiment = makeExperiment({ author: null }); + + claimExperiment(experiment, ALICE); + + expect(experiment.author).toEqual(ALICE); + }); + + it("leaves an already-authored experiment alone", () => { + const experiment = makeExperiment({ author: BOB }); + + claimExperiment(experiment, ALICE); + + expect(experiment.author).toEqual(BOB); + }); +}); + +describe("isAuthoredBy", () => { + it("is true for the matching ORCID iD", () => { + expect(isAuthoredBy(makeExperiment({ author: ALICE }), ALICE.orcid)).toBe( + true + ); + }); + + it("is false for another ORCID iD", () => { + expect(isAuthoredBy(makeExperiment({ author: ALICE }), BOB.orcid)).toBe( + false + ); + }); + + it("is false for an unclaimed experiment", () => { + expect(isAuthoredBy(makeExperiment({ author: null }), ALICE.orcid)).toBe( + false + ); + }); +}); + +describe("partitionExperimentsByAuthor", () => { + it("splits the author's own experiments from everybody else's", () => { + const own = makeExperiment({ author: ALICE }); + const foreign = makeExperiment({ author: BOB }); + const unclaimed = makeExperiment({ author: null }); + + expect( + partitionExperimentsByAuthor([own, foreign, unclaimed], ALICE.orcid) + ).toEqual({ own: [own], foreign: [foreign, unclaimed] }); + }); + + it("preserves the input order within each group", () => { + const first = makeExperiment({ author: ALICE }); + const second = makeExperiment({ author: ALICE }); + + expect( + partitionExperimentsByAuthor([first, second], ALICE.orcid).own + ).toEqual([first, second]); + }); + + it("puts everything in foreign when no account is signed in", () => { + const experiment = makeExperiment({ author: ALICE }); + + expect(partitionExperimentsByAuthor([experiment], "").foreign).toEqual([ + experiment + ]); + }); +}); + +describe("planPull", () => { + it("downloads an experiment with no local copy", () => { + const listings = [makeListing("remote-id", "2024-01-01T00:00:00.000Z")]; + + expect(planPull(listings, [])).toEqual(["remote-id"]); + }); + + it("downloads an experiment the server holds a newer copy of", () => { + const local = makeExperiment({ updatedAt: "2024-01-01T00:00:00.000Z" }); + const listings = [makeListing(local.id, "2024-06-01T00:00:00.000Z")]; + + expect(planPull(listings, [local])).toEqual([local.id]); + }); + + it("skips an experiment the local copy is newer than", () => { + const local = makeExperiment({ updatedAt: "2024-06-01T00:00:00.000Z" }); + const listings = [makeListing(local.id, "2024-01-01T00:00:00.000Z")]; + + expect(planPull(listings, [local])).toEqual([]); + }); + + it("skips an experiment whose copies are the same age", () => { + const local = makeExperiment({ updatedAt: "2024-01-01T00:00:00.000Z" }); + const listings = [makeListing(local.id, "2024-01-01T00:00:00.000Z")]; + + expect(planPull(listings, [local])).toEqual([]); + }); + + it("skips a blob that isn't an experiment archive", () => { + const listings = [ + makeListing("some-json", "2024-01-01T00:00:00.000Z", "application/json") + ]; + + expect(planPull(listings, [])).toEqual([]); + }); + + it("skips a blob whose push time can't be parsed", () => { + const local = makeExperiment({ updatedAt: "2024-01-01T00:00:00.000Z" }); + const listings = [makeListing(local.id, "whenever")]; + + expect(planPull(listings, [local])).toEqual([]); + }); + + it("downloads a replacement when the local timestamp can't be parsed", () => { + const local = makeExperiment({ updatedAt: "whenever" }); + const listings = [makeListing(local.id, "2024-01-01T00:00:00.000Z")]; + + expect(planPull(listings, [local])).toEqual([local.id]); + }); +}); diff --git a/src/features/sync/components/SyncPreferences.vue b/src/features/sync/components/SyncPreferences.vue new file mode 100644 index 0000000..cbea497 --- /dev/null +++ b/src/features/sync/components/SyncPreferences.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/features/sync/components/SyncRecentExperimentsDialog.vue b/src/features/sync/components/SyncRecentExperimentsDialog.vue new file mode 100644 index 0000000..1f5d794 --- /dev/null +++ b/src/features/sync/components/SyncRecentExperimentsDialog.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/src/features/sync/components/SyncRecentExperimentsList.vue b/src/features/sync/components/SyncRecentExperimentsList.vue new file mode 100644 index 0000000..4e8e4b0 --- /dev/null +++ b/src/features/sync/components/SyncRecentExperimentsList.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/features/sync/composable/useExperimentSync.ts b/src/features/sync/composable/useExperimentSync.ts new file mode 100644 index 0000000..41c25ba --- /dev/null +++ b/src/features/sync/composable/useExperimentSync.ts @@ -0,0 +1,151 @@ +import { computed } from "vue"; +import type { Pinia } from "pinia"; +import { useDebounceFn, watchIgnorable } from "@vueuse/core"; +import type { Experiment } from "@/features/experiment"; +import { useCurrentExperimentStore } from "@/stores/current-experiment.store"; +import { useRecentExperimentsStore } from "@/stores/recent-experiments.store"; +import { useSyncStore } from "@/stores/sync.store"; +import { + buildExperimentArchive, + restoreExperimentArchive +} from "../api/experiment-archive.api"; +import { + fetchSyncedArchive, + fetchSyncUser, + listSyncedArchives, + postSyncLogout, + pushSyncedArchive +} from "../api/sync-client.api"; +import { buildLoginUrl, isAuthoredBy, touchExperiment } from "../api/sync.api"; +import { + pullSyncedExperiments, + reconcileAuthorship +} from "../api/sync-session.api"; + +/** Quiet period after the last edit before the experiment is pushed, in ms. */ +const PUSH_DEBOUNCE_MS = 3000; + +/** + * Mirror the signed-in account's experiments onto the sync server, and drive + * the sign-in / sign-out flow. + * @param pinia Pinia instance to resolve stores from, for use outside components. + */ +export function useExperimentSync(pinia?: Pinia) { + const syncStore = useSyncStore(pinia); + const currentExperimentStore = useCurrentExperimentStore(pinia); + const recentExperimentsStore = useRecentExperimentsStore(pinia); + + /** Every experiment held on this computer, current one first. */ + const localExperiments = computed(() => [ + currentExperimentStore.experiment, + ...recentExperimentsStore.recents + ]); + + /** + * Should this experiment be mirrored: the account is signed in, owns it, and + * has not turned its sync off. + * @param experiment Experiment to check. + */ + function isSynced(experiment: Experiment): boolean { + const user = syncStore.user; + return ( + user !== null && + isAuthoredBy(experiment, user.orcid) && + syncStore.isSyncEnabled(experiment.id) + ); + } + + /** + * Push one experiment to the server, if it is meant to sync. + * @param experiment Experiment to push. + */ + async function pushExperiment(experiment: Experiment) { + if (!isSynced(experiment)) return; + + await pushSyncedArchive( + experiment.id, + await buildExperimentArchive(experiment) + ); + } + + /** Push every experiment on this computer that is meant to sync. */ + async function pushAllExperiments() { + for (const experiment of localExperiments.value) { + await pushExperiment(experiment); + } + } + + /** Leave Pinpoint for ORCID, returning to this exact URL once signed in. */ + function signIn() { + window.location.assign(buildLoginUrl(window.location.href)); + } + + /** Sign out on the server and locally, leaving local experiments in place. */ + async function signOut() { + await postSyncLogout(); + syncStore.signOut(); + } + + /** + * Adopt the live server session, if any: claim unauthored experiments, turn + * sync off for another author's, pull down what this computer is missing, and + * mirror everything back up. + */ + async function restoreSession() { + const user = await fetchSyncUser(); + if (!user) { + syncStore.signOut(); + return; + } + + syncStore.signIn(user); + reconcileAuthorship(localExperiments.value, user, syncStore.setSyncEnabled); + + // A blob whose experiment was deleted locally has its sync turned off, so + // filtering here keeps a delete from being undone by the next pull. + const listings = (await listSyncedArchives()).filter(({ name }) => + syncStore.isSyncEnabled(name) + ); + const pulled = await pullSyncedExperiments( + listings, + localExperiments.value, + { + fetchArchive: fetchSyncedArchive, + restoreArchive: restoreExperimentArchive + } + ); + for (const experiment of pulled) recentExperimentsStore.add(experiment); + + await pushAllExperiments(); + } + + /** + * Stamp the current experiment on every edit and push it once editing stops. + * @remarks Call once per app; the watcher lives for the app's lifetime. + */ + function watchCurrentExperiment() { + const pushSoon = useDebounceFn( + () => pushExperiment(currentExperimentStore.experiment), + PUSH_DEBOUNCE_MS + ); + + // Stamped synchronously so an edit and its timestamp land in one undo point, + // rather than the stamp becoming an undo point of its own. + const { ignoreUpdates } = watchIgnorable( + () => currentExperimentStore.experiment, + () => { + ignoreUpdates(() => touchExperiment(currentExperimentStore.experiment)); + void pushSoon(); + }, + { deep: true, flush: "sync" } + ); + } + + return { + signIn, + signOut, + restoreSession, + pushExperiment, + watchCurrentExperiment + }; +} diff --git a/src/features/sync/index.ts b/src/features/sync/index.ts new file mode 100644 index 0000000..e23f8fb --- /dev/null +++ b/src/features/sync/index.ts @@ -0,0 +1,15 @@ +export { default as SyncPreferences } from "./components/SyncPreferences.vue"; +export { default as SyncRecentExperimentsDialog } from "./components/SyncRecentExperimentsDialog.vue"; +export { default as SyncRecentExperimentsList } from "./components/SyncRecentExperimentsList.vue"; +export { useExperimentSync } from "./composable/useExperimentSync"; +export type { SyncListing } from "./api/sync.api"; +export { + buildLoginUrl, + claimExperiment, + isAuthoredBy, + partitionExperimentsByAuthor, + planPull, + SYNC_ARCHIVE_CONTENT_TYPE, + SYNC_SERVICE_PATH, + touchExperiment +} from "./api/sync.api"; diff --git a/src/i18n/en-US/index.ts b/src/i18n/en-US/index.ts index e736bc8..d9e7e91 100644 --- a/src/i18n/en-US/index.ts +++ b/src/i18n/en-US/index.ts @@ -292,6 +292,7 @@ export default { scene: "Scene", probe: "Probe", export: "Export", + sync: "Sync", reset: "Reset", close: "Close", @@ -375,5 +376,19 @@ export default { storeProbeLibrary: "Probe Library", storeFavoriteAtlases: "Favorite Atlases", storePreferences: "Preferences" + }, + + sync: { + signIn: "Sync via ORCiD", + signOut: "Stop syncing", + signedInAs: "Signed in as {name}", + signedInHint: + "Your experiments are mirrored to your ORCID account as they are saved, and restored when you sign in on another computer.", + signedOutHint: + "Sign in with ORCID to mirror your experiments to your account. Nothing about Pinpoint changes - your experiments keep working exactly as they do now.", + myExperiments: "Your experiments", + otherAuthors: "Other authors on this computer", + syncExperiment: "Sync this experiment", + unknownAuthor: "Unknown author" } }; diff --git a/src/pages/IndexPage.vue b/src/pages/IndexPage.vue index 839a6b0..c9a5702 100644 --- a/src/pages/IndexPage.vue +++ b/src/pages/IndexPage.vue @@ -22,6 +22,8 @@ import { ProbeLibraryDialog } from "@/features/probe"; import { openPreferencesDialog } from "@/features/preferences"; import { Inspector } from "@/features/inspector"; import { SplashDialog } from "@/features/splash"; +import { SyncRecentExperimentsDialog } from "@/features/sync"; +import { useSyncStore } from "@/stores/sync.store"; import { clamp } from "@/utils/math"; import { ChannelMaps } from "@/features/slice"; @@ -37,6 +39,7 @@ const $q = useQuasar(); const currentExperimentStore = useCurrentExperimentStore(); const preferences = usePreferencesStore(); const recentExperimentsStore = useRecentExperimentsStore(); +const syncStore = useSyncStore(); const { openExperiment, downloadExperiment } = useExperimentFile(); const leftDrawerOpen = ref(false); @@ -60,6 +63,10 @@ const isEditMenuOpen = computed({ openMenu.value = value ? "edit" : null; } }); +/** Recents dialog to open, which gains sync columns once an account is signed in. */ +const openRecentDialogComponent = computed(() => + syncStore.isSignedIn ? SyncRecentExperimentsDialog : RecentExperimentsDialog +); /** * Toggle a drawer's open state. @@ -208,7 +215,7 @@ onUnmounted(() => { {{ $t("layout.openRecent") }} diff --git a/src/stores/sync.store.ts b/src/stores/sync.store.ts new file mode 100644 index 0000000..a54debe --- /dev/null +++ b/src/stores/sync.store.ts @@ -0,0 +1,63 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import type { ExperimentAuthor } from "@/features/experiment"; + +export const useSyncStore = defineStore( + "sync", + () => { + /** Logged-in ORCID account, or null when sync is off. */ + const user = ref(null); + + /** + * Ids of experiments the user has turned sync off for. Experiments sync by + * default, so only the exceptions are tracked. + */ + const disabledExperimentIds = ref([]); + + /** Is an ORCID account signed in. */ + const isSignedIn = computed(() => user.value !== null); + + /** + * Does this experiment id sync. + * @param experimentId Id of the experiment to check. + */ + function isSyncEnabled(experimentId: string): boolean { + return !disabledExperimentIds.value.includes(experimentId); + } + + /** + * Turn sync on or off for an experiment id. + * @param experimentId Id of the experiment to set sync for. + * @param isEnabled Whether the experiment should sync. + */ + function setSyncEnabled(experimentId: string, isEnabled: boolean) { + const index = disabledExperimentIds.value.indexOf(experimentId); + if (isEnabled) { + if (index !== -1) disabledExperimentIds.value.splice(index, 1); + return; + } + if (index === -1) disabledExperimentIds.value.push(experimentId); + } + + /** + * Record the signed-in account. + * @param author Signed-in ORCID account. + */ + function signIn(author: ExperimentAuthor) { + user.value = author; + } + + /** Forget the signed-in account, leaving per-experiment sync choices alone. */ + function signOut() { + user.value = null; + } + + const state = { user, disabledExperimentIds }; + const getters = { isSignedIn }; + const actions = { isSyncEnabled, setSyncEnabled, signIn, signOut }; + return { ...state, ...getters, ...actions }; + }, + { + persist: true + } +); From 0278b89bda112f77587998111db8f77b3d06e824 Mon Sep 17 00:00:00 2001 From: Dan Birman Date: Wed, 12 Aug 2026 16:40:05 +0200 Subject: [PATCH 2/2] fix: enable lefthook properly, run style --- README.md | 2 +- lefthook.yml | 14 +++++++++++++- package.json | 2 +- src/features/sync/api/sync-client.spec.ts | 6 +++--- src/features/sync/api/sync.spec.ts | 4 +++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b2f3c00..f9510b2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lefthook.yml b/lefthook.yml index bd1a16c..d84f215 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -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 diff --git a/package.json b/package.json index 1cc89db..f70723a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/sync/api/sync-client.spec.ts b/src/features/sync/api/sync-client.spec.ts index 6603885..759732b 100644 --- a/src/features/sync/api/sync-client.spec.ts +++ b/src/features/sync/api/sync-client.spec.ts @@ -131,9 +131,9 @@ describe("pushSyncedArchive", () => { it("reports failure rather than throwing", async () => { mockPost.mockRejectedValue(new Error("network error")); - await expect( - pushSyncedArchive("abc", new Uint8Array([1])) - ).resolves.toBe(false); + await expect(pushSyncedArchive("abc", new Uint8Array([1]))).resolves.toBe( + false + ); }); }); diff --git a/src/features/sync/api/sync.spec.ts b/src/features/sync/api/sync.spec.ts index d828ae5..c11ead8 100644 --- a/src/features/sync/api/sync.spec.ts +++ b/src/features/sync/api/sync.spec.ts @@ -53,7 +53,9 @@ describe("buildLoginUrl", () => { describe("touchExperiment", () => { it("replaces the timestamp with a parseable one at or after the old", () => { - const experiment = makeExperiment({ updatedAt: "2020-01-01T00:00:00.000Z" }); + const experiment = makeExperiment({ + updatedAt: "2020-01-01T00:00:00.000Z" + }); touchExperiment(experiment);