diff --git a/package.json b/package.json
index 23e6e7005b..bfadc05e64 100644
--- a/package.json
+++ b/package.json
@@ -23,10 +23,12 @@
"build:electron:linux": "electron-builder build --linux --publish never",
"test": "npm run test:app && npm run test:server",
"test:app": "vitest run --exclude \"**/pipelines.test.ts\"",
+ "test:app:covered": "vitest run --exclude \"**/e2e/*.test.ts\"",
"test:tutorial": "vitest tutorial",
"test:pipelines": "vitest pipelines",
"test:progress": "vitest progress",
"test:metadata": "vitest metadata",
+ "test:coverage:minimal": "npm run test:app:covered && npm run coverage:server",
"test:server": "pytest src/pyflask/tests/ -s -vv",
"wait5s": "node -e \"setTimeout(() => process.exit(0),5000)\"",
"test:executable": "concurrently -n EXE,TEST --kill-others --success first \"node tests/testPyinstallerExecutable.js --port 3434 --forever\" \"npm run wait5s && pytest src/pyflask/tests/ -s --target http://localhost:3434\"",
diff --git a/src/electron/frontend/core/components/BasicTable.js b/src/electron/frontend/core/components/BasicTable.js
index 2c133f6a73..ad047d6d23 100644
--- a/src/electron/frontend/core/components/BasicTable.js
+++ b/src/electron/frontend/core/components/BasicTable.js
@@ -1,10 +1,10 @@
import { LitElement, css, html, unsafeCSS } from "lit";
import { styleMap } from "lit/directives/style-map.js";
-import { header } from "./forms/utils";
+import { header } from "../../utils/text";
import { checkStatus } from "../validation";
import { emojiFontFamily, errorHue, warningHue } from "./globals";
-import * as promises from "../promises";
+import * as promises from "../../utils/promises";
import "./Button";
import { sortTable } from "./Table";
diff --git a/src/electron/frontend/core/components/DandiResults.js b/src/electron/frontend/core/components/DandiResults.js
index 210757647b..9c4360aef2 100644
--- a/src/electron/frontend/core/components/DandiResults.js
+++ b/src/electron/frontend/core/components/DandiResults.js
@@ -1,7 +1,7 @@
import { LitElement, css, html } from "lit";
import { get } from "dandi";
-import { isStaging, getAPIKey } from "./pages/uploads/utils";
+import { isStaging, getAPIKey } from "../../utils/upload";
export class DandiResults extends LitElement {
static get styles() {
diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js
index ce93c02ced..9606356a35 100644
--- a/src/electron/frontend/core/components/Dashboard.js
+++ b/src/electron/frontend/core/components/Dashboard.js
@@ -1,5 +1,4 @@
import { LitElement, html } from "lit";
-import useGlobalStyles from "./utils/useGlobalStyles.js";
import { Main, checkIfPageIsSkipped } from "./Main.js";
import { Sidebar } from "./sidebar.js";
@@ -32,36 +31,14 @@ import "../../../../../node_modules/fomantic-ui/dist/components/accordion.min.cs
import "../../../../../node_modules/@sweetalert2/theme-bulma/bulma.css";
// import "../../node_modules/intro.js/minified/introjs.min.css"
import "../../assets/css/guided.css";
-import { isElectron } from "../../utils/electron.js";
+import { isElectron } from "../../utils/electron";
import { isStorybook, reloadPageToHome } from "../globals.js";
import { getCurrentProjectName, updateAppProgress } from "../progress/index.js";
// import "https://jsuites.net/v4/jsuites.js"
// import "https://bossanova.uk/jspreadsheet/v4/jexcel.js"
-const componentCSS = `
- :host {
- display: flex;
- height: 100%;
- width: 100%;
- }
-
- nwb-main {
- background: #fff;
- border-top: 1px solid #c3c3c3;
- }
-`;
-
export class Dashboard extends LitElement {
- static get styles() {
- const style = useGlobalStyles(
- componentCSS,
- (sheet) => sheet.href && sheet.href.includes("bootstrap"),
- this.shadowRoot
- );
- return style;
- }
-
static get properties() {
return {
renderNameInSidebar: { type: Boolean, reflect: true },
diff --git a/src/electron/frontend/core/components/DateTimeSelector.js b/src/electron/frontend/core/components/DateTimeSelector.js
index a8ba90cf8e..66c3dd5305 100644
--- a/src/electron/frontend/core/components/DateTimeSelector.js
+++ b/src/electron/frontend/core/components/DateTimeSelector.js
@@ -1,5 +1,5 @@
import { LitElement, css } from "lit";
-import { getTimezoneOffset, formatTimezoneOffset } from "../../../../schemas/timezone.schema";
+import { getTimezoneOffset, formatTimezoneOffset } from "../../utils/time";
// Function to format the GMT offset
export function extractISOString(date = new Date(), { offset = false, timezone = undefined } = {}) {
diff --git a/src/electron/frontend/core/components/forms/GlobalFormModal.ts b/src/electron/frontend/core/components/GlobalFormModal.ts
similarity index 93%
rename from src/electron/frontend/core/components/forms/GlobalFormModal.ts
rename to src/electron/frontend/core/components/GlobalFormModal.ts
index bd0f919bcc..52cc393e94 100644
--- a/src/electron/frontend/core/components/forms/GlobalFormModal.ts
+++ b/src/electron/frontend/core/components/GlobalFormModal.ts
@@ -1,11 +1,11 @@
-import { Modal } from "../Modal"
-import { Page } from "../pages/Page.js"
-import { Button } from "../Button.js"
-import { JSONSchemaForm } from "../JSONSchemaForm.js"
-
-import { onThrow } from "../../errors";
-import { merge } from "../pages/utils.js";
-import { save } from "../../progress/index.js";
+import { Modal } from "./Modal"
+import { Page } from "./pages/Page.js"
+import { Button } from "./Button.js"
+import { JSONSchemaForm } from "./JSONSchemaForm.js"
+
+import { onThrow } from "../errors";
+import { merge } from "../../utils/data";
+import { save } from "../progress/index.js";
type SingleIgnorePropsLevel = {
[x:string]: true,
diff --git a/src/electron/frontend/core/components/preview/inspector/InspectorList.js b/src/electron/frontend/core/components/InspectorList.js
similarity index 95%
rename from src/electron/frontend/core/components/preview/inspector/InspectorList.js
rename to src/electron/frontend/core/components/InspectorList.js
index b003dacecf..4e5dc56bc7 100644
--- a/src/electron/frontend/core/components/preview/inspector/InspectorList.js
+++ b/src/electron/frontend/core/components/InspectorList.js
@@ -1,6 +1,6 @@
import { LitElement, css, html } from "lit";
-import { List } from "../../List";
-import { getMessageType, isErrorImportance } from "../../../validation";
+import { List } from "./List";
+import { getMessageType, isErrorImportance } from "../validation";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
diff --git a/src/electron/frontend/core/components/instances/item.ts b/src/electron/frontend/core/components/InstanceListItem.ts
similarity index 98%
rename from src/electron/frontend/core/components/instances/item.ts
rename to src/electron/frontend/core/components/InstanceListItem.ts
index 064522c060..61ebc2afec 100644
--- a/src/electron/frontend/core/components/instances/item.ts
+++ b/src/electron/frontend/core/components/InstanceListItem.ts
@@ -1,6 +1,6 @@
import { LitElement, css, html, unsafeCSS } from "lit";
-import { errorHue, errorSymbol, successHue, successSymbol, warningHue, warningSymbol, emojiFontFamily } from "../globals";
+import { errorHue, errorSymbol, successHue, successSymbol, warningHue, warningSymbol, emojiFontFamily } from "./globals";
export class InstanceListItem extends LitElement {
diff --git a/src/electron/frontend/core/components/InstanceManager.js b/src/electron/frontend/core/components/InstanceManager.js
index 3b9dc49bbb..ad0d8a1e6e 100644
--- a/src/electron/frontend/core/components/InstanceManager.js
+++ b/src/electron/frontend/core/components/InstanceManager.js
@@ -2,7 +2,7 @@ import { LitElement, css, html } from "lit";
import "./Button";
import { notify } from "../dependencies";
import { Accordion } from "./Accordion";
-import { InstanceListItem } from "./instances/item";
+import { InstanceListItem } from "./InstanceListItem";
import { checkStatus } from "../validation";
export class InstanceManager extends LitElement {
diff --git a/src/electron/frontend/core/components/JSONSchemaForm.js b/src/electron/frontend/core/components/JSONSchemaForm.js
index 2a3d9503ce..57c5b0f5ed 100644
--- a/src/electron/frontend/core/components/JSONSchemaForm.js
+++ b/src/electron/frontend/core/components/JSONSchemaForm.js
@@ -4,17 +4,19 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { Accordion } from "./Accordion";
import { checkStatus } from "../validation";
-import { header, replaceRefsWithValue } from "./forms/utils";
-import { resolve } from "../promises";
-import { merge } from "./pages/utils";
-import { resolveProperties } from "./pages/guided-mode/data/utils";
+import { header } from "../../utils/text";
+import { resolveAsJSONSchema } from "../../utils/data";
+import { resolve } from "../../utils/promises";
+import { merge } from "../../utils/data";
+import { resolveProperties } from "../../utils/data";
import { JSONSchemaInput, getEditableItems } from "./JSONSchemaInput";
-import { InspectorListItem } from "./preview/inspector/InspectorList";
+import { InspectorListItem } from "./InspectorList";
import { Validator } from "jsonschema";
import { successHue, warningHue, errorHue } from "./globals";
import { Button } from "./Button";
+import { isObject } from "../../utils/typecheck";
const encode = (str) => {
try {
@@ -69,11 +71,7 @@ const additionalPropPattern = "additional";
const templateNaNMessage = `
Type NaN to represent an unknown value.`;
-var validator = new Validator();
-
-const isObject = (item) => {
- return item && typeof item === "object" && !Array.isArray(item);
-};
+const validator = new Validator();
export const getIgnore = (o, path) => {
if (typeof path === "string") path = path.split(".");
@@ -658,7 +656,7 @@ export class JSONSchemaForm extends LitElement {
set schema(schema) {
this.#schema = schema;
- this.#schema = replaceRefsWithValue(schema);
+ this.#schema = resolveAsJSONSchema(schema);
}
get schema() {
diff --git a/src/electron/frontend/core/components/JSONSchemaInput.js b/src/electron/frontend/core/components/JSONSchemaInput.js
index 0a3b150a57..f9966222f6 100644
--- a/src/electron/frontend/core/components/JSONSchemaInput.js
+++ b/src/electron/frontend/core/components/JSONSchemaInput.js
@@ -3,20 +3,22 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { FilesystemSelector } from "./FileSystemSelector";
import { BasicTable } from "./BasicTable";
-import { header, tempPropertyKey, tempPropertyValueKey } from "./forms/utils";
+import { header } from "../../utils/text";
+import { tempPropertyKey, tempPropertyValueKey } from "./globals.js";
import { Button } from "./Button";
import { List } from "./List";
import { Modal } from "./Modal";
-import { capitalize } from "./forms/utils";
+import { capitalize } from "../../utils/text";
import { JSONSchemaForm, getIgnore } from "./JSONSchemaForm";
import { Search } from "./Search";
import tippy from "tippy.js";
-import { merge } from "./pages/utils";
+import { merge } from "../../utils/data";
import { OptionalSection } from "./OptionalSection";
-import { InspectorListItem } from "./preview/inspector/InspectorList";
+import { InspectorListItem } from "./InspectorList.js";
import { renderDateTime, resolveDateTime } from "./DateTimeSelector";
+import { isObject } from "../../utils/typecheck";
const isDevelopment = !!import.meta.env;
@@ -276,8 +278,7 @@ export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) {
}
// Schema or value indicates editable object
-export const isEditableObject = (schema, value) =>
- schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value));
+export const isEditableObject = (schema, value) => schema.type === "object" || isObject(value);
export const isAdditionalProperties = (pattern) => pattern === "additional";
export const isPatternProperties = (pattern) => pattern && !isAdditionalProperties(pattern);
diff --git a/src/electron/frontend/core/components/Main.js b/src/electron/frontend/core/components/Main.js
index 7270ac1ec7..e34b621a44 100644
--- a/src/electron/frontend/core/components/Main.js
+++ b/src/electron/frontend/core/components/Main.js
@@ -1,5 +1,4 @@
import { LitElement, html } from "lit";
-import useGlobalStyles from "./utils/useGlobalStyles.js";
import { GuidedFooter } from "./pages/guided-mode/GuidedFooter.js";
import { GuidedHeader } from "./pages/guided-mode/GuidedHeader.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
@@ -19,22 +18,7 @@ export const checkIfPageIsSkipped = (page, workflowValues = {}) => {
return false;
};
-const componentCSS = `
- :host {
- display: grid;
- grid-template-rows: fit-content(100%) 1fr fit-content(100%);
- }
-`;
-
export class Main extends LitElement {
- static get styles() {
- return useGlobalStyles(
- componentCSS,
- (sheet) => sheet.href && sheet.href.includes("bootstrap"),
- this.shadowRoot
- );
- }
-
static get properties() {
return {
toRender: { type: Object, reflect: false },
diff --git a/src/electron/frontend/core/components/preview/NWBFilePreview.js b/src/electron/frontend/core/components/NWBFilePreview.js
similarity index 93%
rename from src/electron/frontend/core/components/preview/NWBFilePreview.js
rename to src/electron/frontend/core/components/NWBFilePreview.js
index 5c6cafdad3..84e7a61d5a 100644
--- a/src/electron/frontend/core/components/preview/NWBFilePreview.js
+++ b/src/electron/frontend/core/components/NWBFilePreview.js
@@ -1,12 +1,13 @@
import { LitElement, css, html } from "lit";
-import { InspectorList } from "./inspector/InspectorList";
+import { InspectorList } from "./InspectorList";
import { Neurosift, getURLFromFilePath } from "./Neurosift";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
-import { run } from "../pages/guided-mode/options/utils";
+import { run } from "../../utils/run";
+
import { until } from "lit/directives/until.js";
-import { InstanceManager } from "../InstanceManager";
-import { path } from "../../../utils/electron.js";
-import { FullScreenToggle } from "../FullScreenToggle";
+import { InstanceManager } from "./InstanceManager";
+import { path } from "../../utils/electron";
+import { FullScreenToggle } from "./FullScreenToggle";
export function getSharedPath(array) {
array = array.map((str) => str.replace(/\\/g, "/")); // Convert to Mac-style path
diff --git a/src/electron/frontend/core/components/NavigationSidebar.js b/src/electron/frontend/core/components/NavigationSidebar.js
index c30ca4ee1b..072e1531fe 100644
--- a/src/electron/frontend/core/components/NavigationSidebar.js
+++ b/src/electron/frontend/core/components/NavigationSidebar.js
@@ -1,27 +1,14 @@
import { LitElement, html } from "lit";
-import useGlobalStyles from "./utils/useGlobalStyles.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
const autoOpenValue = Symbol("SECTION_AUTO_OPEN");
-const componentCSS = `
-
-`;
-
function isHTML(str) {
var doc = new DOMParser().parseFromString(str, "text/html");
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
}
export class NavigationSidebar extends LitElement {
- static get styles() {
- return useGlobalStyles(
- componentCSS,
- (sheet) => sheet.href && sheet.href.includes("bootstrap"),
- this.shadowRoot
- );
- }
-
static get properties() {
return {
sections: { type: Object, reflect: false },
diff --git a/src/electron/frontend/core/components/preview/Neurosift.js b/src/electron/frontend/core/components/Neurosift.js
similarity index 91%
rename from src/electron/frontend/core/components/preview/Neurosift.js
rename to src/electron/frontend/core/components/Neurosift.js
index 3f85327d87..4381072054 100644
--- a/src/electron/frontend/core/components/preview/Neurosift.js
+++ b/src/electron/frontend/core/components/Neurosift.js
@@ -1,8 +1,8 @@
import { LitElement, css, html } from "lit";
-import { Loader } from "../Loader";
-import { FullScreenToggle } from "../FullScreenToggle";
-import { baseUrl } from "../../server/globals";
+import { Loader } from "./Loader";
+import { FullScreenToggle } from "./FullScreenToggle";
+import { baseUrl } from "../server/globals";
export function getURLFromFilePath(file, projectName) {
const regexp = new RegExp(`.+(${projectName}.+)`);
diff --git a/src/electron/frontend/core/components/ProgressBar.ts b/src/electron/frontend/core/components/ProgressBar.ts
index 8eeb71dab8..97d1eff230 100644
--- a/src/electron/frontend/core/components/ProgressBar.ts
+++ b/src/electron/frontend/core/components/ProgressBar.ts
@@ -1,7 +1,7 @@
import { LitElement, html, css, unsafeCSS } from 'lit';
-import { humanReadableBytes } from './utils/size';
+import { humanReadableBytes } from '../../utils/bytes';
export type ProgressProps = {
size?: string,
diff --git a/src/electron/frontend/core/components/Search.js b/src/electron/frontend/core/components/Search.js
index 66a988014e..f348b4b265 100644
--- a/src/electron/frontend/core/components/Search.js
+++ b/src/electron/frontend/core/components/Search.js
@@ -5,6 +5,7 @@ import searchSVG from "../../assets/icons/search.svg?raw";
import tippy from "tippy.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { isObject } from "../../utils/typecheck";
const ALTERNATIVE_MODES = ["input", "append"];
@@ -45,9 +46,7 @@ export class Search extends LitElement {
#value;
- #isObject(value = this.#value) {
- return value && typeof value === "object";
- }
+ #isObject = (value = this.#value) => isObject(value);
#getOption = ({ label, value } = {}) => {
return this.options.find((item) => {
diff --git a/src/electron/frontend/core/components/SimpleTable.js b/src/electron/frontend/core/components/SimpleTable.js
index 679dee38db..1db95ee1ad 100644
--- a/src/electron/frontend/core/components/SimpleTable.js
+++ b/src/electron/frontend/core/components/SimpleTable.js
@@ -1,10 +1,10 @@
import { LitElement, css, html, unsafeCSS } from "lit";
-import { header, tempPropertyValueKey } from "./forms/utils";
+import { header } from "../../utils/text";
import { checkStatus } from "../validation";
import { TableCell } from "./table/Cell";
import { ContextMenu } from "./table/ContextMenu";
-import { emojiFontFamily, errorHue, warningHue } from "./globals";
+import { emojiFontFamily, errorHue, tempPropertyValueKey, warningHue } from "./globals";
import { Loader } from "./Loader";
import { styleMap } from "lit/directives/style-map.js";
@@ -14,7 +14,7 @@ import tippy from "tippy.js";
import { sortTable, getEditable } from "./Table";
import { NestedInputCell } from "./table/cells/input";
import { getIgnore } from "./JSONSchemaForm";
-import { merge } from "./pages/utils";
+import { merge } from "../../utils/data";
var isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
diff --git a/src/electron/frontend/core/components/status/StatusBar.ts b/src/electron/frontend/core/components/StatusBar.ts
similarity index 100%
rename from src/electron/frontend/core/components/status/StatusBar.ts
rename to src/electron/frontend/core/components/StatusBar.ts
diff --git a/src/electron/frontend/core/components/status/StatusIndicator.ts b/src/electron/frontend/core/components/StatusIndicator.ts
similarity index 99%
rename from src/electron/frontend/core/components/status/StatusIndicator.ts
rename to src/electron/frontend/core/components/StatusIndicator.ts
index ded6e1b2d6..65dc868dcd 100644
--- a/src/electron/frontend/core/components/status/StatusIndicator.ts
+++ b/src/electron/frontend/core/components/StatusIndicator.ts
@@ -5,7 +5,7 @@ import {
successHue,
warningHue,
issueHue
-} from "../globals";
+} from "./globals";
export type StatusIndicatorProps = {
label: string | any,
diff --git a/src/electron/frontend/core/components/Table.js b/src/electron/frontend/core/components/Table.js
index f4a7c7956a..6c7a669837 100644
--- a/src/electron/frontend/core/components/Table.js
+++ b/src/electron/frontend/core/components/Table.js
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { Handsontable, css } from "./hot";
-import { header } from "./forms/utils";
+import { header } from "../../utils/text";
import { errorHue, warningHue } from "./globals";
import { checkStatus } from "../validation";
import { emojiFontFamily } from "./globals";
diff --git a/src/electron/frontend/core/components/forms/utils.ts b/src/electron/frontend/core/components/forms/utils.ts
deleted file mode 100644
index 43d09296e1..0000000000
--- a/src/electron/frontend/core/components/forms/utils.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { merge } from '../pages/utils'
-
-const toCapitalizeAll = ['nwb', 'api', 'id']
-const toCapitalizeNone = ['or', 'and']
-
-
-export const createRandomString = () => Math.random().toString(36).substring(7);
-export const tempPropertyKey = createRandomString();
-export const tempPropertyValueKey = createRandomString();
-
-export const capitalize = (str: string) => {
- const lowerCase = str.toLowerCase()
- return toCapitalizeAll.includes(lowerCase) ? str.toUpperCase() : (toCapitalizeNone.includes(lowerCase) ? lowerCase : str[0].toUpperCase() + str.slice(1))
-}
-
-
-export const header = (headerStr: string) => headerStr.split(/[_\s]/).filter(str => !!str).map(capitalize).join(' ')
-
-export const textToArray = (value: string) => value.split("\n")
- .map((str) => str.trim())
- .filter((str) => str) // Only keep strings that are not empty
-
-
- export const replaceRefsWithValue = (
- schema: any,
- path: string[] = [],
- parent: { [x:string]: any } = structuredClone(schema)
- ) => {
-
- if (schema && typeof schema === "object" && !Array.isArray(schema)) {
-
- const copy = { ...schema };
-
- for (let propName in copy) {
- const prop = copy[propName];
- if (prop && typeof prop === "object" && !Array.isArray(prop)) {
- const internalCopy = (copy[propName] = { ...prop });
- const refValue = internalCopy["$ref"]
- const allOfValue = internalCopy['allOf']
- if (allOfValue) {
- copy [propName]= allOfValue.reduce((acc, curr) => {
- const result = replaceRefsWithValue({ _temp: curr}, path, parent)
- const resolved = result._temp
- return merge(resolved, acc)
- }, {})
- }
- else if (refValue) {
-
- const refPath = refValue.split('/').slice(1) // NOTE: Assume from base
- const resolved = refPath.reduce((acc, key) => acc[key], parent)
-
- if (resolved) copy[propName] = resolved;
- else delete copy[propName]
- } else {
- for (let key in internalCopy) {
- const fullPath = [...path, propName, key];
- internalCopy[key] = replaceRefsWithValue(internalCopy[key], fullPath, parent);
- }
- }
- }
- }
-
- return copy as { [x:string]: any }
- }
-
- return schema;
- }
diff --git a/src/electron/frontend/core/components/globals.js b/src/electron/frontend/core/components/globals.js
index 3f51572b93..45fa0b372a 100644
--- a/src/electron/frontend/core/components/globals.js
+++ b/src/electron/frontend/core/components/globals.js
@@ -1,4 +1,5 @@
import { css } from "lit";
+import { getRandomString } from "../../utils/random";
export const errorHue = 0;
export const warningHue = 57;
@@ -10,3 +11,6 @@ export const warningSymbol = css`⚠️`;
export const successSymbol = css`✅`;
export const emojiFontFamily = `"Twemoji Mozilla", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", sans-serif;`;
+
+export const tempPropertyKey = getRandomString();
+export const tempPropertyValueKey = getRandomString();
diff --git a/src/electron/frontend/core/components/multiselect/MultiSelectForm.js b/src/electron/frontend/core/components/multiselect/MultiSelectForm.js
deleted file mode 100644
index 91b313c44a..0000000000
--- a/src/electron/frontend/core/components/multiselect/MultiSelectForm.js
+++ /dev/null
@@ -1,158 +0,0 @@
-import { LitElement, css, html } from "lit";
-
-// Adapted from https://web.dev/building-a-multi-select-component/
-
-const componentCSS = `
-
- * {
- box-sizing: border-box;
- }
-
- :host {
- display: inline-block;
- }
-
- :host > div {
- border: 1px solid #ccc;
- border-radius: 4px;
- background: white;
- padding: 25px;
- display: inline-block;
- }
-
- form {
- display: grid;
- gap: 2ch;
- }
-
- @media (pointer: coarse) {
- select[multiple] {
- display: block;
- }
- }
-
- fieldset {
- padding: 2ch;
- border: 1px solid gray;
-
- & > div + div {
- margin-block-start: 2ch;
- }
- }
-
- legend {
- font-weight: bold;
- }
-
- fieldset > div {
- display: flex;
- gap: 2ch;
- align-items: baseline;
- }
-`;
-
-export class MultiSelectForm extends LitElement {
- static get styles() {
- return css([componentCSS]);
- }
-
- static get properties() {
- return {
- options: { type: Object, reflect: true },
- selected: { type: Object, reflect: false },
- };
- }
-
- constructor(props = {}) {
- super();
- this.options = props.options ?? {};
- this.selected = props.selected ?? {};
- }
-
- attributeChangedCallback(changedProperties, oldValue, newValue) {
- super.attributeChangedCallback(changedProperties, oldValue, newValue);
- if (changedProperties === "options") this.requestUpdate();
- }
-
- // NOTE: We can move these into their own components in the future
- async updated() {
- const dataFormatsForm = (this.shadowRoot ?? this).querySelector("#neuroconv-data-formats-form");
- dataFormatsForm.innerHTML = ""; // Clear the form
-
- const formats = this.options;
-
- if (formats.message) {
- throw new Error(formats.message);
- }
-
- // Currently supports two levels of fields
- let modalities = {};
- for (let className in formats) {
- const format = formats[className];
- const name = format.name ?? className;
-
- let modality = modalities[format.modality];
- if (!modality) {
- const fieldset = document.createElement("fieldset");
- const legend = document.createElement("legend");
- legend.textContent = format.modality;
- fieldset.appendChild(legend);
- dataFormatsForm.appendChild(fieldset);
-
- modality = modalities[format.modality] = {
- form: fieldset,
- techniques: {},
- };
- }
-
- // Place in technique or modality div
- const technique = format.technique;
- let targetInfo = modality;
- if (technique) {
- targetInfo = modality.techniques[technique];
- if (!targetInfo) {
- const fieldset = document.createElement("fieldset");
- const legend = document.createElement("legend");
- legend.textContent = technique;
- fieldset.appendChild(legend);
- modality.form.appendChild(fieldset);
-
- targetInfo = modality.techniques[technique] = {
- form: fieldset,
- };
- }
- }
-
- const form = targetInfo.form;
- const div = document.createElement("div");
- const input = document.createElement("input");
- input.type = "checkbox";
- input.value = name;
- input.name = name;
- div.appendChild(input);
- const label = document.createElement("label");
-
- if (this.selected[name]) input.checked = true;
-
- input.onchange = (ev) => {
- this.selected[name] = input.checked;
- };
- label.for = name;
- label.textContent = name;
- div.appendChild(label);
- form.appendChild(div);
- }
- }
-
- render() {
- return html`
-
-
-
- `;
- }
-}
-
-customElements.get("nwb-multiselect-form") || customElements.define("nwb-multiselect-form", MultiSelectForm);
diff --git a/src/electron/frontend/core/components/pages/Page.js b/src/electron/frontend/core/components/pages/Page.js
index 672068b1eb..886987dfbe 100644
--- a/src/electron/frontend/core/components/pages/Page.js
+++ b/src/electron/frontend/core/components/pages/Page.js
@@ -1,15 +1,16 @@
import { LitElement, html } from "lit";
-import { run } from "./guided-mode/options/utils.js";
+import { run } from "../../../utils/run";
import { get, save } from "../../progress/index.js";
import { dismissNotification, notify } from "../../dependencies.js";
import { isStorybook } from "../../globals.js";
-import { randomizeElements, mapSessions, merge } from "./utils";
+import { mapSessions, merge } from "../../../utils/data";
+import { getRandomSample } from "../../../utils/random";
-import { resolveMetadata } from "./guided-mode/data/utils.js";
+import { resolveMetadata } from "../../../utils/data";
import Swal from "sweetalert2";
-import { createProgressPopup } from "../utils/progress.js";
+import { createProgressPopup } from "../../../utils/popups";
export class Page extends LitElement {
// static get styles() {
@@ -153,7 +154,7 @@ export class Page extends LitElement {
// Filter the sessions to run
if (typeof original === "number")
- toRun = randomizeElements(toRun, original); // Grab a random set of sessions
+ toRun = getRandomSample(toRun, original); // Grab a random set of sessions
else if (typeof original === "string") toRun = toRun.filter(({ subject }) => subject === original);
else if (typeof original === "function") toRun = toRun.filter(original);
diff --git a/src/electron/frontend/core/components/pages/guided-mode/GuidedStart.js b/src/electron/frontend/core/components/pages/guided-mode/GuidedStart.js
index a1edff01cc..6884f40597 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/GuidedStart.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/GuidedStart.js
@@ -2,7 +2,7 @@ import { html } from "lit";
import { Page } from "../Page.js";
import "./GuidedFooter";
import { InfoBox } from "../../InfoBox.js";
-import { InspectorListItem } from "../../preview/inspector/InspectorList.js";
+import { InspectorListItem } from "../../inspector/InspectorList.js";
import { sections } from "../globals";
export class GuidedStartPage extends Page {
diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedBackendConfiguration.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedBackendConfiguration.js
index 035e576f31..fabfbe6a39 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedBackendConfiguration.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedBackendConfiguration.js
@@ -3,21 +3,21 @@ import { JSONSchemaForm, get } from "../../../JSONSchemaForm.js";
import { ManagedPage } from "./ManagedPage.js";
import { onThrow } from "../../../../errors";
-import { merge } from "../../utils.js";
+import { merge } from "../../../../../utils/data";
+import { run } from "../../../../../utils/run";
import { html } from "lit";
-import { run } from "../options/utils.js";
import { until } from "lit/directives/until.js";
-import { resolve } from "../../../../promises";
+import { resolve } from "../../../../../utils/promises";
import { InstanceManager } from "../../../InstanceManager.js";
-import { InspectorListItem } from "../../../preview/inspector/InspectorList.js";
+import { InspectorListItem } from "../../../InspectorList.js";
import { getResourceUsageBytes } from "../../../../validation/backend-configuration";
import { resolveBackendResults, updateSchema } from "../../../../../../../schemas/backend-configuration.schema";
-import { getInfoFromId } from "./utils.js";
+import { getInfoFromId } from "../../../../../utils/data";
const itemIgnore = {
full_shape: true,
diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedMetadata.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedMetadata.js
index a5db5846b9..e06a30e544 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedMetadata.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedMetadata.js
@@ -10,17 +10,18 @@ import {
resolveMetadata,
getInfoFromId,
drillSchemaProperties,
- resolveFromPath,
-} from "./utils";
+} from "./../../../../../utils/data";
+
+import { merge } from "../../../../../utils/data";
+import { header } from "../../../../../utils/text";
+import { tempPropertyKey } from "./../../../globals.js";
import Swal from "sweetalert2";
import { SimpleTable } from "../../../SimpleTable.js";
import { onThrow } from "../../../../errors";
-import { merge } from "../../utils";
-import { NWBFilePreview } from "../../../preview/NWBFilePreview.js";
-import { header, tempPropertyKey } from "../../../forms/utils";
+import { NWBFilePreview } from "../../../NWBFilePreview.js";
-import { createGlobalFormModal } from "../../../forms/GlobalFormModal";
+import { createGlobalFormModal } from "../../../GlobalFormModal";
import { Button } from "../../../Button.js";
import globalIcon from "../../../../../assets/icons/global.svg?raw";
@@ -225,7 +226,7 @@ export class GuidedMetadataPage extends ManagedPage {
createForm = ({ subject, session, info }) => {
const hasMultipleSessions = this.workflow.multiple_sessions.value;
- // const results = createResults({ subject, info }, this.info.globalState);
+ // const results = createResultsForSession({ subject, info }, this.info.globalState);
const { globalState } = this.info;
diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedPathExpansion.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedPathExpansion.js
index 1d096586ed..3b9ff99a52 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedPathExpansion.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedPathExpansion.js
@@ -3,19 +3,21 @@ import { Page } from "../../Page.js";
// For Multi-Select Form
import { JSONSchemaForm, getSchema } from "../../../JSONSchemaForm.js";
-import { run } from "../options/utils.js";
import { onThrow } from "../../../../errors";
import pathExpansionSchema from "../../../../../../../schemas/json/path-expansion.schema.json" assert { type: "json" };
-import { merge } from "../../utils";
import { List } from "../../../List";
-import { fs } from "../../../../../utils/electron.js";
+import { fs } from "../../../../../utils/electron";
import { Button } from "../../../Button.js";
import { Modal } from "../../../Modal";
-import { header } from "../../../forms/utils";
import autocompleteIcon from "../../../../../assets/icons/inspect.svg?raw";
+// Utils
+import { header } from "../../../../../utils/text";
+import { merge } from "../../../../../utils/data";
+import { run } from "../../../../../utils/run";
+
const propOrder = ["path", "subject_id", "session_id"];
export async function autocompleteFormatString(path) {
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 2fd038091b..e989e66524 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
@@ -4,19 +4,19 @@ import { JSONSchemaForm } from "../../../JSONSchemaForm.js";
import { InstanceManager } from "../../../InstanceManager.js";
import { ManagedPage } from "./ManagedPage.js";
import { onThrow } from "../../../../errors";
-import { merge, sanitize } from "../../utils";
+import { merge, sanitize } from "../../../../../utils/data";
import preprocessSourceDataSchema from "../../../../../../../schemas/source-data.schema";
import { TimeAlignment } from "./alignment/TimeAlignment.js";
-import { createGlobalFormModal } from "../../../forms/GlobalFormModal";
-import { header } from "../../../forms/utils";
+import { createGlobalFormModal } from "../../../GlobalFormModal";
+import { header } from "../../../../../utils/text";
import { Button } from "../../../Button.js";
import globalIcon from "../../../../../assets/icons/global.svg?raw";
-import { run } from "../options/utils.js";
-import { getInfoFromId } from "./utils";
+import { run } from "../../../../../utils/run";
+import { getInfoFromId } from "../../../../../utils/data";
import { Modal } from "../../../Modal";
import Swal from "sweetalert2";
diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedStructure.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedStructure.js
index 6bf9f36087..09cd85d489 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedStructure.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedStructure.js
@@ -7,9 +7,8 @@ import { supportedInterfaces } from "../../../../globals";
import { Search } from "../../../Search.js";
import { Modal } from "../../../Modal";
import { List } from "../../../List";
-import { baseUrl } from "../../../../server/globals";
import { ready } from "../../../../../../../schemas/interfaces.info";
-import { run } from "../options/utils.js";
+import { run } from "../../../../../utils/run";
const defaultEmptyMessage = "No formats selected";
diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js b/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js
index 68feb0db98..3bbb03c23c 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js
@@ -1,6 +1,6 @@
import { LitElement, css } from "lit";
import { JSONSchemaInput } from "../../../../JSONSchemaInput";
-import { InspectorListItem } from "../../../../preview/inspector/InspectorList";
+import { InspectorListItem } from "../../../../InspectorList";
const options = {
start: {
diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/utils.js b/src/electron/frontend/core/components/pages/guided-mode/data/utils.js
deleted file mode 100644
index b5259e3bb5..0000000000
--- a/src/electron/frontend/core/components/pages/guided-mode/data/utils.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import { getEditableItems } from "../../../JSONSchemaInput.js";
-import { merge } from "../../utils.js";
-
-// Merge project-wide data into metadata
-export function populateWithProjectMetadata(info, globalState) {
- const copy = structuredClone(info);
- const toMerge = Object.entries(globalState.project).filter(([_, value]) => value && typeof value === "object");
- toMerge.forEach(([key, value]) => {
- let internalMetadata = copy[key];
- if (!copy[key]) internalMetadata = copy[key] = {};
- for (let key in value) {
- if (!(key in internalMetadata)) internalMetadata[key] = value[key]; // Prioritize existing results (cannot override with new information...)
- }
- });
-
- return copy;
-}
-
-export const getInfoFromId = (key) => {
- let [subject, session] = key.split("/");
- if (subject.startsWith("sub-")) subject = subject.slice(4);
- if (session.startsWith("ses-")) session = session.slice(4);
-
- return { subject, session };
-};
-
-export function resolveGlobalOverrides(subject, globalState, resolveMultiSessionOverrides = true) {
- const subjectMetadataCopy = { ...(globalState.subjects?.[subject] ?? {}) };
- delete subjectMetadataCopy.sessions; // Remove extra key from metadata
-
- if (resolveMultiSessionOverrides) {
- const overrides = structuredClone(globalState.project ?? {}); // Copy project-wide metadata
-
- merge(subjectMetadataCopy, overrides.Subject ?? (overrides.Subject = {})); // Ensure Subject exists
-
- return overrides;
- }
-
- return { Subject: subjectMetadataCopy };
-}
-
-const isPatternResult = Symbol("ispatternresult");
-
-export function resolveFromPath(path, target) {
- return path.reduce((acc, key) => {
- if (!acc) return;
- if (acc[isPatternResult]) return acc;
- if (key in acc) return acc[key];
- else {
- const items = getEditableItems(acc, true, { name: key });
- const object = items.reduce((acc, { key, value }) => (acc[key] = value), {});
- object[isPatternResult] = true;
- return object;
- }
- }, target);
-}
-
-export function drillSchemaProperties(schema = {}, callback, target, path = [], inPatternProperties = false) {
- const properties = schema.properties ?? {};
-
- const patternProperties = schema.patternProperties ?? {};
-
- for (let regexp in patternProperties) {
- const info = patternProperties[regexp];
- const updatedPath = [...path, regexp];
- callback(updatedPath, info, undefined, true);
- drillSchemaProperties(info, callback, undefined, updatedPath, true, schema);
- }
-
- for (let name in properties) {
- const info = properties[name];
-
- if (name === "definitions") continue;
-
- const updatedPath = [...path, name];
-
- callback(updatedPath, info, target, undefined, schema);
-
- drillSchemaProperties(info, callback, target?.[name], updatedPath, inPatternProperties);
- }
-
- return schema;
-}
-
-export function resolveProperties(properties = {}, target, globals = {}) {
- if ("properties" in properties && "type" in properties) properties = properties.properties; // Correct for when a schema is passed instead
-
- for (let name in properties) {
- const info = properties[name];
-
- const props = info.properties;
-
- if (!(name in target)) {
- if (target.__disabled?.[name]) continue; // Skip disabled properties
-
- if (props) target[name] = {}; // Regisiter new interfaces in results
- // if (info.type === "array") target[name] = []; // Auto-populate arrays (NOTE: Breaks PyNWB when adding to TwoPhotonSeries field...)
-
- // Apply global or default value if empty
- if (name in globals) target[name] = globals[name];
- else if (info.default) target[name] = info.default;
- }
-
- if (target[name]) resolveProperties(props, target[name], globals[name]);
- }
-
- return target;
-}
-
-// Explicitly resolve the results for a particular session (from both GUIDE-defined globals and the NWB Schema)
-export function resolveMetadata(subject, session, globalState) {
- const overrides = resolveGlobalOverrides(subject, globalState); // Unique per-subject (but not sessions)
- const metadata = globalState.results[subject][session].metadata;
- const results = structuredClone(metadata); // Copy the metadata results from the form
- const schema = globalState.schema.metadata[subject][session];
- resolveProperties(schema, results, overrides);
- return results;
-}
-
-export function createResults({ subject, info }, globalState) {
- const results = populateWithProjectMetadata(info.metadata, globalState);
- const subjectGlobalsCopy = { ...globalState.subjects[subject] };
- delete subjectGlobalsCopy.sessions; // Remove extra key from metadata
- merge(subjectGlobalsCopy, results.Subject);
- return results;
-}
diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedInspectorPage.js b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedInspectorPage.js
index 80e96c708e..37cd66eba1 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedInspectorPage.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedInspectorPage.js
@@ -4,21 +4,21 @@ import { Page } from "../../Page.js";
import { unsafeSVG } from "lit/directives/unsafe-svg.js";
import folderOpenSVG from "../../../../../assets/icons/folder_open.svg?raw";
-import { electron } from "../../../../../utils/electron.js";
-import { getSharedPath, removeFilePaths, truncateFilePaths } from "../../../preview/NWBFilePreview.js";
+import { electron } from "../../../../../utils/electron";
+import { getSharedPath, removeFilePaths, truncateFilePaths } from "../../../NWBFilePreview.js";
const { ipcRenderer } = electron;
import { until } from "lit/directives/until.js";
-import { run } from "./utils.js";
-import { InspectorList, InspectorLegend } from "../../../preview/inspector/InspectorList.js";
+import { run } from "../../../../../utils/run";
+import { InspectorList, InspectorLegend } from "../../../InspectorList.js";
import { getStubArray } from "./GuidedStubPreview.js";
import { InstanceManager } from "../../../InstanceManager.js";
import { getMessageType } from "../../../../validation/index.js";
import { Button } from "../../../Button";
-import { download } from "../../inspect/utils.js";
-import { createProgressPopup } from "../../../utils/progress.js";
-import { resolve } from "../../../../promises";
+import { download } from "../../../../../utils/download";
+import { createProgressPopup } from "../../../../../utils/popups";
+import { resolve } from "../../../../../utils/promises";
const filter = (list, toFilter) => {
return list.filter((item) => {
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 c82d45bc7c..23a2f69085 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
@@ -4,8 +4,8 @@ import { Page } from "../../Page.js";
import { unsafeSVG } from "lit/directives/unsafe-svg.js";
import folderOpenSVG from "../../../../../assets/icons/folder_open.svg?raw";
-import { electron } from "../../../../../utils/electron.js";
-import { NWBFilePreview, getSharedPath } from "../../../preview/NWBFilePreview.js";
+import { electron } from "../../../../../utils/electron";
+import { NWBFilePreview, getSharedPath } from "../../../NWBFilePreview.js";
const { ipcRenderer } = electron;
export const getStubArray = (stubs) =>
diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js
index 50f400bc8c..0208317bcf 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js
@@ -2,7 +2,7 @@ import { html } from "lit";
import { JSONSchemaForm } from "../../../JSONSchemaForm.js";
import { Page } from "../../Page.js";
import { onThrow } from "../../../../errors";
-import { merge } from "../../utils";
+import { merge } from "../../../../../utils/data";
import Swal from "sweetalert2";
import dandiUploadSchema, { ready, regenerateDandisets } from "../../../../../../../schemas/dandi-upload.schema";
import { createDandiset, uploadToDandi } from "../../uploads/UploadsPage.js";
@@ -12,13 +12,13 @@ import { Button } from "../../../Button.js";
import keyIcon from "../../../../../assets/icons/key.svg?raw";
-import { validate } from "../../uploads/utils";
+import { validate } from "../../../../../utils/upload";
import { global } from "../../../../progress/index.js";
import dandiGlobalSchema from "../../../../../../../schemas/json/dandi/global.json";
-import { createFormModal } from "../../../forms/GlobalFormModal";
+import { createFormModal } from "../../../GlobalFormModal";
import { validateDANDIApiKey } from "../../../../validation/dandi";
-import { resolve } from "../../../../promises";
+import { resolve } from "../../../../../utils/promises";
export class GuidedUploadPage extends Page {
constructor(...args) {
diff --git a/src/electron/frontend/core/components/pages/guided-mode/results/GuidedResults.js b/src/electron/frontend/core/components/pages/guided-mode/results/GuidedResults.js
index b0eb8eafc6..e093a57f1b 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/results/GuidedResults.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/results/GuidedResults.js
@@ -4,9 +4,9 @@ import folderOpenSVG from "../../../../../assets/icons/folder_open.svg?raw";
import { Page } from "../../Page.js";
import { getStubArray } from "../options/GuidedStubPreview.js";
-import { getSharedPath } from "../../../preview/NWBFilePreview.js";
+import { getSharedPath } from "../../../NWBFilePreview.js";
-import { electron, path } from "../../../../../utils/electron.js";
+import { electron, path } from "../../../../../utils/electron";
import manualActionsJSON from "../../../../../../../schemas/json/manual_actions.json";
diff --git a/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedNewDatasetInfo.js b/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedNewDatasetInfo.js
index 54bb5b12cf..b5c63f7ad5 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedNewDatasetInfo.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedNewDatasetInfo.js
@@ -6,9 +6,10 @@ import { validateOnChange } from "../../../../validation/index.js";
import projectGeneralSchema from "../../../../../../../schemas/json/project/general.json" assert { type: "json" };
import projectGlobalSchema from "../../../../../../../schemas/json/project/globals.json" assert { type: "json" };
-import { merge } from "../../utils";
import { onThrow } from "../../../../errors";
-import { header } from "../../../forms/utils";
+
+import { merge } from "../../../../../utils/data";
+import { header } from "../../../../../utils/text";
const projectMetadataSchema = merge(projectGlobalSchema, projectGeneralSchema);
diff --git a/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedSubjects.js b/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedSubjects.js
index 26c77c67e6..bf522e7287 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedSubjects.js
+++ b/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedSubjects.js
@@ -4,11 +4,11 @@ import getSubjectSchema from "../../../../../../../schemas/subject.schema";
import { validateOnChange } from "../../../../validation/index.js";
import { Table } from "../../../Table.js";
-import { updateResultsFromSubjects } from "./utils";
+import { updateResultsFromSubjects } from "../../../../../utils/data";
import { preprocessMetadataSchema } from "../../../../../../../schemas/base-metadata.schema";
import { Button } from "../../../Button.js";
-import { createGlobalFormModal } from "../../../forms/GlobalFormModal";
-import { header } from "../../../forms/utils";
+import { createGlobalFormModal } from "../../../GlobalFormModal";
+import { header } from "../../../../../utils/text";
import globalIcon from "../../../../../assets/icons/global.svg?raw";
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 e608e2dcd9..1b444a3c76 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
@@ -2,7 +2,6 @@ import { html } from "lit";
import { JSONSchemaForm } from "../../../JSONSchemaForm.js";
import { Page } from "../../Page.js";
import { onThrow } from "../../../../errors";
-import { merge } from "../../utils.js";
import timezoneSchema from "../../../../../../../schemas/timezone.schema";
// ------------------------------------------------------------------------------
diff --git a/src/electron/frontend/core/components/pages/guided-mode/setup/utils.ts b/src/electron/frontend/core/components/pages/guided-mode/setup/utils.ts
deleted file mode 100644
index 63dbe6c1b8..0000000000
--- a/src/electron/frontend/core/components/pages/guided-mode/setup/utils.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-
-export const updateResultsFromSubjects = (results: any, subjects: any, sourceDataObject = {}, nameMap: {[x:string]: string} = {}) => {
-
- const oldResults = structuredClone(results);
-
- const toRemove = Object.keys(results).filter((sub) => !Object.keys(subjects).includes(sub));
- for (let sub of toRemove) {
- if (sub in nameMap) results[nameMap[sub]] = results[sub];
- delete results[sub]; // Delete extra subjects from results
- }
-
-
- for (let subject in subjects) {
- const { sessions = [] } = subjects[subject];
- let subObj = results[subject];
-
- if (!subObj) subObj = results[subject] = {};
- else {
- const toRemove = Object.keys(subObj).filter((s) => !sessions.includes(s));
- for (let s of toRemove) {
-
- // Skip removal if your data has been mapped
- if (subject in nameMap) {
- const oldSessionInfo = oldResults[subject]
- const newSubResults = results[nameMap[subject]]
- if (s in oldSessionInfo) newSubResults[s] = oldSessionInfo[s];
- }
-
- delete subObj[s]; // Delete extra sessions from results
- }
- if (!sessions.length && !Object.keys(subObj).length) delete results[subject]; // Delete subjects without sessions
- }
-
- for (let session of sessions) {
- if (!(session in subObj))
- subObj[session] = {
- source_data: { ...sourceDataObject },
- metadata: {
- NWBFile: { session_id: session },
- Subject: { subject_id: subject },
- },
- };
- }
- }
-
- return results
-
-}
diff --git a/src/electron/frontend/core/components/pages/inspect/InspectPage.js b/src/electron/frontend/core/components/pages/inspect/InspectPage.js
index 2dea9bb24c..0b645209f9 100644
--- a/src/electron/frontend/core/components/pages/inspect/InspectPage.js
+++ b/src/electron/frontend/core/components/pages/inspect/InspectPage.js
@@ -3,12 +3,13 @@ import { Page } from "../Page.js";
import { onThrow } from "../../../errors";
import { Button } from "../../Button.js";
-import { run } from "../guided-mode/options/utils.js";
+import { run } from "../../../../utils/run";
import { Modal } from "../../Modal";
-import { getSharedPath, truncateFilePaths } from "../../preview/NWBFilePreview.js";
-import { InspectorList, InspectorLegend } from "../../preview/inspector/InspectorList.js";
-import { download } from "./utils";
-import { createProgressPopup } from "../../utils/progress.js";
+import { getSharedPath, truncateFilePaths } from "../../NWBFilePreview.js";
+import { InspectorList, InspectorLegend } from "../../InspectorList.js";
+import { download } from "../../../../utils/download";
+
+import { createProgressPopup } from "../../../../utils/popups";
import { ready } from "../../../../../../schemas/dandi-upload.schema";
import { JSONSchemaForm } from "../../JSONSchemaForm.js";
diff --git a/src/electron/frontend/core/components/pages/inspect/utils.js b/src/electron/frontend/core/components/pages/inspect/utils.js
deleted file mode 100644
index 1a96883c6e..0000000000
--- a/src/electron/frontend/core/components/pages/inspect/utils.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export function download(name, toSave) {
- const saveType = typeof toSave === "string" ? "text/plain" : "application/json";
- const text = typeof toSave === "string" ? toSave : JSON.stringify(toSave, null, 2);
- const blob = new Blob([text], { type: saveType });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = name;
- a.click();
- URL.revokeObjectURL(url);
-}
diff --git a/src/electron/frontend/core/components/pages/preview/PreviewPage.js b/src/electron/frontend/core/components/pages/preview/PreviewPage.js
index e6de3b95ae..8dfe7b4d6f 100644
--- a/src/electron/frontend/core/components/pages/preview/PreviewPage.js
+++ b/src/electron/frontend/core/components/pages/preview/PreviewPage.js
@@ -2,7 +2,7 @@ import { html } from "lit";
import { Page } from "../Page.js";
import { onThrow } from "../../../errors";
import { JSONSchemaInput } from "../../JSONSchemaInput.js";
-import { Neurosift } from "../../preview/Neurosift.js";
+import { Neurosift } from "../../Neurosift.js";
import { baseUrl } from "../../../server/globals";
export class PreviewPage extends Page {
diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js
index 2f67cea669..ae866c70f9 100644
--- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js
+++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js
@@ -10,14 +10,14 @@ import { validateDANDIApiKey } from "../../../validation/dandi";
import { Button } from "../../Button.js";
import { global, remove, save } from "../../../progress/index.js";
-import { merge, setUndefinedIfNotDeclared } from "../utils";
+import { merge, setUndefinedIfNotDeclared } from "../../../../utils/data";
import { notyf } from "../../../dependencies.js";
import { homeDirectory, testDataFolderPath } from "../../../globals.js";
-import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js";
+import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron";
-import { onUpdateAvailable, onUpdateProgress } from "../../../../utils/auto-update.js";
+import { onUpdateAvailable, onUpdateProgress } from "../../../../utils/auto-update";
import saveSVG from "../../../../assets/icons/save.svg?raw";
import folderSVG from "../../../../assets/icons/folder_open.svg?raw";
@@ -26,14 +26,14 @@ import generateSVG from "../../../../assets/icons/restart.svg?raw";
import downloadSVG from "../../../../assets/icons/download.svg?raw";
import infoSVG from "../../../../assets/icons/info.svg?raw";
-import { header } from "../../forms/utils";
+import { header } from "../../../../utils/text";
import examplePipelines from "../../../../../../example_pipelines.yml";
-import { run } from "../guided-mode/options/utils.js";
+import { run } from "../../../../utils/run";
import { joinPath } from "../../../globals";
import { Modal } from "../../Modal";
import { ProgressBar } from "../../ProgressBar";
-import { humanReadableBytes } from "../../utils/size";
+import { humanReadableBytes } from "../../../../utils/bytes";
const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data");
const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset");
diff --git a/src/electron/frontend/core/components/pages/uploads/UploadsPage.js b/src/electron/frontend/core/components/pages/uploads/UploadsPage.js
index a0be671437..d2762c13a1 100644
--- a/src/electron/frontend/core/components/pages/uploads/UploadsPage.js
+++ b/src/electron/frontend/core/components/pages/uploads/UploadsPage.js
@@ -14,13 +14,13 @@ import dandiUploadSchema, {
import dandiStandaloneSchema from "../../../../../../schemas/json/dandi/standalone.json";
const dandiSchema = merge(dandiUploadSchema, structuredClone(dandiStandaloneSchema), { arrays: "append" });
-import dandiCreateSchema from "../../../../../../schemas/dandi-create.schema";
+import dandiCreateSchema from "../../../../../../schemas/json/dandi/create.json" assert { type: "json" };
import { Button } from "../../Button.js";
import { global } from "../../../progress/index.js";
-import { merge } from "../utils";
+import { merge } from "../../../../utils/data";
-import { run } from "../guided-mode/options/utils.js";
+import { run } from "../../../../utils/run";
import { Modal } from "../../Modal";
import { DandiResults } from "../../DandiResults.js";
@@ -31,8 +31,15 @@ import * as dandi from "dandi";
import keyIcon from "../../../../assets/icons/key.svg?raw";
-import { AWARD_VALIDATION_FAIL_MESSAGE, awardNumberValidator, isStaging, validate, getAPIKey } from "./utils";
-import { createFormModal } from "../../forms/GlobalFormModal";
+import {
+ AWARD_VALIDATION_FAIL_MESSAGE,
+ awardNumberValidator,
+ isStaging,
+ validate,
+ getAPIKey,
+} from "../../../../utils/upload";
+
+import { createFormModal } from "../../GlobalFormModal";
export function createDandiset(results = {}) {
let notification;
diff --git a/src/electron/frontend/core/components/pages/utils.js b/src/electron/frontend/core/components/pages/utils.js
deleted file mode 100644
index 0947b2690f..0000000000
--- a/src/electron/frontend/core/components/pages/utils.js
+++ /dev/null
@@ -1,77 +0,0 @@
-export const randomizeIndex = (count) => Math.floor(count * Math.random());
-
-export const randomizeElements = (array, count) => {
- if (count > array.length) throw new Error("Array size cannot be smaller than expected random numbers count.");
- const result = [];
- const guardian = new Set();
- while (result.length < count) {
- const index = randomizeIndex(array.length);
- if (guardian.has(index)) continue;
- const element = array[index];
- guardian.add(index);
- result.push(element);
- }
- return result;
-};
-
-const isObject = (item) => {
- return item && typeof item === "object" && !Array.isArray(item);
-};
-
-export const setUndefinedIfNotDeclared = (schemaProps, resolved) => {
- if ("properties" in schemaProps) schemaProps = schemaProps.properties;
- for (const prop in schemaProps) {
- const propInfo = schemaProps[prop]?.properties;
- if (propInfo) setUndefinedIfNotDeclared(propInfo, resolved[prop]);
- else if (!(prop in resolved)) resolved[prop] = undefined;
- }
-};
-
-export const isPrivate = (k) => k.slice(0, 2) === "__";
-
-export const sanitize = (item, condition = isPrivate) => {
- if (isObject(item)) {
- for (const [k, value] of Object.entries(item)) {
- if (condition(k, value)) delete item[k];
- else sanitize(value, condition);
- }
- } else if (Array.isArray(item)) item.forEach((value) => sanitize(value, condition));
-
- return item;
-};
-
-export function merge(toMerge = {}, target = {}, mergeOptions = {}) {
- // Deep merge objects
- for (const [k, value] of Object.entries(toMerge)) {
- const targetValue = target[k];
- // if (isPrivate(k)) continue;
- const arrayMergeMethod = mergeOptions.arrays;
- if (arrayMergeMethod && Array.isArray(value) && Array.isArray(targetValue)) {
- if (arrayMergeMethod === "append")
- target[k] = [...targetValue, ...value]; // Append array entries together
- else {
- target[k] = targetValue.map((targetItem, i) => merge(value[i], targetItem, mergeOptions)); // Merge array entries
- }
- } else if (value === undefined) {
- delete target[k]; // Remove matched values
- // if (mergeOptions.remove !== false) delete target[k]; // Remove matched values
- } else if (isObject(value)) {
- if (isObject(targetValue)) target[k] = merge(value, targetValue, mergeOptions);
- else {
- if (mergeOptions.clone)
- target[k] = merge(value, {}, mergeOptions); // Replace primitive values
- else target[k] = value; // Replace object values
- }
- } else target[k] = value; // Replace primitive values
- }
-
- return target;
-}
-
-export function mapSessions(callback = (value) => value, toIterate = {}) {
- return Object.entries(toIterate)
- .map(([subject, sessions]) => {
- return Object.entries(sessions).map(([session, info], i) => callback({ subject, session, info }, i));
- })
- .flat(2);
-}
diff --git a/src/electron/frontend/core/components/sidebar.js b/src/electron/frontend/core/components/sidebar.js
index 6d79ed6e0a..062a796d4d 100644
--- a/src/electron/frontend/core/components/sidebar.js
+++ b/src/electron/frontend/core/components/sidebar.js
@@ -1,18 +1,7 @@
import { LitElement, html } from "lit";
-import useGlobalStyles from "./utils/useGlobalStyles.js";
-import { header } from "./forms/utils";
-
-const componentCSS = ``; // These are not active until the component is using shadow DOM
+import { header } from "../../utils/text";
export class Sidebar extends LitElement {
- static get styles() {
- return useGlobalStyles(
- componentCSS,
- (sheet) => sheet.href && sheet.href.includes("bootstrap"),
- this.shadowRoot
- );
- }
-
static get properties() {
return {
pages: { type: Object, reflect: false },
diff --git a/src/electron/frontend/core/components/table/cells/base.ts b/src/electron/frontend/core/components/table/cells/base.ts
index cf9dbbe37a..19a963b59c 100644
--- a/src/electron/frontend/core/components/table/cells/base.ts
+++ b/src/electron/frontend/core/components/table/cells/base.ts
@@ -1,5 +1,5 @@
-import { LitElement, PropertyValueMap, css, html } from "lit"
-import { placeCaretAtEnd } from "../utils"
+import { LitElement, css, html } from "lit"
+import { placeCaretAtEnd } from "../../../../utils/table"
type BaseTableProps = {
info: {
diff --git a/src/electron/frontend/core/components/table/cells/input.ts b/src/electron/frontend/core/components/table/cells/input.ts
index 4c02b508de..9911c52016 100644
--- a/src/electron/frontend/core/components/table/cells/input.ts
+++ b/src/electron/frontend/core/components/table/cells/input.ts
@@ -6,7 +6,7 @@ import { Modal } from "../../Modal.js";
import { SimpleTable } from "../../SimpleTable.js";
import { JSONSchemaInput } from "../../JSONSchemaInput.js";
-import { header } from "../../forms/utils.js";
+import { header } from "../../../../utils/text";
export class NestedEditor extends LitElement {
diff --git a/src/electron/frontend/core/components/table/utils.ts b/src/electron/frontend/core/components/table/utils.ts
deleted file mode 100644
index 9a851f902f..0000000000
--- a/src/electron/frontend/core/components/table/utils.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-export function placeCaretAtEnd(inputElement) {
- inputElement.focus();
- if (typeof window.getSelection != "undefined" && typeof document.createRange != "undefined") {
- var range = document.createRange();
- range.selectNodeContents(inputElement);
- range.collapse(false);
- var selection = window.getSelection();
- selection.removeAllRanges();
- selection.addRange(range);
- } else if (typeof document.body.createTextRange != "undefined") {
- var textRange = document.body.createTextRange();
- textRange.moveToElementText(inputElement);
- textRange.collapse(false);
- textRange.select();
- }
-}
diff --git a/src/electron/frontend/core/components/utils/useGlobalStyles.js b/src/electron/frontend/core/components/utils/useGlobalStyles.js
deleted file mode 100644
index addc13601a..0000000000
--- a/src/electron/frontend/core/components/utils/useGlobalStyles.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { css } from "lit";
-
-const useGlobalStyles = (componentCSS, condition, toApply = true) => {
- if (!toApply || !condition) return css([componentCSS]);
-
- const sheets = Object.values(document.styleSheets);
- const selectedSheets = condition instanceof Function ? sheets.filter(condition) : sheets;
- const rules = selectedSheets.map((sheet) =>
- Object.values(sheet.cssRules)
- .map((rule) => rule.cssText)
- .join("\n")
- );
- return css([componentCSS, ...rules]);
-};
-
-export default useGlobalStyles;
diff --git a/src/electron/frontend/core/errors.ts b/src/electron/frontend/core/errors.ts
index ba1794a915..8c64621d12 100644
--- a/src/electron/frontend/core/errors.ts
+++ b/src/electron/frontend/core/errors.ts
@@ -1,5 +1,2 @@
import { notify } from './dependencies'
-
-export const onThrow = (message: string, id?: string) => {
- return notify(id ? `[${id}]: ${message}` : message, "error", 7000);
-}
+export const onThrow = (message: string, id?: string) => notify(id ? `[${id}]: ${message}` : message, "error", 7000);
diff --git a/src/electron/frontend/core/globals.js b/src/electron/frontend/core/globals.js
index 0ca85c1a23..8bebbaaf29 100644
--- a/src/electron/frontend/core/globals.js
+++ b/src/electron/frontend/core/globals.js
@@ -1,4 +1,4 @@
-import { os, path, crypto, isElectron, isTestEnvironment } from "../utils/electron.js";
+import { os, path, crypto, isElectron, isTestEnvironment } from "../utils/electron";
import paths from "../../../paths.config.json" assert { type: "json" };
diff --git a/src/electron/frontend/core/index.ts b/src/electron/frontend/core/index.ts
index c7d0a48973..815094fc84 100644
--- a/src/electron/frontend/core/index.ts
+++ b/src/electron/frontend/core/index.ts
@@ -1,5 +1,5 @@
import "./pages.js"
-import { isElectron, electron } from '../utils/electron.js'
+import { isElectron, electron } from '../utils/electron'
import { isTestEnvironment } from './globals.js'
const { ipcRenderer } = electron;
diff --git a/src/electron/frontend/core/progress/index.js b/src/electron/frontend/core/progress/index.js
index 617ebf6086..eeb945ba56 100644
--- a/src/electron/frontend/core/progress/index.js
+++ b/src/electron/frontend/core/progress/index.js
@@ -9,12 +9,12 @@ import {
ENCRYPTION_IV,
} from "../globals.js";
-import { fs, crypto } from "../../utils/electron.js";
+import { fs, crypto } from "../../utils/electron";
import { joinPath, runOnLoad } from "../globals";
-import { merge } from "../components/pages/utils.js";
+import { merge } from "../../utils/data";
import { updateAppProgress, updateFile } from "./update.js";
-import { updateURLParams } from "../../utils/url.js";
+import { updateURLParams } from "../../utils/url";
import * as operations from "./operations.js";
diff --git a/src/electron/frontend/core/progress/update.js b/src/electron/frontend/core/progress/update.js
index 1b73bbc0e7..4fb6e506f1 100644
--- a/src/electron/frontend/core/progress/update.js
+++ b/src/electron/frontend/core/progress/update.js
@@ -1,6 +1,6 @@
-import { updateURLParams } from "../../utils/url.js";
+import { updateURLParams } from "../../utils/url";
import { guidedProgressFilePath } from "../globals.js";
-import { fs } from "../../utils/electron.js";
+import { fs } from "../../utils/electron";
import { joinPath } from "../globals";
import { get, hasEntry } from "./index.js";
diff --git a/src/electron/frontend/core/server/globals.ts b/src/electron/frontend/core/server/globals.ts
index 6f23ea2c7b..55f1326676 100644
--- a/src/electron/frontend/core/server/globals.ts
+++ b/src/electron/frontend/core/server/globals.ts
@@ -1,27 +1,15 @@
-import { isElectron, app, port } from '../../utils/electron.js'
+import { isElectron, app, port } from '../../utils/electron'
import serverSVG from "../../assets/icons/server.svg?raw";
import webAssetSVG from "../../assets/icons/web_asset.svg?raw";
import wifiSVG from "../../assets/icons/wifi.svg?raw";
+import { StatusBar } from "../components/StatusBar.js";
+import { unsafeSVG } from "lit/directives/unsafe-svg.js";
+
// Base Request URL for Python Server
export const baseUrl = `http://127.0.0.1:${port}`;
-const isPromise = (object) => object && typeof object === 'object' && typeof object.then === 'function'
-
-export const resolve = (object, callback) => {
- if (isPromise(object)) {
- return new Promise(resolvePromise => {
- object.then((res) => resolvePromise((callback) ? callback(res) : res))
- })
- } else return (callback) ? callback(object) : object
-}
-
-// -------------------------------------------------
-
-import { StatusBar } from "../components/status/StatusBar.js";
-import { unsafeSVG } from "lit/directives/unsafe-svg.js";
-
const appVersion = app?.getVersion();
export const statusBar = new StatusBar({
diff --git a/src/electron/frontend/core/server/index.ts b/src/electron/frontend/core/server/index.ts
index aecc5b3f18..89edce13a7 100644
--- a/src/electron/frontend/core/server/index.ts
+++ b/src/electron/frontend/core/server/index.ts
@@ -1,4 +1,4 @@
-import { isElectron, electron, app } from '../../utils/electron.js'
+import { isElectron, electron, app } from '../../utils/electron'
const { ipcRenderer } = electron;
import { isTestEnvironment } from '../globals.js'
diff --git a/src/electron/frontend/core/validation/backend-configuration.ts b/src/electron/frontend/core/validation/backend-configuration.ts
index 16431dc93c..b93250634d 100644
--- a/src/electron/frontend/core/validation/backend-configuration.ts
+++ b/src/electron/frontend/core/validation/backend-configuration.ts
@@ -1,4 +1,4 @@
-import { humanReadableBytes } from "../components/utils/size";
+import { humanReadableBytes } from "../../utils/bytes";
const prod = (arr: number[]) => arr.reduce((accumulator, currentValue) => accumulator * currentValue, 1);
diff --git a/src/electron/frontend/core/validation/index.js b/src/electron/frontend/core/validation/index.js
index b7311e7814..e5561ac8da 100644
--- a/src/electron/frontend/core/validation/index.js
+++ b/src/electron/frontend/core/validation/index.js
@@ -1,4 +1,4 @@
-import { resolveAll } from "../promises";
+import { resolveAll } from "../../utils/promises";
import { baseUrl } from "../server/globals";
import validationSchema from "./validation";
diff --git a/src/electron/frontend/utils/auto-update.js b/src/electron/frontend/utils/auto-update.ts
similarity index 100%
rename from src/electron/frontend/utils/auto-update.js
rename to src/electron/frontend/utils/auto-update.ts
diff --git a/src/electron/frontend/core/components/utils/size.ts b/src/electron/frontend/utils/bytes.ts
similarity index 90%
rename from src/electron/frontend/core/components/utils/size.ts
rename to src/electron/frontend/utils/bytes.ts
index 2e08881342..18304dbab0 100644
--- a/src/electron/frontend/core/components/utils/size.ts
+++ b/src/electron/frontend/utils/bytes.ts
@@ -7,7 +7,7 @@ export function humanReadableBytes(size: number | string) {
let index = 0;
// Convert the size to a floating point number
- size = parseFloat(size);
+ size = typeof size === 'string' ? parseFloat(size) : size;
// Loop until the size is less than 1024 and increment the unit
while (size >= 1000 && index < units.length - 1) {
diff --git a/src/electron/frontend/utils/data.ts b/src/electron/frontend/utils/data.ts
new file mode 100644
index 0000000000..730a881a17
--- /dev/null
+++ b/src/electron/frontend/utils/data.ts
@@ -0,0 +1,290 @@
+import { isObject } from "./typecheck.js";
+
+type Schema = { properties: Record };
+
+type ConditionFunction = (key: string, value: any) => boolean;
+
+type Object = Record;
+
+type MergeOptions = {
+ arrays?: "append" | "merge";
+ clone?: boolean;
+ remove?: boolean;
+};
+
+// Merge project-wide data into metadata
+export function populateWithProjectMetadata(info, globalState) {
+ const copy = structuredClone(info);
+ const toMerge = Object.entries(globalState.project).filter(([_, value]) => value && typeof value === "object");
+ toMerge.forEach(([key, value]) => {
+ let internalMetadata = copy[key];
+ if (!copy[key]) internalMetadata = copy[key] = {};
+ for (let key in value) {
+ if (!(key in internalMetadata)) internalMetadata[key] = value[key]; // Prioritize existing results (cannot override with new information...)
+ }
+ });
+
+ return copy;
+}
+
+export const getInfoFromId = (key) => {
+ let [subject, session] = key.split("/");
+ if (subject.startsWith("sub-")) subject = subject.slice(4);
+ if (session.startsWith("ses-")) session = session.slice(4);
+
+ return { subject, session };
+};
+
+export function resolveGlobalOverrides(subject, globalState, resolveMultiSessionOverrides = true) {
+ const subjectMetadataCopy = { ...(globalState.subjects?.[subject] ?? {}) };
+ delete subjectMetadataCopy.sessions; // Remove extra key from metadata
+
+ if (resolveMultiSessionOverrides) {
+ const overrides = structuredClone(globalState.project ?? {}); // Copy project-wide metadata
+
+ merge(subjectMetadataCopy, overrides.Subject ?? (overrides.Subject = {})); // Ensure Subject exists
+
+ return overrides;
+ }
+
+ return { Subject: subjectMetadataCopy };
+}
+
+export function drillSchemaProperties(schema = {}, callback, target, path = [], inPatternProperties = false) {
+ const properties = schema.properties ?? {};
+
+ const patternProperties = schema.patternProperties ?? {};
+
+ for (let regexp in patternProperties) {
+ const info = patternProperties[regexp];
+ const updatedPath = [...path, regexp];
+ callback(updatedPath, info, undefined, true);
+ drillSchemaProperties(info, callback, undefined, updatedPath, true, schema);
+ }
+
+ for (let name in properties) {
+ const info = properties[name];
+
+ if (name === "definitions") continue;
+
+ const updatedPath = [...path, name];
+
+ callback(updatedPath, info, target, undefined, schema);
+
+ drillSchemaProperties(info, callback, target?.[name], updatedPath, inPatternProperties);
+ }
+
+ return schema;
+}
+
+export function resolveProperties(properties = {}, target, globals = {}) {
+ if ("properties" in properties && "type" in properties) properties = properties.properties; // Correct for when a schema is passed instead
+
+ for (let name in properties) {
+ const info = properties[name];
+
+ const props = info.properties;
+
+ if (!(name in target)) {
+ if (target.__disabled?.[name]) continue; // Skip disabled properties
+
+ if (props) target[name] = {}; // Regisiter new interfaces in results
+ // if (info.type === "array") target[name] = []; // Auto-populate arrays (NOTE: Breaks PyNWB when adding to TwoPhotonSeries field...)
+
+ // Apply global or default value if empty
+ if (name in globals) target[name] = globals[name];
+ else if (info.default) target[name] = info.default;
+ }
+
+ if (target[name]) resolveProperties(props, target[name], globals[name]);
+ }
+
+ return target;
+}
+
+// Explicitly resolve the results for a particular session (from both GUIDE-defined globals and the NWB Schema)
+export function resolveMetadata(subject, session, globalState) {
+ const overrides = resolveGlobalOverrides(subject, globalState); // Unique per-subject (but not sessions)
+ const metadata = globalState.results[subject][session].metadata;
+ const results = structuredClone(metadata); // Copy the metadata results from the form
+ const schema = globalState.schema.metadata[subject][session];
+ resolveProperties(schema, results, overrides);
+ return results;
+}
+
+export function createResultsForSession({ subject, info }, globalState) {
+ const results = populateWithProjectMetadata(info.metadata, globalState);
+ const subjectGlobalsCopy = { ...globalState.subjects[subject] };
+ delete subjectGlobalsCopy.sessions; // Remove extra key from metadata
+ results.Subject = merge(subjectGlobalsCopy, results.Subject);
+ return results;
+}
+
+
+
+export const updateResultsFromSubjects = (results: any, subjects: any, sourceDataObject = {}, nameMap: {[x:string]: string} = {}) => {
+
+ const oldResults = structuredClone(results);
+
+ const toRemove = Object.keys(results).filter((sub) => !Object.keys(subjects).includes(sub));
+ for (let sub of toRemove) {
+ if (sub in nameMap) results[nameMap[sub]] = results[sub];
+ delete results[sub]; // Delete extra subjects from results
+ }
+
+
+ for (let subject in subjects) {
+ const { sessions = [] } = subjects[subject];
+ let subObj = results[subject];
+
+ if (!subObj) subObj = results[subject] = {};
+ else {
+ const toRemove = Object.keys(subObj).filter((s) => !sessions.includes(s));
+ for (let s of toRemove) {
+
+ // Skip removal if your data has been mapped
+ if (subject in nameMap) {
+ const oldSessionInfo = oldResults[subject]
+ const newSubResults = results[nameMap[subject]]
+ if (s in oldSessionInfo) newSubResults[s] = oldSessionInfo[s];
+ }
+
+ delete subObj[s]; // Delete extra sessions from results
+ }
+ if (!sessions.length && !Object.keys(subObj).length) delete results[subject]; // Delete subjects without sessions
+ }
+
+ for (let session of sessions) {
+ if (!(session in subObj))
+ subObj[session] = {
+ source_data: { ...sourceDataObject },
+ metadata: {
+ NWBFile: { session_id: session },
+ Subject: { subject_id: subject },
+ },
+ };
+ }
+ }
+
+ return results
+
+}
+
+export const setUndefinedIfNotDeclared = (
+ schemaProps: Schema | Schema["properties"],
+ resolved: Record = {}
+) => {
+
+ const resolvedProps = "properties" in schemaProps ? schemaProps.properties : schemaProps;
+
+ for (const prop in resolvedProps) {
+ const propInfo = resolvedProps[prop]?.properties;
+ if (propInfo) setUndefinedIfNotDeclared(propInfo, resolved[prop]);
+ else if (!(prop in resolved)) resolved[prop] = undefined;
+ }
+};
+
+const isPrivate = (k: string) => k.slice(0, 2) === "__";
+
+export const sanitize = (
+ item: any,
+ condition: ConditionFunction = isPrivate
+) => {
+ if (isObject(item)) {
+ for (const [k, value] of Object.entries(item)) {
+ if (condition(k, value)) delete item[k];
+ else sanitize(value, condition);
+ }
+ } else if (Array.isArray(item)) item.forEach((value) => sanitize(value, condition));
+
+ return item;
+};
+
+export function merge(
+ toMerge: Object = {},
+ target: Object = {},
+ mergeOptions: MergeOptions = {}
+) {
+ // Deep merge objects
+ for (const [k, value] of Object.entries(toMerge)) {
+ const targetValue = target[k];
+ // if (isPrivate(k)) continue;
+ const arrayMergeMethod = mergeOptions.arrays;
+ if (arrayMergeMethod && Array.isArray(value) && Array.isArray(targetValue)) {
+ if (arrayMergeMethod === "append")
+ target[k] = [...targetValue, ...value]; // Append array entries together
+ else {
+ target[k] = targetValue.map((targetItem, i) => merge(value[i], targetItem, mergeOptions)); // Merge array entries
+ }
+ } else if (value === undefined) {
+ delete target[k]; // Remove matched values
+ // if (mergeOptions.remove !== false) delete target[k]; // Remove matched values
+ } else if (isObject(value)) {
+ if (isObject(targetValue)) target[k] = merge(value, targetValue, mergeOptions);
+ else {
+ if (mergeOptions.clone)
+ target[k] = merge(value, {}, mergeOptions); // Replace primitive values
+ else target[k] = value; // Replace object values
+ }
+ } else target[k] = value; // Replace primitive values
+ }
+
+ return target;
+}
+
+export function mapSessions(callback = (value) => value, toIterate = {}) {
+ return Object.entries(toIterate)
+ .map(([subject, sessions]) => {
+ return Object.entries(sessions).map(([session, info], i) => callback({ subject, session, info }, i));
+ })
+ .flat(2);
+}
+
+
+export const resolveAsJSONSchema = (
+ schema: Schema,
+ path: string[] = [],
+ parent: { [x:string]: any } = structuredClone(schema)
+) => {
+
+ const copy = { ...schema }
+
+ if (isObject(schema)) {
+
+ const resolvedProps = copy // "properties" in copy ? copy.properties : copy;
+
+ for (let propName in resolvedProps) {
+ const prop = resolvedProps[propName];
+
+ if (isObject(prop)) {
+ const internalCopy = (resolvedProps[propName] = { ...prop });
+ const refValue = internalCopy["$ref"]
+ const allOfValue = internalCopy['allOf']
+
+ if (allOfValue) {
+ resolvedProps[propName]= allOfValue.reduce((acc, curr) => {
+ const result = resolveAsJSONSchema({ _temp: curr}, path, parent)
+ const resolved = result._temp
+ return merge(resolved, acc)
+ }, {})
+ }
+
+ else if (refValue) {
+
+ const refPath = refValue.split('/').slice(1) // NOTE: Assume from base
+ const resolved = refPath.reduce((acc, key) => acc[key], parent)
+
+ if (resolved) resolvedProps[propName] = resolved;
+ else delete resolvedProps[propName]
+ }
+
+ // Find refs on any level of an object
+ else resolvedProps[propName] = resolveAsJSONSchema(internalCopy, [...path, propName], parent);
+ }
+ }
+
+ return copy as { [x:string]: any }
+ }
+
+ return schema;
+}
diff --git a/src/electron/frontend/utils/download.ts b/src/electron/frontend/utils/download.ts
new file mode 100644
index 0000000000..364e6ede27
--- /dev/null
+++ b/src/electron/frontend/utils/download.ts
@@ -0,0 +1,14 @@
+export function download(
+ name: string,
+ body: string | object
+) {
+ const saveType = typeof body === "string" ? "text/plain" : "application/json";
+ const text = typeof body === "string" ? body : JSON.stringify(body, null, 2);
+ const blob = new Blob([text], { type: saveType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/electron/frontend/utils/electron.js b/src/electron/frontend/utils/electron.ts
similarity index 95%
rename from src/electron/frontend/utils/electron.js
rename to src/electron/frontend/utils/electron.ts
index 8531cfdb0a..557475666e 100644
--- a/src/electron/frontend/utils/electron.js
+++ b/src/electron/frontend/utils/electron.ts
@@ -1,5 +1,5 @@
-import { registerUpdate, registerUpdateProgress } from "./auto-update.js";
-import { updateURLParams } from "./url.js";
+import { registerUpdate, registerUpdateProgress } from "./auto-update";
+import { updateURLParams } from "./url";
export const isTestEnvironment = globalThis?.process?.env?.VITEST;
diff --git a/src/electron/frontend/utils/forms.ts b/src/electron/frontend/utils/forms.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/electron/frontend/core/components/utils/progress.js b/src/electron/frontend/utils/popups.ts
similarity index 58%
rename from src/electron/frontend/core/components/utils/progress.js
rename to src/electron/frontend/utils/popups.ts
index 9d525057ac..396e2ccc8c 100644
--- a/src/electron/frontend/core/components/utils/progress.js
+++ b/src/electron/frontend/utils/popups.ts
@@ -1,120 +1,123 @@
-import { openProgressSwal } from "../pages/guided-mode/options/utils.js";
-import { ProgressBar } from "../ProgressBar";
-import { baseUrl } from "../../server/globals";
-import { createRandomString } from "../forms/utils";
-
-export const createProgressPopup = async (options, tqdmCallback) => {
- const cancelController = new AbortController();
-
- if (!("showCancelButton" in options)) {
- options.showCancelButton = true;
- options.customClass = { actions: "swal-conversion-actions" };
- }
-
- const popup = await openProgressSwal(options, (result) => {
- if (!result.isConfirmed) cancelController.abort();
- });
-
- let elements = {};
- popup.hideLoading();
- const element = (elements.container = popup.getHtmlContainer());
- element.innerText = "";
-
- Object.assign(element.style, {
- marginTop: "5px",
- });
-
- const container = document.createElement("div");
- Object.assign(container.style, {
- textAlign: "left",
- display: "flex",
- flexDirection: "column",
- overflow: "hidden",
- width: "100%",
- gap: "5px",
- });
- element.append(container);
-
- const bars = {};
-
- const getBar = (id, large = false) => {
- if (!bars[id]) {
- const bar = new ProgressBar({ size: large ? undefined : "small" });
- bars[id] = bar;
- container.append(bar);
- }
- return bars[id];
- };
-
- const globalSymbol = Symbol("global");
-
- elements.progress = getBar(globalSymbol, true);
-
- elements.bars = bars;
-
- const commonReturnValue = { swal: popup, fetch: { signal: cancelController.signal }, elements, ...options };
-
- // Provide a default callback
- let lastUpdate;
-
- const id = createRandomString();
-
- const onProgressMessage = ({ data }) => {
- const parsed = JSON.parse(data);
- const { request_id, ...update } = parsed;
- console.warn("parsed", parsed);
-
- if (request_id && request_id !== id) return;
- lastUpdate = Date.now();
-
- const _barId = parsed.progress_bar_id;
- const barId = id === _barId ? globalSymbol : _barId;
- const bar = getBar(barId);
- if (!tqdmCallback) bar.format = parsed.format_dict;
- else tqdmCallback(update);
- };
-
- progressHandler.addEventListener("message", onProgressMessage);
-
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
-
- const close = async () => {
- if (lastUpdate) {
- // const timeSinceLastUpdate = now - lastUpdate;
- const animationLeft = 1000; // ProgressBar.animationDuration - timeSinceLastUpdate; // Add 100ms to ensure the animation has time to complete
- if (animationLeft) await sleep(animationLeft);
- }
-
- popup.close();
-
- progressHandler.removeEventListener("message", onProgressMessage);
- };
-
- return { ...commonReturnValue, id, close };
-};
-
-const progressEventsUrl = new URL("/neuroconv/events/progress", baseUrl).href;
-
-class ProgressHandler {
- constructor(props) {
- const { url, callbacks, ...otherProps } = props;
-
- const source = (this.source = new EventSource(url));
- Object.assign(this, otherProps);
-
- source.addEventListener("error", this.onerror(), false);
-
- source.addEventListener("open", () => this.onopen(), false);
-
- source.addEventListener("message", (event) => this.onmessage(event), false);
- }
-
- onopen = () => {};
- onmessage = () => {};
- onerror = () => {};
-
- addEventListener = (...args) => this.source.addEventListener(...args);
- removeEventListener = (...args) => this.source.removeEventListener(...args);
-}
-
-export const progressHandler = new ProgressHandler({ url: progressEventsUrl });
+
+import Swal, { SweetAlertOptions } from "sweetalert2";
+import { ProgressBar } from "../core/components/ProgressBar";
+import { getRandomString } from "./random";
+
+import progressHandler from "./progress";
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export const openProgressSwal = (
+ options: SweetAlertOptions,
+ callback: (result: any) => void
+): Promise => {
+ return new Promise((resolve) => {
+ Swal.fire({
+ title: "Requesting data from server",
+ allowEscapeKey: false,
+ allowOutsideClick: false,
+ showConfirmButton: false,
+ heightAuto: false,
+ backdrop: "rgba(0,0,0, 0.4)",
+ timerProgressBar: false,
+ didOpen: () => {
+ Swal.showLoading();
+ resolve(true);
+ },
+ ...options,
+ }).then((result) => callback?.(result));
+ });
+};
+
+export const createProgressPopup = async (
+ options: SweetAlertOptions,
+ tqdmCallback: (update: any) => void
+) => {
+ const cancelController = new AbortController();
+
+ if (!("showCancelButton" in options)) {
+ options.showCancelButton = true;
+ options.customClass = { actions: "swal-conversion-actions" };
+ }
+
+ const popup = await openProgressSwal(options, (result) => {
+ if (!result.isConfirmed) cancelController.abort();
+ });
+
+ let elements: Record> = {};
+ Swal.hideLoading();
+ const element = elements.container = Swal.getHtmlContainer()!;
+ element.innerText = "";
+
+ Object.assign(element.style, {
+ marginTop: "5px",
+ });
+
+ const container = document.createElement("div");
+ Object.assign(container.style, {
+ textAlign: "left",
+ display: "flex",
+ flexDirection: "column",
+ overflow: "hidden",
+ width: "100%",
+ gap: "5px",
+ });
+ element.append(container);
+
+ const bars: Record = {};
+
+ const getBar = (
+ id: string | symbol,
+ large = false
+ ) => {
+ if (!bars[id]) {
+ const bar = new ProgressBar({ size: large ? undefined : "small" });
+ bars[id] = bar;
+ container.append(bar);
+ }
+ return bars[id];
+ };
+
+ const globalSymbol = Symbol("global");
+
+ elements.progress = getBar(globalSymbol, true);
+
+ elements.bars = bars;
+
+ const commonReturnValue = { swal: popup, fetch: { signal: cancelController.signal }, elements, ...options };
+
+ // Provide a default callback
+ let lastUpdate: number;
+
+ const id = getRandomString();
+
+ const onProgressMessage = ({ data }) => {
+ const parsed = JSON.parse(data);
+ const { request_id, ...update } = parsed;
+
+ if (request_id && request_id !== id) return;
+ lastUpdate = Date.now();
+
+ const _barId = parsed.progress_bar_id;
+ const barId = id === _barId ? globalSymbol : _barId;
+ const bar = getBar(barId);
+ if (!tqdmCallback) bar.format = parsed.format_dict;
+ else tqdmCallback(update);
+ };
+
+ progressHandler.addEventListener("message", onProgressMessage);
+
+ const close = async () => {
+ if (lastUpdate) {
+ // const timeSinceLastUpdate = now - lastUpdate;
+ const animationLeft = 1000; // ProgressBar.animationDuration - timeSinceLastUpdate; // Add 100ms to ensure the animation has time to complete
+ if (animationLeft) await sleep(animationLeft);
+ }
+
+ Swal.close();
+
+ progressHandler.removeEventListener("message", onProgressMessage);
+ };
+
+ return { ...commonReturnValue, id, close };
+};
diff --git a/src/electron/frontend/utils/progress.ts b/src/electron/frontend/utils/progress.ts
new file mode 100644
index 0000000000..c03c65fbcd
--- /dev/null
+++ b/src/electron/frontend/utils/progress.ts
@@ -0,0 +1,43 @@
+import { baseUrl } from "../core/server/globals";
+
+const progressEventsUrl = new URL("/neuroconv/events/progress", baseUrl).href;
+
+type OnOpenCallback = () => void;
+type OnMessageCallback = (event: MessageEvent) => void;
+type OnErrorCallback = (event: Event) => void;
+
+type ProgressHandlerProps = {
+ url: string;
+ onopen?: OnOpenCallback;
+ onmessage?: OnMessageCallback;
+ onerror?: OnErrorCallback;
+}
+
+class ProgressHandler {
+
+ source: EventSource;
+
+ onopen: OnOpenCallback = () => {};
+ onmessage: OnMessageCallback = () => {};
+ onerror: OnErrorCallback = () => {};
+
+ constructor(props: ProgressHandlerProps) {
+ const { url, ...otherProps } = props;
+
+ const source = (this.source = new EventSource(url));
+ Object.assign(this, otherProps);
+
+ source.addEventListener("error", this.onerror, false);
+
+ source.addEventListener("open", () => this.onopen(), false);
+
+ source.addEventListener("message", this.onmessage, false);
+ }
+
+ addEventListener = (type: string, listener: any, options?: any) => this.source.addEventListener(type, listener, options);
+ removeEventListener = (type: string, listener: any, options?: any) => this.source.removeEventListener(type, listener, options);
+}
+
+// Create a single global instance of ProgressHandler
+const progressHandler = new ProgressHandler({ url: progressEventsUrl });
+export default progressHandler
diff --git a/src/electron/frontend/core/promises.ts b/src/electron/frontend/utils/promises.ts
similarity index 81%
rename from src/electron/frontend/core/promises.ts
rename to src/electron/frontend/utils/promises.ts
index be290c0578..736d34ce54 100644
--- a/src/electron/frontend/core/promises.ts
+++ b/src/electron/frontend/utils/promises.ts
@@ -4,6 +4,6 @@ type ResolveCallback = (object: any, wasPromise?: true) => any
export const isPromise = (p: PossiblePromise) => typeof p === 'object' && typeof p.then === 'function'
-export const resolve = (object: PossiblePromise, callback: ResolveCallback) => (isPromise(object)) ? object.then((value:any) => callback(value, true)) : callback(object)
+export const resolve = (object: PossiblePromise, callback: ResolveCallback) => isPromise(object) ? object.then((value:any) => callback(value, true)) : callback(object)
export const resolveAll = (arr: PossiblePromise[], callback: ResolveCallback) => arr.find(object => isPromise(object)) ? Promise.all(arr).then((arr:any[]) => callback(arr, true)) : callback(arr)
diff --git a/src/electron/frontend/utils/random.ts b/src/electron/frontend/utils/random.ts
new file mode 100644
index 0000000000..04fcbc60e2
--- /dev/null
+++ b/src/electron/frontend/utils/random.ts
@@ -0,0 +1,21 @@
+
+export const getRandomIndex = (count: number) => Math.floor(count * Math.random());
+
+export const getRandomString = () => Math.random().toString(36).substring(7);
+
+export const getRandomSample = (
+ array: any[],
+ count: number
+) => {
+ if (count > array.length) throw new Error("Array size cannot be smaller than expected random numbers count.");
+ const result = [];
+ const guardian = new Set();
+ while (result.length < count) {
+ const index = getRandomIndex(array.length);
+ if (guardian.has(index)) continue;
+ const element = array[index];
+ guardian.add(index);
+ result.push(element);
+ }
+ return result;
+};
diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/utils.js b/src/electron/frontend/utils/run.ts
similarity index 56%
rename from src/electron/frontend/core/components/pages/guided-mode/options/utils.js
rename to src/electron/frontend/utils/run.ts
index 7e6e8bc439..288d8f91ef 100644
--- a/src/electron/frontend/core/components/pages/guided-mode/options/utils.js
+++ b/src/electron/frontend/utils/run.ts
@@ -1,28 +1,23 @@
-import Swal from "sweetalert2";
-import { sanitize } from "../../utils";
-import { baseUrl } from "../../../../server/globals";
-
-export const openProgressSwal = (options, callback) => {
- return new Promise((resolve) => {
- Swal.fire({
- title: "Requesting data from server",
- allowEscapeKey: false,
- allowOutsideClick: false,
- showConfirmButton: false,
- heightAuto: false,
- backdrop: "rgba(0,0,0, 0.4)",
- timerProgressBar: false,
- didOpen: () => {
- Swal.showLoading();
- resolve(Swal);
- },
- ...options,
- }).then((result) => callback?.(result));
- });
-};
+import Swal, { SweetAlertOptions } from "sweetalert2";
+import { sanitize } from "./data";
+import { baseUrl } from "../core/server/globals";
+import { openProgressSwal } from "./popups";
+
+type Options = {
+ swal?: boolean;
+ fetch?: any;
+ onOpen?: (swal: any) => void;
+} & SweetAlertOptions
+
+type PayloadType = Record;
-export const run = async (pathname, payload, options = {}) => {
- let internalSwal;
+export const run = async (
+ pathname: string,
+ payload: PayloadType,
+ options: Options = {}
+) => {
+
+ let internalSwal = false;
if (options.swal === false) {
} else if (!options.swal || options.swal === true) {
@@ -37,17 +32,17 @@ export const run = async (pathname, payload, options = {}) => {
signal: cancelController.signal,
};
- const popup = (internalSwal = await openProgressSwal(options, (result) => {
+ internalSwal = await openProgressSwal(options, (result) => {
if (!result.isConfirmed) cancelController.abort();
}).then(async (swal) => {
if (options.onOpen) await options.onOpen(swal);
return swal;
- }));
+ });
- const element = popup.getHtmlContainer();
+ const element = Swal.getHtmlContainer()!;
- const actions = popup.getActions();
- const loader = actions.querySelector(".swal2-loader");
+ const actions = Swal.getActions()!;
+ const loader = actions.querySelector(".swal2-loader")!;
const container = document.createElement("div");
container.append(loader);
@@ -99,15 +94,10 @@ export const run = async (pathname, payload, options = {}) => {
return results || true;
};
-export const runConversion = async (info, options = {}) =>
- run(`neuroconv/convert`, info, {
- title: "Running the conversion",
- onError: (error) => {
- if (error.message.includes("already exists")) {
- return "File already exists. Please specify another location to store the conversion results";
- } else {
- return "Conversion failed with current metadata. Please try again.";
- }
- },
- ...options,
- });
+export const runConversion = async (
+ info: PayloadType,
+ options = {}
+) => run(`neuroconv/convert`, info, {
+ title: "Running the conversion",
+ ...options,
+});
diff --git a/src/electron/frontend/utils/table.ts b/src/electron/frontend/utils/table.ts
new file mode 100644
index 0000000000..d12899c769
--- /dev/null
+++ b/src/electron/frontend/utils/table.ts
@@ -0,0 +1,17 @@
+
+
+// When clicking into a contenteditable div, the cursor is, by default, placed at the beginning of the text.
+// This function places the cursor at the end of the text.
+export function placeCaretAtEnd(
+ inputElement: HTMLInputElement
+) {
+ inputElement.focus();
+ const range = document.createRange();
+ range.selectNodeContents(inputElement);
+ range.collapse(false);
+ const selection = window.getSelection();
+ if (!selection) return;
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+}
diff --git a/src/electron/frontend/utils/text.ts b/src/electron/frontend/utils/text.ts
new file mode 100644
index 0000000000..0f955bb91d
--- /dev/null
+++ b/src/electron/frontend/utils/text.ts
@@ -0,0 +1,10 @@
+const toCapitalizeAll = ['nwb', 'api', 'id']
+const toCapitalizeNone = ['or', 'and']
+
+export const capitalize = (str: string) => {
+ const lowerCase = str.toLowerCase()
+ return toCapitalizeAll.includes(lowerCase) ? str.toUpperCase() : (toCapitalizeNone.includes(lowerCase) ? lowerCase : str[0].toUpperCase() + str.slice(1))
+}
+
+
+export const header = (headerStr: string) => headerStr.split(/[_\s]/).filter(str => !!str).map(capitalize).join(' ')
diff --git a/src/electron/frontend/utils/time.ts b/src/electron/frontend/utils/time.ts
new file mode 100644
index 0000000000..3b616236cc
--- /dev/null
+++ b/src/electron/frontend/utils/time.ts
@@ -0,0 +1,36 @@
+import { localTimeZone } from "../../../schemas/timezone.schema";
+
+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();
+}
diff --git a/src/electron/frontend/utils/typecheck.ts b/src/electron/frontend/utils/typecheck.ts
new file mode 100644
index 0000000000..2b0e3faf0e
--- /dev/null
+++ b/src/electron/frontend/utils/typecheck.ts
@@ -0,0 +1,9 @@
+export function isNumericString(str: any) {
+
+ if (typeof str != "string") return false // we only process strings!
+
+ return !isNaN(str) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)...
+ !isNaN(parseFloat(str)) // ...and ensure strings of whitespace fail
+ }
+
+export const isObject = (item: any) => (item && typeof item === "object" && !Array.isArray(item)) ? true : false;
diff --git a/src/electron/frontend/core/components/pages/uploads/utils.ts b/src/electron/frontend/utils/upload.ts
similarity index 80%
rename from src/electron/frontend/core/components/pages/uploads/utils.ts
rename to src/electron/frontend/utils/upload.ts
index f82962ee74..9ead9720c4 100644
--- a/src/electron/frontend/core/components/pages/uploads/utils.ts
+++ b/src/electron/frontend/utils/upload.ts
@@ -1,30 +1,38 @@
import { get } from "dandi";
-import dandiUploadSchema, { regenerateDandisets } from "../../../../../../schemas/dandi-upload.schema";
+import dandiUploadSchema, { regenerateDandisets } from "../../../schemas/dandi-upload.schema";
-import { validateDANDIApiKey } from "../../../validation/dandi";
-import { Modal } from "../../Modal";
-import { header } from "../../forms/utils";
+import { validateDANDIApiKey } from "../core/validation/dandi";
+import { Modal } from "../core/components/Modal";
+import { header } from "./text";
-import { JSONSchemaInput } from "../../JSONSchemaInput";
+import { JSONSchemaInput } from "../core/components/JSONSchemaInput";
-import { Button } from "../../Button.js";
-import { global } from "../../../progress/index.js";
-import { merge } from "../utils";
+import { Page } from '../core/components/pages/Page.js';
+import { Button } from "../core/components/Button.js";
+import { global } from "../core/progress/index.js";
+import { merge } from "./data";
-import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json";
+import { NotyfNotification } from "notyf";
+
+import dandiGlobalSchema from "../../../schemas/json/dandi/global.json";
+import { isNumericString } from "./typecheck";
export const isStaging = (id: string) => parseInt(id) >= 100000;
+type NotificationType = {
+ type: string;
+ message: string;
+}
-function isNumeric(str: string) {
- if (typeof str != "string") return false // we only process strings!
- return !isNaN(str) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)...
- !isNaN(parseFloat(str)) // ...and ensure strings of whitespace fail
- }
+type Notifications = NotificationType[];
- export async function validate (name: string, parent: any) {
+export async function validate(
+ this: Page,
+ name: string,
+ parent: any
+): Promise {
const value = parent[name]
@@ -36,7 +44,7 @@ function isNumeric(str: string) {
}
if (name === 'dandiset' && value) {
- if (isNumeric(value)){
+ if (isNumericString(value)) {
if (value.length !== 6) return [{
type: 'error',
@@ -50,7 +58,7 @@ function isNumeric(str: string) {
const dandiset = await get(value, {
type,
token
- })
+ })
if (dandiset.detail) {
if (dandiset.detail.includes('Not found')) return [{
@@ -80,25 +88,24 @@ function isNumeric(str: string) {
}]
}
}
+
+ return true
}
-export const willCreate = (value: string) => !isNumeric(value)
+export const willCreate = (value: string) => !isNumericString(value)
// Regular expression to validate an NIH award number.
// Based on https://era.nih.gov/files/Deciphering_NIH_Application.pdf
// and https://era.nih.gov/erahelp/commons/Commons/understandGrantNums.htm
const NIH_AWARD_REGEX = /^\d \w+ \w{2} \d{6}-\d{2}([A|S|X|P]\d)?$/;
-export function awardNumberValidator(awardNumber: string): boolean {
- return NIH_AWARD_REGEX.test(awardNumber);
-}
+export const awardNumberValidator = (awardNumber: string) => NIH_AWARD_REGEX.test(awardNumber);
export const AWARD_VALIDATION_FAIL_MESSAGE = 'Award number must be properly space-delimited.\n\nExample (exclude quotes):\n"1 R01 CA 123456-01A1"';
-
// this:
export async function getAPIKey(
- // this: Page,
+ this: Page,
staging = false
) {
@@ -108,9 +115,10 @@ export async function getAPIKey(
const errors = await validateDANDIApiKey(api_key, staging);
- const isInvalid = !errors || errors.length;
+ const isInvalid = Array.isArray(errors) ? errors.length : !errors;
if (isInvalid) {
+
const modal = new Modal({
header: `${api_key ? "Update" : "Provide"} your ${header(whichAPIKey)}`,
open: true,
@@ -130,9 +138,9 @@ export async function getAPIKey(
modal.append(container);
- let notification;
+ let notification: NotyfNotification;
- const notify = (message, type) => {
+ const notify = (message: string, type: string) => {
if (notification) this.dismiss(notification);
return (notification = this.notify(message, type));
};
diff --git a/src/electron/frontend/utils/url.js b/src/electron/frontend/utils/url.ts
similarity index 50%
rename from src/electron/frontend/utils/url.js
rename to src/electron/frontend/utils/url.ts
index 15423f61ca..912ead1c9a 100644
--- a/src/electron/frontend/utils/url.js
+++ b/src/electron/frontend/utils/url.ts
@@ -7,7 +7,14 @@ export function updateURLParams(paramsToUpdate) {
}
// Update browser history state
- const value = `${location.pathname}?${params}`;
- if (history.state) Object.assign(history.state, paramsToUpdate);
+ const paramString = params.toString();
+ const value = paramString ? `${location.pathname}?${paramString}` : location.pathname;
+ if (history.state) {
+ Object.entries(paramsToUpdate).forEach(([key, value]) => {
+ if (value == undefined) delete history.state[key];
+ else history.state[key] = value;
+ });
+ }
+
window.history.pushState(history.state, null, value);
}
diff --git a/src/schemas/base-metadata.schema.ts b/src/schemas/base-metadata.schema.ts
index 1dfa0de588..b337c69e97 100644
--- a/src/schemas/base-metadata.schema.ts
+++ b/src/schemas/base-metadata.schema.ts
@@ -1,12 +1,14 @@
-import { serverGlobals, resolve } from '../electron/frontend/core/server/globals'
+import { serverGlobals } from '../electron/frontend/core/server/globals'
+import { resolve } from '../electron/frontend/utils/promises'
-import { header, replaceRefsWithValue } from '../electron/frontend/core/components/forms/utils'
+import { header } from '../electron/frontend/utils/text'
+import { resolveAsJSONSchema } from '../electron/frontend/utils/data'
import baseMetadataSchema from './json/base_metadata_schema.json' assert { type: "json" }
-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'
+import { merge } from '../electron/frontend/utils/data'
+import { drillSchemaProperties } from '../electron/frontend/utils/data'
+import { getISODateInTimezone } from '../electron/frontend/utils/time'
const UV_MATH_FORMAT = `µV`; //``
@@ -73,7 +75,7 @@ function updateEcephysTable(propName, schema, schemaToMerge) {
export const preprocessMetadataSchema = (schema: any = baseMetadataSchema, global = false) => {
- const copy = replaceRefsWithValue(structuredClone(schema))
+ const copy = resolveAsJSONSchema(structuredClone(schema))
// NEUROCONV PATCH: Correct for incorrect array schema
drillSchemaProperties(
diff --git a/src/schemas/dandi-create.schema.ts b/src/schemas/dandi-create.schema.ts
deleted file mode 100644
index 7b08cd927e..0000000000
--- a/src/schemas/dandi-create.schema.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import create from './json/dandi/create.json' assert { type: "json" }
-const schema = structuredClone(create)
-
-export default schema
diff --git a/src/schemas/dandi-upload.schema.ts b/src/schemas/dandi-upload.schema.ts
index 88cc8257e0..1554e224f1 100644
--- a/src/schemas/dandi-upload.schema.ts
+++ b/src/schemas/dandi-upload.schema.ts
@@ -2,7 +2,7 @@ import { Dandiset, getMine } from 'dandi'
import { global } from '../electron/frontend/core/progress'
import upload from './json/dandi/upload.json' assert { type: "json" }
-import { isStaging } from '../electron/frontend/core/components/pages/uploads/utils'
+import { isStaging } from '../electron/frontend/utils/upload'
import { baseUrl, onServerOpen } from '../electron/frontend/core/server/globals'
import { isStorybook } from '../electron/frontend/core/globals'
diff --git a/src/schemas/project-metadata.schema.ts b/src/schemas/project-metadata.schema.ts
deleted file mode 100644
index e31806c08a..0000000000
--- a/src/schemas/project-metadata.schema.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import projectMetadataSchema from './json/project-metadata.schema.json' assert { type: "json" }
-
-export default projectMetadataSchema
diff --git a/src/schemas/source-data.schema.ts b/src/schemas/source-data.schema.ts
index 72c7ba9c43..95f2afca02 100644
--- a/src/schemas/source-data.schema.ts
+++ b/src/schemas/source-data.schema.ts
@@ -9,7 +9,7 @@ export default function preprocessSourceDataSchema (schema) {
}, {})
// Abstract across different interfaces
- Object.entries(schema.properties ?? {}).forEach(([key, schema]: [string, any]) => {
+ Object.entries(schema.properties ?? {}).forEach(([ key, schema ]: [string, any]) => {
const info = interfaces[key] ?? {}
diff --git a/src/schemas/timezone.schema.ts b/src/schemas/timezone.schema.ts
index 8455387fe5..d14dcc4f7b 100644
--- a/src/schemas/timezone.schema.ts
+++ b/src/schemas/timezone.schema.ts
@@ -1,6 +1,6 @@
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";
+import { header } from "../electron/frontend/utils/text";
const setReady: any = {}
@@ -31,9 +31,6 @@ onServerOpen(async () => {
});
});
-
-
-
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)
@@ -44,42 +41,6 @@ export const timezoneProperties = [
[ "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.",
diff --git a/stories/components/InspectorList.stories.js b/stories/components/InspectorList.stories.js
index a26ab02869..c59c2d1c07 100644
--- a/stories/components/InspectorList.stories.js
+++ b/stories/components/InspectorList.stories.js
@@ -1,4 +1,4 @@
-import { InspectorList } from "../../src/electron/frontend/core/components/preview/inspector/InspectorList";
+import { InspectorList } from "../../src/electron/frontend/core/components/InspectorList";
import testInspectorList from "../inputs/inspector_output.json";
export default {
diff --git a/stories/components/Multiselect.stories.js b/stories/components/Multiselect.stories.js
deleted file mode 100644
index 211e2d8a65..0000000000
--- a/stories/components/Multiselect.stories.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import { MultiSelectForm } from "../../src/electron/frontend/core/components/multiselect/MultiSelectForm.js";
-
-export default {
- title: "Components/Multiselect Form",
- parameters: {
- chromatic: { disableSnapshot: false },
- },
-};
-
-const Template = (args) => new MultiSelectForm(args);
-
-export const Default = Template.bind({});
-Default.args = {
- header: "Test Header",
- options: {
- option1: {
- name: "Option 1",
- modality: "Modality 1",
- technique: "Technique 1",
- },
-
- option2: {
- name: "Option 2",
- modality: "Modality 1",
- technique: "Technique 1",
- },
-
- otheroption1: {
- name: "Other Option 1",
- modality: "Modality 2",
- technique: "Technique 1",
- },
- },
-};
diff --git a/stories/components/StatusBar.stories.js b/stories/components/StatusBar.stories.js
index a3c9bd5552..a272036d7c 100644
--- a/stories/components/StatusBar.stories.js
+++ b/stories/components/StatusBar.stories.js
@@ -1,4 +1,4 @@
-import { StatusBar } from "../../src/electron/frontend/core/components/status/StatusBar";
+import { StatusBar } from "../../src/electron/frontend/core/components/StatusBar";
import { unsafeSVG } from "lit/directives/unsafe-svg.js";
import pythonSVG from "../../src/electron/frontend/assets/icons/python.svg?raw";
import webAssetSVG from "../../src/electron/frontend/assets/icons/web_asset.svg?raw";
diff --git a/tests/e2e/pipelines.test.ts b/tests/e2e/pipelines.test.ts
index 290ebd10a8..053cfcc9cd 100644
--- a/tests/e2e/pipelines.test.ts
+++ b/tests/e2e/pipelines.test.ts
@@ -10,7 +10,7 @@ import examplePipelines from "../../src/example_pipelines.yml";
import paths from "../../src/paths.config.json" assert { type: "json" };
import { evaluate, initTests, takeScreenshot } from './utils'
-import { header } from '../../src/electron/frontend/core/components/forms/utils'
+import { header } from '../../src/electron/frontend/utils/text'
import { sleep } from '../puppeteer';
diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts
index 69b87a9b28..66b8c91d98 100644
--- a/tests/metadata.test.ts
+++ b/tests/metadata.test.ts
@@ -1,14 +1,13 @@
import { describe, expect, test } from 'vitest'
-import { createResults } from '../src/electron/frontend/core/components/pages/guided-mode/data/utils'
-import { mapSessions } from '../src/electron/frontend/core/components/pages/utils'
+import { createResultsForSession } from '../src/electron/frontend/utils/data'
+import { mapSessions } from '../src/electron/frontend/utils/data'
import baseMetadataSchema from '../src/schemas/base-metadata.schema'
import { createMockGlobalState } from './utils'
import { Validator } from 'jsonschema'
-import { textToArray } from '../src/electron/frontend/core/components/forms/utils'
-import { updateResultsFromSubjects } from '../src/electron/frontend/core/components/pages/guided-mode/setup/utils'
+import { updateResultsFromSubjects } from '../src/electron/frontend/utils/data'
import { JSONSchemaForm } from '../src/electron/frontend/core/components/JSONSchemaForm'
import { validateOnChange } from "../src/electron/frontend/core/validation/index.js";
@@ -30,19 +29,12 @@ describe('metadata is specified correctly', () => {
// Allow mouse (full list populated from server)
baseMetadataSchema.properties.Subject.properties.species.enum = ['Mus musculus']
- const result = mapSessions(info => createResults(info, globalState), globalState.results)
+ const result = mapSessions(info => createResultsForSession(info, globalState), globalState.results)
const res = validator.validate(result[0], baseMetadataSchema) // Check first session with JSON Schema
expect(res.errors).toEqual([])
})
})
-test('empty rows are not kept for strings converted to arrays', () => {
- expect(textToArray(' v1\n v2 ')).toEqual(['v1', 'v2'])
- expect(textToArray(' v1\n\n v2 ')).toEqual(['v1', 'v2'])
- expect(textToArray(' v1\n \n v2 ')).toEqual(['v1', 'v2'])
- expect(textToArray(' v1\n v3\n v2 ')).toEqual(['v1', 'v3', 'v2'])
-})
-
test('removing all existing sessions will maintain the related subject entry on the results object', () => {
const results = { subject: { original: {} } }
diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts
new file mode 100644
index 0000000000..4fe0f6c433
--- /dev/null
+++ b/tests/schemas.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "vitest";
+import { preprocessMetadataSchema } from "../src/schemas/base-metadata.schema";
+import processSubjectSchema from "../src/schemas/subject.schema";
+
+// import '../src/schemas/source-data.schema'
+
+describe("Check changes to the Subject schema", () => {
+
+ const baseSchema = preprocessMetadataSchema()
+
+ const schema = processSubjectSchema(baseSchema)
+
+ test("Check sessions property is added to the Subject schema", () => {
+ expect(schema.properties.sessions).toBeDefined()
+ expect(schema.required.includes("sessions")).toBe(true)
+ })
+
+ test("Check that the Subject schema is sorted correctly", () => {
+
+ const desiredOrder = [
+ "sessions",
+ "subject_id",
+ "sex",
+ "species",
+ "age",
+ "age__reference",
+ "date_of_birth",
+ "genotype",
+ "strain",
+ "description",
+ "weight",
+ ]
+
+ const sortedKeys = Object.keys(schema.properties)
+
+ expect(sortedKeys).toEqual(desiredOrder)
+
+ })
+})
diff --git a/tests/utils.test.ts b/tests/utils.test.ts
new file mode 100644
index 0000000000..a05d2fd6f2
--- /dev/null
+++ b/tests/utils.test.ts
@@ -0,0 +1,523 @@
+import { describe, expect, test } from "vitest";
+
+import * as time from "../src/electron/frontend/utils/time";
+import * as url from "../src/electron/frontend/utils/url";
+import * as bytes from "../src/electron/frontend/utils/bytes";
+import * as text from "../src/electron/frontend/utils/text";
+import * as typecheck from "../src/electron/frontend/utils/typecheck";
+import * as random from "../src/electron/frontend/utils/random";
+import * as data from "../src/electron/frontend/utils/data";
+
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+
+describe('URL Utilities', () => {
+ let originalLocation: Location;
+ let originalHistory: History;
+
+ beforeEach(() => {
+ // Save the original location and history objects
+ originalLocation = global.location;
+ originalHistory = global.history;
+
+ // Mock location object
+ global.location = {
+ search: '',
+ pathname: '/test',
+ };
+
+ // Mock history object
+ global.history = {
+ state: {},
+ pushState: vi.fn(),
+ };
+
+ });
+
+ afterEach(() => {
+ // Restore the original location and history objects
+ global.location = originalLocation;
+ global.history = originalHistory;
+ });
+
+ it('should add new parameters to the URL', () => {
+ url.updateURLParams({ foo: 'bar' });
+ expect(global.history.pushState).toHaveBeenCalledWith({ foo: "bar" }, null, '/test?foo=bar');
+ });
+
+ it('should update existing parameters in the URL', () => {
+ global.location.search = '?foo=old';
+ url.updateURLParams({ foo: 'new' });
+ expect(global.history.pushState).toHaveBeenCalledWith({ foo: "new" }, null, '/test?foo=new');
+ });
+
+ it('should delete parameters from the URL when value is undefined', () => {
+ global.location.search = '?foo=bar';
+ url.updateURLParams({ foo: undefined });
+ expect(global.history.pushState).toHaveBeenCalledWith({}, null, '/test');
+ });
+
+ it('should handle multiple parameters correctly', () => {
+ global.location.search = '?foo=bar&baz=qux';
+ url.updateURLParams({ foo: 'new', baz: undefined, quux: 'corge' });
+ expect(global.history.pushState).toHaveBeenCalledWith({ foo: "new", quux: "corge" }, null, '/test?foo=new&quux=corge');
+ });
+
+ it('should merge new parameters with existing state', () => {
+ global.history.state = { existing: 'value' };
+ url.updateURLParams({ foo: 'bar' });
+ expect(global.history.state).toEqual({ existing: 'value', foo: 'bar' });
+ });
+
+ it('should not change the state if history.state is null', () => {
+ global.history.state = null;
+ url.updateURLParams({ foo: 'bar' });
+ expect(global.history.pushState).toHaveBeenCalledWith(null, null, '/test?foo=bar');
+ });
+});
+
+
+describe('Timezone Utilities', () => {
+ const originalDateNow = Date.now;
+
+ beforeEach(() => {
+ Date.now = originalDateNow; // Reset Date.now() mock if it was used
+ });
+
+ describe('getTimezoneOffset', () => {
+ it('should return the correct timezone offset for the local timezone', () => {
+ const offset = time.getTimezoneOffset(new Date('2023-06-12T12:00:00Z'));
+ expect(offset).toBeTypeOf('number');
+ });
+
+ it('should return the correct timezone offset for a specific timezone', () => {
+ const offset = time.getTimezoneOffset(new Date('2023-06-12T12:00:00Z'), 'America/New_York');
+ expect(offset).toBe(14400000); // 4 hours offset in milliseconds
+ });
+
+ it('should handle string date inputs correctly', () => {
+ const offset = time.getTimezoneOffset('2023-06-12T12:00:00Z', 'America/New_York');
+ expect(offset).toBe(14400000); // 4 hours offset in milliseconds
+ });
+ });
+
+ describe('formatTimezoneOffset', () => {
+ it('should format positive offset correctly', () => {
+ const formattedOffset = time.formatTimezoneOffset(-14400000);
+ expect(formattedOffset).toBe('+04:00');
+ });
+
+ it('should format negative offset correctly', () => {
+ const formattedOffset = time.formatTimezoneOffset(14400000);
+ expect(formattedOffset).toBe('-04:00');
+ });
+
+ it('should format zero offset correctly', () => {
+ const formattedOffset = time.formatTimezoneOffset(0);
+ expect(formattedOffset).toBe('+00:00');
+ });
+ });
+
+ describe('getISODateInTimezone', () => {
+
+ it('should return the correct ISO date for a specific timezone', () => {
+ const isoDate = time.getISODateInTimezone(new Date('2023-06-12T12:00:00Z'), 'America/New_York');
+ expect(isoDate).toBe('2023-06-12T08:00:00.000Z'); // Adjusted for 4 hours offset
+ });
+
+ it('should handle string date inputs correctly', () => {
+ const isoDate = time.getISODateInTimezone('2023-06-12T12:00:00Z', 'America/New_York');
+ expect(isoDate).toBe('2023-06-12T08:00:00.000Z'); // Adjusted for 4 hours offset
+ });
+ });
+
+});
+
+describe('Byte Utilities', () => {
+ test('should correctly format bytes', () => {
+ const number = 500;
+ expect(bytes.humanReadableBytes(number)).toBe('500.00 B');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('500.00 B');
+ });
+
+ test('should correctly format kilobytes', () => {
+ const number = 1500;
+ expect(bytes.humanReadableBytes(number)).toBe('1.50 KB');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 KB');
+ });
+
+ test('should correctly format megabytes', () => {
+ const number = 1500000;
+ expect(bytes.humanReadableBytes(number)).toBe('1.50 MB');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 MB');
+ });
+
+ test('should correctly format gigabytes', () => {
+ const number = 1500000000;
+ expect(bytes.humanReadableBytes(number)).toBe('1.50 GB');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 GB');
+ });
+
+ test('should correctly format terabytes', () => {
+ const number = 1500000000000;
+ expect(bytes.humanReadableBytes(number)).toBe('1.50 TB');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 TB');
+ });
+
+ test('should correctly format petabytes', () => {
+ const number = 1500000000000000;
+ expect(bytes.humanReadableBytes(number)).toBe('1.50 PB');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 PB');
+ });
+
+ test('should correctly format exabytes', () => {
+ const number = 1500000000000000000;
+ expect(bytes.humanReadableBytes(number)).toBe('1.50 EB');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 EB');
+ });
+
+ test('should correctly format zettabytes', () => {
+ const number = 1500000000000000000000;
+ expect(bytes.humanReadableBytes(number)).toBe('1.50 ZB');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 ZB');
+ });
+
+ test('should correctly format yottabytes', () => {
+ const number = 1500000000000000000000000;
+ expect(bytes.humanReadableBytes(number)).toBe('1.50 YB');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 YB');
+ });
+
+ test('should handle edge cases with 0 bytes', () => {
+ const number = 0;
+ expect(bytes.humanReadableBytes(number)).toBe('0.00 B');
+ expect(bytes.humanReadableBytes(`${number}`)).toBe('0.00 B');
+ });
+
+ test('should handle very large numbers appropriately', () => {
+ const number = 1e27
+ const expected = '1000.00 YB';
+ expect(bytes.humanReadableBytes(number)).toBe(expected);
+ expect(bytes.humanReadableBytes(`${number}`)).toBe(expected);
+ });
+});
+
+
+describe('Text Utilities', () => {
+
+ describe('header', () => {
+ it('should capitalize and format header strings correctly', () => {
+ expect(text.header('hello_world')).toBe('Hello World');
+ expect(text.header('HELLO_WORLD')).toBe('HELLO WORLD'); // Do not adjust user-defined capitalization
+ expect(text.header('hello world')).toBe('Hello World');
+ expect(text.header('api and id')).toBe('API and ID');
+ expect(text.header('nwb_or_and')).toBe('NWB or and');
+ expect(text.header('nwb or and api')).toBe('NWB or and API');
+ });
+
+ it('should handle strings with multiple delimiters correctly', () => {
+ expect(text.header('hello_world_and_everyone')).toBe('Hello World and Everyone');
+ expect(text.header('api_or_nwb_id')).toBe('API or NWB ID');
+ expect(text.header('nwb or_and api')).toBe('NWB or and API');
+ });
+
+ it('should handle empty strings', () => {
+ expect(text.header('')).toBe('');
+ });
+
+ it('should handle strings with only delimiters', () => {
+ expect(text.header('_ _')).toBe('');
+ expect(text.header('_or_')).toBe('or');
+ });
+
+ it('consider words linked with a special character as one word', () => {
+ expect(text.header('hello_world-and-all')).toBe('Hello World-and-all');
+ });
+
+ });
+
+})
+
+describe('Type Check Utilities', () => {
+ describe('isNumeric', () => {
+ it('should return false for non-string inputs', () => {
+ expect(typecheck.isNumericString(123)).toBe(false);
+ expect(typecheck.isNumericString(true)).toBe(false);
+ expect(typecheck.isNumericString(null)).toBe(false);
+ expect(typecheck.isNumericString(undefined)).toBe(false);
+ expect(typecheck.isNumericString({})).toBe(false);
+ expect(typecheck.isNumericString([])).toBe(false);
+ });
+
+ it('should return true for numeric strings', () => {
+ expect(typecheck.isNumericString('123')).toBe(true);
+ expect(typecheck.isNumericString('123.45')).toBe(true);
+ expect(typecheck.isNumericString('-123')).toBe(true);
+ expect(typecheck.isNumericString('-123.45')).toBe(true);
+ expect(typecheck.isNumericString('0')).toBe(true);
+ });
+
+ it('should return false for non-numeric strings', () => {
+ expect(typecheck.isNumericString('abc')).toBe(false);
+ expect(typecheck.isNumericString('123abc')).toBe(false);
+ expect(typecheck.isNumericString('abc123')).toBe(false);
+ expect(typecheck.isNumericString('')).toBe(false);
+ expect(typecheck.isNumericString(' ')).toBe(false);
+ });
+
+ it('should handle strings with spaces', () => {
+ expect(typecheck.isNumericString(' 123 ')).toBe(true);
+ expect(typecheck.isNumericString(' 123.45 ')).toBe(true);
+ expect(typecheck.isNumericString(' -123 ')).toBe(true);
+ });
+
+ it('should handle special numeric values', () => {
+ expect(typecheck.isNumericString('Infinity')).toBe(true);
+ expect(typecheck.isNumericString('-Infinity')).toBe(true);
+ expect(typecheck.isNumericString('NaN')).toBe(false);
+ });
+ });
+ describe('isObject', () => {
+
+ it('should return true for objects', () => {
+ expect(typecheck.isObject({})).toBe(true);
+ expect(typecheck.isObject({ foo: 'bar' })).toBe(true);
+ expect(typecheck.isObject(new Date())).toBe(true);
+ expect(typecheck.isObject(Object.create(null))).toBe(true);
+ });
+
+ it('should return false for non-objects', () => {
+ expect(typecheck.isObject(123)).toBe(false);
+ expect(typecheck.isObject('string')).toBe(false);
+ expect(typecheck.isObject(true)).toBe(false);
+ expect(typecheck.isObject(null)).toBe(false);
+ expect(typecheck.isObject(undefined)).toBe(false);
+ });
+
+ it('should return false for arrays', () => {
+ expect(typecheck.isObject([])).toBe(false);
+ expect(typecheck.isObject([1, 2, 3])).toBe(false);
+ });
+
+ it('should handle special cases', () => {
+ expect(typecheck.isObject(function () { })).toBe(false);
+ expect(typecheck.isObject(Symbol('symbol'))).toBe(false);
+ });
+ })
+
+
+})
+
+
+describe('Randomization Utilities', () => {
+
+ describe('randomIndex', () => {
+ it('should return a random index within the given count', () => {
+ const count = 10;
+ const index = random.getRandomIndex(count);
+ expect(index).toBeGreaterThanOrEqual(0);
+ expect(index).toBeLessThan(count);
+ });
+
+ it('should return 0 for count of 1', () => {
+ const count = 1;
+ const index = random.getRandomIndex(count);
+ expect(index).toBe(0);
+ });
+ });
+
+ describe('getRandomSample', () => {
+ it('should return an array with the given count of random elements', () => {
+ const array = [1, 2, 3, 4, 5];
+ const count = 3;
+ const result = random.getRandomSample(array, count);
+ expect(result).toHaveLength(count);
+ result.forEach(element => {
+ expect(array).toContain(element);
+ });
+ });
+
+ it('should throw an error if count is greater than array length', () => {
+ const array = [1, 2, 3];
+ const count = 5;
+ expect(() => random.getRandomSample(array, count)).toThrow('Array size cannot be smaller than expected random numbers count.');
+ });
+
+ it('should not have duplicate elements in the result', () => {
+ const array = [1, 2, 3, 4, 5];
+ const count = 3;
+ const result = random.getRandomSample(array, count);
+ const uniqueResult = new Set(result);
+ expect(uniqueResult.size).toBe(result.length);
+ });
+
+ it('should return an empty array if count is 0', () => {
+ const array = [1, 2, 3];
+ const count = 0;
+ const result = random.getRandomSample(array, count);
+ expect(result).toEqual([]);
+ });
+
+ it('should return the full array if count equals array length', () => {
+ const array = [1, 2, 3];
+ const count = array.length;
+ const result = random.getRandomSample(array, count);
+ expect(result.sort()).toEqual(array.sort());
+ });
+ });
+
+ describe('getRandomString', () => {
+ it('should return a string', () => {
+ const result = random.getRandomString();
+ expect(typeof result).toBe('string');
+ });
+
+ it('should return a string of expected length', () => {
+ const result = random.getRandomString();
+ expect(result.length).toBeGreaterThanOrEqual(5); // Length might vary slightly due to the nature of random
+ expect(result.length).toBeLessThanOrEqual(11); // Usually the length is around 7-10 characters
+ });
+
+ it('should return a different string each time it is called', () => {
+ const result1 = random.getRandomString();
+ const result2 = random.getRandomString();
+ expect(result1).not.toBe(result2);
+ });
+ });
+
+})
+
+
+describe('Data Manipulation Utilities', () => {
+
+ describe('populateWithProjectMetadata', () => {
+ it('should merge project metadata into the provided info object', () => {
+ const info = { key1: { subKey: 'value1' } };
+ const globalState = { project: { key1: { subKey2: 'value2' }, key2: { subKey3: 'value3' } } };
+ const result = data.populateWithProjectMetadata(info, globalState);
+ expect(result).toEqual({
+ key1: { subKey: 'value1', subKey2: 'value2' },
+ key2: { subKey3: 'value3' }
+ });
+ });
+ });
+
+ describe('getInfoFromId', () => {
+ it('should extract subject and session from the key', () => {
+ const key = 'sub-1234/ses-5678';
+ const result = data.getInfoFromId(key);
+ expect(result).toEqual({ subject: '1234', session: '5678' });
+ });
+ });
+
+ describe('resolveGlobalOverrides', () => {
+ it('should merge subject metadata and project-wide metadata', () => {
+ const subject = 'testSubject';
+ const globalState = { subjects: { testSubject: { testKey: 'testValue' } }, project: { Subject: { projectKey: 'projectValue' } } };
+ const result = data.resolveGlobalOverrides(subject, globalState);
+ expect(result.Subject).toEqual({ testKey: 'testValue', projectKey: 'projectValue' });
+ });
+ });
+
+ describe('drillSchemaProperties', () => {
+ it('should call the callback for each property in the schema', () => {
+ const schema = { properties: { key1: { type: 'string' }, key2: { type: 'number' } } };
+ const callback = vi.fn();
+ data.drillSchemaProperties(schema, callback);
+ expect(callback).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('resolveProperties', () => {
+ it('should resolve properties and apply default values', () => {
+ const properties = { key1: { type: 'string', default: 'defaultValue' } };
+ const target = {};
+ data.resolveProperties(properties, target);
+ expect(target).toEqual({ key1: 'defaultValue' });
+ });
+ });
+
+ describe('resolveMetadata', () => {
+ it('should resolve metadata for the subject and session', () => {
+ const subject = 'testSubject';
+ const session = 'testSession';
+ const globalState = {
+ results: { testSubject: { testSession: { metadata: { testKey: 'testValue' } } } },
+ schema: { metadata: { testSubject: { testSession: { properties: { testKey: { type: 'string' } } } } } },
+ project: {}
+ };
+ const result = data.resolveMetadata(subject, session, globalState);
+ expect(result).toEqual({ testKey: 'testValue' });
+ });
+ });
+
+ describe('createResultsForSession', () => {
+ it('should create results with project metadata and subject globals', () => {
+ const input = { subject: 'testSubject', info: { metadata: { NWBFile: { key1: 'value1' } } } };
+ const globalState = { project: { NWBFile: { key1: 'projectValue1', global: true } }, subjects: { testSubject: { key2: 'value2' } } };
+ const result = data.createResultsForSession(input, globalState);
+ expect(result).toEqual({ NWBFile: { key1: 'value1', global:true }, Subject: { key2: 'value2' } }); // Input value has priority over project metadata
+ });
+ });
+
+ describe('updateResultsFromSubjects', () => {
+ it('should update results based on the subjects', () => {
+ const results = { subject1: { session1: { metadata: { testKey: 'testValue' } } } };
+ const subjects = { subject1: { sessions: ['session1'] }, subject2: { sessions: ['session2'] } };
+ const result = data.updateResultsFromSubjects(results, subjects);
+ expect(result).toEqual({
+ subject1: { session1: { metadata: { testKey: 'testValue' } } },
+ subject2: { session2: { source_data: {}, metadata: { NWBFile: { session_id: 'session2' }, Subject: { subject_id: 'subject2' } } } }
+ });
+ });
+ });
+
+ describe('setUndefinedIfNotDeclared', () => {
+ it('should set undefined for properties not declared', () => {
+ const schemaProps = { properties: { key1: { type: 'string' } } };
+ const resolved = {};
+ data.setUndefinedIfNotDeclared(schemaProps, resolved);
+ expect(resolved).toEqual({ key1: undefined });
+ });
+ });
+
+ describe('sanitize', () => {
+ it('should remove private properties from the object', () => {
+ const item = { __private: 'value', public: 'value' };
+ const result = data.sanitize(item);
+ expect(result).toEqual({ public: 'value' });
+ });
+ });
+
+ describe('merge', () => {
+ it('should merge two objects deeply', () => {
+ const toMerge = { key1: 'value1', key2: { subKey: 'value2' } };
+ const target = { key2: { subKey: 'oldValue', subKey2: 'value3' } };
+ const result = data.merge(toMerge, target);
+ expect(result).toEqual({ key1: 'value1', key2: { subKey: 'value2', subKey2: 'value3' } });
+ });
+ });
+
+ describe('mapSessions', () => {
+ it('should map sessions using the provided callback', () => {
+ const toIterate = { subject1: { session1: 'info1', session2: 'info2' } };
+ const callback = ({ subject, session, info }) => `${subject}/${session}: ${info}`;
+ const result = data.mapSessions(callback, toIterate);
+ expect(result).toEqual(['subject1/session1: info1', 'subject1/session2: info2']);
+ });
+ });
+
+ describe('resolveAsJSONSchema', () => {
+ it('should replace $ref with the referenced value', () => {
+ const schema = { properties: { key1: { $ref: '#/definitions/ref1' } }, definitions: { ref1: { type: 'string' } } };
+ const result = data.resolveAsJSONSchema(schema);
+ expect(result).toEqual({ properties: { key1: { type: 'string' } }, definitions: { ref1: { type: 'string' } } });
+ });
+
+ it('it should resolve allOf', () => {
+ const schema = { properties: { key1: { allOf: [{ type: 'string' }, { minLength: 5 }] } } };
+ const result = data.resolveAsJSONSchema(schema,);
+ expect(result).toEqual({ properties: { key1: { type: 'string', minLength: 5 } } });
+ })
+ });
+
+})
diff --git a/vite.config.js b/vite.config.js
index 49241f41ba..e79e9e8739 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -8,5 +8,45 @@ export default defineConfig({
environment: "jsdom",
setupFiles: ["dotenv/config"],
testTimeout: 4 * 60 * 1000,
+ coverage: {
+ include: ["src"],
+ exclude: [
+ "**/assets",
+
+ // No Electron code should be tested
+ "**/electron/main",
+ "**/electron/preload",
+
+ // No test for the rendered pages, as they're composed of components
+ "**/components/pages",
+
+ // Electron Only (for the most part)
+ "src/schemas/dandi-upload.schema.ts",
+ "src/schemas/interfaces.info.ts",
+ "src/schemas/timezone.schema.ts",
+ "src/electron/frontend/utils/electron.ts",
+ "src/electron/frontend/utils/auto-update.ts",
+
+ // High-Level App Configuration
+ "src/electron/frontend/core/index.ts",
+ "src/electron/frontend/core/pages.js",
+ "src/electron/frontend/core/dependencies.js",
+ "src/electron/frontend/core/globals.js",
+ "src/electron/frontend/core/errors.ts",
+
+ // Server Communication
+ "src/electron/frontend/core/server",
+ "src/electron/frontend/utils/run.ts",
+ "src/electron/frontend/utils/progress.ts",
+
+ // Pure Native Rendering Interaction
+ "src/electron/frontend/utils/table.ts",
+
+ // Unclear how to test
+ "src/electron/frontend/utils/popups.ts",
+ "src/electron/frontend/utils/download.ts",
+ "src/electron/frontend/utils/upload.ts",
+ ],
+ },
},
});