diff --git a/pyflask/apis/neuroconv.py b/pyflask/apis/neuroconv.py index 77661b8ff..8b0dbd375 100644 --- a/pyflask/apis/neuroconv.py +++ b/pyflask/apis/neuroconv.py @@ -122,6 +122,78 @@ def post(self): neuroconv_api.abort(500, str(e)) +@neuroconv_api.route("/inspect_file") +class InspectNWBFile(Resource): + @neuroconv_api.doc(responses={200: "Success", 400: "Bad Request", 500: "Internal server error"}) + def post(self): + try: + import json + from nwbinspector import inspect_nwbfile + from nwbinspector.nwbinspector import InspectorOutputJSONEncoder + + return json.loads( + json.dumps( + list( + inspect_nwbfile( + ignore=[ + "check_description", + "check_data_orientation", + ], # TODO: remove when metadata control is exposed + **neuroconv_api.payload, + ) + ), + cls=InspectorOutputJSONEncoder, + ) + ) + + except Exception as e: + if notBadRequestException(e): + neuroconv_api.abort(500, str(e)) + + +@neuroconv_api.route("/inspect_folder") +class InspectNWBFolder(Resource): + @neuroconv_api.doc(responses={200: "Success", 400: "Bad Request", 500: "Internal server error"}) + def post(self): + try: + import json + from nwbinspector import inspect_all + from nwbinspector.nwbinspector import InspectorOutputJSONEncoder + + messages = list( + inspect_all( + n_jobs=-2, # uses number of CPU - 1 + ignore=[ + "check_description", + "check_data_orientation", + ], # TODO: remove when metadata control is exposed + **neuroconv_api.payload, + ) + ) + + return json.loads(json.dumps(messages, cls=InspectorOutputJSONEncoder)) + + except Exception as e: + if notBadRequestException(e): + neuroconv_api.abort(500, str(e)) + + +@neuroconv_api.route("/html") +class NWBToHTML(Resource): + @neuroconv_api.doc(responses={200: "Success", 400: "Bad Request", 500: "Internal server error"}) + def post(self): + try: + from pynwb import NWBHDF5IO + + with NWBHDF5IO(neuroconv_api.payload.nwbfile_path, mode="r") as io: + html = io.read()._repr_html_() + return html + + except Exception as e: + if notBadRequestException(e): + neuroconv_api.abort(500, str(e)) + + @neuroconv_api.route("/generate_dataset") class GenerateDataset(Resource): @neuroconv_api.doc(responses={200: "Success", 400: "Bad Request", 500: "Internal server error"}) diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index fbcfd62d8..29949b0c6 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -327,7 +327,7 @@ def update_conversion_progress(**kwargs): if not default_output_directory.exists(): os.symlink(resolved_output_directory, default_output_directory) - return str(resolved_output_path) + return dict(file=str(resolved_output_path)) def upload_to_dandi( diff --git a/src/renderer/src/index.ts b/src/renderer/src/index.ts index f00643058..efbc4b093 100644 --- a/src/renderer/src/index.ts +++ b/src/renderer/src/index.ts @@ -50,7 +50,7 @@ async function isOffline() { await Swal.fire({ title: "No Internet Connection", icon: "warning", - text: "It appears that your computer is not connected to the internet. You may continue, but you will not be able to use features of NWB GUIDE related to uploading data to DANDI.", + text: "You may continue, but certain features (e.g. uploading data to DANDI, viewing data on Neurosift, etc.) will be unavailable.", heightAuto: false, backdrop: "rgba(0,0,0, 0.4)", confirmButtonText: "I understand", @@ -87,7 +87,7 @@ async function checkInternetConnection() { window.addEventListener('online', isOnline); window.addEventListener('offline', isOffline); - let hasInternet = navigator.onLine + const hasInternet = navigator.onLine if (hasInternet) isOnline() else await isOffline() diff --git a/src/renderer/src/stories/InstanceManager.js b/src/renderer/src/stories/InstanceManager.js index 328882416..639766b94 100644 --- a/src/renderer/src/stories/InstanceManager.js +++ b/src/renderer/src/stories/InstanceManager.js @@ -132,13 +132,20 @@ export class InstanceManager extends LitElement { this.instances = props.instances ?? {}; this.header = props.header; this.instanceType = props.instanceType ?? "Instance"; - if (props.renderInstance) this.renderInstance = props.renderInstance; if (props.onAdded) this.onAdded = props.onAdded; if (props.onRemoved) this.onRemoved = props.onRemoved; this.controls = props.controls ?? []; } - renderInstance = (_, value) => value.content ?? value; + #dynamicInstances = {}; + + getInstanceContent = ({ id, metadata }) => { + const content = metadata.content ?? metadata; + if (typeof content === "function") { + this.#dynamicInstances[id] = content; + return ""; + } else return content; + }; updateState = (id, state) => { const item = this.#items.find((i) => i.id === id); @@ -211,6 +218,8 @@ export class InstanceManager extends LitElement { el.removeAttribute("selected"); this.shadowRoot.querySelector(`div[data-instance="${instance}"]`).setAttribute("hidden", ""); }); + + this.#onSelected(); }; #isCategory(value) { @@ -258,6 +267,13 @@ export class InstanceManager extends LitElement { #onSelected = () => { const selected = this.shadowRoot.querySelector("#selectedName"); selected.innerText = this.#selected; + + const dynamic = this.#dynamicInstances[this.#selected]; + if (typeof dynamic === "function") { + this.shadowRoot + .querySelector(`div[data-instance="${this.#selected}"]`) + .append((this.#dynamicInstances[this.#selected] = dynamic())); + } }; #render(toRender = this.instances, path = []) { @@ -293,7 +309,7 @@ export class InstanceManager extends LitElement { const list = html` ${Object.entries(instances).map(([key, info], i) => { - if (info instanceof HTMLElement) info = { content: info }; + if (info instanceof HTMLElement || typeof info === "function") info = { content: info }; const listItemInfo = { id: key, label: key.split("/").pop(), @@ -399,7 +415,7 @@ export class InstanceManager extends LitElement {
- ${this.#selected} +
${this.controls.map((o) => { return html` { return html`
- ${this.renderInstance(item.id, item.metadata)} + ${this.getInstanceContent(item)}
`; })} diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index 4ecd4447f..abb1918d1 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -8,6 +8,7 @@ import { merge } from "./pages/utils"; import { resolveProperties } from "./pages/guided-mode/data/utils"; import { JSONSchemaInput } from "./JSONSchemaInput"; +import { InspectorListItem } from "./preview/inspector/InspectorList"; const componentCSS = ` @@ -29,30 +30,6 @@ const componentCSS = ` background: rgb(255, 229, 228) !important; } - .errors { - color: #9d0b0b; - } - - .errors > * { - padding: 25px; - background: #f8d7da; - border: 1px solid #f5c2c7; - border-radius: 4px; - margin: 0 0 1em; - } - - .warnings { - color: #856404; - } - - .warnings > * { - padding: 25px; - background: #fff3cd; - border: 1px solid #ffeeba; - border-radius: 4px; - margin: 0 0 1em; - } - .guided--form-label { display: block; width: 100%; @@ -277,9 +254,8 @@ export class JSONSchemaForm extends LitElement { #addMessage = (name, message, type) => { if (Array.isArray(name)) name = name.join("-"); // Convert array to string const container = this.shadowRoot.querySelector(`#${name} .${type}`); - const p = document.createElement("p"); - p.innerText = message; - container.appendChild(p); + const item = new InspectorListItem(message); + container.appendChild(item); }; #clearMessages = (fullPath, type) => { @@ -625,7 +601,7 @@ export class JSONSchemaForm extends LitElement { this.checkStatus(); // Show aggregated errors and warnings (if any) - warnings.forEach((info) => this.#addMessage(fullPath, info.message, "warnings")); + warnings.forEach((info) => this.#addMessage(fullPath, info, "warnings")); const isFunction = typeof valid === "function"; @@ -658,7 +634,7 @@ export class JSONSchemaForm extends LitElement { [...path, name] ); - errors.forEach((info) => this.#addMessage(fullPath, info.message, "errors")); + errors.forEach((info) => this.#addMessage(fullPath, info, "errors")); // element.title = errors.map((info) => info.message).join("\n"); // Set all errors to show on hover return false; diff --git a/src/renderer/src/stories/List.stories.js b/src/renderer/src/stories/List.stories.js index 36fc9152b..718492ef9 100644 --- a/src/renderer/src/stories/List.stories.js +++ b/src/renderer/src/stories/List.stories.js @@ -6,9 +6,11 @@ export default { const Template = (args) => new List(args); +const generateString = () => Math.floor(Math.random() * Date.now()).toString(36); + export const Default = Template.bind({}); Default.args = { - items: [{ value: "test" }], + items: [{ value: "test" }, { value: Array.from({ length: 1000 }).map(generateString).join("") }], }; export const WithKeys = Template.bind({}); diff --git a/src/renderer/src/stories/List.ts b/src/renderer/src/stories/List.ts index 7a6a3bb21..69c57b1b8 100644 --- a/src/renderer/src/stories/List.ts +++ b/src/renderer/src/stories/List.ts @@ -1,17 +1,20 @@ import { LitElement, html, css } from 'lit'; import { Button } from './Button' -import { empty } from 'handsontable/helpers/dom'; +import { styleMap } from "lit/directives/style-map.js"; type ListItemType = { key: string, - label: string, + content: string, value: any, } export interface ListProps { onChange?: () => void; items?: ListItemType[] - emptyMessage?: string + emptyMessage?: string, + editable?: boolean, + unordered?: boolean, + listStyles?: any } export class List extends LitElement { @@ -19,16 +22,15 @@ export class List extends LitElement { static get styles() { return css` + :host { + overflow: auto; + } + #empty { padding: 20px 10px; color: gray; } - li > div { - display: flex; - align-items: center; - } - li { padding-bottom: 10px; } @@ -37,19 +39,35 @@ export class List extends LitElement { padding-bottom: 0px; } + li > div { + display: flex; + align-items: center; + } + li > div > div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis } - :host([keys]) ol { - list-style-type:none; + :host([unordered]) ol { + list-style-type: none; + display: flex; + flex-wrap: wrap; + margin: 0; + padding: 0; } - :host([keys]) ol > li > div { + :host([unordered]) ol > li { + width: 100%; + } + + :host([unordered]) ol > li { justify-content: center; + display: flex; + align-items: center; } + `; } @@ -57,12 +75,22 @@ export class List extends LitElement { return { items: { type: Array, - reflect: true - }, - emptyMessage: { - type: String, - reflect: true - } + // reflect: true // NOTE: Cannot reflect items since they could include an element + }, + + editable: { + type: Boolean, + reflect: true + }, + + unordered: { + type: Boolean, + // reflect: true + }, + emptyMessage: { + type: String, + reflect: true + } }; } @@ -74,22 +102,36 @@ export class List extends LitElement { return this.items.map(o => o.value) } - declare items: ListItemType[] + #items: ListItemType[] = [] + set items(value) { + const oldVal = this.#items + this.#items = value + this.onChange() + this.requestUpdate('items', oldVal) + } + + get items() { return this.#items } + declare emptyMessage: string + declare editable: boolean + declare unordered: boolean + + declare listStyles: any + + constructor(props: ListProps = {}) { super(); this.items = props.items ?? [] this.emptyMessage = props.emptyMessage ?? '' - if (props.onChange) this.onChange = props.onChange + this.editable = props.editable ?? true + this.unordered = props.unordered ?? false + this.listStyles = props.listStyles ?? {} - } + if (props.onChange) this.onChange = props.onChange - attributeChangedCallback(name: string, _old: string | null, value: string | null): void { - super.attributeChangedCallback(name, _old, value) - if (name === 'items') this.onChange() } add = (item: ListItemType) => { @@ -97,13 +139,13 @@ export class List extends LitElement { } #renderListItem = (item: ListItemType, i: number) => { - const { key, value, label = value } = item; + const { key, value, content = value } = item; const li = document.createElement("li"); - const div = document.createElement('div') - li.append(div) - const innerDiv = document.createElement('div') - div.append(innerDiv) + const outerDiv = document.createElement('div') + const div = document.createElement('div') + li.append(outerDiv) + outerDiv.append(div) const keyEl = document.createElement("span"); @@ -112,7 +154,7 @@ export class List extends LitElement { if (key) { - this.setAttribute('keys', '') + this.setAttribute('unordered', '') // Ensure no duplicate keys let i = 0; @@ -124,51 +166,58 @@ export class List extends LitElement { keyEl.innerText = resolvedKey; keyEl.contentEditable = true; keyEl.style.cursor = "text"; - innerDiv.appendChild(keyEl); const sepEl = document.createElement("span"); sepEl.innerHTML = " - "; - innerDiv.appendChild(sepEl); + div.append(keyEl, sepEl); this.object[resolvedKey] = value; } else this.object[i] = value; - const valueEl = document.createElement("span"); - valueEl.innerText = label; - innerDiv.appendChild(valueEl); + if (typeof content === 'string') { + const valueEl = document.createElement("span"); + valueEl.innerText = content; + div.appendChild(valueEl); + } + else li.append(content) + - const button = new Button({ - label: "Delete", - size: "small", - }); - button.style.marginLeft = "1rem"; + if (div.innerText) li.title = div.innerText - div.appendChild(button); - // Stop enter key from creating new line - keyEl.addEventListener("keydown", function (e) { - if (e.keyCode === 13) { - keyEl.blur(); - return false; - } - }); + if (this.editable) { + const button = new Button({ + label: "Delete", + size: "small", + }); - const deleteListItem = () => this.delete(i); + button.style.marginLeft = "1rem"; - keyEl.addEventListener("blur", () => { - const newKey = keyEl.innerText; - if (newKey === "") keyEl.innerText = resolvedKey; // Reset to original value - else { - delete this.object[resolvedKey]; - resolvedKey = newKey; - this.object[resolvedKey] = value; - } - }); + outerDiv.appendChild(button); - button.onClick = deleteListItem; + // Stop enter key from creating new line + keyEl.addEventListener("keydown", function (e) { + if (e.keyCode === 13) { + keyEl.blur(); + return false; + } + }); - innerDiv.title = innerDiv.innerText + const deleteListItem = () => this.delete(i); + + keyEl.addEventListener("blur", () => { + const newKey = keyEl.innerText; + if (newKey === "") keyEl.innerText = resolvedKey; // Reset to original value + else { + delete this.object[resolvedKey]; + resolvedKey = newKey; + this.object[resolvedKey] = value; + } + }); + + button.onClick = deleteListItem; + } return li }; @@ -184,13 +233,15 @@ export class List extends LitElement { render() { - this.removeAttribute('keys') + this.removeAttribute('unordered') + if (this.unordered) this.setAttribute('unordered', '') + this.object = {} const { items, emptyMessage} = this return items.length || !emptyMessage ? html` -
    +
      ${items.map(this.#renderListItem)}
    ` : html`
    ${emptyMessage}
    ` } diff --git a/src/renderer/src/stories/Loader.ts b/src/renderer/src/stories/Loader.ts index 9e0b93062..97e3febf9 100644 --- a/src/renderer/src/stories/Loader.ts +++ b/src/renderer/src/stories/Loader.ts @@ -10,6 +10,19 @@ export class Loader extends LitElement { :host { display: block; } + + span { + font-size: 90%; + padding-left: 10px; + } + + :host > div { + display: flex; + align-items: center; + justif-content: center; + } + + .lds-default { display: inline-block; position: relative; @@ -96,12 +109,20 @@ export class Loader extends LitElement { ` } - constructor(){ + declare message: string + + constructor(props: any){ super() + Object.assign(this, props) } render() { - return html`
    ` + return html` +
    +
    + ${this.message ? html`${this.message}` : ''} +
    + ` } } diff --git a/src/renderer/src/stories/Neurosift.js b/src/renderer/src/stories/Neurosift.js deleted file mode 100644 index 1080a04e9..000000000 --- a/src/renderer/src/stories/Neurosift.js +++ /dev/null @@ -1,33 +0,0 @@ -import { LitElement, css, html } from "lit"; -import { baseUrl } from "../globals"; - -export function getURLFromFilePath(file, projectName) { - const regexp = new RegExp(`.+(${projectName}.+)`); - return `${baseUrl}/stubs/${file.match(regexp)[1]}`; -} - -export class Neurosift extends LitElement { - static get styles() { - return css` - iframe { - width: 100%; - height: 100%; - border: 0; - } - `; - } - - constructor({ url }) { - super(); - this.url = url; - } - - render() { - return html``; - } -} - -customElements.get("neurosift-iframe") || customElements.define("neurosift-iframe", Neurosift); diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js index 4cda34c94..fc65953bd 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js @@ -10,7 +10,7 @@ import Swal from "sweetalert2"; import { SimpleTable } from "../../../SimpleTable.js"; import { onThrow } from "../../../../errors"; import { merge } from "../../utils.js"; -import { Neurosift, getURLFromFilePath } from "../../../Neurosift.js"; +import { NWBFilePreview } from "../../../preview/NWBFilePreview.js"; const getInfoFromId = (key) => { let [subject, session] = key.split("/"); @@ -39,11 +39,12 @@ export class GuidedMetadataPage extends ManagedPage { // Preview a single random conversion delete this.info.globalState.stubs; // Clear the preview results - const results = await this.runConversions({ stub_test: true }, undefined, { + const stubs = await this.runConversions({ stub_test: true }, undefined, { title: "Running stub conversion on all sessions...", }); - this.info.globalState.stubs = results; // Save the preview results + // Save the preview results + this.info.globalState.stubs = stubs; this.unsavedUpdates = true; @@ -199,11 +200,6 @@ export class GuidedMetadataPage extends ManagedPage { throw e; }); - const firstSubject = Object.values(results)[0]; - const file = Object.values(firstSubject)[0]; // Get the information from the first subject - - console.log(firstSubject, file, results); - const modal = new Modal({ header: `Conversion Preview: ${key}`, open: true, @@ -212,9 +208,9 @@ export class GuidedMetadataPage extends ManagedPage { height: "100%", }); - modal.append( - new Neurosift({ url: getURLFromFilePath(file, this.info.globalState.project.name) }) - ); + const { project } = this.info.globalState; + + modal.append(new NWBFilePreview({ project: project.name, files: results })); document.body.append(modal); }, }, diff --git a/src/renderer/src/stories/pages/guided-mode/options/GuidedConversionOptions.js b/src/renderer/src/stories/pages/guided-mode/options/GuidedConversionOptions.js index cdab8f1b0..a3f60670e 100644 --- a/src/renderer/src/stories/pages/guided-mode/options/GuidedConversionOptions.js +++ b/src/renderer/src/stories/pages/guided-mode/options/GuidedConversionOptions.js @@ -15,11 +15,11 @@ export class GuidedConversionOptionsPage extends Page { await this.form.validate(); // Will throw an error in the callback // Preview a random conversion - delete this.info.globalState.preview; // Clear the preview results + delete this.info.globalState.stubs; // Clear the preview results const results = await this.runConversions({ stub_test: true }, 1, { title: "Testing conversion on a random session", }); - this.info.globalState.preview = results[0]; // Save the preview results + this.info.globalState.stubs = results; // Save the preview results this.to(1); }, diff --git a/src/renderer/src/stories/pages/guided-mode/options/GuidedStubPreview.js b/src/renderer/src/stories/pages/guided-mode/options/GuidedStubPreview.js index 55780c2d2..b39a1e91b 100644 --- a/src/renderer/src/stories/pages/guided-mode/options/GuidedStubPreview.js +++ b/src/renderer/src/stories/pages/guided-mode/options/GuidedStubPreview.js @@ -5,22 +5,30 @@ import { unsafeSVG } from "lit/directives/unsafe-svg.js"; import folderOpenSVG from "../../../assets/folder_open.svg?raw"; import { electron } from "../../../../electron/index.js"; -import { Neurosift, getURLFromFilePath } from "../../../Neurosift.js"; -import { InstanceManager } from "../../../InstanceManager.js"; - +import { NWBFilePreview, getSharedPath } from "../../../preview/NWBFilePreview.js"; const { shell } = electron; +const getStubArray = (stubs) => + Object.values(stubs) + .map((o) => Object.values(o)) + .flat(); + export class GuidedStubPreviewPage extends Page { constructor(...args) { super(...args); } header = { - subtitle: () => this.info.globalState.stubs[0], + subtitle: () => `${getStubArray(this.info.globalState.stubs).length} Files`, controls: () => html` (shell ? shell.showItemInFolder(this.info.globalState.stubs[0]) : "")} + @click=${() => + shell + ? shell.showItemInFolder( + getSharedPath(getStubArray(this.info.globalState.stubs).map((o) => o.file)) + ) + : ""} >${unsafeSVG(folderOpenSVG)}`, }; @@ -38,33 +46,11 @@ export class GuidedStubPreviewPage extends Page { }, }; - createInstance = ({ subject, session, info }) => { - const { project, stubs } = this.info.globalState; - - return { - subject, - session, - display: new Neurosift({ url: getURLFromFilePath(stubs[subject][session], project.name) }), - }; - }; - render() { - const { stubs } = this.info.globalState; - - const _instances = this.mapSessions(this.createInstance); - - const instances = _instances.reduce((acc, { subject, session, display }) => { - if (!acc[`sub-${subject}`]) acc[`sub-${subject}`] = {}; - acc[`sub-${subject}`][`ses-${session}`] = display; - return acc; - }, {}); + const { stubs, project } = this.info.globalState; return stubs - ? (this.manager = new InstanceManager({ - header: "Sessions", - instanceType: "Session", - instances, - })) + ? new NWBFilePreview({ project: project.name, files: stubs }) : html`

    Your conversion preview failed. Please try again.

    `; } } diff --git a/src/renderer/src/stories/pages/guided-mode/options/utils.js b/src/renderer/src/stories/pages/guided-mode/options/utils.js index deda08db6..6d1d0020e 100644 --- a/src/renderer/src/stories/pages/guided-mode/options/utils.js +++ b/src/renderer/src/stories/pages/guided-mode/options/utils.js @@ -21,7 +21,7 @@ export const openProgressSwal = (options) => { }; export const run = async (url, payload, options = {}) => { - const needsSwal = !options.swal; + const needsSwal = !options.swal && options.swal !== false; if (needsSwal) openProgressSwal(options).then((swal) => (options.onOpen ? options.onOpen(swal) : undefined)); const results = await fetch(`${baseUrl}/neuroconv/${url}`, { diff --git a/src/renderer/src/stories/pages/guided-mode/results/GuidedResults.js b/src/renderer/src/stories/pages/guided-mode/results/GuidedResults.js index f0a97aed5..fc3d64df2 100644 --- a/src/renderer/src/stories/pages/guided-mode/results/GuidedResults.js +++ b/src/renderer/src/stories/pages/guided-mode/results/GuidedResults.js @@ -93,7 +93,8 @@ export class GuidedResultsPage extends Page {
      ${Object.values(results) - .flat((v) => Object.values(v)) + .map((v) => Object.values(v)) + .flat() .map((o) => html`
    1. ${o.file}
    2. `)}
diff --git a/src/renderer/src/stories/preview/NWBFilePreview.js b/src/renderer/src/stories/preview/NWBFilePreview.js new file mode 100644 index 000000000..b752f9917 --- /dev/null +++ b/src/renderer/src/stories/preview/NWBFilePreview.js @@ -0,0 +1,149 @@ +import { LitElement, css, html } from "lit"; +import { InspectorList } from "./inspector/InspectorList"; +import { Neurosift, getURLFromFilePath } from "./Neurosift"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { run } from "../pages/guided-mode/options/utils"; +import { until } from "lit/directives/until.js"; +import { InstanceManager } from "../InstanceManager"; +import { path } from "../../electron"; + +export function getSharedPath(array) { + array = array.map((str) => str.replace(/\\/g, "/")); // Convert to Mac-style path + const mapped = array.map((str) => str.split("/")); + let shared = mapped.shift(); + mapped.forEach((arr, i) => { + for (let j in arr) { + if (arr[j] !== shared[j]) { + shared = shared.slice(0, j); + break; + } + } + }); + + return path.normalize(shared.join("/")); // Convert back to OS-specific path +} + +class NWBPreviewInstance extends LitElement { + constructor({ file }, project) { + super(); + this.file = file; + this.project = project; + + window.addEventListener("online", () => this.requestUpdate()); + window.addEventListener("offline", () => this.requestUpdate()); + } + + render() { + const isOnline = navigator.onLine; + + return isOnline + ? new Neurosift({ url: getURLFromFilePath(this.file, this.project) }) + : until( + (async () => { + const htmlRep = await run("html", { nwbfile_path: this.file }, { swal: false }); + return unsafeHTML(htmlRep); + })(), + html`Loading HTML representation...` + ); + } +} + +customElements.get("nwb-preview-instance") || customElements.define("nwb-preview-instance", NWBPreviewInstance); + +export class NWBFilePreview extends LitElement { + static get styles() { + return css` + iframe { + width: 100%; + height: 100%; + border: 0; + } + `; + } + + constructor({ files = {}, project }) { + super(); + this.project = project; + this.files = files; + } + + createInstance = ({ subject, session, info }) => { + return { + subject, + session, + display: () => new NWBPreviewInstance(info, this.project), + }; + }; + + render() { + const fileArr = Object.entries(this.files) + .map(([subject, v]) => + Object.entries(v).map(([session, info]) => { + return { subject, session, info }; + }) + ) + .flat(); + + const onlyFirstFile = fileArr.length <= 1; + + return html`
+
+ ${(() => { + if (onlyFirstFile) return new NWBPreviewInstance(fileArr[0].info, this.project); + else { + const _instances = fileArr.map(this.createInstance); + + const instances = _instances.reduce((acc, { subject, session, display }) => { + if (!acc[`sub-${subject}`]) acc[`sub-${subject}`] = {}; + acc[`sub-${subject}`][`ses-${session}`] = display; + return acc; + }, {}); + + return new InstanceManager({ + header: "Stub Files", + instances, + }); + } + })()} +
+
+

Inspector Report

+ ${until( + (async () => { + const opts = {}; // NOTE: Currently options are handled on the Python end until exposed to the user + + const title = "Inspecting your file"; + + const items = onlyFirstFile + ? ( + await run("inspect_file", { nwbfile_path: fileArr[0].info.file, ...opts }, { title }) + ).map((o) => { + delete o.file_path; + return o; + }) // Inspect the first file + : await (async () => { + const path = getSharedPath(fileArr.map((o) => o.info.file)); + const report = await run("inspect_folder", { path, ...opts }, { title: title + "s" }); + return report.map((o) => { + o.file_path = o.file_path + .replace(`${path}/`, "") // Mac + .replace(`${path}\\`, ""); // Windows + return o; + }); + })(); + + const list = new InspectorList({ + items: items, + listStyles: { maxWidth: "350px" }, + }); + list.style.padding = "10px"; + return list; + })(), + html`Loading inspector report...` + )} +
+
`; + } +} + +customElements.get("nwb-file-preview") || customElements.define("nwb-file-preview", NWBFilePreview); diff --git a/src/renderer/src/stories/preview/Neurosift.js b/src/renderer/src/stories/preview/Neurosift.js new file mode 100644 index 000000000..5c8b81d8a --- /dev/null +++ b/src/renderer/src/stories/preview/Neurosift.js @@ -0,0 +1,69 @@ +import { LitElement, css, html } from "lit"; +import { baseUrl } from "../../globals"; + +import { Loader } from "../Loader"; + +export function getURLFromFilePath(file, projectName) { + const regexp = new RegExp(`.+(${projectName}.+)`); + return `${baseUrl}/stubs/${file.match(regexp)[1]}`; +} + +export class Neurosift extends LitElement { + static get styles() { + return css` + :host { + width: 100%; + height: 100%; + display: grid; + grid-template-rows: 100%; + grid-template-columns: 100%; + position: relative; + --loader-color: hsl(200, 80%, 50%); + } + + :host > * { + width: 100%; + height: 100%; + } + + div { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + left: 0; + } + + span { + font-size: 14px; + } + + small { + padding-left: 10px; + } + + iframe { + border: 0; + } + `; + } + + constructor({ url }) { + super(); + this.url = url; + } + + render() { + return html`
${new Loader({ message: "Loading Neurosift view..." })}
+ `; + } +} + +customElements.get("neurosift-iframe") || customElements.define("neurosift-iframe", Neurosift); diff --git a/src/renderer/src/stories/preview/inspector/InspectorList.js b/src/renderer/src/stories/preview/inspector/InspectorList.js new file mode 100644 index 000000000..5475d3a5e --- /dev/null +++ b/src/renderer/src/stories/preview/inspector/InspectorList.js @@ -0,0 +1,119 @@ +import { LitElement, css, html } from "lit"; +import { List } from "../../List"; + +const sortList = (items) => { + return items + .sort((a, b) => { + const aCritical = a.importance === "CRITICAL"; + const bCritical = b.importance === "CRITICAL"; + if (aCritical && bCritical) return 0; + else if (aCritical) return -1; + else return 1; + }) + .sort((a, b) => { + const aLow = a.severity == "LOW"; + const bLow = b.severity === "LOW"; + if (aLow && bLow) return 0; + else if (aLow) return 1; + else return -1; + }); +}; + +export class InspectorList extends List { + constructor({ items, listStyles }) { + super({ + editable: false, + unordered: true, + items: sortList(items).map((o) => { + const item = new InspectorListItem(o); + item.style.flexGrow = "1"; + return { content: item }; + }), + listStyles, + }); + } +} + +customElements.get("inspector-list") || customElements.define("inspector-list", InspectorList); + +export class InspectorListItem extends LitElement { + static get styles() { + return css` + :host { + display: block; + background: gainsboro; + border: 1px solid gray; + border-radius: 10px; + padding: 5px 10px; + overflow: hidden; + text-wrap: wrap; + } + + #message { + display: block; + font-size: 14px; + font-weight: bold; + } + + #filepath { + font-size: 10px; + } + + :host > * { + margin: 0px; + } + + :host([type="error"]) { + color: #9d0b0b; + padding: 25px; + background: #f8d7da; + border: 1px solid #f5c2c7; + border-radius: 4px; + margin: 0 0 1em; + } + + :host([type="warning"]) { + color: #856404; + padding: 25px; + background: #fff3cd; + border: 1px solid #ffeeba; + border-radius: 4px; + margin: 0 0 1em; + } + `; + } + + constructor(props) { + super(); + this.ORIGINAL_TYPE = props.type; + Object.assign(this, props); + } + + static get properties() { + return { + type: { + type: String, + reflect: true, + }, + }; + } + + render() { + this.type = this.ORIGINAL_TYPE ?? (this.importance === "CRITICAL" ? "error" : "warning"); + + this.setAttribute("title", this.message); + + const hasObjectType = "object_type" in this; + const hasMetadata = hasObjectType && "object_name" in this; + + return html` + ${this.file_path ? html`${this.file_path}` : ""} + ${hasMetadata ? html`${this.message}` : html`

${this.message}

`} + ${hasMetadata + ? html`${this.object_name}${hasObjectType ? ` (${this.object_type})` : ""} ` + : ""} + `; + } +} + +customElements.get("inspector-list-item") || customElements.define("inspector-list-item", InspectorListItem); diff --git a/src/renderer/src/stories/preview/inspector/InspectorList.stories.js b/src/renderer/src/stories/preview/inspector/InspectorList.stories.js new file mode 100644 index 000000000..bcb331913 --- /dev/null +++ b/src/renderer/src/stories/preview/inspector/InspectorList.stories.js @@ -0,0 +1,13 @@ +import { InspectorList } from "./InspectorList"; +import testInspectorList from "./test.json"; + +export default { + title: "Components/Inspector List", +}; + +const Template = (args) => new InspectorList(args); + +export const Default = Template.bind({}); +Default.args = { + items: testInspectorList, +}; diff --git a/src/renderer/src/stories/preview/inspector/test.json b/src/renderer/src/stories/preview/inspector/test.json new file mode 100644 index 000000000..aa7f9d4dc --- /dev/null +++ b/src/renderer/src/stories/preview/inspector/test.json @@ -0,0 +1,162 @@ +[ + { + "message": "Experimenter is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_experimenter_exists", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse1/sub-mouse1_ses-070623.nwb" + }, + { + "message": "Experiment description is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_experiment_description", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse1/sub-mouse1_ses-070623.nwb" + }, + { + "message": "Metadata /general/institution is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_institution", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse1/sub-mouse1_ses-070623.nwb" + }, + { + "message": "Metadata /general/keywords is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_keywords", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse1/sub-mouse1_ses-070623.nwb" + }, + { + "message": "Experimenter is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_experimenter_exists", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse2/sub-mouse2_ses-070623.nwb" + }, + { + "message": "Experiment description is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_experiment_description", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse2/sub-mouse2_ses-070623.nwb" + }, + { + "message": "Metadata /general/institution is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_institution", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse2/sub-mouse2_ses-070623.nwb" + }, + { + "message": "Metadata /general/keywords is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_keywords", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse2/sub-mouse2_ses-070623.nwb" + }, + { + "message": "Experimenter is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_experimenter_exists", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse2/sub-mouse2_ses-060623.nwb" + }, + { + "message": "Experiment description is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_experiment_description", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse2/sub-mouse2_ses-060623.nwb" + }, + { + "message": "Metadata /general/institution is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_institution", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse2/sub-mouse2_ses-060623.nwb" + }, + { + "message": "Metadata /general/keywords is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_keywords", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse2/sub-mouse2_ses-060623.nwb" + }, + { + "message": "Experimenter is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_experimenter_exists", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse1/sub-mouse1_ses-060623.nwb" + }, + { + "message": "Experiment description is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_experiment_description", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse1/sub-mouse1_ses-060623.nwb" + }, + { + "message": "Metadata /general/institution is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_institution", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse1/sub-mouse1_ses-060623.nwb" + }, + { + "message": "Metadata /general/keywords is missing.", + "importance": "BEST_PRACTICE_SUGGESTION", + "severity": "LOW", + "check_function_name": "check_keywords", + "object_type": "NWBFile", + "object_name": "root", + "location": "/", + "file_path": "/Users/garrettflynn/NWB_GUIDE/stubs/NWB GUIDE Tutorial Data/sub-mouse1/sub-mouse1_ses-060623.nwb" + } +]