diff --git a/environments/environment-Linux.yml b/environments/environment-Linux.yml
index 54b78ea4e7..ea486f110c 100644
--- a/environments/environment-Linux.yml
+++ b/environments/environment-Linux.yml
@@ -21,3 +21,4 @@ dependencies:
- pytest-cov == 4.1.0
- scikit-learn == 1.4.0
- tqdm_publisher >= 0.0.1
+ - tzlocal >= 5.2
diff --git a/environments/environment-MAC-apple-silicon.yml b/environments/environment-MAC-apple-silicon.yml
index 3100f7f765..84e29d4f33 100644
--- a/environments/environment-MAC-apple-silicon.yml
+++ b/environments/environment-MAC-apple-silicon.yml
@@ -27,3 +27,4 @@ dependencies:
- pytest-cov == 4.1.0
- scikit-learn == 1.4.0
- tqdm_publisher >= 0.0.1
+ - tzlocal >= 5.2
diff --git a/environments/environment-MAC-intel.yml b/environments/environment-MAC-intel.yml
index 7e5933c15b..3cb9377672 100644
--- a/environments/environment-MAC-intel.yml
+++ b/environments/environment-MAC-intel.yml
@@ -24,3 +24,4 @@ dependencies:
- pytest-cov == 4.1.0
- scikit-learn == 1.4.0
- tqdm_publisher >= 0.0.1
+ - tzlocal >= 5.2
diff --git a/environments/environment-Windows.yml b/environments/environment-Windows.yml
index 10bef56c95..1cb6f2e23d 100644
--- a/environments/environment-Windows.yml
+++ b/environments/environment-Windows.yml
@@ -24,3 +24,4 @@ dependencies:
- pytest-cov == 4.1.0
- scikit-learn == 1.4.0
- tqdm_publisher >= 0.0.1
+ - tzlocal >= 5.2
diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js
index d2a72d0e83..126aa62704 100644
--- a/src/electron/frontend/core/components/Dashboard.js
+++ b/src/electron/frontend/core/components/Dashboard.js
@@ -245,6 +245,26 @@ export class Dashboard extends LitElement {
this.page.set(toPass, false);
+ // Constrain based on workflow configuration
+ const workflowConfig = page.workflow ?? (page.workflow = {});
+ const workflowValues = page.info.globalState?.project?.workflow ?? {};
+
+ // Define the value for each workflow value
+ Object.entries(workflowValues).forEach(([key, value]) => {
+ const config = workflowConfig[key] ?? (workflowConfig[key] = {});
+ config.value = value;
+ });
+
+ // Toggle elements based on workflow configuration
+ Object.entries(workflowConfig).forEach(([key, config]) => {
+ const { value, elements } = config;
+ if (elements) {
+ if (value) elements.forEach((el) => el.removeAttribute("hidden"));
+ else elements.forEach((el) => el.setAttribute("hidden", true));
+ }
+ });
+
+ // Ensure that all states are synced to the proper state for this page (e.g. conversions have been run)
this.page
.checkSyncState()
.then(async () => {
@@ -254,25 +274,34 @@ export class Dashboard extends LitElement {
? `
${projectName}
Conversion Pipeline`
: projectName;
- this.updateSections({ sidebar: false, main: true });
-
- if (this.#transitionPromise.value) this.#transitionPromise.trigger(page); // This ensures calls to page.to() can be properly awaited until the next page is ready
-
const { skipped } = this.subSidebar.sections[info.section]?.pages?.[info.id] ?? {};
if (skipped) {
if (isStorybook) return; // Do not skip on storybook
- // Run skip functions
- Object.entries(page.workflow).forEach(([key, state]) => {
- if (typeof state.skip === "function") state.skip();
- });
-
- // Skip right over the page if configured as such
- if (previous && previous.info.previous === this.page) await this.page.onTransition(-1);
- else await this.page.onTransition(1);
+ const backwards = previous && previous.info.previous === this.page;
+
+ return (
+ Promise.all(
+ Object.entries(page.workflow).map(async ([_, state]) => {
+ if (typeof state.skip === "function" && !backwards) return await state.skip(); // Run skip functions
+ })
+ )
+
+ // Skip right over the page if configured as such
+ .then(async () => {
+ if (backwards) await this.main.onTransition(-1);
+ else await this.main.onTransition(1);
+ })
+ );
}
+
+ page.requestUpdate(); // Re-render the page on each load
+
+ // Update main to render page
+ this.updateSections({ sidebar: false, main: true });
})
+
.catch((e) => {
const previousId = previous?.info?.id ?? -1;
this.main.onTransition(previousId); // Revert back to previous page
@@ -283,6 +312,9 @@ export class Dashboard extends LitElement {
: `Fallback to previous page after error occurred
${e}`,
"error"
);
+ })
+ .finally(() => {
+ if (this.#transitionPromise.value) this.#transitionPromise.trigger(this.main.page); // This ensures calls to page.to() can be properly awaited until the next page is ready
});
}
@@ -342,9 +374,15 @@ export class Dashboard extends LitElement {
if (!active) active = this.activePage; // default to active page
this.main.onTransition = async (transition) => {
- const promise = (this.#transitionPromise.value = new Promise(
- (resolve) => (this.#transitionPromise.trigger = resolve)
- ));
+ const promise =
+ this.#transitionPromise.value ??
+ (this.#transitionPromise.value = new Promise(
+ (resolve) =>
+ (this.#transitionPromise.trigger = (value) => {
+ delete this.#transitionPromise.value;
+ resolve(value);
+ })
+ ));
if (typeof transition === "number") {
const info = this.page.info;
diff --git a/src/electron/frontend/core/components/DateTimeSelector.js b/src/electron/frontend/core/components/DateTimeSelector.js
index 4da3a5fbee..a8ba90cf8e 100644
--- a/src/electron/frontend/core/components/DateTimeSelector.js
+++ b/src/electron/frontend/core/components/DateTimeSelector.js
@@ -1,14 +1,35 @@
import { LitElement, css } from "lit";
+import { getTimezoneOffset, formatTimezoneOffset } from "../../../../schemas/timezone.schema";
-const convertToDateTimeLocalString = (date) => {
+// Function to format the GMT offset
+export function extractISOString(date = new Date(), { offset = false, timezone = undefined } = {}) {
+ if (typeof date === "string") date = new Date();
+
+ // Extract the GMT offset
+ const offsetMs = getTimezoneOffset(date, timezone);
+ const gmtOffset = formatTimezoneOffset(offsetMs);
+
+ // Format the date back to the original format with GMT offset
const year = date.getFullYear();
- const month = (date.getMonth() + 1).toString().padStart(2, "0");
- const day = date.getDate().toString().padStart(2, "0");
- const hours = date.getHours().toString().padStart(2, "0");
- const minutes = date.getMinutes().toString().padStart(2, "0");
- return `${year}-${month}-${day}T${hours}:${minutes}`;
+ const month = String(date.getMonth() + 1).padStart(2, "0"); // Months are zero-indexed
+ const day = String(date.getDate()).padStart(2, "0");
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+ const seconds = String(date.getSeconds()).padStart(2, "0");
+
+ // Recreate the ISO string with the GMT offset
+ const formattedDate = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
+ return offset ? formattedDate + gmtOffset : formattedDate;
+}
+
+export const renderDateTime = (value) => {
+ if (typeof value === "string") return extractISOString(new Date(value));
+ else if (value instanceof Date) return extractISOString(value);
+ return value;
};
+export const resolveDateTime = renderDateTime;
+
export class DateTimeSelector extends LitElement {
static get styles() {
return css`
@@ -20,31 +41,33 @@ export class DateTimeSelector extends LitElement {
}
get value() {
- return this.input.value;
+ const date = new Date(this.input.value);
+ const resolved = resolveDateTime(date);
+
+ console.log(this.input.value, resolved);
+ // return this.input.value;
+ return resolved;
}
set value(newValue) {
- if (newValue) this.input.value = newValue;
- else {
- const d = new Date();
- d.setHours(0, 0, 0, 0);
- this.input.value = convertToDateTimeLocalString(d);
- }
+ const date = newValue ? new Date(newValue) : new Date();
+ if (!newValue) date.setHours(0, 0, 0, 0);
+ this.input.value = resolveDateTime(date);
}
get min() {
return this.input.min;
}
- set min(newValue) {
- this.input.min = newValue;
+ set min(value) {
+ this.input.min = value;
}
get max() {
return this.input.max;
}
- set max(newValue) {
- this.input.max = newValue;
+ set max(value) {
+ this.input.max = value;
}
constructor({ value, min, max } = {}) {
diff --git a/src/electron/frontend/core/components/JSONSchemaForm.js b/src/electron/frontend/core/components/JSONSchemaForm.js
index 2acc454116..9c9572d3fb 100644
--- a/src/electron/frontend/core/components/JSONSchemaForm.js
+++ b/src/electron/frontend/core/components/JSONSchemaForm.js
@@ -12,6 +12,10 @@ import { resolveProperties } from "./pages/guided-mode/data/utils";
import { JSONSchemaInput, getEditableItems } from "./JSONSchemaInput";
import { InspectorListItem } from "./preview/inspector/InspectorList";
+import { Validator } from "jsonschema";
+import { successHue, warningHue, errorHue } from "./globals";
+import { Button } from "./Button";
+
const encode = (str) => {
try {
document.querySelector(`#${str}`);
@@ -65,10 +69,6 @@ const additionalPropPattern = "additional";
const templateNaNMessage = `
Type NaN to represent an unknown value.`;
-import { Validator } from "jsonschema";
-import { successHue, warningHue, errorHue } from "./globals";
-import { Button } from "./Button";
-
var validator = new Validator();
const isObject = (item) => {
@@ -902,7 +902,7 @@ export class JSONSchemaForm extends LitElement {
if (!parent) parent = this.#get(path, this.resolved);
if (!schema) schema = this.getSchema(localPath);
- const value = parent[name];
+ let value = parent[name];
const skipValidation = this.validateEmptyValues === null && value === undefined;
@@ -910,6 +910,7 @@ export class JSONSchemaForm extends LitElement {
// Run validation functions
const jsonSchemaErrors = validateArgs.length === 2 ? this.validateSchema(...validateArgs, name) : [];
+
const valid = skipValidation ? true : await this.validateOnChange(name, parent, pathToValidate, value);
if (valid === null) return null; // Skip validation / data change if the value is null
diff --git a/src/electron/frontend/core/components/JSONSchemaInput.js b/src/electron/frontend/core/components/JSONSchemaInput.js
index 5e65737232..9e186fc381 100644
--- a/src/electron/frontend/core/components/JSONSchemaInput.js
+++ b/src/electron/frontend/core/components/JSONSchemaInput.js
@@ -16,21 +16,10 @@ import tippy from "tippy.js";
import { merge } from "./pages/utils";
import { OptionalSection } from "./OptionalSection";
import { InspectorListItem } from "./preview/inspector/InspectorList";
+import { renderDateTime, resolveDateTime } from "./DateTimeSelector";
const isDevelopment = !!import.meta.env;
-const dateTimeRegex = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/;
-
-function resolveDateTime(value) {
- if (typeof value === "string") {
- const match = value.match(dateTimeRegex);
- if (match) return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`;
- return value;
- }
-
- return value;
-}
-
export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) {
const name = fullPath.slice(-1)[0];
const path = fullPath.slice(0, -1);
@@ -1226,7 +1215,7 @@ export class JSONSchemaInput extends LitElement {
? "datetime-local"
: schema.format ?? (schema.type === "string" ? "text" : schema.type);
- const value = isDateTime ? resolveDateTime(this.value) : this.value;
+ const value = isDateTime ? renderDateTime(this.value) : this.value;
const { minimum, maximum, exclusiveMax, exclusiveMin } = schema;
const min = exclusiveMin ?? minimum;
@@ -1249,6 +1238,7 @@ export class JSONSchemaInput extends LitElement {
if (isInteger) value = newValue = parseInt(value);
else if (isNumber) value = newValue = parseFloat(value);
+ else if (isDateTime) value = newValue = resolveDateTime(value);
if (isNumber) {
if ("min" in schema && newValue < schema.min) newValue = schema.min;
diff --git a/src/electron/frontend/core/components/Main.js b/src/electron/frontend/core/components/Main.js
index 14e6b61e09..a718756107 100644
--- a/src/electron/frontend/core/components/Main.js
+++ b/src/electron/frontend/core/components/Main.js
@@ -73,24 +73,6 @@ export class Main extends LitElement {
page.onTransition = this.onTransition;
page.updatePages = this.updatePages;
- // Constrain based on workflow configuration
- const workflowConfig = page.workflow ?? (page.workflow = {});
- const workflowValues = page.info.globalState?.project?.workflow ?? {};
-
- Object.entries(workflowConfig).forEach(([key, state]) => {
- workflowConfig[key].value = workflowValues[key];
-
- const value = workflowValues[key];
-
- if (state.elements) {
- const elements = state.elements;
- if (value) elements.forEach((el) => el.removeAttribute("hidden"));
- else elements.forEach((el) => el.setAttribute("hidden", true));
- }
- });
-
- page.requestUpdate(); // Ensure the page is re-rendered with new workflow configurations
-
if (this.content)
this.toRender = toRender.page ? toRender : { page }; // Ensure re-render in either case
else this.#queue.push(page);
diff --git a/src/electron/frontend/core/components/Search.js b/src/electron/frontend/core/components/Search.js
index 5b133e2f03..dcd2647618 100644
--- a/src/electron/frontend/core/components/Search.js
+++ b/src/electron/frontend/core/components/Search.js
@@ -33,6 +33,7 @@ export class Search extends LitElement {
}
#close = () => {
+ console.log("CLOSING", this.getSelectedOption());
if (this.listMode === "input" && this.getAttribute("interacted") === "true") {
this.setAttribute("interacted", false);
this.#onSelect(this.getSelectedOption());
diff --git a/src/electron/frontend/core/components/pages/Page.js b/src/electron/frontend/core/components/pages/Page.js
index 93bc5047d8..e340ad6ef5 100644
--- a/src/electron/frontend/core/components/pages/Page.js
+++ b/src/electron/frontend/core/components/pages/Page.js
@@ -159,72 +159,75 @@ export class Page extends LitElement {
const { close: closeProgressPopup } = swalOpts;
const fileConfiguration = [];
- for (let info of toRun) {
- const { subject, session, globalState = this.info.globalState } = info;
- const file = `sub-${subject}/sub-${subject}_ses-${session}.nwb`;
-
- const { conversion_output_folder, name, SourceData, alignment } = globalState.project;
-
- const sessionResults = globalState.results[subject][session];
-
- const sourceDataCopy = structuredClone(sessionResults.source_data);
-
- // Resolve the correct session info from all of the metadata for this conversion
- const sessionInfo = {
- ...sessionResults,
- metadata: resolveMetadata(subject, session, globalState),
- source_data: merge(SourceData, sourceDataCopy),
- };
-
- const payload = {
- output_folder: conversionOptions.stub_test ? undefined : conversion_output_folder,
- project_name: name,
- nwbfile_path: file,
- overwrite: true, // We assume override is true because the native NWB file dialog will not allow the user to select an existing file (unless they approve the overwrite)
- ...sessionInfo, // source_data and metadata are passed in here
- ...conversionOptions, // Any additional conversion options override the defaults
-
- interfaces: globalState.interfaces,
- alignment,
- };
+ try {
+ for (let info of toRun) {
+ const { subject, session, globalState = this.info.globalState } = info;
+ const file = `sub-${subject}/sub-${subject}_ses-${session}.nwb`;
+
+ const { conversion_output_folder, name, SourceData, alignment } = globalState.project;
+
+ const sessionResults = globalState.results[subject][session];
+
+ const sourceDataCopy = structuredClone(sessionResults.source_data);
+
+ // Resolve the correct session info from all of the metadata for this conversion
+ const metadata = resolveMetadata(subject, session, globalState);
+
+ const sessionInfo = {
+ ...sessionResults,
+ metadata,
+ source_data: merge(SourceData, sourceDataCopy),
+ };
+
+ const payload = {
+ output_folder: conversionOptions.stub_test ? undefined : conversion_output_folder,
+ project_name: name,
+ nwbfile_path: file,
+ overwrite: true, // We assume override is true because the native NWB file dialog will not allow the user to select an existing file (unless they approve the overwrite)
+ ...sessionInfo, // source_data and metadata are passed in here
+ ...conversionOptions, // Any additional conversion options override the defaults
+ interfaces: globalState.interfaces,
+ alignment,
+ timezone: this.workflow.timezone.value,
+ };
+
+ fileConfiguration.push(payload);
+ }
- fileConfiguration.push(payload);
- }
+ const conversionResults = await run(
+ `neuroconv/convert`,
+ {
+ files: fileConfiguration,
+ max_workers: 2, // TODO: Make this configurable and confirm default value
+ request_id: swalOpts.id,
+ },
+ {
+ title: "Running the conversion",
+ onError: () => "Conversion failed with current metadata. Please try again.",
+ ...swalOpts,
+ }
+ ).catch(async (error) => {
+ let message = error.message;
- const conversionResults = await run(
- `neuroconv/convert`,
- {
- files: fileConfiguration,
- max_workers: 2, // TODO: Make this configurable and confirm default value
- request_id: swalOpts.id,
- },
- {
- title: "Running the conversion",
- onError: () => "Conversion failed with current metadata. Please try again.",
- ...swalOpts,
- }
- ).catch(async (error) => {
- let message = error.message;
+ if (message.includes("The user aborted a request.")) {
+ this.notify("Conversion was cancelled.", "warning");
+ throw error;
+ }
- if (message.includes("The user aborted a request.")) {
- this.notify("Conversion was cancelled.", "warning");
+ this.notify(message, "error");
throw error;
- }
+ });
- this.notify(message, "error");
+ conversionResults.forEach((info) => {
+ const { file } = info;
+ const fileName = file.split("/").pop();
+ const [subject, session] = fileName.match(/sub-(.+)_ses-(.+)\.nwb/).slice(1);
+ const subRef = results[subject] ?? (results[subject] = {});
+ subRef[session] = info;
+ });
+ } finally {
await closeProgressPopup();
- throw error;
- });
-
- conversionResults.forEach((info) => {
- const { file } = info;
- const fileName = file.split("/").pop();
- const [subject, session] = fileName.match(/sub-(.+)_ses-(.+)\.nwb/).slice(1);
- const subRef = results[subject] ?? (results[subject] = {});
- subRef[session] = info;
- });
-
- await closeProgressPopup();
+ }
return results;
}
diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js
index 4a84177ba6..8bda2a9e54 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js
@@ -289,15 +289,12 @@ export class GuidedSourceDataPage extends ManagedPage {
alignment: alignmentInfo,
};
- console.warn("Sending", sessionInfo);
-
const data = await run("neuroconv/alignment", sessionInfo, {
title: "Checking Alignment",
message: "Please wait...",
});
const { metadata } = data;
- console.warn("GOT", data);
if (Object.keys(metadata).length === 0) {
this.notify(
diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedStubPreview.js b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedStubPreview.js
index 55979eaa5a..c82d45bc7c 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedStubPreview.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedStubPreview.js
@@ -45,7 +45,7 @@ export class GuidedStubPreviewPage extends Page {
};
render() {
- const { preview, project } = this.info.globalState;
+ const { preview = {}, project } = this.info.globalState;
return preview.stubs
? new NWBFilePreview({ project: project.name, files: preview.stubs })
diff --git a/src/electron/frontend/core/components/pages/guided-mode/setup/Preform.js b/src/electron/frontend/core/components/pages/guided-mode/setup/Preform.js
index 81f0a63756..7ba682da87 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/setup/Preform.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/setup/Preform.js
@@ -3,6 +3,8 @@ import { JSONSchemaForm } from "../../../JSONSchemaForm.js";
import { Page } from "../../Page.js";
import { onThrow } from "../../../../errors";
+import timezoneSchema from "../../../../../../../schemas/timezone.schema";
+
// ------------------------------------------------------------------------------
// ------------------------ Preform Configuration -------------------------------
// ------------------------------------------------------------------------------
@@ -55,6 +57,12 @@ const questions = {
},
},
+ timezone: {
+ ...timezoneSchema,
+ title: "What timezone is your data in?",
+ required: true,
+ },
+
upload_to_dandi: {
type: "boolean",
title: "Would you like to upload your data to DANDI?",
@@ -66,34 +74,56 @@ const questions = {
// ------------------------ Derived from the above information -------------------------------
// -------------------------------------------------------------------------------------------
-const dependents = Object.entries(questions).reduce((acc, [name, info]) => {
- acc[name] = [];
-
- const deps = info.dependencies;
-
- if (deps) {
- if (Array.isArray(deps))
- deps.forEach((dep) => {
- if (!acc[dep]) acc[dep] = [];
- acc[dep].push({ name });
- });
- else
- Object.entries(deps).forEach(([dep, opts]) => {
- if (!acc[dep]) acc[dep] = [];
- acc[dep].push({ name, ...opts });
- });
- }
- return acc;
-}, {});
+const getSchema = (questions) => {
+ // Inject latest timezone schema each render
+ questions.timezone = { ...questions.timezone, ...timezoneSchema };
+
+ const dependents = Object.entries(questions).reduce((acc, [name, info]) => {
+ acc[name] = [];
+
+ const deps = info.dependencies;
+
+ if (deps) {
+ if (Array.isArray(deps))
+ deps.forEach((dep) => {
+ if (!acc[dep]) acc[dep] = [];
+ acc[dep].push({ name });
+ });
+ else
+ Object.entries(deps).forEach(([dep, opts]) => {
+ if (!acc[dep]) acc[dep] = [];
+ acc[dep].push({ name, ...opts });
+ });
+ }
+ return acc;
+ }, {});
+
+ const defaults = Object.entries(questions).reduce((acc, [name, info]) => {
+ acc[name] = info.default;
+ return acc;
+ }, {});
-const projectWorkflowSchema = {
- type: "object",
- properties: Object.entries(questions).reduce((acc, [name, info]) => {
- acc[name] = info;
+ const required = Object.entries(questions).reduce((acc, [name, info]) => {
+ if (info.required) acc.push(name);
return acc;
- }, {}),
- order: Object.keys(questions),
- additionalProperties: false,
+ }, []);
+
+ const projectWorkflowSchema = {
+ type: "object",
+ properties: Object.entries(questions).reduce((acc, [name, info]) => {
+ acc[name] = info;
+ return acc;
+ }, {}),
+ order: Object.keys(questions),
+ required,
+ additionalProperties: false,
+ };
+
+ return {
+ schema: structuredClone(projectWorkflowSchema),
+ defaults,
+ dependents,
+ };
};
// ----------------------------------------------------------------------
@@ -125,9 +155,15 @@ export class GuidedPreform extends Page {
};
updateForm = () => {
- const schema = structuredClone(projectWorkflowSchema);
+ const { schema, dependents, defaults } = getSchema(questions);
const projectState = this.info.globalState.project ?? {};
if (!projectState.workflow) projectState.workflow = {};
+
+ // Set defaults for missing values
+ Object.entries(defaults).forEach(([key, value]) => {
+ if (!(key in projectState.workflow)) projectState.workflow[key] = value;
+ });
+
this.state = structuredClone(projectState.workflow);
this.form = new JSONSchemaForm({
diff --git a/src/electron/frontend/core/progress/index.js b/src/electron/frontend/core/progress/index.js
index aede8554b0..617ebf6086 100644
--- a/src/electron/frontend/core/progress/index.js
+++ b/src/electron/frontend/core/progress/index.js
@@ -87,8 +87,8 @@ class GlobalAppConfig {
save() {
const encoded = encodeObject(this.data);
-
- fs.writeFileSync(this.path, JSON.stringify(encoded, null, 2));
+ if (fs) fs.writeFileSync(this.path, JSON.stringify(encoded, null, 2));
+ else localStorage.setItem(this.path, JSON.stringify(encoded));
}
}
@@ -115,7 +115,7 @@ export const save = (page, overrides = {}) => {
};
export const getEntries = () => {
- if (!fs.existsSync(guidedProgressFilePath)) fs.mkdirSync(guidedProgressFilePath, { recursive: true }); //Check if progress folder exists. If not, create it.
+ if (fs && !fs.existsSync(guidedProgressFilePath)) fs.mkdirSync(guidedProgressFilePath, { recursive: true }); //Check if progress folder exists. If not, create it.
const progressFiles = fs ? fs.readdirSync(guidedProgressFilePath) : Object.keys(localStorage);
return progressFiles.filter((path) => path.slice(-5) === ".json");
};
@@ -143,7 +143,7 @@ export const getAll = (progressFiles) => {
return progressFiles.map((progressFile) => {
let progressFilePath = joinPath(guidedProgressFilePath, progressFile);
return transformProgressFile(
- JSON.parse(fs ? fs.readFileSync(progressFilePath) : localStorage.getItem(progressFilePath))
+ JSON.parse(fs ? fs.readFileSync(progressFilePath) : localStorage.getItem(progressFile))
);
});
};
diff --git a/src/electron/frontend/core/progress/operations.js b/src/electron/frontend/core/progress/operations.js
index 3154e349d8..8d60e238ef 100644
--- a/src/electron/frontend/core/progress/operations.js
+++ b/src/electron/frontend/core/progress/operations.js
@@ -7,13 +7,17 @@ export const remove = (name) => {
const progressFilePathToDelete = joinPath(guidedProgressFilePath, name + ".json");
//delete the progress file
- if (fs.existsSync(progressFilePathToDelete)) fs.unlinkSync(progressFilePathToDelete);
+ if (fs) {
+ if (fs.existsSync(progressFilePathToDelete)) fs.unlinkSync(progressFilePathToDelete);
+ } else localStorage.removeItem(progressFilePathToDelete);
- // delete default preview location
- fs.rmSync(joinPath(previewSaveFolderPath, name), { recursive: true, force: true });
+ if (fs) {
+ // delete default preview location
+ fs.rmSync(joinPath(previewSaveFolderPath, name), { recursive: true, force: true });
- // delete default conversion location
- fs.rmSync(joinPath(conversionSaveFolderPath, name), { recursive: true, force: true });
+ // delete default conversion location
+ fs.rmSync(joinPath(conversionSaveFolderPath, name), { recursive: true, force: true });
+ }
return true;
};
diff --git a/src/electron/frontend/core/progress/update.js b/src/electron/frontend/core/progress/update.js
index f07ea80b19..1b73bbc0e7 100644
--- a/src/electron/frontend/core/progress/update.js
+++ b/src/electron/frontend/core/progress/update.js
@@ -15,7 +15,12 @@ export const rename = (newDatasetName, previousDatasetName) => {
// update old progress file with new dataset name
const oldProgressFilePath = `${guidedProgressFilePath}/${previousDatasetName}.json`;
const newProgressFilePath = `${guidedProgressFilePath}/${newDatasetName}.json`;
- fs.renameSync(oldProgressFilePath, newProgressFilePath);
+
+ if (fs) fs.renameSync(oldProgressFilePath, newProgressFilePath);
+ else {
+ localStorage.setItem(newProgressFilePath, localStorage.getItem(oldProgressFilePath));
+ localStorage.removeItem(oldProgressFilePath);
+ }
} else throw new Error("No previous project name provided");
};
@@ -50,11 +55,12 @@ export const updateFile = (projectName, callback) => {
data["last-modified"] = new Date(); // Always update the last modified time
- var guidedFilePath = joinPath(guidedProgressFilePath, projectName + ".json");
-
- console.log(guidedProgressFilePath);
+ const projectFileName = projectName + ".json";
+ const guidedFilePath = joinPath(guidedProgressFilePath, projectFileName);
// Save the file through the available mechanisms
- if (!fs.existsSync(guidedProgressFilePath)) fs.mkdirSync(guidedProgressFilePath, { recursive: true }); //create progress folder if one does not exist
- fs.writeFileSync(guidedFilePath, JSON.stringify(data, null, 2));
+ if (fs) {
+ if (!fs.existsSync(guidedProgressFilePath)) fs.mkdirSync(guidedProgressFilePath, { recursive: true }); //create progress folder if one does not exist
+ fs.writeFileSync(guidedFilePath, JSON.stringify(data, null, 2));
+ } else localStorage.setItem(guidedFilePath, JSON.stringify(data));
};
diff --git a/src/pyflask/manageNeuroconv/manage_neuroconv.py b/src/pyflask/manageNeuroconv/manage_neuroconv.py
index 2e02e16404..cd23882eb9 100644
--- a/src/pyflask/manageNeuroconv/manage_neuroconv.py
+++ b/src/pyflask/manageNeuroconv/manage_neuroconv.py
@@ -7,6 +7,7 @@
import os
import re
import traceback
+import zoneinfo
from datetime import datetime
from pathlib import Path
from shutil import copytree, rmtree
@@ -991,6 +992,15 @@ def update_conversion_progress(message):
del ecephys_metadata["ElectrodeColumns"]
+ # Correct timezone in metadata fields
+ resolved_metadata["NWBFile"]["session_start_time"] = datetime.fromisoformat(
+ resolved_metadata["NWBFile"]["session_start_time"]
+ ).replace(tzinfo=zoneinfo.ZoneInfo(info["timezone"]))
+ if "date_of_birth" in resolved_metadata["Subject"]:
+ resolved_metadata["Subject"]["date_of_birth"] = datetime.fromisoformat(
+ resolved_metadata["Subject"]["date_of_birth"]
+ ).replace(tzinfo=zoneinfo.ZoneInfo(info["timezone"]))
+
# Actually run the conversion
converter.run_conversion(
metadata=resolved_metadata,
diff --git a/src/pyflask/namespaces/system.py b/src/pyflask/namespaces/system.py
index 491cf36aa2..bd4382f150 100644
--- a/src/pyflask/namespaces/system.py
+++ b/src/pyflask/namespaces/system.py
@@ -1,6 +1,6 @@
"""An API for handling general system information."""
-from typing import Dict, Union
+from typing import Dict, List, Union
import flask_restx
@@ -20,3 +20,27 @@ def get(self) -> Union[Dict[str, int], None]:
logical = cpu_count()
return dict(physical=physical, logical=logical)
+
+
+@system_namespace.route("/all_timezones")
+class GetTimezones(flask_restx.Resource):
+
+ @system_namespace.doc(
+ description="Request the available timezones available to the backend.",
+ )
+ def get(self) -> List[str]:
+ import zoneinfo
+
+ return list(zoneinfo.available_timezones())
+
+
+@system_namespace.route("/local_timezone")
+class GetTimezones(flask_restx.Resource):
+
+ @system_namespace.doc(
+ description="Request the current timezone on the system.",
+ )
+ def get(self) -> str:
+ import tzlocal
+
+ return tzlocal.get_localzone_name()
diff --git a/src/schemas/base-metadata.schema.ts b/src/schemas/base-metadata.schema.ts
index 1a05c58c81..dc17ce9820 100644
--- a/src/schemas/base-metadata.schema.ts
+++ b/src/schemas/base-metadata.schema.ts
@@ -6,6 +6,8 @@ import baseMetadataSchema from './json/base_metadata_schema.json' assert { type:
import { merge } from '../electron/frontend/core/components/pages/utils'
import { drillSchemaProperties } from '../electron/frontend/core/components/pages/guided-mode/data/utils'
+import { getISODateInTimezone } from './timezone.schema'
+
const UV_MATH_FORMAT = `µV`; //``
const UV_PROPERTIES = ["gain_to_uV", "offset_to_uV"]
@@ -41,14 +43,6 @@ function getSpeciesInfo(species: any[][] = []) {
}
-// Borrowed from https://stackoverflow.com/a/29774197/7290573
-function getCurrentDate() {
- const date = new Date()
- const offset = date.getTimezoneOffset();
- return (new Date(date.getTime() - (offset*60*1000))).toISOString();
-}
-
-
function updateEcephysTable(propName, schema, schemaToMerge) {
const ecephys = schema.properties.Ecephys
@@ -101,6 +95,10 @@ export const preprocessMetadataSchema = (schema: any = baseMetadataSchema, globa
copy.order = [ "NWBFile", "Subject" ]
+ const minDate = "1900-01-01T00:00"
+ const maxDate = getISODateInTimezone().slice(0, -2) // Restrict date to current date with timezone awareness
+
+
// Add unit to weight
const subjectProps = copy.properties.Subject.properties
subjectProps.weight.unit = 'kg'
@@ -121,13 +119,18 @@ export const preprocessMetadataSchema = (schema: any = baseMetadataSchema, globa
strict: false,
description: 'The species of your subject.'
}
- subjectProps.date_of_birth.minimum = "1900-01-01T00:00"
- subjectProps.date_of_birth.maximum = getCurrentDate().slice(0, -2)
+
+ subjectProps.date_of_birth.minimum = minDate
+ subjectProps.date_of_birth.maximum = maxDate
// copy.order = ['NWBFile', 'Subject']
- copy.properties.NWBFile.title = 'General Metadata'
const nwbProps = copy.properties.NWBFile.properties
+ copy.properties.NWBFile.title = 'General Metadata'
+
+ nwbProps.session_start_time.minimum = minDate
+ nwbProps.session_start_time.maximum = maxDate
+
nwbProps.keywords.items.description = "Provide a single keyword (e.g. Neural circuits, V1, etc.)"
// Resolve species suggestions
diff --git a/src/schemas/timezone.schema.ts b/src/schemas/timezone.schema.ts
new file mode 100644
index 0000000000..8455387fe5
--- /dev/null
+++ b/src/schemas/timezone.schema.ts
@@ -0,0 +1,126 @@
+import { baseUrl, onServerOpen } from "../electron/frontend/core/server/globals";
+import { isStorybook } from '../electron/frontend/core/globals'
+import { header } from "../electron/frontend/core/components/forms/utils";
+
+const setReady: any = {}
+
+const createPromise = (prop: string) => new Promise((resolve) => setReady[prop] = resolve)
+
+export const ready = {
+ timezones: createPromise("timezones"),
+ timezone: createPromise("timezone"),
+}
+
+// Get timezones
+onServerOpen(async () => {
+ await fetch(new URL("/system/all_timezones", baseUrl))
+ .then((res) => res.json())
+ .then((timezones) => setReady.timezones(timezones))
+ .catch(() => {
+ if (isStorybook) setReady.timezones([])
+ });
+});
+
+// Get timezone
+onServerOpen(async () => {
+ await fetch(new URL("/system/local_timezone", baseUrl))
+ .then((res) => res.json())
+ .then((timezone) => setReady.timezone(timezone))
+ .catch(() => {
+ if (isStorybook) setReady.timezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
+ });
+});
+
+
+
+
+export const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+// export const getTimeZoneName = (timezone, timeZoneName = 'long') => new Date().toLocaleDateString(undefined, {day:'2-digit', timeZone: timezone, timeZoneName }).substring(4)
+
+// NOTE: Used before validation and conversion to add timezone information to the data
+export const timezoneProperties = [
+ [ "NWBFile", "session_start_time" ],
+ [ "Subject", "date_of_birth" ]
+]
+
+export const getTimezoneOffset = (
+ date = new Date(),
+ timezone = localTimeZone
+) => {
+
+ if (typeof date === 'string') date = new Date(date)
+
+ const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
+ const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
+ return utcDate.getTime() - tzDate.getTime();
+}
+
+export const formatTimezoneOffset = (
+ milliseconds: number
+) => {
+ let offsetInMinutes = -((milliseconds / 1000) / 60); // getTimezoneOffset returns the difference in minutes from UTC
+ const sign = offsetInMinutes >= 0 ? "+" : "-";
+ offsetInMinutes = Math.abs(offsetInMinutes);
+ const hours = String(Math.floor(offsetInMinutes / 60)).padStart(2, "0");
+ const minutes = String(offsetInMinutes % 60).padStart(2, "0");
+ return `${sign}${hours}:${minutes}`;
+}
+
+export function getISODateInTimezone(
+ date = new Date(),
+ timezone = localTimeZone
+) {
+
+ if (typeof date === 'string') date = new Date(date)
+
+ const offset = getTimezoneOffset(date, timezone)
+ const adjustedDate = new Date(date.getTime() - offset);
+ return adjustedDate.toISOString();
+}
+
+
+const timezoneSchema = {
+ type: "string",
+ description: "Provide a base timezone for all date and time operations in the GUIDE.",
+ enum: [ localTimeZone ],
+ default: localTimeZone,
+ strict: true
+}
+
+ready.timezones.then((timezones) => {
+ ready.timezone.then((timezone) => {
+
+ timezoneSchema.strict = true
+ timezoneSchema.search = true
+
+ const filteredTimezones = timezoneSchema.enum = timezones.filter(tz => {
+ return tz.split('/').length > 1
+ && !tz.toLowerCase().includes('etc/')
+ });
+
+ if (!filteredTimezones.includes(timezone)) filteredTimezones.push(timezone) // Add the local timezone if it's not in the list
+
+ timezoneSchema.enumLabels = filteredTimezones.reduce((acc, tz) => {
+ const [ _, ...other ] = tz.split('/')
+ acc[tz] = other.map(part => header(part)).join(' — ')
+ return acc
+ }, {})
+
+ timezoneSchema.enumKeywords = filteredTimezones.reduce((acc, tz) => {
+ const [ region ] = tz.split('/')
+ acc[tz] = [ header(region) ]
+ return acc
+ }, {})
+
+ timezoneSchema.enumCategories = filteredTimezones.reduce((acc, tz) => {
+ const [ region ] = tz.split('/')
+ acc[tz] = region
+ return acc
+ }, {})
+
+ timezoneSchema.default = timezone;
+ })
+})
+
+export default timezoneSchema
diff --git a/stories/pages/Preform.stories.js b/stories/pages/Preform.stories.js
new file mode 100644
index 0000000000..eec7622253
--- /dev/null
+++ b/stories/pages/Preform.stories.js
@@ -0,0 +1,14 @@
+import { globalState, PageTemplate } from "./storyStates";
+
+export default {
+ title: "Pages/Guided Mode/Workflow",
+ parameters: {
+ chromatic: { disableSnapshot: false },
+ },
+};
+
+export const Default = PageTemplate.bind({});
+Default.args = {
+ activePage: "//workflow",
+ globalState,
+};
diff --git a/stories/pages/storyStates.ts b/stories/pages/storyStates.ts
index d25ef602f5..c2f62a301d 100644
--- a/stories/pages/storyStates.ts
+++ b/stories/pages/storyStates.ts
@@ -23,6 +23,12 @@ export const globalState = {
Subject: {
species: "Mus musculus",
},
+ workflow: {
+ multiple_sessions: true,
+ locate_data: true,
+ base_directory: "path/to/data",
+ upload_to_dandi: true,
+ }
},
structure: {
results: {},