/**
* The folder / category settings editor for the Manage tab. Edits a folder's name, its `.json`
* sidecar (priority, description, and — for lists — forceList), and its `_`-markers abstracted into
* plain controls: a Force-prefix toggle and a group mode (Auto / Always / Never, backed by the
* `_enable-group-list` / `_disable-group-list` markers). Saving hot-applies via `onChanged`.
* @module gui/components/ManageFolderEditor
*/
import { useEffect, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { readFile, saveSidecar, setMarker, fsOp } from "../lib/manageApi.js";
import { dialog } from "../lib/dialog.js";
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
const msgs = defineMessages({
folderName: {
id: "folderEd.folderName",
defaultMessage: "Folder name"
},
category: {
id: "folderEd.category",
defaultMessage: "category"
},
folder: {
id: "folderEd.folder",
defaultMessage: "folder"
},
rename: {
id: "folderEd.rename",
defaultMessage: "Rename"
},
saving: {
id: "folderEd.saving",
defaultMessage: "Saving…"
},
save: {
id: "folderEd.save",
defaultMessage: "Save"
},
description: {
id: "folderEd.description",
defaultMessage: "Description"
},
descriptionPh: {
id: "folderEd.descriptionPh",
defaultMessage: "Category tooltip"
},
priority: {
id: "folderEd.priority",
defaultMessage: "Priority"
},
priorityPh: {
id: "folderEd.priorityPh",
defaultMessage: "1000 (lower sorts higher; blank = default)"
},
forcePrefixTitle: {
id: "folderEd.forcePrefixTitle",
defaultMessage: "Show this folder's path in the token (e.g. d/general)"
},
forcePrefix: {
id: "folderEd.forcePrefix",
defaultMessage: "Force prefix"
},
groupMode: {
id: "folderEd.groupMode",
defaultMessage: "Group mode"
},
groupAuto: {
id: "folderEd.groupAuto",
defaultMessage: "Auto (group when it has 2+ members)"
},
groupAlways: {
id: "folderEd.groupAlways",
defaultMessage: "Always a group"
},
groupNever: {
id: "folderEd.groupNever",
defaultMessage: "Never a group"
},
forceListTitle: {
id: "folderEd.forceListTitle",
defaultMessage: "Keep this folder as its own sub-category even with a single list"
},
forceList: {
id: "folderEd.forceList",
defaultMessage: "Force list category"
},
deleteFolder: {
id: "folderEd.deleteFolder",
defaultMessage: "Delete folder"
},
loading: {
id: "folderEd.loading",
defaultMessage: "Loading…"
},
saved: {
id: "folderEd.saved",
defaultMessage: "Saved."
},
renamed: {
id: "folderEd.renamed",
defaultMessage: "Renamed."
},
deleteConfirm: {
id: "folderEd.deleteConfirm",
defaultMessage: 'Delete the folder "{name}" and everything in it ({count, plural, one {# entry} other {# entries}})? This can\'t be undone.'
}
});
/**
* @param {object} props
* @param {object} props.node The folder model node `{ root, path, name, isCategory, forcePrefix, enableGroup, disableGroup }`.
* @param {Function} props.onChanged Called after a save/rename to hot-apply + refresh. Gets the new path on rename.
* @returns {JSX.Element}
*/
export default function ManageFolderEditor({
node,
onChanged
}) {
const intl = useIntl();
const {
root,
path
} = node;
const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [status, setStatus] = useState("");
const [name, setName] = useState(node.name);
const [description, setDescription] = useState("");
const [priority, setPriority] = useState("");
const [forceList, setForceList] = useState(false);
const [forcePrefix, setForcePrefix] = useState(node.forcePrefix);
const [groupMode, setGroupMode] = useState(node.enableGroup ? "always" : node.disableGroup ? "never" : "auto");
useEffect(() => {
let alive = true;
setLoading(true);
setError("");
setStatus("");
setName(node.name);
setForcePrefix(node.forcePrefix);
setGroupMode(node.enableGroup ? "always" : node.disableGroup ? "never" : "auto");
(async () => {
let meta = {};
try {
meta = JSON.parse(await readFile(root, `${path}.json`));
} catch {
meta = {};
}
if (!alive) return;
setDescription(meta.description || "");
setPriority(typeof meta.priority === "number" ? String(meta.priority) : "");
setForceList(meta.forceList === true);
setLoading(false);
})();
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [root, path]);
async function save() {
setSaving(true);
setError("");
setStatus("");
try {
const prioNum = priority.trim() === "" ? null : Number(priority);
await saveSidecar(root, path, {
description: description.trim() || null,
priority: Number.isFinite(prioNum) ? prioNum : null,
forceList: root === "lists" && forceList ? true : null
});
await setMarker(root, path, "_force-prefix", forcePrefix);
await setMarker(root, path, "_enable-group-list", groupMode === "always");
await setMarker(root, path, "_disable-group-list", groupMode === "never");
setStatus(intl.formatMessage(msgs.saved));
await onChanged?.();
} catch (e) {
setError(e.message || String(e));
} finally {
setSaving(false);
}
}
async function rename() {
const clean = name.trim().replace(/\//g, "");
if (!clean || clean === node.name) return;
const target = parent ? `${parent}/${clean}` : clean;
setSaving(true);
setError("");
try {
await fsOp("move", {
root,
path,
to: target
});
try {
await fsOp("move", {
root,
path: `${path}.json`,
to: `${target}.json`
});
} catch {
// no folder sidecar to move
}
setStatus(intl.formatMessage(msgs.renamed));
await onChanged?.({
...node,
path: target,
name: clean
});
} catch (e) {
setError(e.message || String(e));
} finally {
setSaving(false);
}
}
async function remove() {
if (!(await dialog.confirm({
message: intl.formatMessage(msgs.deleteConfirm, {
name: node.name,
count: node.entryCount
}),
confirmLabel: intl.formatMessage(msgs.deleteFolder),
destructive: true
}))) return;
setSaving(true);
setError("");
try {
await fsOp("delete", {
root,
path
});
try {
await fsOp("delete", {
root,
path: `${path}.json`
});
} catch {
/* no folder sidecar */
}
await onChanged?.({
deleted: true
});
} catch (e) {
setError(e.message || String(e));
setSaving(false);
}
}
if (loading) return /*#__PURE__*/_jsxDEV("section", {
className: "card mg-detail",
children: /*#__PURE__*/_jsxDEV("p", {
className: "empty",
children: intl.formatMessage(msgs.loading)
}, void 0, false)
}, void 0, false);
return /*#__PURE__*/_jsxDEV("section", {
className: "card mg-detail mg-editor",
children: [/*#__PURE__*/_jsxDEV("div", {
className: "mg-editor-head",
children: [/*#__PURE__*/_jsxDEV("input", {
className: "mg-name-input",
value: name,
onChange: e => setName(e.target.value),
"aria-label": intl.formatMessage(msgs.folderName)
}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
className: "mg-kind",
children: intl.formatMessage(node.isCategory ? msgs.category : msgs.folder)
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
className: "link-btn",
onClick: rename,
disabled: saving || name.trim() === node.name,
children: intl.formatMessage(msgs.rename)
}, void 0, false), /*#__PURE__*/_jsxDEV("div", {
className: "grow"
}, void 0, false), /*#__PURE__*/_jsxDEV("button", {
className: "primary",
onClick: save,
disabled: saving,
children: intl.formatMessage(saving ? msgs.saving : msgs.save)
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("p", {
className: "mg-path",
children: /*#__PURE__*/_jsxDEV("code", {
children: [root, "/", path]
}, void 0, true)
}, void 0, false), /*#__PURE__*/_jsxDEV("label", {
className: "mg-field",
children: [/*#__PURE__*/_jsxDEV("span", {
children: intl.formatMessage(msgs.description)
}, void 0, false), /*#__PURE__*/_jsxDEV("input", {
value: description,
onChange: e => setDescription(e.target.value),
placeholder: intl.formatMessage(msgs.descriptionPh)
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("label", {
className: "mg-field",
children: [/*#__PURE__*/_jsxDEV("span", {
children: intl.formatMessage(msgs.priority)
}, void 0, false), /*#__PURE__*/_jsxDEV("input", {
type: "number",
value: priority,
onChange: e => setPriority(e.target.value),
placeholder: intl.formatMessage(msgs.priorityPh)
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("label", {
className: "mg-field mg-check",
children: [/*#__PURE__*/_jsxDEV("input", {
type: "checkbox",
checked: forcePrefix,
onChange: e => setForcePrefix(e.target.checked)
}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
title: intl.formatMessage(msgs.forcePrefixTitle),
children: intl.formatMessage(msgs.forcePrefix)
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("label", {
className: "mg-field",
children: [/*#__PURE__*/_jsxDEV("span", {
children: intl.formatMessage(msgs.groupMode)
}, void 0, false), /*#__PURE__*/_jsxDEV("select", {
value: groupMode,
onChange: e => setGroupMode(e.target.value),
children: [/*#__PURE__*/_jsxDEV("option", {
value: "auto",
children: intl.formatMessage(msgs.groupAuto)
}, void 0, false), /*#__PURE__*/_jsxDEV("option", {
value: "always",
children: intl.formatMessage(msgs.groupAlways)
}, void 0, false), /*#__PURE__*/_jsxDEV("option", {
value: "never",
children: intl.formatMessage(msgs.groupNever)
}, void 0, false)]
}, void 0, true)]
}, void 0, true), root === "lists" && /*#__PURE__*/_jsxDEV("label", {
className: "mg-field mg-check",
children: [/*#__PURE__*/_jsxDEV("input", {
type: "checkbox",
checked: forceList,
onChange: e => setForceList(e.target.checked)
}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
title: intl.formatMessage(msgs.forceListTitle),
children: intl.formatMessage(msgs.forceList)
}, void 0, false)]
}, void 0, true), /*#__PURE__*/_jsxDEV("div", {
className: "mg-editor-foot",
children: [/*#__PURE__*/_jsxDEV("button", {
className: "link-btn mg-danger",
onClick: remove,
disabled: saving,
children: intl.formatMessage(msgs.deleteFolder)
}, void 0, false), status && /*#__PURE__*/_jsxDEV("span", {
className: "mg-ok",
children: status
}, void 0, false), error && /*#__PURE__*/_jsxDEV("span", {
className: "error",
children: error
}, void 0, false)]
}, void 0, true)]
}, void 0, true);
}