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`; //`µ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: {},