diff --git a/.github/workflows/Build-and-deploy-mac.yml b/.github/workflows/Build-and-deploy-mac.yml index dd7a77988..7e8afcfe5 100644 --- a/.github/workflows/Build-and-deploy-mac.yml +++ b/.github/workflows/Build-and-deploy-mac.yml @@ -22,7 +22,6 @@ jobs: - uses: conda-incubator/setup-miniconda@v2 with: activate-environment: nwb-guide - #mamba-version: "*" environment-file: environments/environment-MAC.yml auto-activate-base: false diff --git a/.github/workflows/pyflask-build-and-dist-tests.yml b/.github/workflows/pyflask-build-and-dist-tests.yml index 5fbea858d..b2b89d50b 100644 --- a/.github/workflows/pyflask-build-and-dist-tests.yml +++ b/.github/workflows/pyflask-build-and-dist-tests.yml @@ -30,19 +30,16 @@ jobs: os: ubuntu-latest label: environments/environment-Linux.yml prefix: /usr/share/miniconda3/envs/nwb-guide - shorthand: unix - - python-version: "3.10" + - python-version: "3.9" os: macos-latest label: environments/environment-Mac.yml prefix: /Users/runner/miniconda3/envs/nwb-guide - shorthand: unix - - python-version: "3.10" + - python-version: "3.9" os: windows-latest label: environments/environment-Windows.yml prefix: C:\Miniconda3\envs\nwb-guide - shorthand: win steps: - uses: actions/checkout@v3 @@ -88,15 +85,7 @@ jobs: run: rm -f /Users/runner/miniconda3/envs/nwb-guide/lib/python3.9/site-packages/sonpy/linux/sonpy.so - name: Build PyFlask distribution - run: npm run build:flask:${{ matrix.shorthand }} - - #- if: matrix.os == 'windows-latest' - # name: Run test on build executable - # run: node tests/testPyinstallerExecutable.js --script ./build/nwb-guide/nwb-guide.exe - - #- if: matrix.os != 'windows-latest' - # name: Run test on build executable - # run: node tests/testPyinstallerExecutable.js --script ./build/nwb-guide/nwb-guide + run: npm run build:flask - name: Run test on distributed executable run: node tests/testPyinstallerExecutable.js diff --git a/.gitignore b/.gitignore index 852548470..0cda7e019 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,6 @@ semantic.json build/ yarn.lock -*.spec - *.pyc src/.DS_Store .DS_Store diff --git a/nwb-guide.spec b/nwb-guide.spec new file mode 100644 index 000000000..8b4f5649d --- /dev/null +++ b/nwb-guide.spec @@ -0,0 +1,74 @@ +# -*- mode: python ; coding: utf-8 -*- +import sys +from pathlib import Path + +sys.setrecursionlimit(sys.getrecursionlimit() * 5) + +from PyInstaller.utils.hooks import collect_data_files +from PyInstaller.utils.hooks import collect_all + +datas = [('./paths.config.json', '.'), ('./package.json', '.')] +binaries = [] +hiddenimports = ['scipy._distributor_init', 'scipy._lib.messagestream', 'scipy._lib._ccallback', 'scipy._lib._testutils', 'email_validator'] +datas += collect_data_files('jsonschema_specifications') +tmp_ret = collect_all('nwbinspector') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('neuroconv') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('pynwb') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('hdmf') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('ndx_dandi_icephys') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('ci_info') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +block_cipher = None + + +a = Analysis( + [f"{Path('pyflask') / 'app.py'}"], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='nwb-guide', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='nwb-guide', +) diff --git a/package.json b/package.json index 3ea6741de..28727a469 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,14 @@ "dev:app": "vite src/renderer", "dev:server": "cd pyflask && python -m flask run --port 4242", "build": "npm run build:app", + "echo": "python -c \"print('hello')\"", "build:app": "electron-vite build --outDir build", - "build:win": "npm run build && npm run build:flask:win && npm run build:electron:win", - "build:mac": "npm run build && npm run build:flask:unix && npm run build:electron:mac", - "build:linux": "npm run build && npm run build:flask:unix && npm run build:electron:linux", - "build:flask:base": "python -m PyInstaller --log-level DEBUG --name nwb-guide --onedir --clean --noconfirm ./pyflask/app.py --distpath ./build/flask --collect-data jsonschema_specifications --collect-all nwbinspector --collect-all neuroconv --collect-all pynwb --collect-all hdmf --collect-all ci_info --collect-all ndx_dandi_icephys --hidden-import scipy._distributor_init --hidden-import scipy._lib.messagestream --hidden-import scipy._lib._ccallback --hidden-import scipy._lib._testutils --hidden-import email_validator", - "build:flask:win": "npm run build:flask:base -- --add-data ./paths.config.json;. --add-data ./package.json;.", - "build:flask:unix": "npm run build:flask:base -- --add-data ./paths.config.json:. --add-data ./package.json:.", + "build:win": "npm run build && npm run build:flask && npm run build:electron:win", + "build:mac": "npm run build && npm run build:flask && npm run build:electron:mac", + "build:linux": "npm run build && npm run build:flask && npm run build:electron:linux", + "build:flask": "python -m PyInstaller nwb-guide.spec --log-level DEBUG --clean --noconfirm --distpath ./build/flask", + "build:flask:spec:base": "pyi-makespec --name nwb-guide --onedir --collect-data jsonschema_specifications --collect-all nwbinspector --collect-all neuroconv --collect-all pynwb --collect-all hdmf --collect-all ndx_dandi_icephys --collect-all ci_info --hidden-import scipy._distributor_init --hidden-import scipy._lib.messagestream --hidden-import scipy._lib._ccallback --hidden-import scipy._lib._testutils --hidden-import email_validator ./pyflask/app.py", + "build:flask:spec": "npm run build:flask:spec:base && python prepare_pyinstaller_spec.py", "build:electron:win": "electron-builder build --win --publish never", "build:electron:mac": "electron-builder build --mac --publish never", "build:electron:linux": "electron-builder build --linux --publish never", @@ -57,7 +58,7 @@ { "ext": "nwb", "name": "NWB File", - "role": "Editor" + "role": "Viewer" } ], "files": [ diff --git a/prepare_pyinstaller_spec.py b/prepare_pyinstaller_spec.py new file mode 100644 index 000000000..db06d8e91 --- /dev/null +++ b/prepare_pyinstaller_spec.py @@ -0,0 +1,30 @@ +""" +Calling `pyi-makespec` regenerates the base spec file, but we need to extend the recursion limit. + +This script is run automatically as a part of `npm run build:flask:spec` after the `pyi-makespec` call. +""" +from pathlib import Path + +with open(file=Path(__file__).parent / "nwb-guide.spec", mode="r") as io: + lines = io.readlines() + +lines.insert(1, "import sys\n") +lines.insert(2, "from pathlib import Path\n") +lines.insert(3, "\n") +lines.insert(4, "sys.setrecursionlimit(sys.getrecursionlimit() * 5)\n") +lines.insert(5, "\n") + +# Originally this was a separate `npm` command per platform to account for CLI syntax differences between ; and : +# The spec file is, however, the same across platforms +data_line_index = lines.index("datas = []\n") +lines[data_line_index] = "datas = [('./paths.config.json', '.'), ('./package.json', '.')]\n" + +# Another platform specific difference is the app.py location +app_py_line_index = next(index for index, line in enumerate(lines) if "app.py" in line) +app_py_line = " [f\"{Path('pyflask') / 'app.py'}\"],\n" +lines[app_py_line_index] = app_py_line + +with open(file=Path(__file__).parent / "nwb-guide.spec", mode="w") as io: + io.writelines(lines) + +print("Sucessfully injected recursion depth extension and json paths!") diff --git a/pyflask/app.py b/pyflask/app.py index a87618798..890d9bede 100644 --- a/pyflask/app.py +++ b/pyflask/app.py @@ -2,15 +2,18 @@ import sys import json import multiprocessing +from os.path import sep from logging import Formatter, DEBUG from logging.handlers import RotatingFileHandler from pathlib import Path +from urllib.parse import unquote + # https://stackoverflow.com/questions/32672596/pyinstaller-loads-script-multiple-times#comment103216434_32677108 multiprocessing.freeze_support() -from flask import Flask, request, send_from_directory +from flask import Flask, request, send_from_directory, send_file from flask_cors import CORS from flask_restx import Api, Resource @@ -52,6 +55,29 @@ api.add_namespace(neuroconv_api) api.init_app(app) +registered = {} + + +@app.route("/files") +def get_all_files(): + return list(registered.keys()) + + +@app.route("/files/", methods=["GET", "POST"]) +def handle_file_request(path): + if request.method == "GET": + if registered[path]: + return send_file(unquote(path)) + else: + app.abort(404, "Resource is not accessible.") + + else: + if ".nwb" in path: + registered[path] = True + return request.base_url + else: + app.abort(400, str("Path does not point to an NWB file.")) + @app.route("/conversions/") def send_conversions(path): diff --git a/schemas/json/dandi/global.json b/schemas/json/dandi/global.json index 15ad771c0..1680fd34d 100644 --- a/schemas/json/dandi/global.json +++ b/schemas/json/dandi/global.json @@ -1,9 +1,20 @@ { + "description": "Log in to DANDI, click on your user initials in the top-right corner, and copy your API key from the resulting pop-up.
Note: The main archive and the staging (testing) server have different API keys.", "properties": { - "api_key": { - "type": "string", - "format": "password" + "api_keys": { + "properties": { + "main_api_key": { + "type": "string", + "format": "password", + "description": "From the main archive" + }, + "staging_api_key": { + "type": "string", + "format": "password", + "description": "From the staging (testing) server" + } + }, + "required": ["main_api_key", "staging_api_key"] } - }, - "required": ["api_key"] + } } diff --git a/schemas/json/dandi/upload.json b/schemas/json/dandi/upload.json index 4ece77105..40f130e47 100644 --- a/schemas/json/dandi/upload.json +++ b/schemas/json/dandi/upload.json @@ -1,7 +1,8 @@ { "properties": { "dandiset_id": { - "type": "string" + "type": "string", + "description": "The unique identifier for your dandiset. Will automatically determine whether to upload to the main DANDI archive or the development staging server." }, "cleanup": { "type": "boolean", diff --git a/src/main/main.ts b/src/main/main.ts index d00c1219b..582090189 100755 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -70,6 +70,7 @@ let globals: { }, set mainWindowReady(v) { + if (!globals.mainWindow) throw new Error('Main window cannot be ready. It does not exist...') mainWindowReady = v if (v) readyQueue.forEach(f => onWindowReady(f)) readyQueue = [] @@ -80,7 +81,7 @@ function send(this: BrowserWindow, ...args: any[]) { return this.webContents.send(...args) } -const onWindowReady = (f: (win: BrowserWindow) => any) => (mainWindowReady) ? f(globals.mainWindow) : readyQueue.push(f) +const onWindowReady = (f: (win: BrowserWindow) => any) => (globals.mainWindowReady) ? f(globals.mainWindow) : readyQueue.push(f) // Pass all important log functions to the application @@ -355,14 +356,18 @@ function initialize() { else app.on("ready", onAppReady) } -function onFileOpened(_, path: string) { +function isValidFile(filepath: string) { + return !fs.existsSync(filepath) && path.extname(filepath) === '.nwb' +} + +function onFileOpened(_, filepath: string) { restoreWindow() || initialize(); // Ensure the application is properly visible - onWindowReady((win) => win.webContents.send('fileOpened', path)) + onWindowReady((win) => send.call(win, 'fileOpened', filepath)) } if (isWindows && process.argv.length >= 2) { const openFilePath = process.argv[1]; - if (openFilePath !== "") onFileOpened(null, openFilePath) + if (isValidFile(openFilePath)) onFileOpened(null, openFilePath) } // Make this app a single instance app. @@ -383,7 +388,7 @@ function makeSingleInstance() { else app.on("second-instance", () => restoreWindow()); } -if (process.platform === 'darwin') initialize(); +initialize(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) initialize() diff --git a/src/renderer/assets/css/global.css b/src/renderer/assets/css/global.css index 69b046c46..252a2e657 100755 --- a/src/renderer/assets/css/global.css +++ b/src/renderer/assets/css/global.css @@ -8,6 +8,15 @@ /* src: local('Source Code Pro'), local('SourceCodePro'), url(fonts/SourceCodePro-Regular.ttf) format('truetype'); */ } +/* Notfy */ +.notyf__toast { + max-width: clamp(300px, 40vw, 500px) !important; +} + +.notyf__message { + word-wrap: break-word; +} + /* Global ---------------------------- */ * { diff --git a/src/renderer/src/electron/index.js b/src/renderer/src/electron/index.js index 4f9b30865..a20b50acc 100644 --- a/src/renderer/src/electron/index.js +++ b/src/renderer/src/electron/index.js @@ -1,3 +1,4 @@ +import { updateURLParams } from "../../utils/url.js"; import isElectron from "./check.js"; export { isElectron }; @@ -21,8 +22,12 @@ if (isElectron) { remote = require("@electron/remote"); app = remote.app; - electron.ipcRenderer.on("fileOpened", (info, ...args) => { - console.log("File opened!", ...args); + electron.ipcRenderer.on("fileOpened", (info, filepath) => { + updateURLParams({ file: filepath }); + const dashboard = document.querySelector("nwb-dashboard"); + const activePage = dashboard.getAttribute("activePage"); + if (activePage === "preview") dashboard.requestUpdate(); + else dashboard.setAttribute("activePage", "preview"); }); ["log", "warn", "error"].forEach((method) => diff --git a/src/renderer/src/pages.js b/src/renderer/src/pages.js index 09ba41811..a02bed0c8 100644 --- a/src/renderer/src/pages.js +++ b/src/renderer/src/pages.js @@ -22,11 +22,14 @@ import { TutorialPage } from "./stories/pages/tutorial/Tutorial"; import tutorialIcon from "./stories/assets/exploration.svg?raw"; import uploadIcon from "./stories/assets/dandi.svg?raw"; import inspectIcon from "./stories/assets/inspect.svg?raw"; +import neurosiftIcon from "./stories/assets/neurosift-logo.svg?raw"; + import settingsIcon from "./stories/assets/settings.svg?raw"; import { UploadsPage } from "./stories/pages/uploads/UploadsPage"; import { SettingsPage } from "./stories/pages/settings/SettingsPage"; import { InspectPage } from "./stories/pages/inspect/InspectPage"; +import { PreviewPage } from "./stories/pages/preview/PreviewPage"; let dashboard = document.querySelector("nwb-dashboard"); if (!dashboard) dashboard = new Dashboard(); @@ -165,14 +168,18 @@ const pages = { }), }, }), - uploads: new UploadsPage({ - label: "Uploads", - icon: uploadIcon, - }), inspect: new InspectPage({ label: "Inspect", icon: inspectIcon, }), + preview: new PreviewPage({ + label: "Neurosift", + icon: neurosiftIcon, + }), + uploads: new UploadsPage({ + label: "Uploads", + icon: uploadIcon, + }), tutorial: new TutorialPage({ label: "Tutorial", icon: tutorialIcon, diff --git a/src/renderer/src/progress/index.js b/src/renderer/src/progress/index.js index b3dfb9ea8..8e39c7e97 100644 --- a/src/renderer/src/progress/index.js +++ b/src/renderer/src/progress/index.js @@ -11,7 +11,8 @@ import { import { fs } from "../electron/index.js"; import { joinPath, runOnLoad } from "../globals.js"; import { merge } from "../stories/pages/utils.js"; -import { updateAppProgress, updateFile, updateURLParams } from "./update.js"; +import { updateAppProgress, updateFile } from "./update.js"; +import { updateURLParams } from "../../utils/url.js"; export * from "./update"; diff --git a/src/renderer/src/progress/update.js b/src/renderer/src/progress/update.js index 06b422639..8756db83a 100644 --- a/src/renderer/src/progress/update.js +++ b/src/renderer/src/progress/update.js @@ -1,3 +1,4 @@ +import { updateURLParams } from "../../utils/url.js"; import { guidedProgressFilePath } from "../dependencies/simple.js"; import { fs } from "../electron/index.js"; import { joinPath } from "../globals.js"; @@ -23,21 +24,6 @@ export const update = (newDatasetName, previousDatasetName) => { return "Dataset name updated"; } else throw new Error("No previous dataset name provided"); }; - -export function updateURLParams(paramsToUpdate) { - const params = new URLSearchParams(location.search); - for (let key in paramsToUpdate) { - const value = paramsToUpdate[key]; - if (value == undefined) params.delete(key); - else params.set(key, value); - } - - // Update browser history state - const value = `${location.pathname}?${params}`; - if (history.state) Object.assign(history.state, paramsToUpdate); - window.history.pushState(history.state, null, value); -} - export const updateAppProgress = ( pageId, dataOrProjectName = {}, diff --git a/src/renderer/src/stories/DandiResults.js b/src/renderer/src/stories/DandiResults.js new file mode 100644 index 000000000..8f1c4cd52 --- /dev/null +++ b/src/renderer/src/stories/DandiResults.js @@ -0,0 +1,106 @@ +import { LitElement, css, html } from "lit"; + +import { get } from "dandi"; +import { isStaging } from "./pages/uploads/UploadsPage.js"; + +export class DandiResults extends LitElement { + static get styles() { + return css` + :host { + display: block; + } + `; + } + + constructor(props) { + super(); + Object.assign(this, props); + } + + async updated() { + const handleId = (str, info) => { + let value = info[str]; + if (str === "modified") value = new Date(value).toString(); + + const el = this.shadowRoot.querySelector(`#${str}`); + el.innerText = value; + + if (el.tagName === "A") { + if (str === "doi") value = `http://doi.org/${value}`; + el.href = value; + el.target = "_blank"; + } + }; + + const elIds = ["name", "modified"]; + + const otherElIds = ["embargo_status"]; + + const dandiset = await get(this.id, isStaging(this.id) ? "staging" : undefined); + + otherElIds.forEach((str) => handleId(str, dandiset)); + elIds.forEach((str) => handleId(str, dandiset.draft_version)); + + const info = await dandiset.getInfo({ version: dandiset.draft_version.version }); + + const secondElIds = ["description", "url"]; + secondElIds.forEach((str) => handleId(str, info)); + + const publicationEl = this.shadowRoot.querySelector(`#publication`); + publicationEl.innerHTML = ""; + const publications = (info.relatedResource ?? []).filter((o) => o.relation === "dcite:IsDescribedBy"); + + if (publications.length) + publicationEl.append( + ...(await Promise.all( + publications.map(async (o) => { + const li = document.createElement("li"); + const { message } = await fetch( + `http://api.crossref.org/works${new URL(o.identifier).pathname}` + ).then((res) => res.json()); + li.innerHTML = `${message.author.map((o) => `${o.family}, ${o.given[0]}.`).join(", ")} (${ + message.created["date-parts"][0][0] + }). ${message.title[0]}. ${message["container-title"]}, ${message.volume}(${ + message.issue + }), ${message.page}. doi:${message.DOI}`; + return li; + }) + )) + ); + else publicationEl.innerText = "N/A"; + } + + render() { + return html` +
+
+

+

+ +

Identifier: ${this.id}

+

Upload Time:

+

Embargo Status:

+ + URL:
+ +

Related Publications

+
+
    + + ${this.files + ? html`

    Files Uploaded with this Conversion

    +
    +
      + ${Object.values(this.files) + .map((v) => Object.values(v)) + .flat() + .map((o) => html`
    1. ${o.file}
    2. `)} +
    ` + : ""} +
    +
    + `; + } +} + +customElements.get("dandi-results") || customElements.define("dandi-results", DandiResults); diff --git a/src/renderer/src/stories/Dashboard.js b/src/renderer/src/stories/Dashboard.js index 3d3240331..70ad37502 100644 --- a/src/renderer/src/stories/Dashboard.js +++ b/src/renderer/src/stories/Dashboard.js @@ -133,6 +133,10 @@ export class Dashboard extends LitElement { this.#updated(); } + requestPageUpdate() { + if (this.#active) this.#active.requestUpdate(); + } + createRenderRoot() { return this; } @@ -229,10 +233,13 @@ export class Dashboard extends LitElement { pageState = state.pages[id] = { visited: false, active: false, + saved: false, pageLabel: page.info.label, pageTitle: page.info.title, }; + info.states = pageState; + state.active = false; pageState.active = false; diff --git a/src/renderer/src/stories/FileSystemSelector.js b/src/renderer/src/stories/FileSystemSelector.js index 06d397672..81967a6ad 100644 --- a/src/renderer/src/stories/FileSystemSelector.js +++ b/src/renderer/src/stories/FileSystemSelector.js @@ -55,6 +55,8 @@ export class FilesystemSelector extends LitElement { super(); if (props.onSelect) this.onSelect = props.onSelect; if (props.onChange) this.onChange = props.onChange; + if (props.onThrow) this.onThrow = props.onThrow; + this.multiple = props.multiple; this.type = props.type ?? "file"; this.value = props.value ?? ""; this.dialogOptions = props.dialogOptions ?? {}; @@ -66,6 +68,11 @@ export class FilesystemSelector extends LitElement { onSelect = () => {}; onChange = () => {}; + #onThrow = (title, message) => { + message = message ? `
    ${title}
    ${message}` : title; + if (this.onThrow) this.onThrow(message); + throw new Error(message); + }; display = document.createElement("small"); @@ -76,41 +83,100 @@ export class FilesystemSelector extends LitElement { "noResolveAliases", ...(options.properties ?? []), ]; + + if (this.multiple && !options.properties.includes("multiSelections")) + options.properties.push("multiSelections"); + this.classList.add("active"); const result = await dialog[this.dialogType](options); + this.classList.remove("active"); - if (result.canceled) throw new Error("No file selected"); + if (result.canceled) this.#onCancel(); return result; }; - #handleFile = async (path) => { - if (!path) throw new Error("Unable to parse file path"); - this.value = path; + #onCancel = () => { + this.#onThrow(`No ${this.type} selected`, "The request was cancelled by the user"); + }; + + #checkType = (value) => { + const isLikelyFile = value.split(".").length !== 1; + if ((this.type === "directory" && isLikelyFile) || (this.type === "file" && !isLikelyFile)) + this.#onThrow("Incorrect filesystem object", `Please provide a ${this.type} instead.`); + }; + + #handleFiles = async (pathOrPaths) => { + if (!pathOrPaths) + this.#onThrow("No paths detected", `Unable to parse ${this.type} path${this.multiple ? "s" : ""}`); + + if (Array.isArray(pathOrPaths)) pathOrPaths.forEach(this.#checkType); + else this.#checkType(pathOrPaths); + + let resolvedValue = pathOrPaths; + if (Array.isArray(resolvedValue) && !this.multiple) { + if (resolvedValue.length > 1) + this.#onThrow( + `Too many ${this.type === "directory" ? "directories" : "files"} detected`, + `This selector will only accept one.` + ); + resolvedValue = resolvedValue[0]; + } + + if (this.multiple && !Array.isArray(resolvedValue)) resolvedValue = []; + + this.value = resolvedValue; this.onSelect(this.value); const event = new Event("change"); // Create a new change event this.dispatchEvent(event); }; render() { - const preprocessed = this.value.replaceAll("\\", "/"); - if (preprocessed !== this.value) { - this.value = preprocessed; - this.#handleFile(this.value); // Notify of the change to the separators + let resolved, isUpdated; + + const isArray = Array.isArray(this.value); + const len = isArray ? this.value.length : 0; + + if (isArray) { + resolved = this.value.map((str) => str.replaceAll("\\", "/")); + isUpdated = JSON.stringify(resolved) !== JSON.stringify(this.value); + } else { + resolved = this.value.replaceAll("\\", "/"); + isUpdated = resolved !== this.value; } + if (isUpdated) { + this.value = resolved; + this.#handleFiles(this.value); // Notify of the change to the separators + return; + } + + const resolvedValueDisplay = isArray + ? len > 1 + ? `${this.value[0]} and ${len - 1} other${len > 2 ? "s" : ""}` + : this.value[0] + : this.value; + + const objectTypeReference = this.multiple + ? this.type === "directory" + ? "directories" + : "files" + : `a ${this.type}`; + return html` `; } diff --git a/src/renderer/src/stories/FileSystemSelector.stories.js b/src/renderer/src/stories/FileSystemSelector.stories.js index 6656407c0..035d61938 100644 --- a/src/renderer/src/stories/FileSystemSelector.stories.js +++ b/src/renderer/src/stories/FileSystemSelector.stories.js @@ -11,3 +11,12 @@ export const Folder = Template.bind({}); Folder.args = { type: "directory", }; + +export const FileMultiple = Template.bind({}); +FileMultiple.args = { multiple: true }; + +export const FolderMultiple = Template.bind({}); +FolderMultiple.args = { + type: "directory", + multiple: true, +}; diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index f16987160..e1902a2a3 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -1,8 +1,10 @@ import { LitElement, css, html } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + import { Accordion } from "./Accordion"; import { checkStatus } from "../validation"; -import { capitalize, header } from "./forms/utils"; +import { header } from "./forms/utils"; import { resolve } from "../promises"; import { merge } from "./pages/utils"; import { resolveProperties } from "./pages/guided-mode/data/utils"; @@ -113,13 +115,21 @@ pre { color: transparent; } + h4 { + margin: 0; + margin-bottom: 5px; + padding-bottom: 5px; + border-bottom: 1px solid gainsboro; + } .guided--text-input-instructions { font-size: 13px; width: 100%; padding-top: 4px; color: dimgray !important; - } + margin: 0 0 1em; + line-height: 1.4285em; +} `; document.addEventListener("dragover", (e) => { @@ -244,10 +254,10 @@ export class JSONSchemaForm extends LitElement { // NOTE: Forms with nested forms will handle their own state updates if (!value) { - delete resultParent[name]; + if (fullPath.length === 1) delete resultParent[name]; delete resolvedParent[name]; } else { - resultParent[name] = value; + if (fullPath.length === 1) resultParent[name] = value; resolvedParent[name] = value; } @@ -422,11 +432,6 @@ export class JSONSchemaForm extends LitElement { > ${interactiveInput} - ${info.description - ? html`

    - ${capitalize(info.description)}${info.description.slice(-1)[0] === "." ? "" : "."} -

    ` - : ""}
    @@ -479,18 +484,39 @@ export class JSONSchemaForm extends LitElement { } }; - #getRenderable = (schema = {}, required, path) => { + #getRenderable = (schema = {}, required, path, recursive = false) => { const entries = Object.entries(schema.properties ?? {}); - return entries.filter(([key, value]) => { - if (!value.properties && key === "definitions") return false; // Skip definitions - if (this.ignore.includes(key)) return false; - if (this.showLevelOverride >= path.length) return true; - if (required[key]) return true; - if (this.#getLink([...this.#base, ...path, key])) return true; - if (!this.onlyRequired) return true; - return false; - }); + const isArrayOfArrays = (arr) => !!arr.find((v) => Array.isArray(v)); + + const flattenRecursedValues = (arr) => { + const newArr = []; + arr.forEach((o) => { + if (isArrayOfArrays(o)) newArr.push(...o); + else newArr.push(o); + }); + + return newArr; + }; + + const isRenderable = (key, value) => { + if (recursive && value.properties) return this.#getRenderable(value, required[key], [...path, key], true); + else return [key, value]; + }; + + const res = entries + .map(([key, value]) => { + if (!value.properties && key === "definitions") return false; // Skip definitions + if (this.ignore.includes(key)) return false; + if (this.showLevelOverride >= path.length) return isRenderable(key, value); + if (required[key]) return isRenderable(key, value); + if (this.#getLink([...this.#base, ...path, key])) return isRenderable(key, value); + if (!this.onlyRequired) return isRenderable(key, value); + return false; + }) + .filter((o) => !!o); + + return flattenRecursedValues(res); // Flatten on the last pass }; validateOnChange = () => {}; @@ -768,7 +794,7 @@ export class JSONSchemaForm extends LitElement { const accordion = new Accordion({ sections: { [headerName]: { - subtitle: `${this.#getRenderable(info, required[name], fullPath).length} fields`, + subtitle: `${this.#getRenderable(info, required[name], fullPath, true).length} fields`, content: this.#nestedForms[name], }, }, @@ -847,7 +873,10 @@ export class JSONSchemaForm extends LitElement { return html`
    - ${false ? html`

    ${schema.title}

    ` : ""} ${false ? html`

    ${schema.description}

    ` : ""} + ${schema.description + ? html`

    Description

    +

    ${unsafeHTML(schema.description)}

    ` + : ""} ${this.#render(schema, this.resolved, this.#requirements)}
    `; diff --git a/src/renderer/src/stories/JSONSchemaForm.stories.js b/src/renderer/src/stories/JSONSchemaForm.stories.js index 97dad2eb3..a59857ef8 100644 --- a/src/renderer/src/stories/JSONSchemaForm.stories.js +++ b/src/renderer/src/stories/JSONSchemaForm.stories.js @@ -22,6 +22,7 @@ const defaultSchema = { test: { type: "string", default: true, + description: "This is a test description", }, warn: { type: "string", diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index 5d13ea390..6918014bb 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -1,4 +1,5 @@ import { LitElement, css, html } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { FilesystemSelector } from "./FileSystemSelector"; import { BasicTable } from "./BasicTable"; @@ -8,7 +9,13 @@ import { Button } from "./Button"; import { List } from "./List"; import { Modal } from "./Modal"; -const filesystemQueries = ["file", "directory"]; +import { capitalize } from "./forms/utils"; + +const isFilesystemSelector = (format) => { + 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 +}; export class JSONSchemaInput extends LitElement { static get styles() { @@ -69,6 +76,15 @@ export class JSONSchemaInput extends LitElement { 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 1em; + line-height: 1.4285em; + } `; } @@ -118,6 +134,20 @@ export class JSONSchemaInput extends LitElement { } render() { + const { info } = this; + + const input = this.#render(); + return html` + ${input} + ${info.description + ? html`

    + ${unsafeHTML(capitalize(info.description))}${info.description.slice(-1)[0] === "." ? "" : "."} +

    ` + : ""} + `; + } + + #render() { const { validateOnChange, info, path: fullPath } = this; const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; @@ -128,27 +158,58 @@ export class JSONSchemaInput extends LitElement { const hasItemsRef = "items" in info && "$ref" in info.items; if (!("items" in info) || (!("type" in info.items) && !hasItemsRef)) info.items = { type: "string" }; + // Handle file and directory formats + const createFilesystemSelector = (format) => { + const el = new FilesystemSelector({ + type: format, + value: this.value, + onSelect: (filePath) => this.#updateData(fullPath, filePath), + onChange: (filePath) => validateOnChange && this.#triggerValidation(name, el, path), + onThrow: (...args) => this.form?.onThrow(...args), + dialogOptions: this.form?.dialogOptions, + dialogType: this.form?.dialogType, + multiple: isArray, + }); + el.classList.add("schema-input"); + return el; + }; + if (isArray) { // if ('value' in this && !Array.isArray(this.value)) this.value = [ this.value ] // Catch tables - const itemSchema = this.form.getSchema("items", info); + const itemSchema = this.form ? this.form.getSchema("items", info) : info["items"]; const isTable = itemSchema.type === "object"; - if (isTable) { + + const fileSystemFormat = isFilesystemSelector(itemSchema.format); + if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); + else if (isTable) { const tableMetadata = { schema: itemSchema, data: this.value, + + // NOTE: This is likely an incorrect declaration of the table validation call validateOnChange: (key, parent, v) => { - return validateOnChange && this.form.validateOnChange(key, parent, fullPath, v); + return ( + validateOnChange && + (this.onValidate + ? this.onValidate() + : this.form + ? this.form.validateOnChange(key, parent, fullPath, v) + : "") + ); }, - onStatusChange: () => this.form.checkStatus(), // Check status on all elements - validateEmptyCells: this.form.validateEmptyValues, - deferLoading: this.form.deferLoading, + + onStatusChange: () => this.form?.checkStatus(), // Check status on all elements + validateEmptyCells: this.form?.validateEmptyValues, + deferLoading: this.form?.deferLoading, onLoaded: () => { - this.form.nLoaded++; - this.form.checkAllLoaded(); + if (this.form) { + this.form.nLoaded++; + this.form.checkAllLoaded(); + } }, - onThrow: (...args) => this.form.onThrow(...args), + onThrow: (...args) => this.form?.onThrow(...args), }; return (this.form.tables[name] = @@ -242,24 +303,8 @@ export class JSONSchemaInput extends LitElement { @change=${(ev) => validateOnChange && this.#triggerValidation(name, ev.target, path)} />`; } else if (info.type === "string" || info.type === "number") { - let format = info.format; - const matched = name.match(/(.+_)?(.+)_path/); - if (!format && matched) format = matched[2] === "folder" ? "directory" : matched[2]; - - // Handle file and directory formats - if (filesystemQueries.includes(format)) { - const el = new FilesystemSelector({ - type: format, - value: this.value, - onSelect: (filePath) => this.#updateData(fullPath, filePath), - onChange: (filePath) => validateOnChange && this.#triggerValidation(name, el, path), - dialogOptions: this.form?.dialogOptions, - dialogType: this.form?.dialogType, - }); - el.classList.add("schema-input"); - return el; - } - + const fileSystemFormat = isFilesystemSelector(info.format); + if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); // Handle long string formats else if (info.format === "long" || isArray) return html`