mirror of
https://github.com/fofolee/uTools-quickcommand.git
synced 2026-10-26 01:54:48 +08:00
MCP 建出来的命令 features.icon 是空的,命令卡片上就是个空位。现在 icon 留空时 按 program 填对应语言的默认图标(shell -> logo/shell.png、python -> logo/python.png,编排用内置图标),显式传了 icon 就用传入的。
361 lines
12 KiB
JavaScript
361 lines
12 KiB
JavaScript
/**
|
||
* 快捷命令的读写与校验,供 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,
|
||
};
|