Skip to content

Commit

Permalink
Improved Path Expansion Interaction (#609)
Browse files Browse the repository at this point in the history
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Cody Baker <[email protected]>
  • Loading branch information
3 people authored Feb 20, 2024
1 parent 7762b61 commit d80764a
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 14 deletions.
13 changes: 12 additions & 1 deletion pyflask/apis/neuroconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
get_all_interface_info,
get_all_converter_info,
locate_data,
autocomplete_format_string,
get_source_schema,
get_metadata_schema,
convert_to_nwb,
validate_metadata,
listen_to_neuroconv_events,
generate_dataset,
inspect_nwb_file,
inspect_nwb_folder,
inspect_multiple_filesystem_objects,
Expand Down Expand Up @@ -78,6 +78,17 @@ def post(self):
neuroconv_api.abort(500, str(exception))


@neuroconv_api.route("/locate/autocomplete")
class Locate(Resource):
@neuroconv_api.doc(responses={200: "Success", 400: "Bad Request", 500: "Internal server error"})
def post(self):
try:
return autocomplete_format_string(neuroconv_api.payload)
except Exception as exception:
if notBadRequestException(exception):
neuroconv_api.abort(500, str(exception))


@neuroconv_api.route("/metadata")
class Metadata(Resource):
@neuroconv_api.doc(responses={200: "Success", 400: "Bad Request", 500: "Internal server error"})
Expand Down
1 change: 1 addition & 0 deletions pyflask/manageNeuroconv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
get_all_interface_info,
get_all_converter_info,
locate_data,
autocomplete_format_string,
get_source_schema,
get_metadata_schema,
convert_to_nwb,
Expand Down
47 changes: 46 additions & 1 deletion pyflask/manageNeuroconv/manage_neuroconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@
announcer = MessageAnnouncer()


def is_path_contained(child, parent):
parent = Path(parent)
child = Path(child)

# Attempt to construct a relative path from parent to child
try:
child.relative_to(parent)
return True
except ValueError:
return False


def replace_nan_with_none(data):
if isinstance(data, dict):
# If it's a dictionary, iterate over its items and replace NaN values with None
Expand Down Expand Up @@ -109,6 +121,39 @@ def coerce_schema_compliance_recursive(obj, schema):
)


def autocomplete_format_string(info: dict) -> str:
from neuroconv.tools.path_expansion import construct_path_template

base_directory = info["base_directory"]
filesystem_entry_path = info["path"]

if not is_path_contained(filesystem_entry_path, base_directory):
raise ValueError("Path is not contained in the provided base directory.")

full_format_string = construct_path_template(
filesystem_entry_path,
subject_id=info["subject_id"],
session_id=info["session_id"],
**info["additional_metadata"],
)

parent = Path(base_directory).resolve()
child = Path(full_format_string).resolve()

format_string = str(child.relative_to(parent))

to_locate_info = dict(base_directory=base_directory)

if Path(filesystem_entry_path).is_dir():
to_locate_info["folder_path"] = format_string
else:
to_locate_info["file_path"] = format_string

all_matched = locate_data(dict(autocomplete=to_locate_info))

return dict(matched=all_matched, format_string=format_string)


def locate_data(info: dict) -> dict:
"""Locate data from the specifies directories using fstrings."""
from neuroconv.tools import LocalPathExpander
Expand Down Expand Up @@ -622,7 +667,7 @@ def generate_dataset(input_path: str, output_path: str):
if base_id in file:
os.rename(os.path.join(root, file), os.path.join(root, file.replace(base_id, full_id)))

phy_output_dir.symlink_to(phy_base_directory, True)
copytree(phy_base_directory, phy_output_dir)

return {"output_path": str(output_path)}

Expand Down
10 changes: 0 additions & 10 deletions src/renderer/src/stories/FileSystemSelector.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,6 @@ export class FilesystemSelector extends LitElement {

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 = typeof this.value === "string" ? this.value.replaceAll("\\", "/") : this.value;
isUpdated = resolved !== this.value;
}

if (isUpdated) this.#handleFiles(resolved); // Notify of the change to the separators

const resolvedValueDisplay = isArray
? len > 1
? `${this.value[0]} and ${len - 1} other${len > 2 ? "s" : ""}`
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/stories/JSONSchemaForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,8 @@ export class JSONSchemaForm extends LitElement {
results: { ...nestedResults },
globals: this.globals?.[name],

controls: this.controls[name],

onUpdate: (internalPath, value, forceUpdate) => {
const path = [...localPath, ...internalPath];
this.updateData(path, value, forceUpdate);
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/stories/JSONSchemaInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ export const isEditableObject = (schema, value) =>
export const isAdditionalProperties = (pattern) => pattern === "additional";
export const isPatternProperties = (pattern) => pattern && !isAdditionalProperties(pattern);

export const getEditableItems = (value, pattern, { name, schema } = {}) => {
export const getEditableItems = (value = {}, pattern, { name, schema } = {}) => {
let items = Object.entries(value);

const allowAdditionalProperties = isAdditionalProperties(pattern);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,143 @@ import { CodeBlock } from "../../../CodeBlock.js";
import { List } from "../../../List";
import { fs } from "../../../../electron/index.js";
import { joinPath } from "../../../../globals.js";
import { Button } from "../../../Button.js";
import { Modal } from "../../../Modal";
import { header } from "../../../forms/utils";

import autocompleteIcon from "../../../assets/inspect.svg?raw";

export async function autocompleteFormatString(path) {
let notification;

const { base_directory } = path.reduce((acc, key) => acc[key] ?? {}, this.form.resolved);

const notify = (message, type) => {
if (notification) this.dismiss(notification);
return (notification = this.notify(message, type));
};

if (!base_directory) {
const message = `Please fill out the <b>base directory</b> for ${header(path[0])} before attempting auto-completion.`;
notify(message, "error");
throw new Error(message);
}

const modal = new Modal({
header: "Autocomplete Format String",
});

const content = document.createElement("div");
Object.assign(content.style, {
padding: "25px",
paddingBottom: "0px",
});

const propOrder = ["path", "subject_id", "session_id"];
const form = new JSONSchemaForm({
schema: {
type: "object",
properties: {
path: {
type: "string",
title: "Example Filesystem Entry",
format: ["file", "directory"],
description: "Provide an example filesystem entry for the selected interface",
},
subject_id: {
type: "string",
description: "The subject ID in the above entry",
},
session_id: {
type: "string",
description: "The session ID in the above entry",
},
},
required: propOrder,
order: propOrder,
},
validateOnChange: async (name, parent) => {
const value = parent[name];

if (name === "path") {
if (value) {
if (fs.lstatSync(value).isSymbolicLink())
return [
{
type: "error",
message: "This feature does not support symbolic links. Please provide a valid path.",
},
];

if (base_directory) {
if (!value.includes(base_directory))
return [
{
type: "error",
message:
"The provided path must include the base directory.<br><small>This is likely due to the target being contained in a symlink, which is unsupported by this feature.</small>",
},
];
}

const errors = [];
for (let key in parent) {
if (key === name) continue;
if (!value.includes(parent[key]))
errors.push({
type: "error",
message: `${header(name)} not found in the updated path.`,
});
}
}
} else {
if (!parent.path || !parent.path.includes(value))
return [
{
type: "error",
message: `${header(name)} not found in the provided path.`,
},
];
}
},
});

content.append(form);
modal.append(content);

modal.onClose = async () => notify("Format String Path was not completed.", "error");

return new Promise((resolve) => {
const button = new Button({
label: "Create",
primary: true,
onClick: async () => {
await form.validate().catch((e) => {
notify(e.message, "error");
throw e;
});

const results = await run("locate/autocomplete", {
base_directory,
additional_metadata: {},
...form.results,
});
const input = this.form.getFormElement([...path, "format_string_path"]);
input.updateData(results.format_string);
this.save();
resolve(results.format_string);
},
});

modal.footer = button;

modal.open = true;

document.body.append(modal);
}).finally(() => {
modal.remove();
});
}

const exampleFileStructure = `mylab/
¦ Subjects/
Expand Down Expand Up @@ -271,8 +408,23 @@ export class GuidedPathExpansionPage extends Page {

// Require properties for all sources
const generatedSchema = { type: "object", properties: {}, additionalProperties: false };
for (let key in this.info.globalState.interfaces)
const controls = {};
for (let key in this.info.globalState.interfaces) {
generatedSchema.properties[key] = { type: "object", ...pathExpansionSchema };

controls[key] = {
format_string_path: [
new Button({
label: "Autocomplete",
icon: autocompleteIcon,
buttonStyles: {
width: "max-content",
},
onClick: async () => autocompleteFormatString.call(this, [key]),
}),
],
};
}
structureState.schema = generatedSchema;

this.optional.requestUpdate();
Expand All @@ -282,6 +434,8 @@ export class GuidedPathExpansionPage extends Page {
onThrow,
validateEmptyValues: false,

controls,

// NOTE: These are custom coupled form inputs
onUpdate: (path, value) => {
this.unsavedUpdates = "conversions";
Expand Down

0 comments on commit d80764a

Please sign in to comment.