From b68a94af2bf8516a675937d9fad2f95adb027cf1 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Fri, 5 Apr 2024 12:50:55 -0700 Subject: [PATCH 01/30] Provide better alignment visualization with mocked editing inputs --- .../guided-mode/data/GuidedSourceData.js | 91 +------ .../data/alignment/TimeAlignment.js | 230 ++++++++++++++++++ 2 files changed, 238 insertions(+), 83 deletions(-) create mode 100644 src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 12f5a3818c..f5ae76b595 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -18,6 +18,7 @@ import { baseUrl } from "../../../../server/globals"; import { run } from "../options/utils.js"; import { getInfoFromId } from "./utils.js"; import { Modal } from "../../../Modal"; +import { TimeAlignment } from "./alignment/TimeAlignment.js"; const propsToIgnore = { "*": { @@ -263,94 +264,18 @@ export class GuidedSourceDataPage extends ManagedPage { }); const header = document.createElement("div"); - const h2 = document.createElement("h2"); - Object.assign(h2.style, { - marginBottom: "10px", - }); + const h2 = document.createElement("h3"); + Object.assign(h2.style, { margin: "0px", }); h2.innerText = `Alignment Preview: ${subject}/${session}`; - const warning = document.createElement("small"); - warning.innerHTML = - "Warning: This is just a preview. We do not currently have the features implemented to change the alignment of your interfaces."; - header.append(h2, warning); - - const modal = new Modal({ - header, - }); - - document.body.append(modal); - - const content = document.createElement("div"); - Object.assign(content.style, { - display: "flex", - flexDirection: "column", - gap: "20px", - padding: "20px", - }); - - modal.append(content); - - const flatTimes = Object.values(results) - .map((interfaceTimestamps) => { - return [interfaceTimestamps[0], interfaceTimestamps.slice(-1)[0]]; - }) - .flat() - .filter((timestamp) => !isNaN(timestamp)); - - const minTime = Math.min(...flatTimes); - const maxTime = Math.max(...flatTimes); - - const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); - const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; - for (let name in results) { - const container = document.createElement("div"); - const label = document.createElement("label"); - label.innerText = name; - container.append(label); + header.append(h2); - const data = results[name]; + const modal = new Modal({ header }); - const barContainer = document.createElement("div"); - Object.assign(barContainer.style, { - height: "10px", - width: "100%", - marginTop: "5px", - border: "1px solid lightgray", - position: "relative", - }); - - if (data.length) { - const firstTime = data[0]; - const lastTime = data[data.length - 1]; - - label.innerText += ` (${firstTime.toFixed(2)} - ${lastTime.toFixed(2)} sec)`; - - const firstTimePct = normalizeTimePct(firstTime); - const lastTimePct = normalizeTimePct(lastTime); - - const width = `calc(${lastTimePct} - ${firstTimePct})`; - - const bar = document.createElement("div"); - - Object.assign(bar.style, { - position: "absolute", - - left: firstTimePct, - width: width, - height: "100%", - background: "blue", - }); - - barContainer.append(bar); - } else { - barContainer.style.background = - "repeating-linear-gradient(45deg, lightgray, lightgray 10px, white 10px, white 20px)"; - } - - container.append(barContainer); + document.body.append(modal); - content.append(container); - } + const alignment = new TimeAlignment({ results }) + modal.append(alignment) modal.open = true; }, diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js new file mode 100644 index 0000000000..cb374e6137 --- /dev/null +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -0,0 +1,230 @@ +import { LitElement, css } from "lit"; +import { JSONSchemaInput } from "../../../../JSONSchemaInput"; + + + +const options = [ + { + name: "Timestamps", + schema: { + type: 'string', + format: 'file', + description: 'A CSV file containing the timestamps of the recording.' + } + }, + { + name: "Start Time", + schema: { + type: 'number', + description: 'The start time of the recording in seconds.', + min: 0 + } + }, + { + name: "Linked Recording", + schema: { + type: 'string', + description: 'The name of the linked recording.' + } + } +] + +export class TimeAlignment extends LitElement { + + static get styles() { + return css` + + * { + box-sizing: border-box; + } + + :host { + display: block; + padding: 20px; + } + + :host > div { + display: flex; + flex-direction: column; + gap: 10px; + } + + :host > div > div { + display: flex; + align-items: center; + gap: 20px; + } + + :host > div > div > *:nth-child(1) { + width: 100%; + } + + :host > div > div > *:nth-child(2) { + display: flex; + flex-direction: column; + justify-content: center; + white-space: nowrap; + font-size: 90%; + min-width: 130px; + } + + :host > div > div > *:nth-child(2) > div { + cursor: pointer; + padding: 5px 10px; + border: 1px solid lightgray; + } + + :host > div > div > *:nth-child(3) { + width: 700px; + } + + .disclaimer { + font-size: 90%; + color: gray; + } + + label { + font-weight: bold; + } + ` + } + + static get properties() { + return { + results: { type: Object }, + }; + } + + constructor({ results = {} }) { + super(); + this.results = results; + } + + render() { + + const container = document.createElement("div"); + + const results = this.results; + const flatTimes = Object.values(results) + .map((interfaceTimestamps) => { + return [interfaceTimestamps[0], interfaceTimestamps.slice(-1)[0]]; + }) + .flat() + .filter((timestamp) => !isNaN(timestamp)); + + const minTime = Math.min(...flatTimes); + const maxTime = Math.max(...flatTimes); + + const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); + const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; + + for (let name in results) { + + const row = document.createElement("div"); + // Object.assign(row.style, { + // display: 'flex', + // alignItems: 'center', + // justifyContent: 'space-between', + // gap: '10px', + // }); + + const barCell = document.createElement("div"); + + const label = document.createElement("label"); + label.innerText = name; + barCell.append(label); + + const data = results[name]; + + const barContainer = document.createElement("div"); + Object.assign(barContainer.style, { + height: "10px", + width: "100%", + marginTop: "5px", + border: "1px solid lightgray", + position: "relative", + }); + + barCell.append(barContainer); + + + const hasData = data.length > 0; + + if (hasData) { + const firstTime = data[0]; + const lastTime = data[data.length - 1]; + + const smallLabel = document.createElement("small"); + smallLabel.innerText = `${firstTime.toFixed(2)} - ${lastTime.toFixed(2)} sec`; + + const firstTimePct = normalizeTimePct(firstTime); + const lastTimePct = normalizeTimePct(lastTime); + + const width = `calc(${lastTimePct} - ${firstTimePct})`; + + const bar = document.createElement("div"); + + Object.assign(bar.style, { + position: "absolute", + left: firstTimePct, + width: width, + height: "100%", + background: "blue", + }); + + barContainer.append(bar); + barCell.append(smallLabel); + + } else { + barContainer.style.background = + "repeating-linear-gradient(45deg, lightgray, lightgray 10px, white 10px, white 20px)"; + } + + + + row.append(barCell); + + if (hasData) { + const selectionCell = document.createElement("div"); + const resultCell = document.createElement("div"); + + const elements = options.map((option) => { + const clickableElement = document.createElement("div"); + clickableElement.innerText = option.name; + clickableElement.onclick = () => { + const element = new JSONSchemaInput({ + schema: option.schema, + path: [ ], + }) + + resultCell.innerHTML = ''; + resultCell.append(element) + } + return clickableElement; + }) + + selectionCell.append(...elements) + + row.append(selectionCell, resultCell); + elements[0].click(); + + } else { + const empty = document.createElement("div"); + const disclaimer = document.createElement("div"); + disclaimer.classList.add("disclaimer"); + disclaimer.innerText = "Edit in Source Data"; + row.append(disclaimer, empty); + } + + container.append(row); + + } + + return container + + } +} + + +customElements.get("nwbguide-time-alignment") || + customElements.define("nwbguide-time-alignment", TimeAlignment); From d573658ea205ccbbf5ed233f302c2fbb0869e012 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Fri, 5 Apr 2024 16:08:44 -0700 Subject: [PATCH 02/30] Show options appropriately for SpikeGLX and Phy --- .../data/alignment/TimeAlignment.js | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index cb374e6137..455e471dff 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -184,37 +184,36 @@ export class TimeAlignment extends LitElement { row.append(barCell); - if (hasData) { - const selectionCell = document.createElement("div"); - const resultCell = document.createElement("div"); - - const elements = options.map((option) => { - const clickableElement = document.createElement("div"); - clickableElement.innerText = option.name; - clickableElement.onclick = () => { - const element = new JSONSchemaInput({ - schema: option.schema, - path: [ ], - }) - - resultCell.innerHTML = ''; - resultCell.append(element) - } - return clickableElement; - }) - - selectionCell.append(...elements) - - row.append(selectionCell, resultCell); - elements[0].click(); + const selectionCell = document.createElement("div"); + const resultCell = document.createElement("div"); + + const resolvedOptions = hasData ? options.slice(0, 2) : options; + + const elements = resolvedOptions.map((option) => { + const clickableElement = document.createElement("div"); + clickableElement.innerText = option.name; + clickableElement.onclick = () => { + const element = new JSONSchemaInput({ + schema: option.schema, + path: [ ], + }) + + resultCell.innerHTML = ''; + resultCell.append(element) + } + return clickableElement; + }) + + selectionCell.append(...elements) - } else { - const empty = document.createElement("div"); - const disclaimer = document.createElement("div"); - disclaimer.classList.add("disclaimer"); - disclaimer.innerText = "Edit in Source Data"; - row.append(disclaimer, empty); - } + row.append(selectionCell, resultCell); + elements[0].click(); + + // const empty = document.createElement("div"); + // const disclaimer = document.createElement("div"); + // disclaimer.classList.add("disclaimer"); + // disclaimer.innerText = "Edit in Source Data"; + // row.append(disclaimer, empty); container.append(row); From c8684b1fe92bb03387d413f37677864652a09d5a Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Mon, 8 Apr 2024 13:51:00 -0700 Subject: [PATCH 03/30] Provide add dummy recording button --- .../guided-mode/data/alignment/TimeAlignment.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 455e471dff..6e20dfe448 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -1,6 +1,6 @@ import { LitElement, css } from "lit"; import { JSONSchemaInput } from "../../../../JSONSchemaInput"; - +import { Button } from "../../../../Button"; const options = [ @@ -25,6 +25,18 @@ const options = [ schema: { type: 'string', description: 'The name of the linked recording.' + }, + controls: function () { + return [ + new Button({ + label: 'Add Dummy Recording', + size: 'small', + primary: true, + onClick: () => { + console.log('Adding dummy recording'); + } + }) + ] } } ] @@ -196,6 +208,7 @@ export class TimeAlignment extends LitElement { const element = new JSONSchemaInput({ schema: option.schema, path: [ ], + controls: option.controls ? option.controls() : [], }) resultCell.innerHTML = ''; From f02aa78e8e6f46fa712c84135b8b35611fe2e0f4 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Thu, 11 Apr 2024 12:29:07 -0700 Subject: [PATCH 04/30] Update based on GUIDE meeting --- .../data/alignment/TimeAlignment.js | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 6e20dfe448..f11376a855 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -1,11 +1,10 @@ import { LitElement, css } from "lit"; import { JSONSchemaInput } from "../../../../JSONSchemaInput"; -import { Button } from "../../../../Button"; const options = [ { - name: "Timestamps", + name: "Upload Timestamps", schema: { type: 'string', format: 'file', @@ -13,7 +12,7 @@ const options = [ } }, { - name: "Start Time", + name: "Adjust Start Time", schema: { type: 'number', description: 'The start time of the recording in seconds.', @@ -21,23 +20,13 @@ const options = [ } }, { - name: "Linked Recording", + name: "Link to Recording", schema: { type: 'string', description: 'The name of the linked recording.' }, - controls: function () { - return [ - new Button({ - label: 'Add Dummy Recording', - size: 'small', - primary: true, - onClick: () => { - console.log('Adding dummy recording'); - } - }) - ] - } + enum: [], + strict: true } ] @@ -77,7 +66,7 @@ export class TimeAlignment extends LitElement { justify-content: center; white-space: nowrap; font-size: 90%; - min-width: 130px; + min-width: 150px; } :host > div > div > *:nth-child(2) > div { @@ -193,13 +182,16 @@ export class TimeAlignment extends LitElement { } - row.append(barCell); const selectionCell = document.createElement("div"); const resultCell = document.createElement("div"); - const resolvedOptions = hasData ? options.slice(0, 2) : options; + const optionsCopy = structuredClone(options); + + options[2].schema.enum = Object.keys(results).filter(str => str.includes('Recording')) + + const resolvedOptions = hasData ? optionsCopy.slice(0, 2) : optionsCopy; const elements = resolvedOptions.map((option) => { const clickableElement = document.createElement("div"); From 24504bec1633c7f4f666aae0ee2a816c87f22869 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Thu, 11 Apr 2024 13:13:49 -0700 Subject: [PATCH 05/30] Update styling --- src/renderer/src/stories/JSONSchemaInput.js | 31 +++++++++++++++++++ .../data/alignment/TimeAlignment.js | 16 ++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index 4241e833d5..95ba5ff71c 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -486,6 +486,15 @@ export class JSONSchemaInput extends LitElement { padding: 0; margin-bottom: 1em; } + + select { + background: url("data:image/svg+xml,") no-repeat; + background-position: calc(100% - 0.75rem) center !important; + -moz-appearance:none !important; + -webkit-appearance: none !important; + appearance: none !important; + padding-right: 2rem !important; + } `; } @@ -1099,6 +1108,28 @@ export class JSONSchemaInput extends LitElement { // Basic enumeration of properties on a select element if (schema.enum && schema.enum.length) { + + // Use generic selector + if (schema.strict && schema.search !== true) { + return html` + + `; + } + + + const options = schema.enum.map((v) => { return { key: v, diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index f11376a855..7c6a831f74 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -23,10 +23,11 @@ const options = [ name: "Link to Recording", schema: { type: 'string', - description: 'The name of the linked recording.' + description: 'The name of the linked recording.', + placeholder: 'Select a recording interface', + enum: [], + strict: true }, - enum: [], - strict: true } ] @@ -87,6 +88,11 @@ export class TimeAlignment extends LitElement { label { font-weight: bold; } + + [selected] { + font-weight: bold; + background: whitesmoke; + } ` } @@ -197,6 +203,10 @@ export class TimeAlignment extends LitElement { const clickableElement = document.createElement("div"); clickableElement.innerText = option.name; clickableElement.onclick = () => { + + elements.forEach(el => el.removeAttribute("selected")); + clickableElement.setAttribute("selected", ""); + const element = new JSONSchemaInput({ schema: option.schema, path: [ ], From 59edb85d736f4b7cca78536c5b1b54c12c873ec4 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Mon, 22 Apr 2024 13:16:06 -0700 Subject: [PATCH 06/30] Actually update time alignment --- pyflask/manageNeuroconv/manage_neuroconv.py | 60 ++++++++++++++- src/renderer/src/stories/pages/Page.js | 3 +- .../guided-mode/data/GuidedSourceData.js | 59 +++++++++----- .../data/alignment/TimeAlignment.js | 76 +++++++++++++------ 4 files changed, 151 insertions(+), 47 deletions(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index a0aec323c8..946970085d 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -341,7 +341,7 @@ def get_all_interface_info() -> dict: # Combine Multiple Interfaces -def get_custom_converter(interface_class_dict: dict): # -> NWBConverter: +def get_custom_converter(interface_class_dict: dict, alignment_info: dict = dict()): # -> NWBConverter: from neuroconv import converters, datainterfaces, NWBConverter class CustomNWBConverter(NWBConverter): @@ -350,11 +350,21 @@ class CustomNWBConverter(NWBConverter): for custom_name, interface_name in interface_class_dict.items() } + # Handle temporal alignment inside the converter + def temporally_align_data_interfaces(self): + set_interface_alignment(self, alignment_info) + return CustomNWBConverter -def instantiate_custom_converter(source_data, interface_class_dict): # -> NWBConverter: - CustomNWBConverter = get_custom_converter(interface_class_dict) +def instantiate_custom_converter( + source_data, + interface_class_dict, + alignment_info: dict = dict() + ): # -> NWBConverter: + + CustomNWBConverter = get_custom_converter(interface_class_dict, alignment_info) + return CustomNWBConverter(source_data) @@ -647,11 +657,49 @@ def validate_metadata(metadata: dict, check_function_name: str) -> dict: return json.loads(json.dumps(result, cls=InspectorOutputJSONEncoder)) +def set_interface_alignment( + converter, + alignment_info + ): + + import numpy as np + import csv + + for name, interface in converter.data_interface_objects.items(): + + interface = converter.data_interface_objects[name] + info = alignment_info.get(name, {}) + + # Set alignment + method = info.get("selected", None) + if method: + value = info["values"].get(method, None) + if (value != None): + if method == "timestamps": + + # Open the input CSV file for reading + with open(value, mode='r', newline='') as csvfile: + reader = csv.reader(csvfile) + rows = list(reader) + timestamps_array = [ float(row[0]) for row in rows ] + interface.set_aligned_timestamps(np.array(timestamps_array)) + + elif method == 'linked': + interface.register_recording(converter.data_interface_objects[value]) # Register the linked interface + + elif method == 'start': + interface.set_aligned_starting_time(value) + def get_interface_alignment(info: dict) -> dict: + + alignment_info = info.get('alignment', {}) converter = instantiate_custom_converter(info["source_data"], info["interfaces"]) + set_interface_alignment(converter, alignment_info) + timestamps = {} for name, interface in converter.data_interface_objects.items(): + # Run interface.get_timestamps if it has the method if hasattr(interface, "get_timestamps"): try: @@ -696,7 +744,11 @@ def convert_to_nwb(info: dict) -> str: info["source_data"], resolve_references(get_custom_converter(info["interfaces"]).get_source_schema()) ) - converter = instantiate_custom_converter(resolved_source_data, info["interfaces"]) + converter = instantiate_custom_converter( + resolved_source_data, + info["interfaces"], + info.get('alignment', {}) + ) def update_conversion_progress(**kwargs): announcer.announce(dict(**kwargs, nwbfile_path=nwbfile_path), "conversion_progress") diff --git a/src/renderer/src/stories/pages/Page.js b/src/renderer/src/stories/pages/Page.js index 4eb7f3cc45..00804bb280 100644 --- a/src/renderer/src/stories/pages/Page.js +++ b/src/renderer/src/stories/pages/Page.js @@ -189,7 +189,7 @@ export class Page extends LitElement { const { subject, session, globalState = this.info.globalState } = info; const file = `sub-${subject}/sub-${subject}_ses-${session}.nwb`; - const { conversion_output_folder, name, SourceData } = globalState.project; + const { conversion_output_folder, name, SourceData, alignment } = globalState.project; const sessionResults = globalState.results[subject][session]; @@ -212,6 +212,7 @@ export class Page extends LitElement { ...conversionOptions, // Any additional conversion options override the defaults interfaces: globalState.interfaces, + alignment }, { swal: popup, fetch: { signal: cancelController.signal }, ...options } ).catch((error) => { diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index f5ae76b595..f8a9cb8421 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -251,31 +251,56 @@ export class GuidedSourceDataPage extends ManagedPage { const { subject, session } = getInfoFromId(id); - const souceCopy = structuredClone(globalState.results[subject][session].source_data); - - const sessionInfo = { - interfaces: globalState.interfaces, - source_data: merge(globalState.project.SourceData, souceCopy), - }; - - const results = await run("alignment", sessionInfo, { - title: "Checking Alignment", - message: "Please wait...", - }); - const header = document.createElement("div"); + Object.assign(header.style, { paddingTop: '10px' }) const h2 = document.createElement("h3"); - Object.assign(h2.style, { margin: "0px", }); - h2.innerText = `Alignment Preview: ${subject}/${session}`; + Object.assign(h2.style, { margin: "0px" }); + const small = document.createElement("small"); + small.innerText = `${subject}/${session}`; + h2.innerText = `Alignment Preview`; - header.append(h2); + header.append(h2, small); const modal = new Modal({ header }); document.body.append(modal); - const alignment = new TimeAlignment({ results }) - modal.append(alignment) + let alignment; + + modal.footer = new Button({ + label: "Update", + primary: true, + onClick: async () => { + console.log('Submit to backend') + + if (alignment) { + globalState.project.alignment = alignment.results + await this.save() + } + + const sourceCopy = structuredClone(globalState.results[subject][session].source_data); + + const alignmentInfo = globalState.project.alignment ?? ( globalState.project.alignment = {} ); + + const sessionInfo = { + interfaces: globalState.interfaces, + source_data: merge(globalState.project.SourceData, sourceCopy), + alignment: alignmentInfo + }; + + const data = await run("alignment", sessionInfo, { + title: "Checking Alignment", + message: "Please wait...", + }); + + + alignment = new TimeAlignment({ data, interfaces: globalState.interfaces, results: alignmentInfo }) + modal.innerHTML = '' + modal.append(alignment) + } + }) + + modal.footer.onClick() modal.open = true; }, diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 7c6a831f74..e1146c764d 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -2,8 +2,8 @@ import { LitElement, css } from "lit"; import { JSONSchemaInput } from "../../../../JSONSchemaInput"; -const options = [ - { +const options = { + timestamps: { name: "Upload Timestamps", schema: { type: 'string', @@ -11,7 +11,7 @@ const options = [ description: 'A CSV file containing the timestamps of the recording.' } }, - { + start: { name: "Adjust Start Time", schema: { type: 'number', @@ -19,7 +19,7 @@ const options = [ min: 0 } }, - { + linked: { name: "Link to Recording", schema: { type: 'string', @@ -29,7 +29,7 @@ const options = [ strict: true }, } -] +} export class TimeAlignment extends LitElement { @@ -98,21 +98,23 @@ export class TimeAlignment extends LitElement { static get properties() { return { - results: { type: Object }, + data: { type: Object }, }; } - constructor({ results = {} }) { + constructor({ data = {}, results = {}, interfaces = {} }) { super(); + this.data = data; this.results = results; + this.interfaces = interfaces; } render() { const container = document.createElement("div"); - const results = this.results; - const flatTimes = Object.values(results) + const data = this.data; + const flatTimes = Object.values(data) .map((interfaceTimestamps) => { return [interfaceTimestamps[0], interfaceTimestamps.slice(-1)[0]]; }) @@ -125,7 +127,13 @@ export class TimeAlignment extends LitElement { const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; - for (let name in results) { + console.log('Got', data) + for (let name in data) { + + if (!(name in this.results)) this.results[name] = { + selected: undefined, + values: {} + } const row = document.createElement("div"); // Object.assign(row.style, { @@ -141,7 +149,7 @@ export class TimeAlignment extends LitElement { label.innerText = name; barCell.append(label); - const data = results[name]; + const info = data[name]; const barContainer = document.createElement("div"); Object.assign(barContainer.style, { @@ -154,12 +162,15 @@ export class TimeAlignment extends LitElement { barCell.append(barContainer); + const interfaceName = this.interfaces[name]; - const hasData = data.length > 0; + const isSortingInterface = interfaceName && interfaceName.includes('Sorting'); + + // Render this way if the interface has data + if (info.length > 0) { - if (hasData) { - const firstTime = data[0]; - const lastTime = data[data.length - 1]; + const firstTime = info[0]; + const lastTime = info[info.length - 1]; const smallLabel = document.createElement("small"); smallLabel.innerText = `${firstTime.toFixed(2)} - ${lastTime.toFixed(2)} sec`; @@ -176,7 +187,7 @@ export class TimeAlignment extends LitElement { left: firstTimePct, width: width, height: "100%", - background: "blue", + background: "#029CFD", }); barContainer.append(bar); @@ -193,36 +204,51 @@ export class TimeAlignment extends LitElement { const selectionCell = document.createElement("div"); const resultCell = document.createElement("div"); - const optionsCopy = structuredClone(options); + const optionsCopy = Object.entries(structuredClone(options)); + + + optionsCopy[2][1].schema.enum = Object.keys(data).filter(str => this.interfaces[str].includes('Recording')) - options[2].schema.enum = Object.keys(results).filter(str => str.includes('Recording')) + const resolvedOptionEntries = isSortingInterface ? optionsCopy : optionsCopy.slice(0, 2); - const resolvedOptions = hasData ? optionsCopy.slice(0, 2) : optionsCopy; + const elements = resolvedOptionEntries.reduce((acc, [ key, option ]) => { + + const optionResults = this.results[name]; - const elements = resolvedOptions.map((option) => { const clickableElement = document.createElement("div"); clickableElement.innerText = option.name; clickableElement.onclick = () => { - elements.forEach(el => el.removeAttribute("selected")); + optionResults.selected = key; + + Object.values(elements).forEach(el => el.removeAttribute("selected")); clickableElement.setAttribute("selected", ""); const element = new JSONSchemaInput({ + value: optionResults.values[key], schema: option.schema, path: [ ], controls: option.controls ? option.controls() : [], + onUpdate: (value) => optionResults.values[key] = value }) resultCell.innerHTML = ''; resultCell.append(element) } - return clickableElement; - }) + + acc[key] = clickableElement; + return acc + }, {}) + + console.log(elements) - selectionCell.append(...elements) + const elArray = Object.values(elements); + selectionCell.append(...elArray) + const selected = this.results[name].selected; row.append(selectionCell, resultCell); - elements[0].click(); + if (selected) elements[selected].click(); + else elArray[0].click(); // const empty = document.createElement("div"); // const disclaimer = document.createElement("div"); From 1e687d8144f67221489c80bebe21644c9faa9fe5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Apr 2024 20:19:05 +0000 Subject: [PATCH 07/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyflask/manageNeuroconv/manage_neuroconv.py | 78 +++++++-------- src/renderer/src/stories/JSONSchemaInput.js | 26 ++--- src/renderer/src/stories/pages/Page.js | 2 +- .../guided-mode/data/GuidedSourceData.js | 30 +++--- .../data/alignment/TimeAlignment.js | 98 ++++++++----------- 5 files changed, 109 insertions(+), 125 deletions(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index 946970085d..c186ed9488 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -357,11 +357,7 @@ def temporally_align_data_interfaces(self): return CustomNWBConverter -def instantiate_custom_converter( - source_data, - interface_class_dict, - alignment_info: dict = dict() - ): # -> NWBConverter: +def instantiate_custom_converter(source_data, interface_class_dict, alignment_info: dict = dict()): # -> NWBConverter: CustomNWBConverter = get_custom_converter(interface_class_dict, alignment_info) @@ -657,42 +653,42 @@ def validate_metadata(metadata: dict, check_function_name: str) -> dict: return json.loads(json.dumps(result, cls=InspectorOutputJSONEncoder)) -def set_interface_alignment( - converter, - alignment_info - ): - - import numpy as np - import csv - - for name, interface in converter.data_interface_objects.items(): - - interface = converter.data_interface_objects[name] - info = alignment_info.get(name, {}) - - # Set alignment - method = info.get("selected", None) - if method: - value = info["values"].get(method, None) - if (value != None): - if method == "timestamps": - - # Open the input CSV file for reading - with open(value, mode='r', newline='') as csvfile: - reader = csv.reader(csvfile) - rows = list(reader) - timestamps_array = [ float(row[0]) for row in rows ] - interface.set_aligned_timestamps(np.array(timestamps_array)) - - elif method == 'linked': - interface.register_recording(converter.data_interface_objects[value]) # Register the linked interface - - elif method == 'start': - interface.set_aligned_starting_time(value) +def set_interface_alignment(converter, alignment_info): + + import numpy as np + import csv + + for name, interface in converter.data_interface_objects.items(): + + interface = converter.data_interface_objects[name] + info = alignment_info.get(name, {}) + + # Set alignment + method = info.get("selected", None) + if method: + value = info["values"].get(method, None) + if value != None: + if method == "timestamps": + + # Open the input CSV file for reading + with open(value, mode="r", newline="") as csvfile: + reader = csv.reader(csvfile) + rows = list(reader) + timestamps_array = [float(row[0]) for row in rows] + interface.set_aligned_timestamps(np.array(timestamps_array)) + + elif method == "linked": + interface.register_recording( + converter.data_interface_objects[value] + ) # Register the linked interface + + elif method == "start": + interface.set_aligned_starting_time(value) + def get_interface_alignment(info: dict) -> dict: - alignment_info = info.get('alignment', {}) + alignment_info = info.get("alignment", {}) converter = instantiate_custom_converter(info["source_data"], info["interfaces"]) set_interface_alignment(converter, alignment_info) @@ -744,11 +740,7 @@ def convert_to_nwb(info: dict) -> str: info["source_data"], resolve_references(get_custom_converter(info["interfaces"]).get_source_schema()) ) - converter = instantiate_custom_converter( - resolved_source_data, - info["interfaces"], - info.get('alignment', {}) - ) + converter = instantiate_custom_converter(resolved_source_data, info["interfaces"], info.get("alignment", {})) def update_conversion_progress(**kwargs): announcer.announce(dict(**kwargs, nwbfile_path=nwbfile_path), "conversion_progress") diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index 95ba5ff71c..f075838484 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -487,11 +487,12 @@ export class JSONSchemaInput extends LitElement { margin-bottom: 1em; } - select { - background: url("data:image/svg+xml,") no-repeat; + select { + background: url("data:image/svg+xml,") + no-repeat; background-position: calc(100% - 0.75rem) center !important; - -moz-appearance:none !important; - -webkit-appearance: none !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; appearance: none !important; padding-right: 2rem !important; } @@ -1108,7 +1109,6 @@ export class JSONSchemaInput extends LitElement { // Basic enumeration of properties on a select element if (schema.enum && schema.enum.length) { - // Use generic selector if (schema.strict && schema.search !== true) { return html` @@ -1118,18 +1118,18 @@ export class JSONSchemaInput extends LitElement { @change=${() => validateOnChange && this.#triggerValidation(name, path)} > - ${schema.enum.sort().map( - (item, i) => - html`` - )} + ${schema.enum + .sort() + .map( + (item, i) => + html`` + )} `; } - - const options = schema.enum.map((v) => { return { key: v, diff --git a/src/renderer/src/stories/pages/Page.js b/src/renderer/src/stories/pages/Page.js index 00804bb280..bc49ef3028 100644 --- a/src/renderer/src/stories/pages/Page.js +++ b/src/renderer/src/stories/pages/Page.js @@ -212,7 +212,7 @@ export class Page extends LitElement { ...conversionOptions, // Any additional conversion options override the defaults interfaces: globalState.interfaces, - alignment + alignment, }, { swal: popup, fetch: { signal: cancelController.signal }, ...options } ).catch((error) => { diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index f8a9cb8421..2c691df03a 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -252,7 +252,7 @@ export class GuidedSourceDataPage extends ManagedPage { const { subject, session } = getInfoFromId(id); const header = document.createElement("div"); - Object.assign(header.style, { paddingTop: '10px' }) + Object.assign(header.style, { paddingTop: "10px" }); const h2 = document.createElement("h3"); Object.assign(h2.style, { margin: "0px" }); const small = document.createElement("small"); @@ -271,21 +271,22 @@ export class GuidedSourceDataPage extends ManagedPage { label: "Update", primary: true, onClick: async () => { - console.log('Submit to backend') + console.log("Submit to backend"); if (alignment) { - globalState.project.alignment = alignment.results - await this.save() + globalState.project.alignment = alignment.results; + await this.save(); } const sourceCopy = structuredClone(globalState.results[subject][session].source_data); - const alignmentInfo = globalState.project.alignment ?? ( globalState.project.alignment = {} ); + const alignmentInfo = + globalState.project.alignment ?? (globalState.project.alignment = {}); const sessionInfo = { interfaces: globalState.interfaces, source_data: merge(globalState.project.SourceData, sourceCopy), - alignment: alignmentInfo + alignment: alignmentInfo, }; const data = await run("alignment", sessionInfo, { @@ -293,14 +294,17 @@ export class GuidedSourceDataPage extends ManagedPage { message: "Please wait...", }); - - alignment = new TimeAlignment({ data, interfaces: globalState.interfaces, results: alignmentInfo }) - modal.innerHTML = '' - modal.append(alignment) - } - }) + alignment = new TimeAlignment({ + data, + interfaces: globalState.interfaces, + results: alignmentInfo, + }); + modal.innerHTML = ""; + modal.append(alignment); + }, + }); - modal.footer.onClick() + modal.footer.onClick(); modal.open = true; }, diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index e1146c764d..26f7af2dd2 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -1,41 +1,38 @@ import { LitElement, css } from "lit"; import { JSONSchemaInput } from "../../../../JSONSchemaInput"; - const options = { timestamps: { name: "Upload Timestamps", schema: { - type: 'string', - format: 'file', - description: 'A CSV file containing the timestamps of the recording.' - } + type: "string", + format: "file", + description: "A CSV file containing the timestamps of the recording.", + }, }, start: { name: "Adjust Start Time", schema: { - type: 'number', - description: 'The start time of the recording in seconds.', - min: 0 - } + type: "number", + description: "The start time of the recording in seconds.", + min: 0, + }, }, linked: { name: "Link to Recording", schema: { - type: 'string', - description: 'The name of the linked recording.', - placeholder: 'Select a recording interface', + type: "string", + description: "The name of the linked recording.", + placeholder: "Select a recording interface", enum: [], - strict: true + strict: true, }, - } -} + }, +}; export class TimeAlignment extends LitElement { - static get styles() { return css` - * { box-sizing: border-box; } @@ -75,7 +72,7 @@ export class TimeAlignment extends LitElement { padding: 5px 10px; border: 1px solid lightgray; } - + :host > div > div > *:nth-child(3) { width: 700px; } @@ -93,9 +90,9 @@ export class TimeAlignment extends LitElement { font-weight: bold; background: whitesmoke; } - ` + `; } - + static get properties() { return { data: { type: Object }, @@ -110,7 +107,6 @@ export class TimeAlignment extends LitElement { } render() { - const container = document.createElement("div"); const data = this.data; @@ -127,13 +123,13 @@ export class TimeAlignment extends LitElement { const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; - console.log('Got', data) + console.log("Got", data); for (let name in data) { - - if (!(name in this.results)) this.results[name] = { - selected: undefined, - values: {} - } + if (!(name in this.results)) + this.results[name] = { + selected: undefined, + values: {}, + }; const row = document.createElement("div"); // Object.assign(row.style, { @@ -164,11 +160,10 @@ export class TimeAlignment extends LitElement { const interfaceName = this.interfaces[name]; - const isSortingInterface = interfaceName && interfaceName.includes('Sorting'); - + const isSortingInterface = interfaceName && interfaceName.includes("Sorting"); + // Render this way if the interface has data if (info.length > 0) { - const firstTime = info[0]; const lastTime = info[info.length - 1]; @@ -192,58 +187,55 @@ export class TimeAlignment extends LitElement { barContainer.append(bar); barCell.append(smallLabel); - } else { barContainer.style.background = "repeating-linear-gradient(45deg, lightgray, lightgray 10px, white 10px, white 20px)"; } - row.append(barCell); const selectionCell = document.createElement("div"); const resultCell = document.createElement("div"); const optionsCopy = Object.entries(structuredClone(options)); - - optionsCopy[2][1].schema.enum = Object.keys(data).filter(str => this.interfaces[str].includes('Recording')) + optionsCopy[2][1].schema.enum = Object.keys(data).filter((str) => + this.interfaces[str].includes("Recording") + ); const resolvedOptionEntries = isSortingInterface ? optionsCopy : optionsCopy.slice(0, 2); - const elements = resolvedOptionEntries.reduce((acc, [ key, option ]) => { - + const elements = resolvedOptionEntries.reduce((acc, [key, option]) => { const optionResults = this.results[name]; const clickableElement = document.createElement("div"); clickableElement.innerText = option.name; clickableElement.onclick = () => { - optionResults.selected = key; - Object.values(elements).forEach(el => el.removeAttribute("selected")); + Object.values(elements).forEach((el) => el.removeAttribute("selected")); clickableElement.setAttribute("selected", ""); const element = new JSONSchemaInput({ value: optionResults.values[key], schema: option.schema, - path: [ ], + path: [], controls: option.controls ? option.controls() : [], - onUpdate: (value) => optionResults.values[key] = value - }) + onUpdate: (value) => (optionResults.values[key] = value), + }); - resultCell.innerHTML = ''; - resultCell.append(element) - } + resultCell.innerHTML = ""; + resultCell.append(element); + }; acc[key] = clickableElement; - return acc - }, {}) + return acc; + }, {}); + + console.log(elements); - console.log(elements) - const elArray = Object.values(elements); - selectionCell.append(...elArray) + selectionCell.append(...elArray); const selected = this.results[name].selected; row.append(selectionCell, resultCell); @@ -257,14 +249,10 @@ export class TimeAlignment extends LitElement { // row.append(disclaimer, empty); container.append(row); - } - return container - + return container; } } - -customElements.get("nwbguide-time-alignment") || - customElements.define("nwbguide-time-alignment", TimeAlignment); +customElements.get("nwbguide-time-alignment") || customElements.define("nwbguide-time-alignment", TimeAlignment); From fd0854c2698fe3dae49fd89be12b09356f991983 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 24 Apr 2024 08:59:22 -0700 Subject: [PATCH 08/30] Add MockRecordingInterface and print errors --- pyflask/manageNeuroconv/manage_neuroconv.py | 78 +++++++++++++++---- .../guided-mode/data/GuidedSourceData.js | 1 + .../data/alignment/TimeAlignment.js | 49 ++++++++---- 3 files changed, 99 insertions(+), 29 deletions(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index c186ed9488..19fdbb786b 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -657,6 +657,10 @@ def set_interface_alignment(converter, alignment_info): import numpy as np import csv + from neuroconv.tools.testing.mock_interfaces import MockRecordingInterface + + + errors = {} for name, interface in converter.data_interface_objects.items(): @@ -668,22 +672,61 @@ def set_interface_alignment(converter, alignment_info): if method: value = info["values"].get(method, None) if value != None: - if method == "timestamps": - # Open the input CSV file for reading - with open(value, mode="r", newline="") as csvfile: - reader = csv.reader(csvfile) - rows = list(reader) - timestamps_array = [float(row[0]) for row in rows] - interface.set_aligned_timestamps(np.array(timestamps_array)) + try: + if method == "timestamps": + + # Open the input CSV file for reading + with open(value, mode="r", newline="") as csvfile: + reader = csv.reader(csvfile) + rows = list(reader) + timestamps_array = [float(row[0]) for row in rows] + + # NOTE: Not sure if it's acceptable to provide timestamps of an arbitrary size + # Use the provided timestamps to align the interface + if hasattr(interface, "sorting_extractor"): + if (not interface.sorting_extractor.has_recording()): + extractor = interface.sorting_extractor + fs = extractor.get_sampling_frequency() + end_frame = len(timestamps_array) + mock_recording_interface = MockRecordingInterface( + sampling_frequency=fs, + durations=[ end_frame / fs ], + num_channels=1 + ) + interface.register_recording(mock_recording_interface) + + interface.set_aligned_timestamps(np.array(timestamps_array)) + - elif method == "linked": - interface.register_recording( - converter.data_interface_objects[value] - ) # Register the linked interface - elif method == "start": - interface.set_aligned_starting_time(value) + # Register the linked interface + elif method == "linked": + interface.register_recording( + converter.data_interface_objects[value] + ) + + elif method == "start": + + # NOTE: Should not need this after a fix in neuroconv for sorting_segment._t_start + # Use information internal to align the interface + if hasattr(interface, "sorting_extractor"): + if (not interface.sorting_extractor.has_recording()): + extractor = interface.sorting_extractor + fs = extractor.get_sampling_frequency() + mock_recording_interface = MockRecordingInterface( + sampling_frequency=fs, + num_channels=1 + ) + interface.register_recording(mock_recording_interface) + + + interface.set_aligned_starting_time(value) + + except Exception as e: + errors[name] = str(e) + + return errors def get_interface_alignment(info: dict) -> dict: @@ -691,7 +734,7 @@ def get_interface_alignment(info: dict) -> dict: alignment_info = info.get("alignment", {}) converter = instantiate_custom_converter(info["source_data"], info["interfaces"]) - set_interface_alignment(converter, alignment_info) + errors = set_interface_alignment(converter, alignment_info) timestamps = {} for name, interface in converter.data_interface_objects.items(): @@ -701,7 +744,7 @@ def get_interface_alignment(info: dict) -> dict: try: interface_timestamps = interface.get_timestamps() if len(interface_timestamps) == 1: - interface_timestamps = interface_timestamps[0] # Correct for video interface nesting + interface_timestamps = interface_timestamps[0] # TODO: Correct for video interface nesting timestamps[name] = interface_timestamps.tolist() except Exception: @@ -709,8 +752,11 @@ def get_interface_alignment(info: dict) -> dict: else: timestamps[name] = [] - return timestamps + return dict( + timestamps=timestamps, + errors=errors, + ) def convert_to_nwb(info: dict) -> str: """Function used to convert the source data to NWB format using the specified metadata.""" diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 2c691df03a..863ef6a7be 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -299,6 +299,7 @@ export class GuidedSourceDataPage extends ManagedPage { interfaces: globalState.interfaces, results: alignmentInfo, }); + modal.innerHTML = ""; modal.append(alignment); }, diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 26f7af2dd2..1949bdac0a 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -1,5 +1,7 @@ import { LitElement, css } from "lit"; import { JSONSchemaInput } from "../../../../JSONSchemaInput"; +import { errorHue } from "../../../../globals"; +import { InspectorListItem } from "../../../../preview/inspector/InspectorList"; const options = { timestamps: { @@ -109,8 +111,9 @@ export class TimeAlignment extends LitElement { render() { const container = document.createElement("div"); - const data = this.data; - const flatTimes = Object.values(data) + const { timestamps, errors } = this.data; + + const flatTimes = Object.values(timestamps) .map((interfaceTimestamps) => { return [interfaceTimestamps[0], interfaceTimestamps.slice(-1)[0]]; }) @@ -123,8 +126,13 @@ export class TimeAlignment extends LitElement { const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; - console.log("Got", data); - for (let name in data) { + const cachedErrors = {} + + console.log(timestamps) + for (let name in timestamps) { + + cachedErrors[name] = {} + if (!(name in this.results)) this.results[name] = { selected: undefined, @@ -145,7 +153,7 @@ export class TimeAlignment extends LitElement { label.innerText = name; barCell.append(label); - const info = data[name]; + const info = timestamps[name]; const barContainer = document.createElement("div"); Object.assign(barContainer.style, { @@ -199,45 +207,60 @@ export class TimeAlignment extends LitElement { const optionsCopy = Object.entries(structuredClone(options)); - optionsCopy[2][1].schema.enum = Object.keys(data).filter((str) => + optionsCopy[2][1].schema.enum = Object.keys(timestamps).filter((str) => this.interfaces[str].includes("Recording") ); const resolvedOptionEntries = isSortingInterface ? optionsCopy : optionsCopy.slice(0, 2); - const elements = resolvedOptionEntries.reduce((acc, [key, option]) => { + + const elements = resolvedOptionEntries.reduce((acc, [selected, option]) => { const optionResults = this.results[name]; const clickableElement = document.createElement("div"); clickableElement.innerText = option.name; clickableElement.onclick = () => { - optionResults.selected = key; + optionResults.selected = selected; Object.values(elements).forEach((el) => el.removeAttribute("selected")); clickableElement.setAttribute("selected", ""); const element = new JSONSchemaInput({ - value: optionResults.values[key], + value: optionResults.values[selected], schema: option.schema, path: [], controls: option.controls ? option.controls() : [], - onUpdate: (value) => (optionResults.values[key] = value), + onUpdate: (value) => (optionResults.values[selected] = value), }); resultCell.innerHTML = ""; resultCell.append(element); + + const errorMessage = cachedErrors[name][selected] + if (errorMessage) { + + const error = new InspectorListItem({ + type: "error", + message: `

Alignment Failed

${errorMessage}`, + }) + + error.style.marginTop = "5px"; + resultCell.append(error); + } + }; - acc[key] = clickableElement; + acc[selected] = clickableElement; return acc; }, {}); - console.log(elements); - const elArray = Object.values(elements); selectionCell.append(...elArray); const selected = this.results[name].selected; + if (errors[name]) cachedErrors[name][selected] = errors[name]; + + row.append(selectionCell, resultCell); if (selected) elements[selected].click(); else elArray[0].click(); From 11618d9468a68d3de168333b9add6d58392edc0d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Apr 2024 16:00:06 +0000 Subject: [PATCH 09/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyflask/manageNeuroconv/manage_neuroconv.py | 25 ++++++------------- .../guided-mode/data/GuidedSourceData.js | 2 +- .../data/alignment/TimeAlignment.js | 17 +++++-------- 3 files changed, 14 insertions(+), 30 deletions(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index 19fdbb786b..bbd0d80578 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -659,7 +659,6 @@ def set_interface_alignment(converter, alignment_info): import csv from neuroconv.tools.testing.mock_interfaces import MockRecordingInterface - errors = {} for name, interface in converter.data_interface_objects.items(): @@ -685,42 +684,32 @@ def set_interface_alignment(converter, alignment_info): # NOTE: Not sure if it's acceptable to provide timestamps of an arbitrary size # Use the provided timestamps to align the interface if hasattr(interface, "sorting_extractor"): - if (not interface.sorting_extractor.has_recording()): + if not interface.sorting_extractor.has_recording(): extractor = interface.sorting_extractor fs = extractor.get_sampling_frequency() end_frame = len(timestamps_array) - mock_recording_interface = MockRecordingInterface( - sampling_frequency=fs, - durations=[ end_frame / fs ], - num_channels=1 + mock_recording_interface = MockRecordingInterface( + sampling_frequency=fs, durations=[end_frame / fs], num_channels=1 ) interface.register_recording(mock_recording_interface) interface.set_aligned_timestamps(np.array(timestamps_array)) - - # Register the linked interface elif method == "linked": - interface.register_recording( - converter.data_interface_objects[value] - ) + interface.register_recording(converter.data_interface_objects[value]) elif method == "start": # NOTE: Should not need this after a fix in neuroconv for sorting_segment._t_start # Use information internal to align the interface if hasattr(interface, "sorting_extractor"): - if (not interface.sorting_extractor.has_recording()): + if not interface.sorting_extractor.has_recording(): extractor = interface.sorting_extractor fs = extractor.get_sampling_frequency() - mock_recording_interface = MockRecordingInterface( - sampling_frequency=fs, - num_channels=1 - ) + mock_recording_interface = MockRecordingInterface(sampling_frequency=fs, num_channels=1) interface.register_recording(mock_recording_interface) - interface.set_aligned_starting_time(value) except Exception as e: @@ -752,12 +741,12 @@ def get_interface_alignment(info: dict) -> dict: else: timestamps[name] = [] - return dict( timestamps=timestamps, errors=errors, ) + def convert_to_nwb(info: dict) -> str: """Function used to convert the source data to NWB format using the specified metadata.""" diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 863ef6a7be..e834fd1e4d 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -299,7 +299,7 @@ export class GuidedSourceDataPage extends ManagedPage { interfaces: globalState.interfaces, results: alignmentInfo, }); - + modal.innerHTML = ""; modal.append(alignment); }, diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 1949bdac0a..e862b9f72e 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -126,12 +126,11 @@ export class TimeAlignment extends LitElement { const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; - const cachedErrors = {} + const cachedErrors = {}; - console.log(timestamps) + console.log(timestamps); for (let name in timestamps) { - - cachedErrors[name] = {} + cachedErrors[name] = {}; if (!(name in this.results)) this.results[name] = { @@ -213,7 +212,6 @@ export class TimeAlignment extends LitElement { const resolvedOptionEntries = isSortingInterface ? optionsCopy : optionsCopy.slice(0, 2); - const elements = resolvedOptionEntries.reduce((acc, [selected, option]) => { const optionResults = this.results[name]; @@ -236,18 +234,16 @@ export class TimeAlignment extends LitElement { resultCell.innerHTML = ""; resultCell.append(element); - const errorMessage = cachedErrors[name][selected] + const errorMessage = cachedErrors[name][selected]; if (errorMessage) { - const error = new InspectorListItem({ type: "error", message: `

Alignment Failed

${errorMessage}`, - }) - + }); + error.style.marginTop = "5px"; resultCell.append(error); } - }; acc[selected] = clickableElement; @@ -260,7 +256,6 @@ export class TimeAlignment extends LitElement { const selected = this.results[name].selected; if (errors[name]) cachedErrors[name][selected] = errors[name]; - row.append(selectionCell, resultCell); if (selected) elements[selected].click(); else elArray[0].click(); From 2307a4f4dfc661c7b4f657ef80bbff6f8378bd18 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 24 Apr 2024 09:01:31 -0700 Subject: [PATCH 10/30] Update labeling --- .../src/stories/pages/guided-mode/data/GuidedSourceData.js | 4 ++-- .../stories/pages/guided-mode/data/alignment/TimeAlignment.js | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 863ef6a7be..c6870896fc 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -244,7 +244,7 @@ export class GuidedSourceDataPage extends ManagedPage { instances, controls: [ { - name: "Check Alignment", + name: "View Temporal Alignment", primary: true, onClick: async (id) => { const { globalState } = this.info; @@ -257,7 +257,7 @@ export class GuidedSourceDataPage extends ManagedPage { Object.assign(h2.style, { margin: "0px" }); const small = document.createElement("small"); small.innerText = `${subject}/${session}`; - h2.innerText = `Alignment Preview`; + h2.innerText = `Temporal Alignment`; header.append(h2, small); diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 1949bdac0a..c1922fd8ea 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -128,7 +128,6 @@ export class TimeAlignment extends LitElement { const cachedErrors = {} - console.log(timestamps) for (let name in timestamps) { cachedErrors[name] = {} From 316b8c425048b4c1cec13566283d16f9e46e2197 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 24 Apr 2024 14:45:08 -0700 Subject: [PATCH 11/30] Only show compatible recording interfaces --- pyflask/manageNeuroconv/manage_neuroconv.py | 30 +++++++++++-------- .../data/alignment/TimeAlignment.js | 26 ++++++++-------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index bbd0d80578..45f926e503 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -700,17 +700,7 @@ def set_interface_alignment(converter, alignment_info): interface.register_recording(converter.data_interface_objects[value]) elif method == "start": - - # NOTE: Should not need this after a fix in neuroconv for sorting_segment._t_start - # Use information internal to align the interface - if hasattr(interface, "sorting_extractor"): - if not interface.sorting_extractor.has_recording(): - extractor = interface.sorting_extractor - fs = extractor.get_sampling_frequency() - mock_recording_interface = MockRecordingInterface(sampling_frequency=fs, num_channels=1) - interface.register_recording(mock_recording_interface) - - interface.set_aligned_starting_time(value) + interface.set_aligned_starting_time(value) # For sorting interfaces, an empty array will still be returned except Exception as e: errors[name] = str(e) @@ -725,9 +715,25 @@ def get_interface_alignment(info: dict) -> dict: errors = set_interface_alignment(converter, alignment_info) + metadata = {} timestamps = {} for name, interface in converter.data_interface_objects.items(): + metadata[name] = dict() + is_sorting = metadata[name]["sorting"] = hasattr(interface, "sorting_extractor") + + if is_sorting: + metadata[name]["compatible"] = [] + for sub_name in alignment_info.keys(): + sub_interface = converter.data_interface_objects[sub_name] + if hasattr(sub_interface, "recording_extractor"): + try: + interface.register_recording(sub_interface) + metadata[name]["compatible"].append(name) + except Exception: + pass + + # Run interface.get_timestamps if it has the method if hasattr(interface, "get_timestamps"): try: @@ -735,13 +741,13 @@ def get_interface_alignment(info: dict) -> dict: if len(interface_timestamps) == 1: interface_timestamps = interface_timestamps[0] # TODO: Correct for video interface nesting timestamps[name] = interface_timestamps.tolist() - except Exception: timestamps[name] = [] else: timestamps[name] = [] return dict( + metadata=metadata, timestamps=timestamps, errors=errors, ) diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 118a42a0ee..9b27a0da13 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -1,17 +1,8 @@ import { LitElement, css } from "lit"; import { JSONSchemaInput } from "../../../../JSONSchemaInput"; -import { errorHue } from "../../../../globals"; import { InspectorListItem } from "../../../../preview/inspector/InspectorList"; const options = { - timestamps: { - name: "Upload Timestamps", - schema: { - type: "string", - format: "file", - description: "A CSV file containing the timestamps of the recording.", - }, - }, start: { name: "Adjust Start Time", schema: { @@ -20,6 +11,14 @@ const options = { min: 0, }, }, + timestamps: { + name: "Upload Timestamps", + schema: { + type: "string", + format: "file", + description: "A CSV file containing the timestamps of the recording.", + }, + }, linked: { name: "Link to Recording", schema: { @@ -111,7 +110,7 @@ export class TimeAlignment extends LitElement { render() { const container = document.createElement("div"); - const { timestamps, errors } = this.data; + const { timestamps, errors, metadata } = this.data; const flatTimes = Object.values(timestamps) .map((interfaceTimestamps) => { @@ -164,9 +163,8 @@ export class TimeAlignment extends LitElement { barCell.append(barContainer); - const interfaceName = this.interfaces[name]; - - const isSortingInterface = interfaceName && interfaceName.includes("Sorting"); + const isSortingInterface = metadata[name].sorting === true + const hasCompatibleInterfaces = isSortingInterface && metadata[name].compatible.length > 0 // Render this way if the interface has data if (info.length > 0) { @@ -209,7 +207,7 @@ export class TimeAlignment extends LitElement { this.interfaces[str].includes("Recording") ); - const resolvedOptionEntries = isSortingInterface ? optionsCopy : optionsCopy.slice(0, 2); + const resolvedOptionEntries = hasCompatibleInterfaces ? optionsCopy : optionsCopy.slice(0, 2); const elements = resolvedOptionEntries.reduce((acc, [selected, option]) => { const optionResults = this.results[name]; From cf572f1d1d3bb9b18651c79383593a67a3f60fbd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Apr 2024 21:45:35 +0000 Subject: [PATCH 12/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyflask/manageNeuroconv/manage_neuroconv.py | 5 +++-- .../pages/guided-mode/data/alignment/TimeAlignment.js | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index 45f926e503..d3d8f58685 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -700,7 +700,9 @@ def set_interface_alignment(converter, alignment_info): interface.register_recording(converter.data_interface_objects[value]) elif method == "start": - interface.set_aligned_starting_time(value) # For sorting interfaces, an empty array will still be returned + interface.set_aligned_starting_time( + value + ) # For sorting interfaces, an empty array will still be returned except Exception as e: errors[name] = str(e) @@ -733,7 +735,6 @@ def get_interface_alignment(info: dict) -> dict: except Exception: pass - # Run interface.get_timestamps if it has the method if hasattr(interface, "get_timestamps"): try: diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 9b27a0da13..47cc6dbed4 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -163,8 +163,8 @@ export class TimeAlignment extends LitElement { barCell.append(barContainer); - const isSortingInterface = metadata[name].sorting === true - const hasCompatibleInterfaces = isSortingInterface && metadata[name].compatible.length > 0 + const isSortingInterface = metadata[name].sorting === true; + const hasCompatibleInterfaces = isSortingInterface && metadata[name].compatible.length > 0; // Render this way if the interface has data if (info.length > 0) { From 72947caa9885833ccd5d406f0e94950ab5ab7dd2 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 24 Apr 2024 14:55:15 -0700 Subject: [PATCH 13/30] Trigger an updated conversion --- .../src/stories/pages/guided-mode/data/GuidedSourceData.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 350d50dc40..3ece0141a7 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -275,6 +275,7 @@ export class GuidedSourceDataPage extends ManagedPage { if (alignment) { globalState.project.alignment = alignment.results; + this.unsavedUpdates = "conversions" await this.save(); } From 2a410cf6c04b016617957e9bb5f6bad2ab56c14f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Apr 2024 21:55:38 +0000 Subject: [PATCH 14/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../src/stories/pages/guided-mode/data/GuidedSourceData.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 3ece0141a7..8369454d98 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -275,7 +275,7 @@ export class GuidedSourceDataPage extends ManagedPage { if (alignment) { globalState.project.alignment = alignment.results; - this.unsavedUpdates = "conversions" + this.unsavedUpdates = "conversions"; await this.save(); } From 2bea2ba5ab3bcfcc1a4e91d4fd852c07a5a6e03b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 May 2024 18:35:06 +0000 Subject: [PATCH 15/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyflask/manageNeuroconv/manage_neuroconv.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index 2587cabd2a..b339666b07 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -341,7 +341,7 @@ def get_all_interface_info() -> dict: # Combine Multiple Interfaces def get_custom_converter(interface_class_dict: dict, alignment_info: dict = dict()): -> "NWBConverter": - from neuroconv import converters, datainterfaces, NWBConverter + from neuroconv import NWBConverter, converters, datainterfaces class CustomNWBConverter(NWBConverter): data_interface_classes = { @@ -682,8 +682,9 @@ def validate_metadata(metadata: dict, check_function_name: str) -> dict: def set_interface_alignment(converter, alignment_info): - import numpy as np import csv + + import numpy as np from neuroconv.tools.testing.mock_interfaces import MockRecordingInterface errors = {} From c488ec733319f85f767c9a2b7d2ffbe16b2199a7 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Mon, 20 May 2024 13:33:55 -0700 Subject: [PATCH 16/30] Update manage_neuroconv.py --- pyflask/manageNeuroconv/manage_neuroconv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index b339666b07..08066a6885 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -340,7 +340,7 @@ def get_all_interface_info() -> dict: # Combine Multiple Interfaces -def get_custom_converter(interface_class_dict: dict, alignment_info: dict = dict()): -> "NWBConverter": +def get_custom_converter(interface_class_dict: dict, alignment_info: dict = dict()) -> "NWBConverter": from neuroconv import NWBConverter, converters, datainterfaces class CustomNWBConverter(NWBConverter): From 0fde1a5559725f4d5d5d3d58a722988f636c9345 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 May 2024 20:34:15 +0000 Subject: [PATCH 17/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyflask/manageNeuroconv/manage_neuroconv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index 08066a6885..4a938fe165 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -340,7 +340,7 @@ def get_all_interface_info() -> dict: # Combine Multiple Interfaces -def get_custom_converter(interface_class_dict: dict, alignment_info: dict = dict()) -> "NWBConverter": +def get_custom_converter(interface_class_dict: dict, alignment_info: dict = dict()) -> "NWBConverter": from neuroconv import NWBConverter, converters, datainterfaces class CustomNWBConverter(NWBConverter): From 26e04913babaeea64f60617b4c1c80d68dda2318 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 29 May 2024 11:57:52 -0700 Subject: [PATCH 18/30] Update GuidedSourceData.js --- .../pages/guided-mode/data/GuidedSourceData.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 9643f68842..928dd0c75f 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -247,6 +247,8 @@ export class GuidedSourceDataPage extends ManagedPage { const { subject, session } = getInfoFromId(id); + this.dismiss() + const header = document.createElement("div"); Object.assign(header.style, { paddingTop: "10px" }); const h2 = document.createElement("h3"); @@ -259,8 +261,6 @@ export class GuidedSourceDataPage extends ManagedPage { const modal = new Modal({ header }); - document.body.append(modal); - let alignment; modal.footer = new Button({ @@ -291,6 +291,12 @@ export class GuidedSourceDataPage extends ManagedPage { message: "Please wait...", }); + const { metadata } = data; + if (Object.keys(metadata).length === 0) { + this.notify(`

Time Alignment Failed

Please ensure that all source data is specified.`, "error"); + return false + } + alignment = new TimeAlignment({ data, interfaces: globalState.interfaces, @@ -299,10 +305,15 @@ export class GuidedSourceDataPage extends ManagedPage { modal.innerHTML = ""; modal.append(alignment); + + return true }, }); - modal.footer.onClick(); + const result = await modal.footer.onClick(); + if (!result) return; + + document.body.append(modal); modal.open = true; }, From 03b1d2c1d4645a92f08f0dd6aa68501aa9e175e6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 20:44:18 +0000 Subject: [PATCH 19/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../core/components/JSONSchemaInput.js | 2621 ++++++++--------- .../frontend/core/components/pages/Page.js | 560 ++-- .../guided-mode/data/GuidedSourceData.js | 13 +- .../data/alignment/TimeAlignment.js | 546 ++-- 4 files changed, 1871 insertions(+), 1869 deletions(-) diff --git a/src/electron/frontend/core/components/JSONSchemaInput.js b/src/electron/frontend/core/components/JSONSchemaInput.js index acf4f3906a..5e65737232 100644 --- a/src/electron/frontend/core/components/JSONSchemaInput.js +++ b/src/electron/frontend/core/components/JSONSchemaInput.js @@ -1,1311 +1,1310 @@ -import { LitElement, css, html } from "lit"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { FilesystemSelector } from "./FileSystemSelector"; - -import { BasicTable } from "./BasicTable"; -import { header, tempPropertyKey, tempPropertyValueKey } from "./forms/utils"; - -import { Button } from "./Button"; -import { List } from "./List"; -import { Modal } from "./Modal"; - -import { capitalize } from "./forms/utils"; -import { JSONSchemaForm, getIgnore } from "./JSONSchemaForm"; -import { Search } from "./Search"; -import tippy from "tippy.js"; -import { merge } from "./pages/utils"; -import { OptionalSection } from "./OptionalSection"; -import { InspectorListItem } from "./preview/inspector/InspectorList"; - -const isDevelopment = !!import.meta.env; - -const dateTimeRegex = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/; - -function resolveDateTime(value) { - if (typeof value === "string") { - const match = value.match(dateTimeRegex); - if (match) return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`; - return value; - } - - return value; -} - -export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) { - const name = fullPath.slice(-1)[0]; - const path = fullPath.slice(0, -1); - const relativePath = this.form?.base ? fullPath.slice(this.form.base.length) : fullPath; - - const schema = this.schema; - const validateOnChange = this.validateOnChange; - - const ignore = this.form?.ignore ? getIgnore(this.form?.ignore, path) : {}; - - const commonValidationFunction = async (tableBasePath, path, parent, newValue, itemPropSchema) => { - const warnings = []; - const errors = []; - - const name = path.slice(-1)[0]; - const completePath = [...tableBasePath, ...path.slice(0, -1)]; - - const result = await (validateOnChange - ? this.onValidate - ? this.onValidate() - : this.form?.triggerValidation - ? this.form.triggerValidation( - name, - completePath, - false, - this, - itemPropSchema, - { ...parent, [name]: newValue }, - { - onError: (error) => { - errors.push(error); // Skip counting errors - }, - onWarning: (warning) => { - warnings.push(warning); // Skip counting warnings - }, - } - ) // NOTE: No pattern properties support - : "" - : true); - - const returnedValue = errors.length ? errors : warnings.length ? warnings : result; - - return returnedValue; - }; - - const commonTableMetadata = { - onStatusChange: () => this.form?.checkStatus && this.form.checkStatus(), // Check status on all elements - validateEmptyCells: this.validateEmptyValue, - deferLoading: this.form?.deferLoading, - onLoaded: () => { - if (this.form) { - if (this.form.nLoaded) this.form.nLoaded++; - if (this.form.checkAllLoaded) this.form.checkAllLoaded(); - } - }, - onThrow: (...args) => onThrow(...args), - }; - - const addPropertyKeyToSchema = (schema) => { - const schemaCopy = structuredClone(schema); - - const schemaItemsRef = schemaCopy["items"]; - - if (!schemaItemsRef.properties) schemaItemsRef.properties = {}; - if (!schemaItemsRef.required) schemaItemsRef.required = []; - - schemaItemsRef.properties[tempPropertyKey] = { title: "Property Key", type: "string", pattern: name }; - if (!schemaItemsRef.order) schemaItemsRef.order = []; - schemaItemsRef.order.unshift(tempPropertyKey); - - schemaItemsRef.required.push(tempPropertyKey); - - return schemaCopy; - }; - - const createNestedTable = (id, value, { name: propName = id, nestedSchema = schema } = {}) => { - const schemaCopy = addPropertyKeyToSchema(nestedSchema); - - const resultPath = [...path]; - - const schemaPath = [...fullPath]; - - // THIS IS AN ISSUE - const rowData = Object.entries(value).map(([key, value]) => { - return !schemaCopy["items"] - ? { [tempPropertyKey]: key, [tempPropertyValueKey]: value } - : { [tempPropertyKey]: key, ...value }; - }); - - if (propName) { - resultPath.push(propName); - schemaPath.push(propName); - } - - const allRemovedKeys = new Set(); - - const keyAlreadyExists = (key) => Object.keys(value).includes(key); - - const previousValidValues = {}; - - function resolvePath(path, target) { - return path - .map((key, i) => { - const ogKey = key; - const nextKey = path[i + 1]; - if (key === tempPropertyKey) key = target[tempPropertyKey]; - if (nextKey === tempPropertyKey) key = []; - - target = target[ogKey] ?? {}; - - if (nextKey === tempPropertyValueKey) return target[tempPropertyKey]; // Grab next property key - if (key === tempPropertyValueKey) return []; - - return key; - }) - .flat(); - } - - function setValueOnAccumulator(row, acc) { - const key = row[tempPropertyKey]; - - if (!key) return acc; - - if (tempPropertyValueKey in row) { - const propValue = row[tempPropertyValueKey]; - if (Array.isArray(propValue)) - acc[key] = propValue.reduce((acc, row) => setValueOnAccumulator(row, acc), {}); - else acc[key] = propValue; - } else { - const copy = { ...row }; - delete copy[tempPropertyKey]; - acc[key] = copy; - } - - return acc; - } - - const nestedIgnore = this.form?.ignore ? getIgnore(this.form?.ignore, schemaPath) : {}; - - merge(overrides.ignore, nestedIgnore); - - merge(overrides.schema, schemaCopy, { arrays: "append" }); - - const tableMetadata = { - keyColumn: tempPropertyKey, - schema: schemaCopy, - data: rowData, - ignore: nestedIgnore, // According to schema - - onUpdate: function (path, newValue) { - const oldKeys = Object.keys(value); - - if (path.slice(-1)[0] === tempPropertyKey && keyAlreadyExists(newValue)) return; // Do not overwrite existing keys - - const result = this.data.reduce((acc, row) => setValueOnAccumulator(row, acc), {}); - - const newKeys = Object.keys(result); - const removedKeys = oldKeys.filter((k) => !newKeys.includes(k)); - removedKeys.forEach((key) => allRemovedKeys.add(key)); - newKeys.forEach((key) => allRemovedKeys.delete(key)); - allRemovedKeys.forEach((key) => (result[key] = undefined)); - - // const resolvedPath = resolvePath(path, this.data) - return onUpdate.call(this, [], result); // Update all table data - }, - - validateOnChange: function (path, parent, newValue) { - const rowIdx = path[0]; - const currentKey = this.data[rowIdx]?.[tempPropertyKey]; - - const updatedPath = resolvePath(path, this.data); - - const resolvedKey = previousValidValues[rowIdx] ?? currentKey; - - // Do not overwrite existing keys - if (path.slice(-1)[0] === tempPropertyKey && resolvedKey !== newValue) { - if (keyAlreadyExists(newValue)) { - if (!previousValidValues[rowIdx]) previousValidValues[rowIdx] = resolvedKey; - - return [ - { - message: `Key already exists.
This value is still ${resolvedKey}.`, - type: "error", - }, - ]; - } else delete previousValidValues[rowIdx]; - } - - const toIterate = updatedPath.filter((value) => typeof value === "string"); - - const itemPropsSchema = toIterate.reduce( - (acc, key) => acc?.properties?.[key] ?? acc?.items?.properties?.[key], - schemaCopy - ); - - return commonValidationFunction([], updatedPath, parent, newValue, itemPropsSchema, 1); - }, - ...commonTableMetadata, - }; - - const table = this.renderTable(id, tableMetadata, fullPath); - - return table; // Try rendering as a nested table with a fake property key (otherwise use nested forms) - }; - - const schemaCopy = structuredClone(schema); - - // Possibly multiple tables - if (isEditableObject(schema, this.value)) { - // One table with nested tables for each property - const data = getEditableItems(this.value, this.pattern, { name, schema: schemaCopy }).reduce( - (acc, { key, value }) => { - acc[key] = value; - return acc; - }, - {} - ); - - const table = createNestedTable(name, data, { schema }); - if (table) return table; - } - - const nestedIgnore = getIgnore(ignore, fullPath); - Object.assign(nestedIgnore, overrides.ignore ?? {}); - - merge(overrides.ignore, nestedIgnore); - - merge(overrides.schema, schemaCopy, { arrays: "append" }); - - // Normal table parsing - const tableMetadata = { - schema: schemaCopy, - data: this.value, - - ignore: nestedIgnore, // According to schema - - onUpdate: function () { - return onUpdate.call(this, relativePath, this.data); // Update all table data - }, - - validateOnChange: (...args) => commonValidationFunction(relativePath, ...args), - - ...commonTableMetadata, - }; - - const table = (this.table = this.renderTable(name, tableMetadata, path)); // Try creating table. Otherwise use nested form - - if (table) { - const tableEl = table === true ? new BasicTable(tableMetadata) : table; - const tables = this.form?.tables; - if (tables) tables[name] = tableEl; - return tableEl; - } -} - -// Schema or value indicates editable object -export const isEditableObject = (schema, value) => - schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value)); - -export const isAdditionalProperties = (pattern) => pattern === "additional"; -export const isPatternProperties = (pattern) => pattern && !isAdditionalProperties(pattern); - -export const getEditableItems = (value = {}, pattern, { name, schema } = {}) => { - let items = Object.entries(value); - - const allowAdditionalProperties = isAdditionalProperties(pattern); - - if (isPatternProperties(pattern)) { - const regex = new RegExp(name); - items = items.filter(([key]) => regex.test(key)); - } else if (allowAdditionalProperties) { - const props = Object.keys(schema.properties ?? {}); - items = items.filter(([key]) => !props.includes(key)); - - const patternProps = Object.keys(schema.patternProperties ?? {}); - patternProps.forEach((key) => { - const regex = new RegExp(key); - items = items.filter(([k]) => !regex.test(k)); - }); - } else if (schema.properties) items = items.filter(([key]) => key in schema.properties); - - items = items.filter(([key]) => !key.includes("__")); // Remove secret properties - - return items.map(([key, value]) => { - return { key, value }; - }); -}; - -const isFilesystemSelector = (name = "", format) => { - if (Array.isArray(format)) return format.map((f) => isFilesystemSelector(name, f)).every(Boolean) ? format : null; - - const matched = name.match(/(.+_)?(.+)_paths?/); - if (!format && matched) format = matched[2] === "folder" ? "directory" : matched[2]; - return ["file", "directory"].includes(format) ? format : null; // Handle file and directory formats -}; - -function getFirstFocusableElement(element) { - const root = element.shadowRoot || element; - const focusableElements = getKeyboardFocusableElements(root); - if (focusableElements.length === 0) { - for (let child of root.children) { - const focusableElement = getFirstFocusableElement(child); - if (focusableElement) return focusableElement; - } - } - return focusableElements[0]; -} - -function getKeyboardFocusableElements(element = document) { - const root = element.shadowRoot || element; - return [ - ...root.querySelectorAll('a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'), - ].filter( - (focusableElement) => - !focusableElement.hasAttribute("disabled") && !focusableElement.getAttribute("aria-hidden") - ); -} - -export class JSONSchemaInput extends LitElement { - static get styles() { - return css` - * { - box-sizing: border-box; - } - - :host(.invalid) .guided--input { - background: rgb(255, 229, 228) !important; - } - - jsonschema-input { - width: 100%; - } - - main { - display: flex; - align-items: center; - } - - #controls { - margin-left: 10px; - flex-grow: 1; - } - - .guided--input { - width: 100%; - border-radius: 4px; - padding: 10px 12px; - font-size: 100%; - font-weight: normal; - border: 1px solid var(--color-border); - transition: border-color 150ms ease-in-out 0s; - outline: none; - color: rgb(33, 49, 60); - background-color: rgb(255, 255, 255); - } - - .guided--input:disabled { - opacity: 0.5; - pointer-events: none; - } - - .guided--input::placeholder { - opacity: 0.5; - } - - .guided--text-area { - height: 5em; - resize: none; - font-family: unset; - } - .guided--text-area-tall { - height: 15em; - } - .guided--input:hover { - box-shadow: rgb(231 238 236) 0px 0px 0px 2px; - } - .guided--input:focus { - outline: 0; - box-shadow: var(--color-light-green) 0px 0px 0px 1px; - } - - input[type="number"].hideStep::-webkit-outer-spin-button, - input[type="number"].hideStep::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - - /* Firefox */ - input[type="number"].hideStep { - -moz-appearance: textfield; - } - - .guided--text-input-instructions { - font-size: 13px; - width: 100%; - padding-top: 4px; - color: dimgray !important; - margin: 0 0; - line-height: 1.4285em; - } - - .nan-handler { - display: flex; - align-items: center; - margin-left: 5px; - white-space: nowrap; - } - - .nan-handler span { - margin-left: 5px; - font-size: 12px; - } - - .schema-input.list { - width: 100%; - } - - .guided--form-label { - display: block; - width: 100%; - margin: 0; - margin-bottom: 10px; - color: black; - font-weight: 600; - font-size: 1.2em !important; - } - - :host([data-table]) .guided--form-label { - margin-bottom: 0px; - } - - .guided--form-label.centered { - text-align: center; - } - - .guided--form-label.header { - font-size: 1.5em !important; - } - - .required label:after { - content: " *"; - color: #ff0033; - } - - :host(:not([validateemptyvalue])) .required label:after { - color: gray; - } - - .required.conditional label:after { - color: transparent; - } - - hr { - display: block; - height: 1px; - border: 0; - border-top: 1px solid #ccc; - padding: 0; - margin-bottom: 1em; - } - - select { - background: url("data:image/svg+xml,") - no-repeat; - background-position: calc(100% - 0.75rem) center !important; - -moz-appearance: none !important; - -webkit-appearance: none !important; - appearance: none !important; - padding-right: 2rem !important; - } - `; - } - - static get properties() { - return { - schema: { type: Object, reflect: false }, - validateEmptyValue: { type: Boolean, reflect: true }, - required: { type: Boolean, reflect: true }, - }; - } - - // Enforce dynamic required properties - attributeChangedCallback(key, _, latest) { - super.attributeChangedCallback(...arguments); - - const formSchema = this.form?.schema; - if (!formSchema) return; - - if (key === "required") { - const name = this.path.slice(-1)[0]; - - if (latest !== null && !this.conditional) { - const requirements = formSchema.required ?? (formSchema.required = []); - if (!requirements.includes(name)) requirements.push(name); - } - - // Remove requirement from form schema (and force if conditional requirement) - else { - const requirements = formSchema.required; - if (requirements && requirements.includes(name)) { - const idx = requirements.indexOf(name); - if (idx > -1) requirements.splice(idx, 1); - } - } - } - } - - // schema, - // parent, - // path, - // form, - // pattern - // showLabel - // description - controls = []; - // required; - validateOnChange = true; - - constructor(props = {}) { - super(); - Object.assign(this, props); - if (props.validateEmptyValue === false) this.validateEmptyValue = true; // False is treated as required but not triggered if empty - } - - // Print the default value of the schema if not caught - onUncaughtSchema = (schema) => { - // In development, show uncaught schemas - if (!isDevelopment) { - if (this.form) { - const inputContainer = this.form.shadowRoot.querySelector(`#${this.path.slice(-1)[0]}`); - inputContainer.style.display = "none"; - } - } - - if (schema.default) return `
${JSON.stringify(schema.default, null, 2)}
`; - - const error = new InspectorListItem({ - message: - "

Internal GUIDE Error

Cannot render this property because of a misformatted schema.", - }); - error.style.width = "100%"; - - return error; - }; - - // onUpdate = () => {} - // onValidate = () => {} - - updateData(value, forceValidate = false) { - if (!forceValidate) { - // Update the actual input element - const inputElement = this.getElement(); - if (!inputElement) return false; - - const hasList = inputElement.querySelector("nwb-list"); - - if (inputElement.type === "checkbox") inputElement.checked = value; - else if (hasList) - hasList.items = this.#mapToList({ value, hasList }); // NOTE: Make sure this is correct - else if (inputElement instanceof Search) inputElement.shadowRoot.querySelector("input").value = value; - else inputElement.value = value; - } - - const { path: fullPath } = this; - const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; - const name = path.splice(-1)[0]; - - this.#updateData(fullPath, value); - this.#triggerValidation(name, path); // NOTE: Is asynchronous - - return true; - } - - getElement = () => this.shadowRoot.querySelector(".schema-input"); - - #activateTimeoutValidation = (name, path, hooks) => { - this.#clearTimeoutValidation(); - this.#validationTimeout = setTimeout(() => { - this.onValidate - ? this.onValidate() - : this.form?.triggerValidation - ? this.form.triggerValidation(name, path, undefined, this, undefined, undefined, hooks) - : ""; - }, 1000); - }; - - #clearTimeoutValidation = () => { - if (this.#validationTimeout) clearTimeout(this.#validationTimeout); - }; - - #validationTimeout = null; - #updateData = (fullPath, value, forceUpdate, hooks = {}) => { - this.onUpdate - ? this.onUpdate(value) - : this.form?.updateData - ? this.form.updateData(fullPath, value, forceUpdate) - : ""; - - const path = [...fullPath]; - const name = path.splice(-1)[0]; - - this.value = value; // Update the latest value - - if (hooks.willTimeout !== false) this.#activateTimeoutValidation(name, path, hooks); - }; - - #triggerValidation = async (name, path) => { - this.#clearTimeoutValidation(); - return this.onValidate - ? this.onValidate() - : this.form?.triggerValidation - ? this.form.triggerValidation(name, path, undefined, this) - : ""; - }; - - updated() { - const inputElement = this.getElement(); - if (inputElement) inputElement.dispatchEvent(new Event("change")); - } - - render() { - const { schema } = this; - - const input = this.#render(); - - if (input === null) return null; // Hide rendering - - const description = this.description ?? schema.description; - - const descriptionHTML = description - ? html`

- ${unsafeHTML(capitalize(description))}${[".", "?", "!"].includes(description.slice(-1)[0]) ? "" : "."} -

` - : ""; - - return html` -
- ${this.showLabel - ? html`` - : ""} -
${input}${this.controls ? html`
${this.controls}
` : ""}
- ${descriptionHTML} -
- `; - } - - #onThrow = (...args) => (this.onThrow ? this.onThrow(...args) : this.form?.onThrow(...args)); - - #list; - #mapToList({ value = this.value, schema = this.schema, list } = {}) { - const { path: fullPath } = this; - const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; - const name = path.splice(-1)[0]; - - const canAddProperties = isEditableObject(this.schema, this.value); - - if (canAddProperties) { - const editable = getEditableItems(this.value, this.pattern, { name, schema }); - - return editable.map(({ key, value }) => { - return { - key, - value, - controls: [ - new Button({ - label: "Edit", - size: "small", - onClick: () => - this.#createModal({ - key, - schema: isAdditionalProperties(this.pattern) ? undefined : schema, - results: value, - list: list ?? this.#list, - }), - }), - ], - }; - }); - } else { - const resolved = value ?? []; - return resolved - ? resolved.map((value) => { - return { value }; - }) - : []; - } - } - - #modal; - - #createModal({ key, schema = {}, results, list, label } = {}) { - const schemaCopy = structuredClone(schema); - - const createNewObject = !results && (schemaCopy.type === "object" || schemaCopy.properties); - - // const schemaProperties = Object.keys(schema.properties ?? {}); - // const additionalProperties = Object.keys(results).filter((key) => !schemaProperties.includes(key)); - // // const additionalElement = html`Cannot edit additional properties (${additionalProperties}) at this time` - - const allowPatternProperties = isPatternProperties(this.pattern); - const allowAdditionalProperties = isAdditionalProperties(this.pattern); - const createNewPatternProperty = allowPatternProperties && createNewObject; - - // Add a property name entry to the schema - if (createNewPatternProperty) { - schemaCopy.properties = { - __: { title: "Property Name", type: "string", pattern: this.pattern }, - ...schemaCopy.properties, - }; - schemaCopy.required = [...(schemaCopy.required ?? []), "__"]; - } - - if (this.#modal) this.#modal.remove(); - - const submitButton = new Button({ - label: "Submit", - primary: true, - }); - - const isObject = schemaCopy.type === "object" || schemaCopy.properties; // NOTE: For formatted strings, this is not an object - - // NOTE: Will be replaced by single instances - let updateTarget = results ?? (isObject ? {} : undefined); - - submitButton.onClick = async () => { - await nestedModalElement.validate(); - - let value = updateTarget; - - if (schemaCopy?.format && schemaCopy.properties) { - let newValue = schemaCopy?.format; - for (let key in schemaCopy.properties) newValue = newValue.replace(`{${key}}`, value[key] ?? "").trim(); - value = newValue; - } - - // Skip if not unique - if (schemaCopy.uniqueItems && list.items.find((item) => item.value === value)) - return this.#modal.toggle(false); - - // Add to the list - if (createNewPatternProperty) { - const key = value.__; - delete value.__; - list.add({ key, value }); - } else list.add({ key, value }); - - this.#modal.toggle(false); - }; - - this.#modal = new Modal({ - header: label ? `${header(label)} Editor` : key ? header(key) : `Property Editor`, - footer: submitButton, - showCloseButton: createNewObject, - }); - - const div = document.createElement("div"); - div.style.padding = "25px"; - - const inputTitle = header(schemaCopy.title ?? label ?? "Value"); - - const nestedModalElement = isObject - ? new JSONSchemaForm({ - schema: schemaCopy, - results: updateTarget, - validateEmptyValues: false, - onUpdate: (internalPath, value) => { - if (!createNewObject) { - const path = [key, ...internalPath]; - this.#updateData(path, value, true); // Live updates - } - }, - renderTable: this.renderTable, - onThrow: this.#onThrow, - }) - : new JSONSchemaForm({ - schema: { - properties: { - [tempPropertyKey]: { - ...schemaCopy, - title: inputTitle, - }, - }, - required: [tempPropertyKey], - }, - validateEmptyValues: false, - results: updateTarget, - onUpdate: (_, value) => { - if (createNewObject) updateTarget[key] = value; - else updateTarget = value; - }, - // renderTable: this.renderTable, - // onThrow: this.#onThrow, - }); - - div.append(nestedModalElement); - - this.#modal.append(div); - - document.body.append(this.#modal); - - setTimeout(() => this.#modal.toggle(true)); - - return this.#modal; - } - - #getType = (value = this.value) => (Array.isArray(value) ? "array" : typeof value); - - #handleNextInput = (idx) => { - const next = Object.values(this.form.inputs)[idx]; - if (next) { - const firstFocusableElement = getFirstFocusableElement(next); - if (firstFocusableElement) { - if (firstFocusableElement.tagName === "BUTTON") return this.#handleNextInput(idx + 1); - firstFocusableElement.focus(); - } - } - }; - - #moveToNextInput = (ev) => { - if (ev.key === "Enter") { - ev.preventDefault(); - if (this.form?.inputs) { - const idx = Object.values(this.form.inputs).findIndex((input) => input === this); - this.#handleNextInput(idx + 1); - } - - ev.target.blur(); - } - }; - - #render() { - const { validateOnChange, schema, path: fullPath } = this; - - this.removeAttribute("data-table"); - - // Do your best to fill in missing schema values - if (!("type" in schema)) schema.type = this.#getType(); - - const resolvedFullPath = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; - const path = [...resolvedFullPath]; - const name = path.splice(-1)[0]; - - const isArray = schema.type === "array"; // Handle string (and related) formats / types - - const itemSchema = this.form?.getSchema ? this.form.getSchema("items", schema) : schema["items"]; - const isTable = itemSchema?.type === "object" && this.renderTable; - - const canAddProperties = isEditableObject(this.schema, this.value); - - if (this.renderCustomHTML) { - const custom = this.renderCustomHTML(name, schema, path, { - onUpdate: this.#updateData, - onThrow: this.#onThrow, - }); - - const renderEmpty = custom === null; - if (custom) return custom; - else if (renderEmpty) { - this.remove(); // Remove from DOM so that parent can be empty - return; - } - } - - // Handle file and directory formats - const createFilesystemSelector = (format) => { - const filesystemSelectorElement = new FilesystemSelector({ - type: format, - value: this.value, - accept: schema.accept, - onSelect: (paths = []) => { - const value = paths.length ? paths : undefined; - this.#updateData(fullPath, value); - }, - onChange: (filePath) => validateOnChange && this.#triggerValidation(name, path), - onThrow: (...args) => this.#onThrow(...args), - dialogOptions: this.form?.dialogOptions, - dialogType: this.form?.dialogType, - multiple: isArray, - }); - filesystemSelectorElement.classList.add("schema-input"); - return filesystemSelectorElement; - }; - - // Transform to single item if maxItems is 1 - if (isArray && schema.maxItems === 1 && !isTable) { - return new JSONSchemaInput({ - value: this.value?.[0], - schema: { - ...schema.items, - strict: schema.strict, - }, - path: fullPath, - validateEmptyValue: this.validateEmptyValue, - required: this.required, - validateOnChange: () => (validateOnChange ? this.#triggerValidation(name, path) : ""), - form: this.form, - onUpdate: (value) => this.#updateData(fullPath, [value]), - }); - } - - if (isArray || canAddProperties) { - // if ('value' in this && !Array.isArray(this.value)) this.value = [ this.value ] - - const allowPatternProperties = isPatternProperties(this.pattern); - const allowAdditionalProperties = isAdditionalProperties(this.pattern); - - // Provide default item types - if (isArray) { - const hasItemsRef = "items" in schema && "$ref" in schema.items; - if (!("items" in schema)) schema.items = {}; - if (!("type" in schema.items) && !hasItemsRef) { - // Guess the type of the first item - if (this.value) { - const itemToCheck = this.value[0]; - schema.items.type = itemToCheck ? this.#getType(itemToCheck) : "string"; - } - - // If no value, handle uncaught schema - else return this.onUncaughtSchema(schema); - } - } - - const fileSystemFormat = isFilesystemSelector(name, itemSchema?.format); - if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); - // Create tables if possible - else if (itemSchema?.type === "string" && !itemSchema.properties) { - const list = new List({ - items: this.value, - emptyMessage: "No items", - onChange: ({ items }) => { - this.#updateData(fullPath, items.length ? items.map((o) => o.value) : undefined); - if (validateOnChange) this.#triggerValidation(name, path); - }, - }); - - if (itemSchema.enum) { - const search = new Search({ - options: itemSchema.enum.map((v) => { - return { - key: v, - value: v, - label: itemSchema.enumLabels?.[v] ?? v, - keywords: itemSchema.enumKeywords?.[v], - description: itemSchema.enumDescriptions?.[v], - link: itemSchema.enumLinks?.[v], - }; - }), - value: this.value, - listMode: schema.strict === false ? "click" : "append", - showAllWhenEmpty: false, - onSelect: async ({ label, value }) => { - if (!value) return; - if (schema.uniqueItems && this.value && this.value.includes(value)) return; - list.add({ content: label, value }); - }, - }); - - search.style.height = "auto"; - return html`
${search}${list}
`; - } else { - const input = document.createElement("input"); - input.classList.add("guided--input"); - input.placeholder = "Provide an item for the list"; - - const submitButton = new Button({ - label: "Submit", - primary: true, - size: "small", - onClick: () => { - const value = input.value; - if (!value) return; - if (schema.uniqueItems && this.value && this.value.includes(value)) return; - list.add({ value }); - input.value = ""; - }, - }); - - input.addEventListener("keydown", (ev) => { - if (ev.key === "Enter") submitButton.onClick(); - }); - - return html`
validateOnChange && this.#triggerValidation(name, path)} - > -
${input}${submitButton}
- ${list} -
`; - } - } else if (isTable) { - const instanceThis = this; - - function updateFunction(path, value = this.data) { - return instanceThis.#updateData(path, value, true, { - willTimeout: false, // Since there is a special validation function, do not trigger a timeout validation call - onError: (e) => e, - onWarning: (e) => e, - }); - } - - const externalPath = this.form?.base ? [...this.form.base, ...resolvedFullPath] : resolvedFullPath; - - const table = createTable.call(this, externalPath, { - onUpdate: updateFunction, - onThrow: this.#onThrow, - }); // Ensure change propagates - - if (table) { - this.setAttribute("data-table", ""); - return table; - } - } - - const addButton = new Button({ - size: "small", - }); - - addButton.innerText = `Add ${canAddProperties ? "Property" : "Item"}`; - - const buttonDiv = document.createElement("div"); - Object.assign(buttonDiv.style, { width: "fit-content" }); - buttonDiv.append(addButton); - - const disableButton = ({ message, submessage }) => { - addButton.setAttribute("disabled", true); - tippy(buttonDiv, { - content: `
${message}
${submessage}
`, - allowHTML: true, - }); - }; - - const list = (this.#list = new List({ - items: this.#mapToList(), - - // Add edit button when new items are added - // NOTE: Duplicates some code in #mapToList - transform: (item) => { - if (canAddProperties) { - const { key, value } = item; - item.controls = [ - new Button({ - label: "Edit", - size: "small", - onClick: () => { - this.#createModal({ - key, - schema, - results: value, - list, - }); - }, - }), - ]; - } - }, - onChange: async ({ object, items }, { object: oldObject }) => { - if (this.pattern) { - const oldKeys = Object.keys(oldObject); - const newKeys = Object.keys(object); - const removedKeys = oldKeys.filter((k) => !newKeys.includes(k)); - const updatedKeys = newKeys.filter((k) => oldObject[k] !== object[k]); - removedKeys.forEach((k) => this.#updateData([...fullPath.slice(1), k], undefined)); - updatedKeys.forEach((k) => this.#updateData([...fullPath.slice(1), k], object[k])); - } else { - this.#updateData(fullPath, items.length ? items.map((o) => o.value) : undefined); - } - - if (validateOnChange) await this.#triggerValidation(name, path); - }, - })); - - if (allowAdditionalProperties) - disableButton({ - message: "Additional properties cannot be added at this time.", - submessage: "They don't have a predictable structure.", - }); - - addButton.onClick = () => - this.#createModal({ label: name, list, schema: allowPatternProperties ? schema : itemSchema }); - - return html` -
validateOnChange && this.#triggerValidation(name, path)}> - ${list} ${buttonDiv} -
- `; - } - - // Basic enumeration of properties on a select element - if (schema.enum && schema.enum.length) { - - // Use generic selector - if (schema.strict && schema.search !== true) { - return html` - - `; - } - - const options = schema.enum.map((v) => { - return { - key: v, - value: v, - category: schema.enumCategories?.[v], - label: schema.enumLabels?.[v] ?? v, - keywords: schema.enumKeywords?.[v], - description: schema.enumDescriptions?.[v], - link: schema.enumLinks?.[v], - }; - }); - - const search = new Search({ - options, - strict: schema.strict, - value: { - value: this.value, - key: this.value, - category: schema.enumCategories?.[this.value], - label: schema.enumLabels?.[this.value], - keywords: schema.enumKeywords?.[this.value], - }, - showAllWhenEmpty: false, - listMode: "input", - onSelect: async ({ value, key }) => { - const result = value ?? key; - this.#updateData(fullPath, result); - if (validateOnChange) await this.#triggerValidation(name, path); - }, - }); - - search.classList.add("schema-input"); - search.onchange = () => validateOnChange && this.#triggerValidation(name, path); // Ensure validation on forced change - - search.addEventListener("keydown", this.#moveToNextInput); - this.style.width = "100%"; - return search; - } else if (schema.type === "boolean") { - const optional = new OptionalSection({ - value: this.value ?? false, - color: "rgb(32,32,32)", - size: "small", - onSelect: (value) => this.#updateData(fullPath, value), - onChange: () => validateOnChange && this.#triggerValidation(name, path), - }); - - optional.classList.add("schema-input"); - return optional; - } else if (schema.type === "string" || schema.type === "number" || schema.type === "integer") { - const isInteger = schema.type === "integer"; - if (isInteger) schema.type = "number"; - const isNumber = schema.type === "number"; - - const isRequiredNumber = isNumber && this.required; - - const fileSystemFormat = isFilesystemSelector(name, schema.format); - if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); - // Handle long string formats - else if (schema.format === "long" || isArray) - return html``; - // Handle other string formats - else { - const isDateTime = schema.format === "date-time"; - - const type = isDateTime - ? "datetime-local" - : schema.format ?? (schema.type === "string" ? "text" : schema.type); - - const value = isDateTime ? resolveDateTime(this.value) : this.value; - - const { minimum, maximum, exclusiveMax, exclusiveMin } = schema; - const min = exclusiveMin ?? minimum; - const max = exclusiveMax ?? maximum; - - return html` - { - let value = ev.target.value; - let newValue = value; - - // const isBlank = value === ''; - - if (isInteger) value = newValue = parseInt(value); - else if (isNumber) value = newValue = parseFloat(value); - - if (isNumber) { - if ("min" in schema && newValue < schema.min) newValue = schema.min; - else if ("max" in schema && newValue > schema.max) newValue = schema.max; - - if (isNaN(newValue)) newValue = undefined; - } - - if (schema.transform) newValue = schema.transform(newValue, this.value, schema); - - // // Do not check pattern if value is empty - // if (schema.pattern && !isBlank) { - // const regex = new RegExp(schema.pattern) - // if (!regex.test(isNaN(newValue) ? value : newValue)) newValue = this.value // revert to last value - // } - - if (isNumber && newValue !== value) { - ev.target.value = newValue; - value = newValue; - } - - if (isRequiredNumber) { - const nanHandler = ev.target.parentNode.querySelector(".nan-handler"); - if (!(newValue && Number.isNaN(newValue))) nanHandler.checked = false; - } - - this.#updateData(fullPath, value); - }} - @change=${(ev) => validateOnChange && this.#triggerValidation(name, path)} - @keydown=${this.#moveToNextInput} - /> - ${schema.unit ?? ""} - ${isRequiredNumber - ? html`
{ - const siblingInput = ev.target.parentNode.previousElementSibling; - if (ev.target.checked) { - this.#updateData(fullPath, null); - siblingInput.setAttribute("disabled", true); - } else { - siblingInput.removeAttribute("disabled"); - const ev = new Event("input"); - siblingInput.dispatchEvent(ev); - } - this.#triggerValidation(name, path); - }} - >I Don't Know
` - : ""} - `; - } - } - - return this.onUncaughtSchema(schema); - } -} - -customElements.get("jsonschema-input") || customElements.define("jsonschema-input", JSONSchemaInput); +import { LitElement, css, html } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { FilesystemSelector } from "./FileSystemSelector"; + +import { BasicTable } from "./BasicTable"; +import { header, tempPropertyKey, tempPropertyValueKey } from "./forms/utils"; + +import { Button } from "./Button"; +import { List } from "./List"; +import { Modal } from "./Modal"; + +import { capitalize } from "./forms/utils"; +import { JSONSchemaForm, getIgnore } from "./JSONSchemaForm"; +import { Search } from "./Search"; +import tippy from "tippy.js"; +import { merge } from "./pages/utils"; +import { OptionalSection } from "./OptionalSection"; +import { InspectorListItem } from "./preview/inspector/InspectorList"; + +const isDevelopment = !!import.meta.env; + +const dateTimeRegex = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/; + +function resolveDateTime(value) { + if (typeof value === "string") { + const match = value.match(dateTimeRegex); + if (match) return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`; + return value; + } + + return value; +} + +export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) { + const name = fullPath.slice(-1)[0]; + const path = fullPath.slice(0, -1); + const relativePath = this.form?.base ? fullPath.slice(this.form.base.length) : fullPath; + + const schema = this.schema; + const validateOnChange = this.validateOnChange; + + const ignore = this.form?.ignore ? getIgnore(this.form?.ignore, path) : {}; + + const commonValidationFunction = async (tableBasePath, path, parent, newValue, itemPropSchema) => { + const warnings = []; + const errors = []; + + const name = path.slice(-1)[0]; + const completePath = [...tableBasePath, ...path.slice(0, -1)]; + + const result = await (validateOnChange + ? this.onValidate + ? this.onValidate() + : this.form?.triggerValidation + ? this.form.triggerValidation( + name, + completePath, + false, + this, + itemPropSchema, + { ...parent, [name]: newValue }, + { + onError: (error) => { + errors.push(error); // Skip counting errors + }, + onWarning: (warning) => { + warnings.push(warning); // Skip counting warnings + }, + } + ) // NOTE: No pattern properties support + : "" + : true); + + const returnedValue = errors.length ? errors : warnings.length ? warnings : result; + + return returnedValue; + }; + + const commonTableMetadata = { + onStatusChange: () => this.form?.checkStatus && this.form.checkStatus(), // Check status on all elements + validateEmptyCells: this.validateEmptyValue, + deferLoading: this.form?.deferLoading, + onLoaded: () => { + if (this.form) { + if (this.form.nLoaded) this.form.nLoaded++; + if (this.form.checkAllLoaded) this.form.checkAllLoaded(); + } + }, + onThrow: (...args) => onThrow(...args), + }; + + const addPropertyKeyToSchema = (schema) => { + const schemaCopy = structuredClone(schema); + + const schemaItemsRef = schemaCopy["items"]; + + if (!schemaItemsRef.properties) schemaItemsRef.properties = {}; + if (!schemaItemsRef.required) schemaItemsRef.required = []; + + schemaItemsRef.properties[tempPropertyKey] = { title: "Property Key", type: "string", pattern: name }; + if (!schemaItemsRef.order) schemaItemsRef.order = []; + schemaItemsRef.order.unshift(tempPropertyKey); + + schemaItemsRef.required.push(tempPropertyKey); + + return schemaCopy; + }; + + const createNestedTable = (id, value, { name: propName = id, nestedSchema = schema } = {}) => { + const schemaCopy = addPropertyKeyToSchema(nestedSchema); + + const resultPath = [...path]; + + const schemaPath = [...fullPath]; + + // THIS IS AN ISSUE + const rowData = Object.entries(value).map(([key, value]) => { + return !schemaCopy["items"] + ? { [tempPropertyKey]: key, [tempPropertyValueKey]: value } + : { [tempPropertyKey]: key, ...value }; + }); + + if (propName) { + resultPath.push(propName); + schemaPath.push(propName); + } + + const allRemovedKeys = new Set(); + + const keyAlreadyExists = (key) => Object.keys(value).includes(key); + + const previousValidValues = {}; + + function resolvePath(path, target) { + return path + .map((key, i) => { + const ogKey = key; + const nextKey = path[i + 1]; + if (key === tempPropertyKey) key = target[tempPropertyKey]; + if (nextKey === tempPropertyKey) key = []; + + target = target[ogKey] ?? {}; + + if (nextKey === tempPropertyValueKey) return target[tempPropertyKey]; // Grab next property key + if (key === tempPropertyValueKey) return []; + + return key; + }) + .flat(); + } + + function setValueOnAccumulator(row, acc) { + const key = row[tempPropertyKey]; + + if (!key) return acc; + + if (tempPropertyValueKey in row) { + const propValue = row[tempPropertyValueKey]; + if (Array.isArray(propValue)) + acc[key] = propValue.reduce((acc, row) => setValueOnAccumulator(row, acc), {}); + else acc[key] = propValue; + } else { + const copy = { ...row }; + delete copy[tempPropertyKey]; + acc[key] = copy; + } + + return acc; + } + + const nestedIgnore = this.form?.ignore ? getIgnore(this.form?.ignore, schemaPath) : {}; + + merge(overrides.ignore, nestedIgnore); + + merge(overrides.schema, schemaCopy, { arrays: "append" }); + + const tableMetadata = { + keyColumn: tempPropertyKey, + schema: schemaCopy, + data: rowData, + ignore: nestedIgnore, // According to schema + + onUpdate: function (path, newValue) { + const oldKeys = Object.keys(value); + + if (path.slice(-1)[0] === tempPropertyKey && keyAlreadyExists(newValue)) return; // Do not overwrite existing keys + + const result = this.data.reduce((acc, row) => setValueOnAccumulator(row, acc), {}); + + const newKeys = Object.keys(result); + const removedKeys = oldKeys.filter((k) => !newKeys.includes(k)); + removedKeys.forEach((key) => allRemovedKeys.add(key)); + newKeys.forEach((key) => allRemovedKeys.delete(key)); + allRemovedKeys.forEach((key) => (result[key] = undefined)); + + // const resolvedPath = resolvePath(path, this.data) + return onUpdate.call(this, [], result); // Update all table data + }, + + validateOnChange: function (path, parent, newValue) { + const rowIdx = path[0]; + const currentKey = this.data[rowIdx]?.[tempPropertyKey]; + + const updatedPath = resolvePath(path, this.data); + + const resolvedKey = previousValidValues[rowIdx] ?? currentKey; + + // Do not overwrite existing keys + if (path.slice(-1)[0] === tempPropertyKey && resolvedKey !== newValue) { + if (keyAlreadyExists(newValue)) { + if (!previousValidValues[rowIdx]) previousValidValues[rowIdx] = resolvedKey; + + return [ + { + message: `Key already exists.
This value is still ${resolvedKey}.`, + type: "error", + }, + ]; + } else delete previousValidValues[rowIdx]; + } + + const toIterate = updatedPath.filter((value) => typeof value === "string"); + + const itemPropsSchema = toIterate.reduce( + (acc, key) => acc?.properties?.[key] ?? acc?.items?.properties?.[key], + schemaCopy + ); + + return commonValidationFunction([], updatedPath, parent, newValue, itemPropsSchema, 1); + }, + ...commonTableMetadata, + }; + + const table = this.renderTable(id, tableMetadata, fullPath); + + return table; // Try rendering as a nested table with a fake property key (otherwise use nested forms) + }; + + const schemaCopy = structuredClone(schema); + + // Possibly multiple tables + if (isEditableObject(schema, this.value)) { + // One table with nested tables for each property + const data = getEditableItems(this.value, this.pattern, { name, schema: schemaCopy }).reduce( + (acc, { key, value }) => { + acc[key] = value; + return acc; + }, + {} + ); + + const table = createNestedTable(name, data, { schema }); + if (table) return table; + } + + const nestedIgnore = getIgnore(ignore, fullPath); + Object.assign(nestedIgnore, overrides.ignore ?? {}); + + merge(overrides.ignore, nestedIgnore); + + merge(overrides.schema, schemaCopy, { arrays: "append" }); + + // Normal table parsing + const tableMetadata = { + schema: schemaCopy, + data: this.value, + + ignore: nestedIgnore, // According to schema + + onUpdate: function () { + return onUpdate.call(this, relativePath, this.data); // Update all table data + }, + + validateOnChange: (...args) => commonValidationFunction(relativePath, ...args), + + ...commonTableMetadata, + }; + + const table = (this.table = this.renderTable(name, tableMetadata, path)); // Try creating table. Otherwise use nested form + + if (table) { + const tableEl = table === true ? new BasicTable(tableMetadata) : table; + const tables = this.form?.tables; + if (tables) tables[name] = tableEl; + return tableEl; + } +} + +// Schema or value indicates editable object +export const isEditableObject = (schema, value) => + schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value)); + +export const isAdditionalProperties = (pattern) => pattern === "additional"; +export const isPatternProperties = (pattern) => pattern && !isAdditionalProperties(pattern); + +export const getEditableItems = (value = {}, pattern, { name, schema } = {}) => { + let items = Object.entries(value); + + const allowAdditionalProperties = isAdditionalProperties(pattern); + + if (isPatternProperties(pattern)) { + const regex = new RegExp(name); + items = items.filter(([key]) => regex.test(key)); + } else if (allowAdditionalProperties) { + const props = Object.keys(schema.properties ?? {}); + items = items.filter(([key]) => !props.includes(key)); + + const patternProps = Object.keys(schema.patternProperties ?? {}); + patternProps.forEach((key) => { + const regex = new RegExp(key); + items = items.filter(([k]) => !regex.test(k)); + }); + } else if (schema.properties) items = items.filter(([key]) => key in schema.properties); + + items = items.filter(([key]) => !key.includes("__")); // Remove secret properties + + return items.map(([key, value]) => { + return { key, value }; + }); +}; + +const isFilesystemSelector = (name = "", format) => { + if (Array.isArray(format)) return format.map((f) => isFilesystemSelector(name, f)).every(Boolean) ? format : null; + + const matched = name.match(/(.+_)?(.+)_paths?/); + if (!format && matched) format = matched[2] === "folder" ? "directory" : matched[2]; + return ["file", "directory"].includes(format) ? format : null; // Handle file and directory formats +}; + +function getFirstFocusableElement(element) { + const root = element.shadowRoot || element; + const focusableElements = getKeyboardFocusableElements(root); + if (focusableElements.length === 0) { + for (let child of root.children) { + const focusableElement = getFirstFocusableElement(child); + if (focusableElement) return focusableElement; + } + } + return focusableElements[0]; +} + +function getKeyboardFocusableElements(element = document) { + const root = element.shadowRoot || element; + return [ + ...root.querySelectorAll('a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'), + ].filter( + (focusableElement) => + !focusableElement.hasAttribute("disabled") && !focusableElement.getAttribute("aria-hidden") + ); +} + +export class JSONSchemaInput extends LitElement { + static get styles() { + return css` + * { + box-sizing: border-box; + } + + :host(.invalid) .guided--input { + background: rgb(255, 229, 228) !important; + } + + jsonschema-input { + width: 100%; + } + + main { + display: flex; + align-items: center; + } + + #controls { + margin-left: 10px; + flex-grow: 1; + } + + .guided--input { + width: 100%; + border-radius: 4px; + padding: 10px 12px; + font-size: 100%; + font-weight: normal; + border: 1px solid var(--color-border); + transition: border-color 150ms ease-in-out 0s; + outline: none; + color: rgb(33, 49, 60); + background-color: rgb(255, 255, 255); + } + + .guided--input:disabled { + opacity: 0.5; + pointer-events: none; + } + + .guided--input::placeholder { + opacity: 0.5; + } + + .guided--text-area { + height: 5em; + resize: none; + font-family: unset; + } + .guided--text-area-tall { + height: 15em; + } + .guided--input:hover { + box-shadow: rgb(231 238 236) 0px 0px 0px 2px; + } + .guided--input:focus { + outline: 0; + box-shadow: var(--color-light-green) 0px 0px 0px 1px; + } + + input[type="number"].hideStep::-webkit-outer-spin-button, + input[type="number"].hideStep::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* Firefox */ + input[type="number"].hideStep { + -moz-appearance: textfield; + } + + .guided--text-input-instructions { + font-size: 13px; + width: 100%; + padding-top: 4px; + color: dimgray !important; + margin: 0 0; + line-height: 1.4285em; + } + + .nan-handler { + display: flex; + align-items: center; + margin-left: 5px; + white-space: nowrap; + } + + .nan-handler span { + margin-left: 5px; + font-size: 12px; + } + + .schema-input.list { + width: 100%; + } + + .guided--form-label { + display: block; + width: 100%; + margin: 0; + margin-bottom: 10px; + color: black; + font-weight: 600; + font-size: 1.2em !important; + } + + :host([data-table]) .guided--form-label { + margin-bottom: 0px; + } + + .guided--form-label.centered { + text-align: center; + } + + .guided--form-label.header { + font-size: 1.5em !important; + } + + .required label:after { + content: " *"; + color: #ff0033; + } + + :host(:not([validateemptyvalue])) .required label:after { + color: gray; + } + + .required.conditional label:after { + color: transparent; + } + + hr { + display: block; + height: 1px; + border: 0; + border-top: 1px solid #ccc; + padding: 0; + margin-bottom: 1em; + } + + select { + background: url("data:image/svg+xml,") + no-repeat; + background-position: calc(100% - 0.75rem) center !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; + appearance: none !important; + padding-right: 2rem !important; + } + `; + } + + static get properties() { + return { + schema: { type: Object, reflect: false }, + validateEmptyValue: { type: Boolean, reflect: true }, + required: { type: Boolean, reflect: true }, + }; + } + + // Enforce dynamic required properties + attributeChangedCallback(key, _, latest) { + super.attributeChangedCallback(...arguments); + + const formSchema = this.form?.schema; + if (!formSchema) return; + + if (key === "required") { + const name = this.path.slice(-1)[0]; + + if (latest !== null && !this.conditional) { + const requirements = formSchema.required ?? (formSchema.required = []); + if (!requirements.includes(name)) requirements.push(name); + } + + // Remove requirement from form schema (and force if conditional requirement) + else { + const requirements = formSchema.required; + if (requirements && requirements.includes(name)) { + const idx = requirements.indexOf(name); + if (idx > -1) requirements.splice(idx, 1); + } + } + } + } + + // schema, + // parent, + // path, + // form, + // pattern + // showLabel + // description + controls = []; + // required; + validateOnChange = true; + + constructor(props = {}) { + super(); + Object.assign(this, props); + if (props.validateEmptyValue === false) this.validateEmptyValue = true; // False is treated as required but not triggered if empty + } + + // Print the default value of the schema if not caught + onUncaughtSchema = (schema) => { + // In development, show uncaught schemas + if (!isDevelopment) { + if (this.form) { + const inputContainer = this.form.shadowRoot.querySelector(`#${this.path.slice(-1)[0]}`); + inputContainer.style.display = "none"; + } + } + + if (schema.default) return `
${JSON.stringify(schema.default, null, 2)}
`; + + const error = new InspectorListItem({ + message: + "

Internal GUIDE Error

Cannot render this property because of a misformatted schema.", + }); + error.style.width = "100%"; + + return error; + }; + + // onUpdate = () => {} + // onValidate = () => {} + + updateData(value, forceValidate = false) { + if (!forceValidate) { + // Update the actual input element + const inputElement = this.getElement(); + if (!inputElement) return false; + + const hasList = inputElement.querySelector("nwb-list"); + + if (inputElement.type === "checkbox") inputElement.checked = value; + else if (hasList) + hasList.items = this.#mapToList({ value, hasList }); // NOTE: Make sure this is correct + else if (inputElement instanceof Search) inputElement.shadowRoot.querySelector("input").value = value; + else inputElement.value = value; + } + + const { path: fullPath } = this; + const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; + const name = path.splice(-1)[0]; + + this.#updateData(fullPath, value); + this.#triggerValidation(name, path); // NOTE: Is asynchronous + + return true; + } + + getElement = () => this.shadowRoot.querySelector(".schema-input"); + + #activateTimeoutValidation = (name, path, hooks) => { + this.#clearTimeoutValidation(); + this.#validationTimeout = setTimeout(() => { + this.onValidate + ? this.onValidate() + : this.form?.triggerValidation + ? this.form.triggerValidation(name, path, undefined, this, undefined, undefined, hooks) + : ""; + }, 1000); + }; + + #clearTimeoutValidation = () => { + if (this.#validationTimeout) clearTimeout(this.#validationTimeout); + }; + + #validationTimeout = null; + #updateData = (fullPath, value, forceUpdate, hooks = {}) => { + this.onUpdate + ? this.onUpdate(value) + : this.form?.updateData + ? this.form.updateData(fullPath, value, forceUpdate) + : ""; + + const path = [...fullPath]; + const name = path.splice(-1)[0]; + + this.value = value; // Update the latest value + + if (hooks.willTimeout !== false) this.#activateTimeoutValidation(name, path, hooks); + }; + + #triggerValidation = async (name, path) => { + this.#clearTimeoutValidation(); + return this.onValidate + ? this.onValidate() + : this.form?.triggerValidation + ? this.form.triggerValidation(name, path, undefined, this) + : ""; + }; + + updated() { + const inputElement = this.getElement(); + if (inputElement) inputElement.dispatchEvent(new Event("change")); + } + + render() { + const { schema } = this; + + const input = this.#render(); + + if (input === null) return null; // Hide rendering + + const description = this.description ?? schema.description; + + const descriptionHTML = description + ? html`

+ ${unsafeHTML(capitalize(description))}${[".", "?", "!"].includes(description.slice(-1)[0]) ? "" : "."} +

` + : ""; + + return html` +
+ ${this.showLabel + ? html`` + : ""} +
${input}${this.controls ? html`
${this.controls}
` : ""}
+ ${descriptionHTML} +
+ `; + } + + #onThrow = (...args) => (this.onThrow ? this.onThrow(...args) : this.form?.onThrow(...args)); + + #list; + #mapToList({ value = this.value, schema = this.schema, list } = {}) { + const { path: fullPath } = this; + const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; + const name = path.splice(-1)[0]; + + const canAddProperties = isEditableObject(this.schema, this.value); + + if (canAddProperties) { + const editable = getEditableItems(this.value, this.pattern, { name, schema }); + + return editable.map(({ key, value }) => { + return { + key, + value, + controls: [ + new Button({ + label: "Edit", + size: "small", + onClick: () => + this.#createModal({ + key, + schema: isAdditionalProperties(this.pattern) ? undefined : schema, + results: value, + list: list ?? this.#list, + }), + }), + ], + }; + }); + } else { + const resolved = value ?? []; + return resolved + ? resolved.map((value) => { + return { value }; + }) + : []; + } + } + + #modal; + + #createModal({ key, schema = {}, results, list, label } = {}) { + const schemaCopy = structuredClone(schema); + + const createNewObject = !results && (schemaCopy.type === "object" || schemaCopy.properties); + + // const schemaProperties = Object.keys(schema.properties ?? {}); + // const additionalProperties = Object.keys(results).filter((key) => !schemaProperties.includes(key)); + // // const additionalElement = html`Cannot edit additional properties (${additionalProperties}) at this time` + + const allowPatternProperties = isPatternProperties(this.pattern); + const allowAdditionalProperties = isAdditionalProperties(this.pattern); + const createNewPatternProperty = allowPatternProperties && createNewObject; + + // Add a property name entry to the schema + if (createNewPatternProperty) { + schemaCopy.properties = { + __: { title: "Property Name", type: "string", pattern: this.pattern }, + ...schemaCopy.properties, + }; + schemaCopy.required = [...(schemaCopy.required ?? []), "__"]; + } + + if (this.#modal) this.#modal.remove(); + + const submitButton = new Button({ + label: "Submit", + primary: true, + }); + + const isObject = schemaCopy.type === "object" || schemaCopy.properties; // NOTE: For formatted strings, this is not an object + + // NOTE: Will be replaced by single instances + let updateTarget = results ?? (isObject ? {} : undefined); + + submitButton.onClick = async () => { + await nestedModalElement.validate(); + + let value = updateTarget; + + if (schemaCopy?.format && schemaCopy.properties) { + let newValue = schemaCopy?.format; + for (let key in schemaCopy.properties) newValue = newValue.replace(`{${key}}`, value[key] ?? "").trim(); + value = newValue; + } + + // Skip if not unique + if (schemaCopy.uniqueItems && list.items.find((item) => item.value === value)) + return this.#modal.toggle(false); + + // Add to the list + if (createNewPatternProperty) { + const key = value.__; + delete value.__; + list.add({ key, value }); + } else list.add({ key, value }); + + this.#modal.toggle(false); + }; + + this.#modal = new Modal({ + header: label ? `${header(label)} Editor` : key ? header(key) : `Property Editor`, + footer: submitButton, + showCloseButton: createNewObject, + }); + + const div = document.createElement("div"); + div.style.padding = "25px"; + + const inputTitle = header(schemaCopy.title ?? label ?? "Value"); + + const nestedModalElement = isObject + ? new JSONSchemaForm({ + schema: schemaCopy, + results: updateTarget, + validateEmptyValues: false, + onUpdate: (internalPath, value) => { + if (!createNewObject) { + const path = [key, ...internalPath]; + this.#updateData(path, value, true); // Live updates + } + }, + renderTable: this.renderTable, + onThrow: this.#onThrow, + }) + : new JSONSchemaForm({ + schema: { + properties: { + [tempPropertyKey]: { + ...schemaCopy, + title: inputTitle, + }, + }, + required: [tempPropertyKey], + }, + validateEmptyValues: false, + results: updateTarget, + onUpdate: (_, value) => { + if (createNewObject) updateTarget[key] = value; + else updateTarget = value; + }, + // renderTable: this.renderTable, + // onThrow: this.#onThrow, + }); + + div.append(nestedModalElement); + + this.#modal.append(div); + + document.body.append(this.#modal); + + setTimeout(() => this.#modal.toggle(true)); + + return this.#modal; + } + + #getType = (value = this.value) => (Array.isArray(value) ? "array" : typeof value); + + #handleNextInput = (idx) => { + const next = Object.values(this.form.inputs)[idx]; + if (next) { + const firstFocusableElement = getFirstFocusableElement(next); + if (firstFocusableElement) { + if (firstFocusableElement.tagName === "BUTTON") return this.#handleNextInput(idx + 1); + firstFocusableElement.focus(); + } + } + }; + + #moveToNextInput = (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + if (this.form?.inputs) { + const idx = Object.values(this.form.inputs).findIndex((input) => input === this); + this.#handleNextInput(idx + 1); + } + + ev.target.blur(); + } + }; + + #render() { + const { validateOnChange, schema, path: fullPath } = this; + + this.removeAttribute("data-table"); + + // Do your best to fill in missing schema values + if (!("type" in schema)) schema.type = this.#getType(); + + const resolvedFullPath = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; + const path = [...resolvedFullPath]; + const name = path.splice(-1)[0]; + + const isArray = schema.type === "array"; // Handle string (and related) formats / types + + const itemSchema = this.form?.getSchema ? this.form.getSchema("items", schema) : schema["items"]; + const isTable = itemSchema?.type === "object" && this.renderTable; + + const canAddProperties = isEditableObject(this.schema, this.value); + + if (this.renderCustomHTML) { + const custom = this.renderCustomHTML(name, schema, path, { + onUpdate: this.#updateData, + onThrow: this.#onThrow, + }); + + const renderEmpty = custom === null; + if (custom) return custom; + else if (renderEmpty) { + this.remove(); // Remove from DOM so that parent can be empty + return; + } + } + + // Handle file and directory formats + const createFilesystemSelector = (format) => { + const filesystemSelectorElement = new FilesystemSelector({ + type: format, + value: this.value, + accept: schema.accept, + onSelect: (paths = []) => { + const value = paths.length ? paths : undefined; + this.#updateData(fullPath, value); + }, + onChange: (filePath) => validateOnChange && this.#triggerValidation(name, path), + onThrow: (...args) => this.#onThrow(...args), + dialogOptions: this.form?.dialogOptions, + dialogType: this.form?.dialogType, + multiple: isArray, + }); + filesystemSelectorElement.classList.add("schema-input"); + return filesystemSelectorElement; + }; + + // Transform to single item if maxItems is 1 + if (isArray && schema.maxItems === 1 && !isTable) { + return new JSONSchemaInput({ + value: this.value?.[0], + schema: { + ...schema.items, + strict: schema.strict, + }, + path: fullPath, + validateEmptyValue: this.validateEmptyValue, + required: this.required, + validateOnChange: () => (validateOnChange ? this.#triggerValidation(name, path) : ""), + form: this.form, + onUpdate: (value) => this.#updateData(fullPath, [value]), + }); + } + + if (isArray || canAddProperties) { + // if ('value' in this && !Array.isArray(this.value)) this.value = [ this.value ] + + const allowPatternProperties = isPatternProperties(this.pattern); + const allowAdditionalProperties = isAdditionalProperties(this.pattern); + + // Provide default item types + if (isArray) { + const hasItemsRef = "items" in schema && "$ref" in schema.items; + if (!("items" in schema)) schema.items = {}; + if (!("type" in schema.items) && !hasItemsRef) { + // Guess the type of the first item + if (this.value) { + const itemToCheck = this.value[0]; + schema.items.type = itemToCheck ? this.#getType(itemToCheck) : "string"; + } + + // If no value, handle uncaught schema + else return this.onUncaughtSchema(schema); + } + } + + const fileSystemFormat = isFilesystemSelector(name, itemSchema?.format); + if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); + // Create tables if possible + else if (itemSchema?.type === "string" && !itemSchema.properties) { + const list = new List({ + items: this.value, + emptyMessage: "No items", + onChange: ({ items }) => { + this.#updateData(fullPath, items.length ? items.map((o) => o.value) : undefined); + if (validateOnChange) this.#triggerValidation(name, path); + }, + }); + + if (itemSchema.enum) { + const search = new Search({ + options: itemSchema.enum.map((v) => { + return { + key: v, + value: v, + label: itemSchema.enumLabels?.[v] ?? v, + keywords: itemSchema.enumKeywords?.[v], + description: itemSchema.enumDescriptions?.[v], + link: itemSchema.enumLinks?.[v], + }; + }), + value: this.value, + listMode: schema.strict === false ? "click" : "append", + showAllWhenEmpty: false, + onSelect: async ({ label, value }) => { + if (!value) return; + if (schema.uniqueItems && this.value && this.value.includes(value)) return; + list.add({ content: label, value }); + }, + }); + + search.style.height = "auto"; + return html`
${search}${list}
`; + } else { + const input = document.createElement("input"); + input.classList.add("guided--input"); + input.placeholder = "Provide an item for the list"; + + const submitButton = new Button({ + label: "Submit", + primary: true, + size: "small", + onClick: () => { + const value = input.value; + if (!value) return; + if (schema.uniqueItems && this.value && this.value.includes(value)) return; + list.add({ value }); + input.value = ""; + }, + }); + + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") submitButton.onClick(); + }); + + return html`
validateOnChange && this.#triggerValidation(name, path)} + > +
${input}${submitButton}
+ ${list} +
`; + } + } else if (isTable) { + const instanceThis = this; + + function updateFunction(path, value = this.data) { + return instanceThis.#updateData(path, value, true, { + willTimeout: false, // Since there is a special validation function, do not trigger a timeout validation call + onError: (e) => e, + onWarning: (e) => e, + }); + } + + const externalPath = this.form?.base ? [...this.form.base, ...resolvedFullPath] : resolvedFullPath; + + const table = createTable.call(this, externalPath, { + onUpdate: updateFunction, + onThrow: this.#onThrow, + }); // Ensure change propagates + + if (table) { + this.setAttribute("data-table", ""); + return table; + } + } + + const addButton = new Button({ + size: "small", + }); + + addButton.innerText = `Add ${canAddProperties ? "Property" : "Item"}`; + + const buttonDiv = document.createElement("div"); + Object.assign(buttonDiv.style, { width: "fit-content" }); + buttonDiv.append(addButton); + + const disableButton = ({ message, submessage }) => { + addButton.setAttribute("disabled", true); + tippy(buttonDiv, { + content: `
${message}
${submessage}
`, + allowHTML: true, + }); + }; + + const list = (this.#list = new List({ + items: this.#mapToList(), + + // Add edit button when new items are added + // NOTE: Duplicates some code in #mapToList + transform: (item) => { + if (canAddProperties) { + const { key, value } = item; + item.controls = [ + new Button({ + label: "Edit", + size: "small", + onClick: () => { + this.#createModal({ + key, + schema, + results: value, + list, + }); + }, + }), + ]; + } + }, + onChange: async ({ object, items }, { object: oldObject }) => { + if (this.pattern) { + const oldKeys = Object.keys(oldObject); + const newKeys = Object.keys(object); + const removedKeys = oldKeys.filter((k) => !newKeys.includes(k)); + const updatedKeys = newKeys.filter((k) => oldObject[k] !== object[k]); + removedKeys.forEach((k) => this.#updateData([...fullPath.slice(1), k], undefined)); + updatedKeys.forEach((k) => this.#updateData([...fullPath.slice(1), k], object[k])); + } else { + this.#updateData(fullPath, items.length ? items.map((o) => o.value) : undefined); + } + + if (validateOnChange) await this.#triggerValidation(name, path); + }, + })); + + if (allowAdditionalProperties) + disableButton({ + message: "Additional properties cannot be added at this time.", + submessage: "They don't have a predictable structure.", + }); + + addButton.onClick = () => + this.#createModal({ label: name, list, schema: allowPatternProperties ? schema : itemSchema }); + + return html` +
validateOnChange && this.#triggerValidation(name, path)}> + ${list} ${buttonDiv} +
+ `; + } + + // Basic enumeration of properties on a select element + if (schema.enum && schema.enum.length) { + // Use generic selector + if (schema.strict && schema.search !== true) { + return html` + + `; + } + + const options = schema.enum.map((v) => { + return { + key: v, + value: v, + category: schema.enumCategories?.[v], + label: schema.enumLabels?.[v] ?? v, + keywords: schema.enumKeywords?.[v], + description: schema.enumDescriptions?.[v], + link: schema.enumLinks?.[v], + }; + }); + + const search = new Search({ + options, + strict: schema.strict, + value: { + value: this.value, + key: this.value, + category: schema.enumCategories?.[this.value], + label: schema.enumLabels?.[this.value], + keywords: schema.enumKeywords?.[this.value], + }, + showAllWhenEmpty: false, + listMode: "input", + onSelect: async ({ value, key }) => { + const result = value ?? key; + this.#updateData(fullPath, result); + if (validateOnChange) await this.#triggerValidation(name, path); + }, + }); + + search.classList.add("schema-input"); + search.onchange = () => validateOnChange && this.#triggerValidation(name, path); // Ensure validation on forced change + + search.addEventListener("keydown", this.#moveToNextInput); + this.style.width = "100%"; + return search; + } else if (schema.type === "boolean") { + const optional = new OptionalSection({ + value: this.value ?? false, + color: "rgb(32,32,32)", + size: "small", + onSelect: (value) => this.#updateData(fullPath, value), + onChange: () => validateOnChange && this.#triggerValidation(name, path), + }); + + optional.classList.add("schema-input"); + return optional; + } else if (schema.type === "string" || schema.type === "number" || schema.type === "integer") { + const isInteger = schema.type === "integer"; + if (isInteger) schema.type = "number"; + const isNumber = schema.type === "number"; + + const isRequiredNumber = isNumber && this.required; + + const fileSystemFormat = isFilesystemSelector(name, schema.format); + if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); + // Handle long string formats + else if (schema.format === "long" || isArray) + return html``; + // Handle other string formats + else { + const isDateTime = schema.format === "date-time"; + + const type = isDateTime + ? "datetime-local" + : schema.format ?? (schema.type === "string" ? "text" : schema.type); + + const value = isDateTime ? resolveDateTime(this.value) : this.value; + + const { minimum, maximum, exclusiveMax, exclusiveMin } = schema; + const min = exclusiveMin ?? minimum; + const max = exclusiveMax ?? maximum; + + return html` + { + let value = ev.target.value; + let newValue = value; + + // const isBlank = value === ''; + + if (isInteger) value = newValue = parseInt(value); + else if (isNumber) value = newValue = parseFloat(value); + + if (isNumber) { + if ("min" in schema && newValue < schema.min) newValue = schema.min; + else if ("max" in schema && newValue > schema.max) newValue = schema.max; + + if (isNaN(newValue)) newValue = undefined; + } + + if (schema.transform) newValue = schema.transform(newValue, this.value, schema); + + // // Do not check pattern if value is empty + // if (schema.pattern && !isBlank) { + // const regex = new RegExp(schema.pattern) + // if (!regex.test(isNaN(newValue) ? value : newValue)) newValue = this.value // revert to last value + // } + + if (isNumber && newValue !== value) { + ev.target.value = newValue; + value = newValue; + } + + if (isRequiredNumber) { + const nanHandler = ev.target.parentNode.querySelector(".nan-handler"); + if (!(newValue && Number.isNaN(newValue))) nanHandler.checked = false; + } + + this.#updateData(fullPath, value); + }} + @change=${(ev) => validateOnChange && this.#triggerValidation(name, path)} + @keydown=${this.#moveToNextInput} + /> + ${schema.unit ?? ""} + ${isRequiredNumber + ? html`
{ + const siblingInput = ev.target.parentNode.previousElementSibling; + if (ev.target.checked) { + this.#updateData(fullPath, null); + siblingInput.setAttribute("disabled", true); + } else { + siblingInput.removeAttribute("disabled"); + const ev = new Event("input"); + siblingInput.dispatchEvent(ev); + } + this.#triggerValidation(name, path); + }} + >I Don't Know
` + : ""} + `; + } + } + + return this.onUncaughtSchema(schema); + } +} + +customElements.get("jsonschema-input") || customElements.define("jsonschema-input", JSONSchemaInput); diff --git a/src/electron/frontend/core/components/pages/Page.js b/src/electron/frontend/core/components/pages/Page.js index 3fc20c7197..ec35a88ffb 100644 --- a/src/electron/frontend/core/components/pages/Page.js +++ b/src/electron/frontend/core/components/pages/Page.js @@ -1,280 +1,280 @@ -import { LitElement, html } from "lit"; -import { runConversion } from "./guided-mode/options/utils.js"; -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 { resolveMetadata } from "./guided-mode/data/utils.js"; -import Swal from "sweetalert2"; -import { createProgressPopup } from "../utils/progress.js"; - -export class Page extends LitElement { - // static get styles() { - // return useGlobalStyles( - // componentCSS, - // (sheet) => sheet.href && sheet.href.includes("bootstrap"), - // this.shadowRoot - // ); - // } - - info = { globalState: {} }; - - constructor(info = {}) { - super(); - Object.assign(this.info, info); - } - - createRenderRoot() { - return this; - } - - query = (input) => { - return (this.shadowRoot ?? this).querySelector(input); - }; - - onSet = () => {}; // User-defined function - - set = (info, rerender = true) => { - if (info) { - Object.assign(this.info, info); - this.onSet(); - if (rerender) this.requestUpdate(); - } - }; - - #notifications = []; - - dismiss = (notification) => { - if (notification) dismissNotification(notification); - else { - this.#notifications.forEach((notification) => dismissNotification(notification)); - this.#notifications = []; - } - }; - - notify = (...args) => { - const ref = notify(...args); - this.#notifications.push(ref); - return ref; - }; - - to = async (transition) => { - // Otherwise note unsaved updates if present - if ( - this.unsavedUpdates || - ("states" in this.info && - transition === 1 && // Only ensure save for standard forward progression - !this.info.states.saved) - ) { - if (transition === 1) - await this.save(); // Save before a single forward transition - else { - await Swal.fire({ - title: "You have unsaved data on this page.", - text: "Would you like to save your changes?", - icon: "warning", - showCancelButton: true, - confirmButtonColor: "#3085d6", - confirmButtonText: "Save and Continue", - cancelButtonText: "Ignore Changes", - }).then(async (result) => { - if (result && result.isConfirmed) await this.save(); - }); - } - } - - return await this.onTransition(transition); - }; - - onTransition = () => {}; // User-defined function - updatePages = () => {}; // User-defined function - beforeSave = () => {}; // User-defined function - - save = async (overrides, runBeforeSave = true) => { - if (runBeforeSave) await this.beforeSave(); - save(this, overrides); - if ("states" in this.info) this.info.states.saved = true; - this.unsavedUpdates = false; - }; - - load = (datasetNameToResume = new URLSearchParams(window.location.search).get("project")) => - (this.info.globalState = get(datasetNameToResume)); - - addSession({ subject, session, info }) { - if (!this.info.globalState.results[subject]) this.info.globalState.results[subject] = {}; - if (this.info.globalState.results[subject][session]) - throw new Error(`Session ${subject}/${session} already exists.`); - info = this.info.globalState.results[subject][session] = info ?? {}; - if (!info.metadata) info.metadata = {}; - if (!info.source_data) info.source_data = {}; - return info; - } - - removeSession({ subject, session }) { - delete this.info.globalState.results[subject][session]; - } - - mapSessions = (callback, data = this.info.globalState.results) => mapSessions(callback, data); - - async convert({ preview } = {}) { - const key = preview ? "preview" : "conversion"; - - delete this.info.globalState[key]; // Clear the preview results - - if (preview) { - const stubs = await this.runConversions({ stub_test: true }, undefined, { - title: "Creating conversion preview for all sessions...", - }); - this.info.globalState[key] = { stubs }; - } else { - this.info.globalState[key] = await this.runConversions({}, true, { title: "Running all conversions" }); - } - - this.unsavedUpdates = true; - - // Indicate conversion has run successfully - const { desyncedData } = this.info.globalState; - if (!desyncedData) this.info.globalState.desyncedData = {}; - - if (desyncedData) { - desyncedData[key] = false; - await this.save({}, false); - } - } - - async runConversions(conversionOptions = {}, toRun, options = {}) { - let original = toRun; - if (!Array.isArray(toRun)) toRun = this.mapSessions(); - - // Filter the sessions to run - if (typeof original === "number") - toRun = randomizeElements(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); - - const results = {}; - - const isMultiple = toRun.length > 1; - - const swalOpts = await createProgressPopup({ title: `Running conversion`, ...options }); - const { close: closeProgressPopup, elements } = swalOpts; - - elements.container.insertAdjacentHTML( - "beforeend", - `Note: This may take a while to complete...
` - ); - - let completed = 0; - elements.progress.format = { n: completed, total: toRun.length }; - - for (let info of toRun) { - const { subject, session, globalState = this.info.globalState } = info; - const file = `sub-${subject}/sub-${subject}_ses-${session}.nwb`; - - const { conversion_output_folder, name, SourceData, alignment } = globalState.project; - - const sessionResults = globalState.results[subject][session]; - - const sourceDataCopy = structuredClone(sessionResults.source_data); - - // Resolve the correct session info from all of the metadata for this conversion - const sessionInfo = { - ...sessionResults, - metadata: resolveMetadata(subject, session, globalState), - source_data: merge(SourceData, sourceDataCopy), - }; - - const result = await runConversion( - { - output_folder: conversionOptions.stub_test ? undefined : conversion_output_folder, - project_name: name, - nwbfile_path: file, - overwrite: true, // We assume override is true because the native NWB file dialog will not allow the user to select an existing file (unless they approve the overwrite) - ...sessionInfo, // source_data and metadata are passed in here - ...conversionOptions, // Any additional conversion options override the defaults - - interfaces: globalState.interfaces, - alignment - }, - swalOpts - ).catch((error) => { - let message = error.message; - - if (message.includes("The user aborted a request.")) { - this.notify("Conversion was cancelled.", "warning"); - throw error; - } - - this.notify(message, "error"); - closeProgressPopup(); - throw error; - }); - - completed++; - if (isMultiple) { - const progressInfo = { n: completed, total: toRun.length }; - elements.progress.format = progressInfo; - } - - const subRef = results[subject] ?? (results[subject] = {}); - subRef[session] = result; - } - - closeProgressPopup(); - elements.container.style.textAlign = ""; // Clear style update - - return results; - } - - // NOTE: Until the shadow DOM is supported in Storybook, we can't use this render function how we'd intend to. - addPage = (id, subpage) => { - if (!this.info.pages) this.info.pages = {}; - this.info.pages[id] = subpage; - this.updatePages(); - }; - - checkSyncState = async (info = this.info, sync = info.sync) => { - if (!sync) return; - if (isStorybook) return; - - const { desyncedData } = info.globalState; - - return Promise.all( - sync.map((k) => { - if (desyncedData?.[k] !== false) { - if (k === "conversion") return this.convert(); - else if (k === "preview") return this.convert({ preview: true }); - } - }) - ); - }; - - updateSections = () => { - const dashboard = document.querySelector("nwb-dashboard"); - dashboard.updateSections({ sidebar: true, main: true }, this.info.globalState); - }; - - #unsaved = false; - get unsavedUpdates() { - return this.#unsaved; - } - - set unsavedUpdates(value) { - this.#unsaved = !!value; - if (value === "conversions") this.info.globalState.desyncedData = { preview: true, conversion: true }; - } - - // NOTE: Make sure you call this explicitly if a child class overwrites this AND data is updated - updated() { - this.unsavedUpdates = false; - } - - render() { - return html``; - } -} - -customElements.get("nwbguide-page") || customElements.define("nwbguide-page", Page); +import { LitElement, html } from "lit"; +import { runConversion } from "./guided-mode/options/utils.js"; +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 { resolveMetadata } from "./guided-mode/data/utils.js"; +import Swal from "sweetalert2"; +import { createProgressPopup } from "../utils/progress.js"; + +export class Page extends LitElement { + // static get styles() { + // return useGlobalStyles( + // componentCSS, + // (sheet) => sheet.href && sheet.href.includes("bootstrap"), + // this.shadowRoot + // ); + // } + + info = { globalState: {} }; + + constructor(info = {}) { + super(); + Object.assign(this.info, info); + } + + createRenderRoot() { + return this; + } + + query = (input) => { + return (this.shadowRoot ?? this).querySelector(input); + }; + + onSet = () => {}; // User-defined function + + set = (info, rerender = true) => { + if (info) { + Object.assign(this.info, info); + this.onSet(); + if (rerender) this.requestUpdate(); + } + }; + + #notifications = []; + + dismiss = (notification) => { + if (notification) dismissNotification(notification); + else { + this.#notifications.forEach((notification) => dismissNotification(notification)); + this.#notifications = []; + } + }; + + notify = (...args) => { + const ref = notify(...args); + this.#notifications.push(ref); + return ref; + }; + + to = async (transition) => { + // Otherwise note unsaved updates if present + if ( + this.unsavedUpdates || + ("states" in this.info && + transition === 1 && // Only ensure save for standard forward progression + !this.info.states.saved) + ) { + if (transition === 1) + await this.save(); // Save before a single forward transition + else { + await Swal.fire({ + title: "You have unsaved data on this page.", + text: "Would you like to save your changes?", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + confirmButtonText: "Save and Continue", + cancelButtonText: "Ignore Changes", + }).then(async (result) => { + if (result && result.isConfirmed) await this.save(); + }); + } + } + + return await this.onTransition(transition); + }; + + onTransition = () => {}; // User-defined function + updatePages = () => {}; // User-defined function + beforeSave = () => {}; // User-defined function + + save = async (overrides, runBeforeSave = true) => { + if (runBeforeSave) await this.beforeSave(); + save(this, overrides); + if ("states" in this.info) this.info.states.saved = true; + this.unsavedUpdates = false; + }; + + load = (datasetNameToResume = new URLSearchParams(window.location.search).get("project")) => + (this.info.globalState = get(datasetNameToResume)); + + addSession({ subject, session, info }) { + if (!this.info.globalState.results[subject]) this.info.globalState.results[subject] = {}; + if (this.info.globalState.results[subject][session]) + throw new Error(`Session ${subject}/${session} already exists.`); + info = this.info.globalState.results[subject][session] = info ?? {}; + if (!info.metadata) info.metadata = {}; + if (!info.source_data) info.source_data = {}; + return info; + } + + removeSession({ subject, session }) { + delete this.info.globalState.results[subject][session]; + } + + mapSessions = (callback, data = this.info.globalState.results) => mapSessions(callback, data); + + async convert({ preview } = {}) { + const key = preview ? "preview" : "conversion"; + + delete this.info.globalState[key]; // Clear the preview results + + if (preview) { + const stubs = await this.runConversions({ stub_test: true }, undefined, { + title: "Creating conversion preview for all sessions...", + }); + this.info.globalState[key] = { stubs }; + } else { + this.info.globalState[key] = await this.runConversions({}, true, { title: "Running all conversions" }); + } + + this.unsavedUpdates = true; + + // Indicate conversion has run successfully + const { desyncedData } = this.info.globalState; + if (!desyncedData) this.info.globalState.desyncedData = {}; + + if (desyncedData) { + desyncedData[key] = false; + await this.save({}, false); + } + } + + async runConversions(conversionOptions = {}, toRun, options = {}) { + let original = toRun; + if (!Array.isArray(toRun)) toRun = this.mapSessions(); + + // Filter the sessions to run + if (typeof original === "number") + toRun = randomizeElements(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); + + const results = {}; + + const isMultiple = toRun.length > 1; + + const swalOpts = await createProgressPopup({ title: `Running conversion`, ...options }); + const { close: closeProgressPopup, elements } = swalOpts; + + elements.container.insertAdjacentHTML( + "beforeend", + `Note: This may take a while to complete...
` + ); + + let completed = 0; + elements.progress.format = { n: completed, total: toRun.length }; + + for (let info of toRun) { + const { subject, session, globalState = this.info.globalState } = info; + const file = `sub-${subject}/sub-${subject}_ses-${session}.nwb`; + + const { conversion_output_folder, name, SourceData, alignment } = globalState.project; + + const sessionResults = globalState.results[subject][session]; + + const sourceDataCopy = structuredClone(sessionResults.source_data); + + // Resolve the correct session info from all of the metadata for this conversion + const sessionInfo = { + ...sessionResults, + metadata: resolveMetadata(subject, session, globalState), + source_data: merge(SourceData, sourceDataCopy), + }; + + const result = await runConversion( + { + output_folder: conversionOptions.stub_test ? undefined : conversion_output_folder, + project_name: name, + nwbfile_path: file, + overwrite: true, // We assume override is true because the native NWB file dialog will not allow the user to select an existing file (unless they approve the overwrite) + ...sessionInfo, // source_data and metadata are passed in here + ...conversionOptions, // Any additional conversion options override the defaults + + interfaces: globalState.interfaces, + alignment, + }, + swalOpts + ).catch((error) => { + let message = error.message; + + if (message.includes("The user aborted a request.")) { + this.notify("Conversion was cancelled.", "warning"); + throw error; + } + + this.notify(message, "error"); + closeProgressPopup(); + throw error; + }); + + completed++; + if (isMultiple) { + const progressInfo = { n: completed, total: toRun.length }; + elements.progress.format = progressInfo; + } + + const subRef = results[subject] ?? (results[subject] = {}); + subRef[session] = result; + } + + closeProgressPopup(); + elements.container.style.textAlign = ""; // Clear style update + + return results; + } + + // NOTE: Until the shadow DOM is supported in Storybook, we can't use this render function how we'd intend to. + addPage = (id, subpage) => { + if (!this.info.pages) this.info.pages = {}; + this.info.pages[id] = subpage; + this.updatePages(); + }; + + checkSyncState = async (info = this.info, sync = info.sync) => { + if (!sync) return; + if (isStorybook) return; + + const { desyncedData } = info.globalState; + + return Promise.all( + sync.map((k) => { + if (desyncedData?.[k] !== false) { + if (k === "conversion") return this.convert(); + else if (k === "preview") return this.convert({ preview: true }); + } + }) + ); + }; + + updateSections = () => { + const dashboard = document.querySelector("nwb-dashboard"); + dashboard.updateSections({ sidebar: true, main: true }, this.info.globalState); + }; + + #unsaved = false; + get unsavedUpdates() { + return this.#unsaved; + } + + set unsavedUpdates(value) { + this.#unsaved = !!value; + if (value === "conversions") this.info.globalState.desyncedData = { preview: true, conversion: true }; + } + + // NOTE: Make sure you call this explicitly if a child class overwrites this AND data is updated + updated() { + this.unsavedUpdates = false; + } + + render() { + return html``; + } +} + +customElements.get("nwbguide-page") || customElements.define("nwbguide-page", Page); 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 b7cc1ae2a4..e1fa9b226d 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 @@ -247,7 +247,7 @@ export class GuidedSourceDataPage extends ManagedPage { const { subject, session } = getInfoFromId(id); - this.dismiss() + this.dismiss(); const header = document.createElement("div"); Object.assign(header.style, { paddingTop: "10px" }); @@ -293,8 +293,11 @@ export class GuidedSourceDataPage extends ManagedPage { const { metadata } = data; if (Object.keys(metadata).length === 0) { - this.notify(`

Time Alignment Failed

Please ensure that all source data is specified.`, "error"); - return false + this.notify( + `

Time Alignment Failed

Please ensure that all source data is specified.`, + "error" + ); + return false; } alignment = new TimeAlignment({ @@ -306,7 +309,7 @@ export class GuidedSourceDataPage extends ManagedPage { modal.innerHTML = ""; modal.append(alignment); - return true + return true; }, }); @@ -348,4 +351,4 @@ export class GuidedSourceDataPage extends ManagedPage { } customElements.get("nwbguide-guided-sourcedata-page") || - customElements.define("nwbguide-guided-sourcedata-page", GuidedSourceDataPage); \ No newline at end of file + customElements.define("nwbguide-guided-sourcedata-page", GuidedSourceDataPage); diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js index 47cc6dbed4..68feb0db98 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js @@ -1,273 +1,273 @@ -import { LitElement, css } from "lit"; -import { JSONSchemaInput } from "../../../../JSONSchemaInput"; -import { InspectorListItem } from "../../../../preview/inspector/InspectorList"; - -const options = { - start: { - name: "Adjust Start Time", - schema: { - type: "number", - description: "The start time of the recording in seconds.", - min: 0, - }, - }, - timestamps: { - name: "Upload Timestamps", - schema: { - type: "string", - format: "file", - description: "A CSV file containing the timestamps of the recording.", - }, - }, - linked: { - name: "Link to Recording", - schema: { - type: "string", - description: "The name of the linked recording.", - placeholder: "Select a recording interface", - enum: [], - strict: true, - }, - }, -}; - -export class TimeAlignment extends LitElement { - static get styles() { - return css` - * { - box-sizing: border-box; - } - - :host { - display: block; - padding: 20px; - } - - :host > div { - display: flex; - flex-direction: column; - gap: 10px; - } - - :host > div > div { - display: flex; - align-items: center; - gap: 20px; - } - - :host > div > div > *:nth-child(1) { - width: 100%; - } - - :host > div > div > *:nth-child(2) { - display: flex; - flex-direction: column; - justify-content: center; - white-space: nowrap; - font-size: 90%; - min-width: 150px; - } - - :host > div > div > *:nth-child(2) > div { - cursor: pointer; - padding: 5px 10px; - border: 1px solid lightgray; - } - - :host > div > div > *:nth-child(3) { - width: 700px; - } - - .disclaimer { - font-size: 90%; - color: gray; - } - - label { - font-weight: bold; - } - - [selected] { - font-weight: bold; - background: whitesmoke; - } - `; - } - - static get properties() { - return { - data: { type: Object }, - }; - } - - constructor({ data = {}, results = {}, interfaces = {} }) { - super(); - this.data = data; - this.results = results; - this.interfaces = interfaces; - } - - render() { - const container = document.createElement("div"); - - const { timestamps, errors, metadata } = this.data; - - const flatTimes = Object.values(timestamps) - .map((interfaceTimestamps) => { - return [interfaceTimestamps[0], interfaceTimestamps.slice(-1)[0]]; - }) - .flat() - .filter((timestamp) => !isNaN(timestamp)); - - const minTime = Math.min(...flatTimes); - const maxTime = Math.max(...flatTimes); - - const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); - const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; - - const cachedErrors = {}; - - for (let name in timestamps) { - cachedErrors[name] = {}; - - if (!(name in this.results)) - this.results[name] = { - selected: undefined, - values: {}, - }; - - const row = document.createElement("div"); - // Object.assign(row.style, { - // display: 'flex', - // alignItems: 'center', - // justifyContent: 'space-between', - // gap: '10px', - // }); - - const barCell = document.createElement("div"); - - const label = document.createElement("label"); - label.innerText = name; - barCell.append(label); - - const info = timestamps[name]; - - const barContainer = document.createElement("div"); - Object.assign(barContainer.style, { - height: "10px", - width: "100%", - marginTop: "5px", - border: "1px solid lightgray", - position: "relative", - }); - - barCell.append(barContainer); - - const isSortingInterface = metadata[name].sorting === true; - const hasCompatibleInterfaces = isSortingInterface && metadata[name].compatible.length > 0; - - // Render this way if the interface has data - if (info.length > 0) { - const firstTime = info[0]; - const lastTime = info[info.length - 1]; - - const smallLabel = document.createElement("small"); - smallLabel.innerText = `${firstTime.toFixed(2)} - ${lastTime.toFixed(2)} sec`; - - const firstTimePct = normalizeTimePct(firstTime); - const lastTimePct = normalizeTimePct(lastTime); - - const width = `calc(${lastTimePct} - ${firstTimePct})`; - - const bar = document.createElement("div"); - - Object.assign(bar.style, { - position: "absolute", - left: firstTimePct, - width: width, - height: "100%", - background: "#029CFD", - }); - - barContainer.append(bar); - barCell.append(smallLabel); - } else { - barContainer.style.background = - "repeating-linear-gradient(45deg, lightgray, lightgray 10px, white 10px, white 20px)"; - } - - row.append(barCell); - - const selectionCell = document.createElement("div"); - const resultCell = document.createElement("div"); - - const optionsCopy = Object.entries(structuredClone(options)); - - optionsCopy[2][1].schema.enum = Object.keys(timestamps).filter((str) => - this.interfaces[str].includes("Recording") - ); - - const resolvedOptionEntries = hasCompatibleInterfaces ? optionsCopy : optionsCopy.slice(0, 2); - - const elements = resolvedOptionEntries.reduce((acc, [selected, option]) => { - const optionResults = this.results[name]; - - const clickableElement = document.createElement("div"); - clickableElement.innerText = option.name; - clickableElement.onclick = () => { - optionResults.selected = selected; - - Object.values(elements).forEach((el) => el.removeAttribute("selected")); - clickableElement.setAttribute("selected", ""); - - const element = new JSONSchemaInput({ - value: optionResults.values[selected], - schema: option.schema, - path: [], - controls: option.controls ? option.controls() : [], - onUpdate: (value) => (optionResults.values[selected] = value), - }); - - resultCell.innerHTML = ""; - resultCell.append(element); - - const errorMessage = cachedErrors[name][selected]; - if (errorMessage) { - const error = new InspectorListItem({ - type: "error", - message: `

Alignment Failed

${errorMessage}`, - }); - - error.style.marginTop = "5px"; - resultCell.append(error); - } - }; - - acc[selected] = clickableElement; - return acc; - }, {}); - - const elArray = Object.values(elements); - selectionCell.append(...elArray); - - const selected = this.results[name].selected; - if (errors[name]) cachedErrors[name][selected] = errors[name]; - - row.append(selectionCell, resultCell); - if (selected) elements[selected].click(); - else elArray[0].click(); - - // const empty = document.createElement("div"); - // const disclaimer = document.createElement("div"); - // disclaimer.classList.add("disclaimer"); - // disclaimer.innerText = "Edit in Source Data"; - // row.append(disclaimer, empty); - - container.append(row); - } - - return container; - } -} - -customElements.get("nwbguide-time-alignment") || customElements.define("nwbguide-time-alignment", TimeAlignment); +import { LitElement, css } from "lit"; +import { JSONSchemaInput } from "../../../../JSONSchemaInput"; +import { InspectorListItem } from "../../../../preview/inspector/InspectorList"; + +const options = { + start: { + name: "Adjust Start Time", + schema: { + type: "number", + description: "The start time of the recording in seconds.", + min: 0, + }, + }, + timestamps: { + name: "Upload Timestamps", + schema: { + type: "string", + format: "file", + description: "A CSV file containing the timestamps of the recording.", + }, + }, + linked: { + name: "Link to Recording", + schema: { + type: "string", + description: "The name of the linked recording.", + placeholder: "Select a recording interface", + enum: [], + strict: true, + }, + }, +}; + +export class TimeAlignment extends LitElement { + static get styles() { + return css` + * { + box-sizing: border-box; + } + + :host { + display: block; + padding: 20px; + } + + :host > div { + display: flex; + flex-direction: column; + gap: 10px; + } + + :host > div > div { + display: flex; + align-items: center; + gap: 20px; + } + + :host > div > div > *:nth-child(1) { + width: 100%; + } + + :host > div > div > *:nth-child(2) { + display: flex; + flex-direction: column; + justify-content: center; + white-space: nowrap; + font-size: 90%; + min-width: 150px; + } + + :host > div > div > *:nth-child(2) > div { + cursor: pointer; + padding: 5px 10px; + border: 1px solid lightgray; + } + + :host > div > div > *:nth-child(3) { + width: 700px; + } + + .disclaimer { + font-size: 90%; + color: gray; + } + + label { + font-weight: bold; + } + + [selected] { + font-weight: bold; + background: whitesmoke; + } + `; + } + + static get properties() { + return { + data: { type: Object }, + }; + } + + constructor({ data = {}, results = {}, interfaces = {} }) { + super(); + this.data = data; + this.results = results; + this.interfaces = interfaces; + } + + render() { + const container = document.createElement("div"); + + const { timestamps, errors, metadata } = this.data; + + const flatTimes = Object.values(timestamps) + .map((interfaceTimestamps) => { + return [interfaceTimestamps[0], interfaceTimestamps.slice(-1)[0]]; + }) + .flat() + .filter((timestamp) => !isNaN(timestamp)); + + const minTime = Math.min(...flatTimes); + const maxTime = Math.max(...flatTimes); + + const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); + const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; + + const cachedErrors = {}; + + for (let name in timestamps) { + cachedErrors[name] = {}; + + if (!(name in this.results)) + this.results[name] = { + selected: undefined, + values: {}, + }; + + const row = document.createElement("div"); + // Object.assign(row.style, { + // display: 'flex', + // alignItems: 'center', + // justifyContent: 'space-between', + // gap: '10px', + // }); + + const barCell = document.createElement("div"); + + const label = document.createElement("label"); + label.innerText = name; + barCell.append(label); + + const info = timestamps[name]; + + const barContainer = document.createElement("div"); + Object.assign(barContainer.style, { + height: "10px", + width: "100%", + marginTop: "5px", + border: "1px solid lightgray", + position: "relative", + }); + + barCell.append(barContainer); + + const isSortingInterface = metadata[name].sorting === true; + const hasCompatibleInterfaces = isSortingInterface && metadata[name].compatible.length > 0; + + // Render this way if the interface has data + if (info.length > 0) { + const firstTime = info[0]; + const lastTime = info[info.length - 1]; + + const smallLabel = document.createElement("small"); + smallLabel.innerText = `${firstTime.toFixed(2)} - ${lastTime.toFixed(2)} sec`; + + const firstTimePct = normalizeTimePct(firstTime); + const lastTimePct = normalizeTimePct(lastTime); + + const width = `calc(${lastTimePct} - ${firstTimePct})`; + + const bar = document.createElement("div"); + + Object.assign(bar.style, { + position: "absolute", + left: firstTimePct, + width: width, + height: "100%", + background: "#029CFD", + }); + + barContainer.append(bar); + barCell.append(smallLabel); + } else { + barContainer.style.background = + "repeating-linear-gradient(45deg, lightgray, lightgray 10px, white 10px, white 20px)"; + } + + row.append(barCell); + + const selectionCell = document.createElement("div"); + const resultCell = document.createElement("div"); + + const optionsCopy = Object.entries(structuredClone(options)); + + optionsCopy[2][1].schema.enum = Object.keys(timestamps).filter((str) => + this.interfaces[str].includes("Recording") + ); + + const resolvedOptionEntries = hasCompatibleInterfaces ? optionsCopy : optionsCopy.slice(0, 2); + + const elements = resolvedOptionEntries.reduce((acc, [selected, option]) => { + const optionResults = this.results[name]; + + const clickableElement = document.createElement("div"); + clickableElement.innerText = option.name; + clickableElement.onclick = () => { + optionResults.selected = selected; + + Object.values(elements).forEach((el) => el.removeAttribute("selected")); + clickableElement.setAttribute("selected", ""); + + const element = new JSONSchemaInput({ + value: optionResults.values[selected], + schema: option.schema, + path: [], + controls: option.controls ? option.controls() : [], + onUpdate: (value) => (optionResults.values[selected] = value), + }); + + resultCell.innerHTML = ""; + resultCell.append(element); + + const errorMessage = cachedErrors[name][selected]; + if (errorMessage) { + const error = new InspectorListItem({ + type: "error", + message: `

Alignment Failed

${errorMessage}`, + }); + + error.style.marginTop = "5px"; + resultCell.append(error); + } + }; + + acc[selected] = clickableElement; + return acc; + }, {}); + + const elArray = Object.values(elements); + selectionCell.append(...elArray); + + const selected = this.results[name].selected; + if (errors[name]) cachedErrors[name][selected] = errors[name]; + + row.append(selectionCell, resultCell); + if (selected) elements[selected].click(); + else elArray[0].click(); + + // const empty = document.createElement("div"); + // const disclaimer = document.createElement("div"); + // disclaimer.classList.add("disclaimer"); + // disclaimer.innerText = "Edit in Source Data"; + // row.append(disclaimer, empty); + + container.append(row); + } + + return container; + } +} + +customElements.get("nwbguide-time-alignment") || customElements.define("nwbguide-time-alignment", TimeAlignment); From acce0793401b70463208cae08f5a9abf89d736c1 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 29 May 2024 13:46:33 -0700 Subject: [PATCH 20/30] Move alignment page --- .../components}/pages/guided-mode/data/alignment/TimeAlignment.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{renderer/src/stories => electron/frontend/core/components}/pages/guided-mode/data/alignment/TimeAlignment.js (100%) diff --git a/src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js b/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js similarity index 100% rename from src/renderer/src/stories/pages/guided-mode/data/alignment/TimeAlignment.js rename to src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js From b9e55532c3818ae8228a0c5e5c9a9392660160e5 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 29 May 2024 13:47:37 -0700 Subject: [PATCH 21/30] Update GuidedSourceData.js --- .../core/components/pages/guided-mode/data/GuidedSourceData.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e1fa9b226d..ac18596b41 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 @@ -286,7 +286,7 @@ export class GuidedSourceDataPage extends ManagedPage { alignment: alignmentInfo, }; - const data = await run("alignment", sessionInfo, { + const data = await run("neuroconv/alignment", sessionInfo, { title: "Checking Alignment", message: "Please wait...", }); From 7c2cae1281781806e08f1e59cdbf1598c6d82bda Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 29 May 2024 13:48:57 -0700 Subject: [PATCH 22/30] Fix imports and extraneous logs --- .../core/components/pages/guided-mode/data/GuidedMetadata.js | 2 -- .../components/pages/guided-mode/data/GuidedSourceData.js | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) 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 0efe5162a6..724327498b 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 @@ -247,8 +247,6 @@ export class GuidedMetadataPage extends ManagedPage { const patternPropsToRetitle = ["Ophys.Fluorescence", "Ophys.DfOverF", "Ophys.SegmentationImages"]; - console.log("schema", structuredClone(schema), structuredClone(results)); - const ophys = schema.properties.Ophys; if (ophys) { drillSchemaProperties( 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 ac18596b41..7bf1029c19 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,7 +4,7 @@ import { JSONSchemaForm } from "../../../JSONSchemaForm.js"; import { InstanceManager } from "../../../InstanceManager.js"; import { ManagedPage } from "./ManagedPage.js"; import { onThrow } from "../../../../errors"; -import { merge } from "../../utils"; +import { merge, sanitize } from "../../utils"; import preprocessSourceDataSchema from "../../../../../../../schemas/source-data.schema"; import { TimeAlignment } from "./alignment/TimeAlignment.js"; @@ -20,6 +20,8 @@ import { getInfoFromId } from "./utils"; import { Modal } from "../../../Modal"; import Swal from "sweetalert2"; +import { baseUrl } from "../../../../server/globals"; + const propsToIgnore = { "*": { verbose: true, From e6f6614a8b9b8301b3596b11fc1ac9561b4b36bb Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Thu, 30 May 2024 09:09:46 -0700 Subject: [PATCH 23/30] Fix compatible interface derivation --- .../manageNeuroconv/manage_neuroconv.py | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/pyflask/manageNeuroconv/manage_neuroconv.py b/src/pyflask/manageNeuroconv/manage_neuroconv.py index 4a938fe165..1e3e4edfb9 100644 --- a/src/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/src/pyflask/manageNeuroconv/manage_neuroconv.py @@ -740,6 +740,15 @@ def set_interface_alignment(converter, alignment_info): def get_interface_alignment(info: dict) -> dict: + from neuroconv.datainterfaces.ecephys.basesortingextractorinterface import ( + BaseSortingExtractorInterface, + ) + + from neuroconv.datainterfaces.ecephys.baserecordingextractorinterface import ( + BaseRecordingExtractorInterface, + ) + + alignment_info = info.get("alignment", {}) converter = instantiate_custom_converter(info["source_data"], info["interfaces"]) @@ -750,18 +759,8 @@ def get_interface_alignment(info: dict) -> dict: for name, interface in converter.data_interface_objects.items(): metadata[name] = dict() - is_sorting = metadata[name]["sorting"] = hasattr(interface, "sorting_extractor") - - if is_sorting: - metadata[name]["compatible"] = [] - for sub_name in alignment_info.keys(): - sub_interface = converter.data_interface_objects[sub_name] - if hasattr(sub_interface, "recording_extractor"): - try: - interface.register_recording(sub_interface) - metadata[name]["compatible"].append(name) - except Exception: - pass + + metadata[name]["sorting"] = hasattr(interface, "sorting_extractor") # Run interface.get_timestamps if it has the method if hasattr(interface, "get_timestamps"): @@ -775,6 +774,24 @@ def get_interface_alignment(info: dict) -> dict: else: timestamps[name] = [] + + + # Derive compatible interfaces + def on_sorting_interface(name, sorting_interface): + metadata[name]["compatible"] = [] + + def on_recording_interface(sub_name, recording_interface): + try: + sorting_interface.register_recording(recording_interface) + metadata[name]["compatible"].append(sub_name) + except Exception: + pass + + map_interfaces(on_recording_interface, converter=converter, to_match=BaseRecordingExtractorInterface) + + map_interfaces(on_sorting_interface, converter=converter, to_match=BaseSortingExtractorInterface) + + # Return the metadata and timestamps return dict( metadata=metadata, timestamps=timestamps, From 475eb161c30a5095128870501b4138d7263227d5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 May 2024 16:10:39 +0000 Subject: [PATCH 24/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyflask/manageNeuroconv/manage_neuroconv.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/pyflask/manageNeuroconv/manage_neuroconv.py b/src/pyflask/manageNeuroconv/manage_neuroconv.py index 1e3e4edfb9..d282e5c8c3 100644 --- a/src/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/src/pyflask/manageNeuroconv/manage_neuroconv.py @@ -740,14 +740,12 @@ def set_interface_alignment(converter, alignment_info): def get_interface_alignment(info: dict) -> dict: - from neuroconv.datainterfaces.ecephys.basesortingextractorinterface import ( - BaseSortingExtractorInterface, - ) - from neuroconv.datainterfaces.ecephys.baserecordingextractorinterface import ( BaseRecordingExtractorInterface, ) - + from neuroconv.datainterfaces.ecephys.basesortingextractorinterface import ( + BaseSortingExtractorInterface, + ) alignment_info = info.get("alignment", {}) converter = instantiate_custom_converter(info["source_data"], info["interfaces"]) @@ -774,8 +772,6 @@ def get_interface_alignment(info: dict) -> dict: else: timestamps[name] = [] - - # Derive compatible interfaces def on_sorting_interface(name, sorting_interface): metadata[name]["compatible"] = [] @@ -786,9 +782,9 @@ def on_recording_interface(sub_name, recording_interface): metadata[name]["compatible"].append(sub_name) except Exception: pass - + map_interfaces(on_recording_interface, converter=converter, to_match=BaseRecordingExtractorInterface) - + map_interfaces(on_sorting_interface, converter=converter, to_match=BaseSortingExtractorInterface) # Return the metadata and timestamps From 0c5f6f891b4624b94539a72911a5790e66e23f3a Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Thu, 30 May 2024 09:16:43 -0700 Subject: [PATCH 25/30] Simplify compatibility derivation and fix error handling during auto-conversion --- .../frontend/core/components/Dashboard.js | 14 ++++---- .../manageNeuroconv/manage_neuroconv.py | 34 ++++++++----------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js index 9dd1287244..433ca978f1 100644 --- a/src/electron/frontend/core/components/Dashboard.js +++ b/src/electron/frontend/core/components/Dashboard.js @@ -274,14 +274,12 @@ export class Dashboard extends LitElement { } }) .catch((e) => { - const previousId = previous?.info?.id; - if (previousId) { - page.onTransition(previousId); // Revert back to previous page - page.notify( - `

Fallback to previous page after error occurred

${e}`, - "error" - ); - } else reloadPageToHome(); + const previousId = previous?.info?.id ?? -1; + this.main.onTransition(previousId); // Revert back to previous page + page.notify( + `

Fallback to previous page after error occurred

${e}`, + "error" + ); }); } diff --git a/src/pyflask/manageNeuroconv/manage_neuroconv.py b/src/pyflask/manageNeuroconv/manage_neuroconv.py index 1e3e4edfb9..c716570557 100644 --- a/src/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/src/pyflask/manageNeuroconv/manage_neuroconv.py @@ -728,9 +728,7 @@ def set_interface_alignment(converter, alignment_info): interface.register_recording(converter.data_interface_objects[value]) elif method == "start": - interface.set_aligned_starting_time( - value - ) # For sorting interfaces, an empty array will still be returned + interface.set_aligned_starting_time(value) # For sorting interfaces, an empty array will still be returned except Exception as e: errors[name] = str(e) @@ -756,11 +754,23 @@ def get_interface_alignment(info: dict) -> dict: metadata = {} timestamps = {} + for name, interface in converter.data_interface_objects.items(): metadata[name] = dict() - metadata[name]["sorting"] = hasattr(interface, "sorting_extractor") + is_sorting = metadata[name]["sorting"] = hasattr(interface, "sorting_extractor") + + if is_sorting: + metadata[name]["compatible"] = [] + + for sibling_name, sibling_interface in converter.data_interface_objects.items(): + if sibling_name != name: + try: + interface.register_recording(sibling_interface) + metadata[name]["compatible"].append(sibling_name) + except Exception: + pass # Run interface.get_timestamps if it has the method if hasattr(interface, "get_timestamps"): @@ -775,22 +785,6 @@ def get_interface_alignment(info: dict) -> dict: timestamps[name] = [] - - # Derive compatible interfaces - def on_sorting_interface(name, sorting_interface): - metadata[name]["compatible"] = [] - - def on_recording_interface(sub_name, recording_interface): - try: - sorting_interface.register_recording(recording_interface) - metadata[name]["compatible"].append(sub_name) - except Exception: - pass - - map_interfaces(on_recording_interface, converter=converter, to_match=BaseRecordingExtractorInterface) - - map_interfaces(on_sorting_interface, converter=converter, to_match=BaseSortingExtractorInterface) - # Return the metadata and timestamps return dict( metadata=metadata, From 42f2339c2faf47d730f11d8035843ade9cf91d99 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 May 2024 16:17:15 +0000 Subject: [PATCH 26/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyflask/manageNeuroconv/manage_neuroconv.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pyflask/manageNeuroconv/manage_neuroconv.py b/src/pyflask/manageNeuroconv/manage_neuroconv.py index 6518cf9976..3905adde2c 100644 --- a/src/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/src/pyflask/manageNeuroconv/manage_neuroconv.py @@ -728,7 +728,9 @@ def set_interface_alignment(converter, alignment_info): interface.register_recording(converter.data_interface_objects[value]) elif method == "start": - interface.set_aligned_starting_time(value) # For sorting interfaces, an empty array will still be returned + interface.set_aligned_starting_time( + value + ) # For sorting interfaces, an empty array will still be returned except Exception as e: errors[name] = str(e) @@ -782,7 +784,6 @@ def get_interface_alignment(info: dict) -> dict: else: timestamps[name] = [] - # Return the metadata and timestamps return dict( metadata=metadata, From 589eb54916cf9cb8e1497b3d2cf48f2daf2734e7 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Thu, 30 May 2024 09:21:33 -0700 Subject: [PATCH 27/30] Update Dashboard.js --- src/electron/frontend/core/components/Dashboard.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js index 433ca978f1..4161163b1d 100644 --- a/src/electron/frontend/core/components/Dashboard.js +++ b/src/electron/frontend/core/components/Dashboard.js @@ -276,8 +276,9 @@ export class Dashboard extends LitElement { .catch((e) => { const previousId = previous?.info?.id ?? -1; this.main.onTransition(previousId); // Revert back to previous page + const hasHTML = /<[^>]*>/.test(e); page.notify( - `

Fallback to previous page after error occurred

${e}`, + hasHTML ? e.message : `

Fallback to previous page after error occurred

${e}`, "error" ); }); From 518c17a3b07e2367bc141ec4947c8c6c53fbc3ec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 May 2024 16:22:20 +0000 Subject: [PATCH 28/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/electron/frontend/core/components/Dashboard.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js index 4161163b1d..d2a72d0e83 100644 --- a/src/electron/frontend/core/components/Dashboard.js +++ b/src/electron/frontend/core/components/Dashboard.js @@ -278,7 +278,9 @@ export class Dashboard extends LitElement { this.main.onTransition(previousId); // Revert back to previous page const hasHTML = /<[^>]*>/.test(e); page.notify( - hasHTML ? e.message : `

Fallback to previous page after error occurred

${e}`, + hasHTML + ? e.message + : `

Fallback to previous page after error occurred

${e}`, "error" ); }); From f248d06b0897590f016b68d475a2f5831db53f52 Mon Sep 17 00:00:00 2001 From: Cody Baker Date: Thu, 30 May 2024 15:08:48 -0400 Subject: [PATCH 29/30] refactors for readability --- .../manageNeuroconv/manage_neuroconv.py | 174 +++++++++++------- 1 file changed, 103 insertions(+), 71 deletions(-) diff --git a/src/pyflask/manageNeuroconv/manage_neuroconv.py b/src/pyflask/manageNeuroconv/manage_neuroconv.py index 3905adde2c..e5e6ded054 100644 --- a/src/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/src/pyflask/manageNeuroconv/manage_neuroconv.py @@ -340,9 +340,11 @@ def get_all_interface_info() -> dict: # Combine Multiple Interfaces -def get_custom_converter(interface_class_dict: dict, alignment_info: dict = dict()) -> "NWBConverter": +def get_custom_converter(interface_class_dict: dict, alignment_info: Union[dict, None] = None) -> "NWBConverter": from neuroconv import NWBConverter, converters, datainterfaces + alignment_info = alignment_info or dict() + class CustomNWBConverter(NWBConverter): data_interface_classes = { custom_name: getattr(datainterfaces, interface_name, getattr(converters, interface_name, None)) @@ -350,17 +352,21 @@ class CustomNWBConverter(NWBConverter): } # Handle temporal alignment inside the converter + # TODO: this currently works off of cross-scoping injection of `alignment_info` - refactor to be more explicit def temporally_align_data_interfaces(self): - set_interface_alignment(self, alignment_info) + set_interface_alignment(self, alignment_info=alignment_info) return CustomNWBConverter -def instantiate_custom_converter(source_data, interface_class_dict, alignment_info: dict = dict()): # -> NWBConverter: +def instantiate_custom_converter( + source_data: Dict, interface_class_dict: Dict, alignment_info: Union[Dict, None] = None +) -> "NWBConverter": + alignment_info = alignment_info or dict() - CustomNWBConverter = get_custom_converter(interface_class_dict, alignment_info) + CustomNWBConverter = get_custom_converter(interface_class_dict=interface_class_dict, alignment_info=alignment_info) - return CustomNWBConverter(source_data) + return CustomNWBConverter(source_data=source_data) def get_source_schema(interface_class_dict: dict) -> dict: @@ -680,11 +686,10 @@ def validate_metadata(metadata: dict, check_function_name: str) -> dict: return json.loads(json.dumps(result, cls=InspectorOutputJSONEncoder)) -def set_interface_alignment(converter, alignment_info): - - import csv +def set_interface_alignment(converter: dict, alignment_info: dict) -> dict: import numpy as np + from neuroconv.datainterfaces.ecephys.basesortingextractorinterface import BaseSortingExtractorInterface from neuroconv.tools.testing.mock_interfaces import MockRecordingInterface errors = {} @@ -696,50 +701,56 @@ def set_interface_alignment(converter, alignment_info): # Set alignment method = info.get("selected", None) - if method: - value = info["values"].get(method, None) - if value != None: + if method is None: + continue + + value = info["values"].get(method, None) + if value is None: + continue + + try: + if method == "timestamps": + + # Open the input file for reading + # Can be .txt, .csv, .tsv, etc. + # But timestamps must be scalars separated by newline characters + with open(file=value, mode="r") as io: + aligned_timestamps = np.array([float(line.strip()) for line in io.readlines()]) + + # Special case for sorting interfaces; to set timestamps they must have a recording registered + must_set_mock_recording = ( + isinstance(interface, BaseSortingExtractorInterface) + and not interface.sorting_extractor.has_recording() + ) + if must_set_mock_recording is True: + sorting_extractor = interface.sorting_extractor + sampling_frequency = sorting_extractor.get_sampling_frequency() + end_frame = timestamps_array.shape[0] + mock_recording_interface = MockRecordingInterface( + sampling_frequency=sampling_frequency, + durations=[end_frame / sampling_frequency], + num_channels=1, + ) + interface.register_recording(recording_interface=mock_recording_interface) - try: - if method == "timestamps": - - # Open the input CSV file for reading - with open(value, mode="r", newline="") as csvfile: - reader = csv.reader(csvfile) - rows = list(reader) - timestamps_array = [float(row[0]) for row in rows] - - # NOTE: Not sure if it's acceptable to provide timestamps of an arbitrary size - # Use the provided timestamps to align the interface - if hasattr(interface, "sorting_extractor"): - if not interface.sorting_extractor.has_recording(): - extractor = interface.sorting_extractor - fs = extractor.get_sampling_frequency() - end_frame = len(timestamps_array) - mock_recording_interface = MockRecordingInterface( - sampling_frequency=fs, durations=[end_frame / fs], num_channels=1 - ) - interface.register_recording(mock_recording_interface) - - interface.set_aligned_timestamps(np.array(timestamps_array)) - - # Register the linked interface - elif method == "linked": - interface.register_recording(converter.data_interface_objects[value]) - - elif method == "start": - interface.set_aligned_starting_time( - value - ) # For sorting interfaces, an empty array will still be returned + interface.set_aligned_timestamps(aligned_timestamps=aligned_timestamps) + + # Special case for sorting interfaces; a recording interface to be converted may be registered/linked + elif method == "linked": + interface.register_recording(converter.data_interface_objects[value]) - except Exception as e: - errors[name] = str(e) + elif method == "start": + interface.set_aligned_starting_time(aligned_starting_time=value) + + except Exception as e: + errors[name] = str(e) return errors def get_interface_alignment(info: dict) -> dict: + from neuroconv.basetemporalalignmentinterface import BaseTemporalAlignmentInterface from neuroconv.datainterfaces.ecephys.baserecordingextractorinterface import ( BaseRecordingExtractorInterface, ) @@ -747,44 +758,61 @@ def get_interface_alignment(info: dict) -> dict: BaseSortingExtractorInterface, ) - alignment_info = info.get("alignment", {}) - converter = instantiate_custom_converter(info["source_data"], info["interfaces"]) + alignment_info = info.get("alignment", dict()) + converter = instantiate_custom_converter(source_data=info["source_data"], interface_class_dict=info["interfaces"]) - errors = set_interface_alignment(converter, alignment_info) + errors = set_interface_alignment(converter=converter, alignment_info=alignment_info) - metadata = {} - timestamps = {} + metadata = dict() + timestamps = dict() for name, interface in converter.data_interface_objects.items(): metadata[name] = dict() - is_sorting = metadata[name]["sorting"] = hasattr(interface, "sorting_extractor") + is_sorting = isinstance(interface, BaseSortingExtractorInterface) + metadata[name]["sorting"] = is_sorting - if is_sorting: + if is_sorting is True: metadata[name]["compatible"] = [] - for sibling_name, sibling_interface in converter.data_interface_objects.items(): - if sibling_name != name: - try: - interface.register_recording(sibling_interface) - metadata[name]["compatible"].append(sibling_name) - except Exception: - pass - - # Run interface.get_timestamps if it has the method - if hasattr(interface, "get_timestamps"): - try: - interface_timestamps = interface.get_timestamps() - if len(interface_timestamps) == 1: - interface_timestamps = interface_timestamps[0] # TODO: Correct for video interface nesting - timestamps[name] = interface_timestamps.tolist() - except Exception: - timestamps[name] = [] - else: + # If at least one recording and sorting interface is selected on the formats page + # Then it is possible the two could be linked (the sorting was applied to the recording) + # But there are very strict conditions from SpikeInterface determining compatibility + # Those conditions are not easily exposed so we just 'try' to register them and skip on error + sibling_recording_interfaces = { + interface_key: interface for interface_key, interface in converter.data_interface_objects.items() + if isinstance(interface, BaseRecordingExtractorInterface) + } + for recording_interface_key, recording_interface in sibling_recording_interfaces.items(): + try: + interface.register_recording(recording_interface=recording_interface) + metadata[name]["compatible"].append(recording_interface_key) + except Exception: + pass + + if not isinstance(interface, BaseTemporalAlignmentInterface): + timestamps[name] = [] + continue + + # Note: it is technically possible to have a BaseTemporalAlignmentInterface that has not yet implemented + # the `get_timestamps` method; try to get this but skip on error + try: + interface_timestamps = interface.get_timestamps() + if len(interface_timestamps) == 1: + interface_timestamps = interface_timestamps[0] + + # Some interfaces, such as video or audio, may return a list of arrays + # corresponding to each file of their `file_paths` input + # Note: GUIDE only currently supports single files for these interfaces + # Thus, unpack only the first array + if isinstance(interface_timestamps, list): + interface_timestamps = interface_timestamps[0] + + timestamps[name] = interface_timestamps.tolist() + except Exception: timestamps[name] = [] - # Return the metadata and timestamps return dict( metadata=metadata, timestamps=timestamps, @@ -822,7 +850,11 @@ def convert_to_nwb(info: dict) -> str: info["source_data"], resolve_references(get_custom_converter(info["interfaces"]).get_source_schema()) ) - converter = instantiate_custom_converter(resolved_source_data, info["interfaces"], info.get("alignment", {})) + converter = instantiate_custom_converter( + source_data=resolved_source_data, + interface_class_dict=info["interfaces"], + alignment_info=info.get("alignment", dict()) + ) def update_conversion_progress(**kwargs): announcer.announce(dict(**kwargs, nwbfile_path=nwbfile_path), "conversion_progress") From 5231454b83be2fa91fdb108acde9c324637fc479 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 May 2024 19:09:31 +0000 Subject: [PATCH 30/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyflask/manageNeuroconv/manage_neuroconv.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pyflask/manageNeuroconv/manage_neuroconv.py b/src/pyflask/manageNeuroconv/manage_neuroconv.py index e5e6ded054..96ad6dc9e0 100644 --- a/src/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/src/pyflask/manageNeuroconv/manage_neuroconv.py @@ -689,7 +689,9 @@ def validate_metadata(metadata: dict, check_function_name: str) -> dict: def set_interface_alignment(converter: dict, alignment_info: dict) -> dict: import numpy as np - from neuroconv.datainterfaces.ecephys.basesortingextractorinterface import BaseSortingExtractorInterface + from neuroconv.datainterfaces.ecephys.basesortingextractorinterface import ( + BaseSortingExtractorInterface, + ) from neuroconv.tools.testing.mock_interfaces import MockRecordingInterface errors = {} @@ -781,7 +783,8 @@ def get_interface_alignment(info: dict) -> dict: # But there are very strict conditions from SpikeInterface determining compatibility # Those conditions are not easily exposed so we just 'try' to register them and skip on error sibling_recording_interfaces = { - interface_key: interface for interface_key, interface in converter.data_interface_objects.items() + interface_key: interface + for interface_key, interface in converter.data_interface_objects.items() if isinstance(interface, BaseRecordingExtractorInterface) } for recording_interface_key, recording_interface in sibling_recording_interfaces.items(): @@ -853,7 +856,7 @@ def convert_to_nwb(info: dict) -> str: converter = instantiate_custom_converter( source_data=resolved_source_data, interface_class_dict=info["interfaces"], - alignment_info=info.get("alignment", dict()) + alignment_info=info.get("alignment", dict()), ) def update_conversion_progress(**kwargs):