Files
uTools-quickcommand/plugin/lib/commandStore.js
T
Mango f2abe34dac 新建命令时按执行方式填上默认图标
MCP 建出来的命令 features.icon 是空的,命令卡片上就是个空位。现在 icon 留空时
按 program 填对应语言的默认图标(shell -> logo/shell.png、python ->
logo/python.png,编排用内置图标),显式传了 icon 就用传入的。
2026-10-13 01:38:05 +08:00

361 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 快捷命令的读写与校验,供 MCP 工具使用(preload 环境,CommonJS)
*
* 逻辑对应 src/js/commandManager.js,那边是 webpack 模块,preload require 不到,
* 只能把必需片段复制过来。改动其中一边时记得同步另一边。
*/
const utoolsFull = window.utools; // App.vue 初始化后 window.utools 会被替换成阉割版,必须在 preload 加载时捕获
const PROGRAMS = [
"quickcommand",
"quickcomposer",
"html",
"shell",
"applescript",
"cmd",
"powershell",
"python",
"javascript",
"ruby",
"php",
"c",
"csharp",
"lua",
"perl",
"custom",
];
const OUTPUTS = [
"ignore",
"nothing",
"text",
"html",
"terminal",
"clip",
"send",
"notice",
];
const PLATFORMS = ["win32", "darwin", "linux"];
const CMD_TYPES = [
"key",
"regex",
"over",
"img",
"files",
"window",
"professional",
];
const ICON_MAX_LENGTH = 32 * 1024;
// 各执行方式的默认图标,与 src/js/options/programs.js 保持一致(那边是 webpack 模块,preload 引不到)
const DEFAULT_ICONS = {
quickcommand: "logo/quickcommand.png",
quickcomposer: "logo/quickcommand.png",
html: "logo/html.png",
shell: "logo/shell.png",
applescript: "logo/applescript.png",
cmd: "logo/cmd.png",
powershell: "logo/powershell.png",
python: "logo/python.png",
javascript: "logo/javascript.png",
ruby: "logo/ruby.png",
php: "logo/php.png",
c: "logo/c.png",
csharp: "logo/csharp.png",
lua: "logo/lua.png",
perl: "logo/perl.png",
custom: "logo/custom.png",
};
const getUniqueId = (options = {}) => {
const { short = false } = options;
const uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
return short ? uuid.substring(0, 8) : uuid;
};
const isDefaultCommand = (code) => String(code).slice(0, 8) === "default_";
const getCmdType = (cmds) => {
const firstCmdType = cmds[0].type || "key";
if (!cmds.find((x) => typeof x !== "string")) return "key";
if (!cmds.find((x) => x.type !== firstCmdType)) return firstCmdType;
return "professional";
};
const getFeatureCode = (cmds) => {
return `${getCmdType(cmds)}_${getUniqueId({ short: true })}`;
};
const dbGet = (id) => {
const db = utoolsFull.db.get(id);
return db ? db.data : null;
};
const dbPut = (value, id) => {
const db = utoolsFull.db.get(id);
return db
? utoolsFull.db.put({ _id: id, data: value, _rev: db._rev })
: utoolsFull.db.put({ _id: id, data: value });
};
const dbRemove = (id) => utoolsFull.db.remove(id);
const getActivatedCodes = () => utoolsFull.getFeatures().map((x) => x.code);
// 是否为不需要显示输入框的输出类型
const outPluginOutputs = ["ignore", "terminal", "clip", "send", "notice"];
const validateCmdRule = (rule, index, errors) => {
const path = `features.cmds[${index}]`;
if (typeof rule === "string") {
if (!rule.trim())
errors.push({ path, error: "关键词不能为空字符串" });
return;
}
if (!rule || typeof rule !== "object") {
errors.push({ path, got: rule, error: "只能是字符串(关键词)或匹配规则对象" });
return;
}
if (!CMD_TYPES.includes(rule.type)) {
errors.push({ path: `${path}.type`, got: rule.type, allow: CMD_TYPES });
return;
}
if (rule.type === "regex" && typeof rule.match !== "string") {
errors.push({ path: `${path}.match`, error: "正则匹配规则需要 match 字段,形如 \"/^https?:\\\\/\\\\//i\"" });
}
if (rule.type === "files" && rule.fileType && !["file", "directory"].includes(rule.fileType)) {
errors.push({ path: `${path}.fileType`, got: rule.fileType, allow: ["file", "directory"] });
}
if (rule.type === "window" && (!rule.match || !Array.isArray(rule.match.app))) {
errors.push({ path: `${path}.match.app`, error: "窗口匹配规则需要 match.app 数组,形如 [\"chrome.exe\"]" });
}
};
const validateCommand = (command) => {
const errors = [];
if (!command || typeof command !== "object" || Array.isArray(command)) {
return [{ path: "", error: "命令必须是一个对象" }];
}
if (!PROGRAMS.includes(command.program)) {
errors.push({ path: "program", got: command.program, allow: PROGRAMS });
}
const features = command.features;
if (!features || typeof features !== "object") {
errors.push({ path: "features", error: "缺少 features 对象" });
return errors;
}
if (typeof features.explain !== "string" || !features.explain.trim()) {
errors.push({ path: "features.explain", error: "名称不能为空,它是命令在 uTools 搜索框里显示的名字" });
}
if (!Array.isArray(features.cmds) || !features.cmds.length) {
errors.push({ path: "features.cmds", error: "至少需要一个触发方式(关键词字符串或匹配规则对象)" });
} else {
features.cmds.forEach((rule, i) => validateCmdRule(rule, i, errors));
}
if (features.platform !== undefined) {
if (
!Array.isArray(features.platform) ||
!features.platform.length ||
features.platform.some((p) => !PLATFORMS.includes(p))
) {
errors.push({ path: "features.platform", got: features.platform, allow: PLATFORMS });
}
}
if (features.mainPush !== undefined && typeof features.mainPush !== "boolean") {
errors.push({ path: "features.mainPush", got: features.mainPush, error: "需要布尔值" });
}
if (typeof features.icon === "string" && features.icon.length > ICON_MAX_LENGTH) {
errors.push({
path: "features.icon",
error: `图标过大(${features.icon.length} 字符,上限 ${ICON_MAX_LENGTH}),请留空让用户自己在界面里设置`,
});
}
if (command.output !== undefined && !OUTPUTS.includes(command.output)) {
errors.push({ path: "output", got: command.output, allow: OUTPUTS });
}
if (command.tags !== undefined && !Array.isArray(command.tags)) {
errors.push({ path: "tags", got: command.tags, error: "需要字符串数组,如 [\"日常\"]" });
}
if (command.program === "quickcomposer") {
if (command.flows !== undefined && !Array.isArray(command.flows)) {
errors.push({ path: "flows", got: command.flows, error: "需要数组" });
}
} else if (typeof command.cmd !== "string") {
errors.push({ path: "cmd", got: command.cmd, error: "脚本内容必须是字符串" });
}
return errors;
};
// 补齐派生字段,等价于 commandManager 里的 getValidCommand
const normalizeCommand = (command) => {
const normalized = JSON.parse(JSON.stringify(command));
const { cmds, explain } = normalized.features;
normalized.features.cmds = cmds.map((cmd) =>
typeof cmd === "string" ? cmd : { ...cmd, label: explain }
);
normalized.features.mainHide = outPluginOutputs.includes(normalized.output);
normalized.features.code =
normalized.features.code || getFeatureCode(normalized.features.cmds);
// 图标留空时用该类型的默认图标,否则命令卡片上是个空位
if (!normalized.features.icon) {
normalized.features.icon =
DEFAULT_ICONS[normalized.program] || DEFAULT_ICONS.quickcommand;
}
if (!Array.isArray(normalized.tags)) normalized.tags = [];
if (normalized.output === undefined) normalized.output = "text";
return normalized;
};
const getDbCommands = () => {
return utoolsFull.db
.allDocs("qc_")
.map((x) => x.data)
// 打包进插件里的默认命令不落库,这条只是兜底
.filter((x) => x && x.features && !String(x.features.code).includes("default_"));
};
const summarize = (command, activatedCodes) => ({
code: command.features.code,
explain: command.features.explain,
cmds: command.features.cmds,
program: command.program,
output: command.output,
tags: command.tags || [],
enabled: activatedCodes.includes(command.features.code),
});
const list = (params = {}) => {
const activatedCodes = getActivatedCodes();
let commands = getDbCommands();
if (params.tag) commands = commands.filter((x) => (x.tags || []).includes(params.tag));
if (params.program) commands = commands.filter((x) => x.program === params.program);
if (typeof params.enabled === "boolean") {
commands = commands.filter((x) => activatedCodes.includes(x.features.code) === params.enabled);
}
if (params.keyword) {
const kw = String(params.keyword).toLowerCase();
commands = commands.filter((x) => {
const haystack = [
x.features.code,
x.features.explain,
JSON.stringify(x.features.cmds),
(x.tags || []).join(" "),
]
.join(" ")
.toLowerCase();
return haystack.includes(kw);
});
}
return {
total: commands.length,
commands: commands.map((x) => summarize(x, activatedCodes)),
};
};
const get = (code) => {
if (!code) throw new Error("缺少 code 参数");
if (isDefaultCommand(code)) {
throw new Error(
`${code} 是插件内置的默认命令,不落库也没有对应的数据,MCP 无法读取。内置命令一律只读,需要类似功能请用 qc_create_command 新建一条。`
);
}
const command = dbGet("qc_" + code);
if (!command) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`);
return command;
};
const create = (command) => {
const errors = validateCommand(command);
if (errors.length) return { errors };
const normalized = normalizeCommand(command);
if (isDefaultCommand(normalized.features.code)) {
normalized.features.code = getFeatureCode(normalized.features.cmds);
}
return { command: persist(normalized) };
};
const update = (code, command) => {
if (!code) throw new Error("缺少 code 参数");
if (isDefaultCommand(code)) {
throw new Error(`${code} 是插件内置的默认命令,不能修改。要改请用 qc_create_command 新建一条自己的命令。`);
}
const current = dbGet("qc_" + code);
if (!current) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`);
const errors = validateCommand(command);
if (errors.length) return { errors };
const normalized = normalizeCommand({ ...command, features: { ...command.features, code } });
return { command: persist(normalized) };
};
const remove = (code) => {
if (!code) throw new Error("缺少 code 参数");
if (isDefaultCommand(code)) {
throw new Error(`${code} 是插件内置的默认命令,不能删除。`);
}
const command = dbGet("qc_" + code);
if (!command) throw new Error(`没有找到 ${code} 这条命令,先用 qc_list_commands 查一下`);
dbRemove("qc_" + code);
utoolsFull.removeFeature(code);
// 历史记录一并清掉,等价于界面上的删除
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("editor_history_" + code)) localStorage.removeItem(key);
}
return command;
};
const setEnabled = (codes, enabled) => {
const changed = [];
codes.forEach((code) => {
if (isDefaultCommand(code)) return;
const command = dbGet("qc_" + code);
if (!command) return;
utoolsFull.removeFeature(code);
if (enabled) utoolsFull.setFeature(window.lodashM.cloneDeep(command.features));
changed.push({ code, enabled });
});
return changed;
};
const listTags = () => {
const counts = {};
getDbCommands().forEach((x) => {
const tags = x.tags && x.tags.length ? x.tags : ["未分类"];
tags.forEach((t) => (counts[t] = (counts[t] || 0) + 1));
});
return Object.keys(counts).map((tag) => ({ tag, count: counts[tag] }));
};
// 落库 + 注册 feature,等价于界面保存
const persist = (command) => {
const code = command.features.code;
dbPut(command, "qc_" + code);
utoolsFull.removeFeature(code);
utoolsFull.setFeature(window.lodashM.cloneDeep(command.features));
notifyUI();
return command;
};
// 通知页面刷新列表,页面没打开时没人监听,忽略即可
const notifyUI = () => {
try {
window.dispatchEvent(new CustomEvent("qc:mcp-changed"));
} catch (e) {
console.log("notifyUI failed", e);
}
};
module.exports = {
list,
get,
create,
update,
remove,
setEnabled,
listTags,
notifyUI,
};