diff --git a/guideGlobalMetadata.json b/guideGlobalMetadata.json index e6012572d..cd27361e2 100644 --- a/guideGlobalMetadata.json +++ b/guideGlobalMetadata.json @@ -21,6 +21,7 @@ "TiffImagingInterface", "MiniscopeImagingInterface", "SbxImagingInterface", + "CaimanSegmentationInterface", "MCSRawRecordingInterface", "MEArecRecordingInterface" ] diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index df6aa4a3d..af15b1ab0 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -88,7 +88,9 @@ def coerce_schema_compliance_recursive(obj, schema): coerce_schema_compliance_recursive(value, prop_schema) elif isinstance(obj, list): for item in obj: - coerce_schema_compliance_recursive(item, schema.get("items", {})) + coerce_schema_compliance_recursive( + item, schema.get("items", schema if "properties" else {}) + ) # NEUROCONV PATCH return obj diff --git a/schemas/json/generated/CaimanSegmentationInterface.json b/schemas/json/generated/CaimanSegmentationInterface.json new file mode 100644 index 000000000..db580d9a5 --- /dev/null +++ b/schemas/json/generated/CaimanSegmentationInterface.json @@ -0,0 +1,29 @@ +{ + "required": [], + "properties": { + "CaimanSegmentationInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string" + }, + "verbose": { + "type": "boolean", + "default": true + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index 0753e421b..334a03ed6 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -12,6 +12,8 @@ import { resolveProperties } from "./pages/guided-mode/data/utils"; import { JSONSchemaInput } from "./JSONSchemaInput"; import { InspectorListItem } from "./preview/inspector/InspectorList"; +const selfRequiredSymbol = Symbol(); + const componentCSS = ` * { @@ -153,7 +155,7 @@ export class JSONSchemaForm extends LitElement { }; } - #base = []; + base = []; #nestedForms = {}; tables = {}; #nErrors = 0; @@ -205,7 +207,7 @@ export class JSONSchemaForm extends LitElement { if (props.onStatusChange) this.onStatusChange = props.onStatusChange; - if (props.base) this.#base = props.base; + if (props.base) this.base = props.base; } getTable = (path) => { @@ -242,8 +244,8 @@ export class JSONSchemaForm extends LitElement { } // Track resolved values for the form (data only) - updateData(fullPath, value) { - const path = [...fullPath]; + updateData(localPath, value) { + const path = [...localPath]; const name = path.pop(); const reducer = (acc, key) => (key in acc ? acc[key] : (acc[key] = {})); // NOTE: Create nested objects if required to set a new path @@ -261,7 +263,7 @@ export class JSONSchemaForm extends LitElement { resolvedParent[name] = value; } - if (hasUpdate) this.onUpdate(fullPath, value); // Ensure the value has actually changed + if (hasUpdate) this.onUpdate(localPath, value); // Ensure the value has actually changed } #addMessage = (name, message, type) => { @@ -271,9 +273,9 @@ export class JSONSchemaForm extends LitElement { container.appendChild(item); }; - #clearMessages = (fullPath, type) => { - if (Array.isArray(fullPath)) fullPath = fullPath.join("-"); // Convert array to string - const container = this.shadowRoot.querySelector(`#${fullPath} .${type}`); + #clearMessages = (localPath, type) => { + if (Array.isArray(localPath)) localPath = localPath.join("-"); // Convert array to string + const container = this.shadowRoot.querySelector(`#${localPath} .${type}`); if (container) { const nChildren = container.children.length; @@ -348,15 +350,15 @@ export class JSONSchemaForm extends LitElement { }; #get = (path, object = this.resolved) => { - // path = path.slice(this.#base.length); // Correct for base path + // path = path.slice(this.base.length); // Correct for base path return path.reduce((acc, curr) => (acc = acc[curr]), object); }; - #checkRequiredAfterChange = async (fullPath) => { - const path = [...fullPath]; + #checkRequiredAfterChange = async (localPath) => { + const path = [...localPath]; const name = path.pop(); const element = this.shadowRoot - .querySelector(`#${fullPath.join("-")}`) + .querySelector(`#${localPath.join("-")}`) .querySelector("jsonschema-input") .getElement(); const isValid = await this.triggerValidation(name, element, path, false); @@ -367,13 +369,13 @@ export class JSONSchemaForm extends LitElement { if (typeof path === "string") path = path.split("."); // NOTE: Still must correct for the base here - if (this.#base.length) { - const base = this.#base.slice(-1)[0]; + if (this.base.length) { + const base = this.base.slice(-1)[0]; const indexOf = path.indexOf(base); if (indexOf !== -1) path = path.slice(indexOf + 1); } - const resolved = path.reduce((acc, curr) => (acc = acc[curr]), schema); + const resolved = this.#get(path, schema); if (resolved["$ref"]) return this.getSchema(resolved["$ref"].split("/").slice(1)); // NOTE: This assumes reference to the root of the schema return resolved; @@ -382,8 +384,8 @@ export class JSONSchemaForm extends LitElement { #renderInteractiveElement = (name, info, required, path = []) => { let isRequired = required[name]; - const fullPath = [...path, name]; - const externalPath = [...this.#base, ...fullPath]; + const localPath = [...path, name]; + const externalPath = [...this.base, ...localPath]; const resolved = this.#get(path, this.resolved); const value = resolved[name]; @@ -392,11 +394,11 @@ export class JSONSchemaForm extends LitElement { if (isConditional && !isRequired) isRequired = required[name] = async () => { - const isRequiredAfterChange = await this.#checkRequiredAfterChange(fullPath); + const isRequiredAfterChange = await this.#checkRequiredAfterChange(localPath); if (isRequiredAfterChange) { return true; } else { - const linkResults = await this.#applyToLinkedProperties(this.#checkRequiredAfterChange, fullPath); // Check links + const linkResults = await this.#applyToLinkedProperties(this.#checkRequiredAfterChange, localPath); // Check links if (linkResults.includes(true)) return true; // Handle updates when no longer required else return false; @@ -405,7 +407,7 @@ export class JSONSchemaForm extends LitElement { const interactiveInput = new JSONSchemaInput({ info, - path: fullPath, + path: localPath, value, form: this, required: isRequired, @@ -425,7 +427,7 @@ export class JSONSchemaForm extends LitElement { return html`
= path.length) return isRenderable(key, value); if (required[key]) return isRenderable(key, value); - if (this.#getLink([...this.#base, ...path, key])) return isRenderable(key, value); + if (this.#getLink([...this.base, ...path, key])) return isRenderable(key, value); if (!this.onlyRequired) return isRenderable(key, value); return false; }) @@ -549,7 +555,7 @@ export class JSONSchemaForm extends LitElement { #isLinkResolved = async (pathArr) => { return ( await this.#applyToLinkedProperties((link) => { - const isRequired = this.#isRequired(link.slice((this.#base ?? []).length)); + const isRequired = this.#isRequired(link.slice((this.base ?? []).length)); if (typeof isRequired === "function") return !isRequired.call(this.resolved); else return !isRequired; }, pathArr) @@ -558,8 +564,11 @@ export class JSONSchemaForm extends LitElement { #isRequired = (path) => { if (typeof path === "string") path = path.split("-"); - // path = path.slice(this.#base.length); // Remove base path - return path.reduce((obj, key) => obj && obj[key], this.#requirements); + // path = path.slice(this.base.length); // Remove base path + const res = path.reduce((obj, key) => obj && obj[key], this.#requirements); + + if (typeof res === "object") res = res[selfRequiredSymbol]; + return res; }; #getLinkElement = (externalPath) => { @@ -572,15 +581,17 @@ export class JSONSchemaForm extends LitElement { triggerValidation = async (name, element, path = [], checkLinks = true) => { const parent = this.#get(path, this.resolved); + const pathToValidate = [...(this.base ?? []), ...path]; + const valid = !this.validateEmptyValues && !(name in parent) ? true - : await this.validateOnChange(name, parent, [...(this.#base ?? []), ...path]); + : await this.validateOnChange(name, parent, pathToValidate); - const fullPath = [...path, name]; // Use basePath to augment the validation - const externalPath = [...this.#base, name]; + const localPath = [...path, name]; // Use basePath to augment the validation + const externalPath = [...this.base, name]; - const isRequired = this.#isRequired(fullPath); + const isRequired = this.#isRequired(localPath); let warnings = Array.isArray(valid) ? valid.filter((info) => info.type === "warning" && (!isRequired || !info.missing)) : []; @@ -599,7 +610,7 @@ export class JSONSchemaForm extends LitElement { // Clear old errors and warnings on linked properties this.#applyToLinkedProperties((path) => { - const internalPath = path.slice((this.#base ?? []).length); + const internalPath = path.slice((this.base ?? []).length); this.#clearMessages(internalPath, "errors"); this.#clearMessages(internalPath, "warnings"); }, externalPath); @@ -613,9 +624,9 @@ export class JSONSchemaForm extends LitElement { } // Clear old errors and warnings - this.#clearMessages(fullPath, "errors"); - this.#clearMessages(fullPath, "warnings"); - this.#clearMessages(fullPath, "info"); + this.#clearMessages(localPath, "errors"); + this.#clearMessages(localPath, "warnings"); + this.#clearMessages(localPath, "info"); const isFunction = typeof valid === "function"; const isValid = @@ -632,8 +643,8 @@ export class JSONSchemaForm extends LitElement { this.checkStatus(); // Show aggregated errors and warnings (if any) - warnings.forEach((info) => this.#addMessage(fullPath, info, "warnings")); - info.forEach((info) => this.#addMessage(fullPath, info, "info")); + warnings.forEach((info) => this.#addMessage(localPath, info, "warnings")); + info.forEach((info) => this.#addMessage(localPath, info, "info")); if (isValid && errors.length === 0) { element.classList.remove("invalid"); @@ -643,7 +654,7 @@ export class JSONSchemaForm extends LitElement { await this.#applyToLinkedProperties((path, element) => { element.classList.remove("required", "conditional"); // Links manage their own error and validity states, but only one needs to be valid - }, fullPath); + }, localPath); if (isFunction) valid(); // Run if returned value is a function @@ -661,7 +672,7 @@ export class JSONSchemaForm extends LitElement { [...path, name] ); - errors.forEach((info) => this.#addMessage(fullPath, info, "errors")); + errors.forEach((info) => this.#addMessage(localPath, info, "errors")); // element.title = errors.map((info) => info.message).join("\n"); // Set all errors to show on hover return false; @@ -679,7 +690,7 @@ export class JSONSchemaForm extends LitElement { if (renderable.length === 0) return html`

No properties to render

`; let renderableWithLinks = renderable.reduce((acc, [name, info]) => { - const externalPath = [...this.#base, ...path, name]; + const externalPath = [...this.base, ...path, name]; const link = this.#getLink(externalPath); // Use the base path to find a link if (link) { @@ -740,7 +751,7 @@ export class JSONSchemaForm extends LitElement { // Render linked properties if (entry[isLink]) { const linkedProperties = info.properties.map((path) => { - const pathCopy = [...path].slice((this.#base ?? []).length); + const pathCopy = [...path].slice((this.base ?? []).length); const name = pathCopy.pop(); return this.#renderInteractiveElement(name, schema.properties[name], required, pathCopy); }); @@ -756,7 +767,7 @@ export class JSONSchemaForm extends LitElement { const hasMany = renderable.length > 1; // How many siblings? - const fullPath = [...path, name]; + const localPath = [...path, name]; if (this.mode === "accordion" && hasMany) { const headerName = header(name); @@ -767,8 +778,10 @@ export class JSONSchemaForm extends LitElement { results: { ...results[name] }, globals: this.globals?.[name], + mode: this.mode, + onUpdate: (internalPath, value) => { - const path = [...fullPath, ...internalPath]; + const path = [...localPath, ...internalPath]; this.updateData(path, value); }, @@ -793,13 +806,13 @@ export class JSONSchemaForm extends LitElement { this.checkAllLoaded(); }, renderTable: (...args) => this.renderTable(...args), - base: fullPath, + base: [...this.base, ...localPath], }); const accordion = new Accordion({ sections: { [headerName]: { - subtitle: `${this.#getRenderable(info, required[name], fullPath, true).length} fields`, + subtitle: `${this.#getRenderable(info, required[name], localPath, true).length} fields`, content: this.#nestedForms[name], }, }, @@ -811,7 +824,7 @@ export class JSONSchemaForm extends LitElement { } // Render properties in the sub-schema - const rendered = this.#render(info, results?.[name], required[name], fullPath); + const rendered = this.#render(info, results?.[name], required[name], localPath); return hasMany || path.length > 1 ? html`
@@ -834,7 +847,9 @@ export class JSONSchemaForm extends LitElement { Object.entries(schema.properties).forEach(([key, value]) => { if (value.properties) { let nextAccumulator = acc[key]; - if (!nextAccumulator || typeof nextAccumulator !== "object") nextAccumulator = acc[key] = {}; + const isNotObject = typeof nextAccumulator !== "object"; + if (!nextAccumulator || isNotObject) + nextAccumulator = acc[key] = { [selfRequiredSymbol]: !!nextAccumulator }; this.#registerRequirements(value, requirements[key], nextAccumulator); } }); diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index 67e2920a5..d20c54ecd 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -200,7 +200,7 @@ export class JSONSchemaInput extends LitElement { (this.onValidate ? this.onValidate() : this.form - ? this.form.validateOnChange(key, parent, fullPath, v) + ? this.form.validateOnChange(key, parent, [...this.form.base, ...fullPath], v) : "") ); }, diff --git a/src/renderer/src/stories/pages/guided-mode/SourceData.stories.js b/src/renderer/src/stories/pages/guided-mode/SourceData.stories.js index 55c0b736a..e668929eb 100644 --- a/src/renderer/src/stories/pages/guided-mode/SourceData.stories.js +++ b/src/renderer/src/stories/pages/guided-mode/SourceData.stories.js @@ -20,6 +20,7 @@ import ScanImageImagingInterfaceSchema from "../../../../../../schemas/json/gene import TiffImagingInterfaceSchema from "../../../../../../schemas/json/generated/TiffImagingInterface.json"; import MiniscopeImagingInterfaceSchema from "../../../../../../schemas/json/generated/MiniscopeImagingInterface.json"; import SbxImagingInterfaceSchema from "../../../../../../schemas/json/generated/SbxImagingInterface.json"; +import CaimanSegmentationInterfaceSchema from "../../../../../../schemas/json/generated/CaimanSegmentationInterface.json"; import MCSRawRecordingInterfaceSchema from "../../../../../../schemas/json/generated/MCSRawRecordingInterface.json"; import MEArecRecordingInterfaceSchema from "../../../../../../schemas/json/generated/MEArecRecordingInterface.json"; @@ -75,6 +76,8 @@ globalStateCopy.schema.source_data.properties.MiniscopeImagingInterface = MiniscopeImagingInterfaceSchema.properties.MiniscopeImagingInterface; globalStateCopy.schema.source_data.properties.SbxImagingInterface = SbxImagingInterfaceSchema.properties.SbxImagingInterface; +globalStateCopy.schema.source_data.properties.CaimanSegmentationInterface = + CaimanSegmentationInterfaceSchema.properties.CaimanSegmentationInterface; globalStateCopy.schema.source_data.properties.MCSRawRecordingInterface = MCSRawRecordingInterfaceSchema.properties.MCSRawRecordingInterface; globalStateCopy.schema.source_data.properties.MEArecRecordingInterface = @@ -218,6 +221,12 @@ SbxImagingInterfaceGlobalCopy.interfaces.interface = SbxImagingInterface; SbxImagingInterfaceGlobalCopy.schema.source_data = SbxImagingInterfaceSchema; SbxImagingInterface.args = { activePage, globalState: SbxImagingInterfaceGlobalCopy }; +export const CaimanSegmentationInterface = PageTemplate.bind({}); +const CaimanSegmentationInterfaceGlobalCopy = JSON.parse(JSON.stringify(globalState)); +CaimanSegmentationInterfaceGlobalCopy.interfaces.interface = CaimanSegmentationInterface; +CaimanSegmentationInterfaceGlobalCopy.schema.source_data = CaimanSegmentationInterfaceSchema; +CaimanSegmentationInterface.args = { activePage, globalState: CaimanSegmentationInterfaceGlobalCopy }; + export const MCSRawRecordingInterface = PageTemplate.bind({}); const MCSRawRecordingInterfaceGlobalCopy = JSON.parse(JSON.stringify(globalState)); MCSRawRecordingInterfaceGlobalCopy.interfaces.interface = MCSRawRecordingInterface; diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js index d3de48412..e265e15d5 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js @@ -128,7 +128,7 @@ export class GuidedMetadataPage extends ManagedPage { this.#checkAllLoaded(); }, - onUpdate: (...args) => { + onUpdate: () => { this.unsavedUpdates = true; }, diff --git a/src/renderer/src/stories/pages/guided-mode/data/utils.js b/src/renderer/src/stories/pages/guided-mode/data/utils.js index e5ab73f9d..17a06aa0e 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/utils.js +++ b/src/renderer/src/stories/pages/guided-mode/data/utils.js @@ -29,10 +29,18 @@ export function resolveProperties(properties = {}, target, globals = {}) { for (let name in properties) { const info = properties[name]; + + // NEUROCONV PATCH: Correct for incorrect array schema + if (info.properties && info.type === "array") { + info.items = { type: "object", properties: info.properties, required: info.required }; + delete info.properties; + } + const props = info.properties; if (!(name in target)) { 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];