(function () {
const state = {
step: 1,
phrases: [""],
cmdId: "",
cmdIdAuto: true,
existingIds: [],
actionType: null,
action: null,
confirmSound: null,
};
const els = {
steps: document.querySelectorAll(".step"),
panels: document.querySelectorAll(".panel"),
phrase0: document.getElementById("phrase-0"),
extra: document.getElementById("extra-phrases"),
addPhrase: document.getElementById("add-phrase"),
cmdId: document.getElementById("cmd-id"),
cmdIdHint: document.getElementById("cmd-id-hint"),
actionGrid: document.getElementById("action-grid"),
paramsForm: document.getElementById("params-form"),
yamlPreview: document.getElementById("yaml-preview"),
saveBtn: document.getElementById("save-btn"),
cancelBtn: document.getElementById("cancel-btn"),
saveStatus: document.getElementById("save-status"),
toast: document.getElementById("toast"),
toStep3: document.getElementById("to-step-3"),
tabs: document.querySelectorAll(".tab"),
tabContents: document.querySelectorAll("[data-tab-content]"),
llmPrompt: document.getElementById("llm-prompt"),
llmGenerate: document.getElementById("llm-generate"),
llmStatus: document.getElementById("llm-status"),
llmSuggestions: document.getElementById("llm-suggestions"),
};
function api() {
return (window.pywebview && window.pywebview.api) || null;
}
async function callApi(method, ...args) {
const a = api();
if (!a || typeof a[method] !== "function") {
return { ok: false, error: "bridge not ready: " + method };
}
try {
return await a[method](...args);
} catch (ex) {
return { ok: false, error: String(ex) };
}
}
function setStep(n) {
state.step = n;
els.panels.forEach((p) => {
p.hidden = parseInt(p.dataset.panel, 10) !== n;
});
els.steps.forEach((s) => {
s.classList.toggle("active", parseInt(s.dataset.step, 10) === n);
});
if (n === 4) {
renderPreview();
}
}
function showToast(msg, isError) {
els.toast.textContent = msg;
els.toast.classList.toggle("error", !!isError);
els.toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => { els.toast.hidden = true; }, 2800);
}
function refreshExtraPhrases() {
els.extra.innerHTML = "";
for (let i = 1; i < state.phrases.length; i++) {
const row = document.createElement("div");
row.className = "extra-row";
const inp = document.createElement("input");
inp.type = "text";
inp.value = state.phrases[i];
inp.placeholder = "синоним";
inp.addEventListener("input", () => {
state.phrases[i] = inp.value;
});
const rm = document.createElement("button");
rm.type = "button";
rm.textContent = "удалить";
rm.addEventListener("click", () => {
state.phrases.splice(i, 1);
refreshExtraPhrases();
});
row.appendChild(inp);
row.appendChild(rm);
els.extra.appendChild(row);
}
}
function transliterateSlug(s) {
const map = {
"а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ё":"e","ж":"zh",
"з":"z","и":"i","й":"y","к":"k","л":"l","м":"m","н":"n","о":"o",
"п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"ts",
"ч":"ch","ш":"sh","щ":"sch","ъ":"","ы":"y","ь":"","э":"e","ю":"yu","я":"ya",
};
let out = "";
const lower = (s || "").toLowerCase().trim();
for (const ch of lower) {
if (map[ch] !== undefined) out += map[ch];
else if (/[a-z0-9]/i.test(ch)) out += ch;
else if (ch === " " || ch === "_" || ch === "-") out += "_";
}
out = out.replace(/_+/g, "_").replace(/^_|_$/g, "");
return out || "cmd";
}
function checkCmdIdCollision() {
if (!state.cmdId) {
els.cmdIdHint.className = "row-hint";
els.cmdIdHint.textContent = "";
return;
}
if (state.existingIds.includes(state.cmdId)) {
els.cmdIdHint.className = "row-hint error";
els.cmdIdHint.textContent = `Идентификатор «${state.cmdId}» уже используется`;
} else {
els.cmdIdHint.className = "row-hint ok";
els.cmdIdHint.textContent = "Свободен";
}
}
function syncCmdId() {
if (state.cmdIdAuto) {
state.cmdId = transliterateSlug(state.phrases[0] || "");
els.cmdId.value = state.cmdId;
}
checkCmdIdCollision();
}
function renderActionForm() {
const t = state.actionType;
const f = els.paramsForm;
f.innerHTML = "";
if (!t) {
f.innerHTML = `
Сначала выберите тип на шаге 2.
`;
return;
}
if (t === "exe") {
const wrap = document.createElement("div");
wrap.innerHTML = `
`;
f.appendChild(wrap);
const sel = wrap.querySelector("#exe-file");
callApi("list_exes").then((list) => {
if (Array.isArray(list)) {
for (const name of list) {
const o = document.createElement("option");
o.value = `${name}.exe`;
o.textContent = name;
sel.appendChild(o);
}
if (state.action && state.action.file) sel.value = state.action.file;
}
});
sel.addEventListener("change", () => updateAction());
wrap.querySelector("#exe-delay").addEventListener("input", () => updateAction());
wrap.querySelector("#exe-wait").addEventListener("change", () => updateAction());
if (state.action) {
wrap.querySelector("#exe-delay").value = state.action.delay_ms ?? "";
wrap.querySelector("#exe-wait").checked = !!state.action.wait;
}
return;
}
if (t === "url") {
f.innerHTML = `
`;
const inp = f.querySelector("#url-href");
const sel = f.querySelector("#url-browser");
if (state.action) {
inp.value = state.action.href || "";
sel.value = state.action.browser || "";
}
inp.addEventListener("input", () => updateAction());
sel.addEventListener("change", () => updateAction());
return;
}
if (t === "keys") {
f.innerHTML = `
`;
const mode = f.querySelector("#keys-mode");
const seqField = f.querySelector("#keys-seq-field");
const textField = f.querySelector("#keys-text-field");
const seq = f.querySelector("#keys-sequence");
const text = f.querySelector("#keys-text");
if (state.action && state.action.text) {
mode.value = "text";
text.value = state.action.text;
seqField.hidden = true;
textField.hidden = false;
} else if (state.action && state.action.sequence) {
seq.value = state.action.sequence;
}
const sync = () => {
if (mode.value === "sequence") {
seqField.hidden = false;
textField.hidden = true;
} else {
seqField.hidden = true;
textField.hidden = false;
}
updateAction();
};
mode.addEventListener("change", sync);
seq.addEventListener("input", () => updateAction());
text.addEventListener("input", () => updateAction());
return;
}
if (t === "play_sound") {
f.innerHTML = `
`;
const sel = f.querySelector("#snd-name");
callApi("list_sound_names").then((list) => {
if (Array.isArray(list)) {
for (const name of list) {
const o = document.createElement("option");
o.value = name;
o.textContent = name;
sel.appendChild(o);
}
if (state.action && state.action.name) sel.value = state.action.name;
updateAction();
}
});
sel.addEventListener("change", () => updateAction());
f.querySelector("#snd-preview").addEventListener("click", () => {
callApi("preview_sound", sel.value);
});
return;
}
if (t === "system") {
f.innerHTML = `
`;
const sel = f.querySelector("#sys-op");
if (state.action && state.action.op) sel.value = state.action.op;
sel.addEventListener("change", () => updateAction());
return;
}
if (t === "shell") {
f.innerHTML = `
`;
const cmd = f.querySelector("#shell-cmd");
const wait = f.querySelector("#shell-wait");
if (state.action) {
cmd.value = state.action.cmd || "";
wait.checked = !!state.action.wait;
}
cmd.addEventListener("input", () => updateAction());
wait.addEventListener("change", () => updateAction());
return;
}
if (t === "sleep") {
f.innerHTML = `
Обычно используется внутри последовательности.
`;
const inp = f.querySelector("#sleep-ms");
if (state.action && state.action.ms != null) inp.value = state.action.ms;
inp.addEventListener("input", () => updateAction());
return;
}
if (t === "multi") {
renderMultiForm();
return;
}
}
function renderMultiForm() {
const f = els.paramsForm;
f.innerHTML = `
Шаги выполняются по порядку.
`;
const list = f.querySelector("#steps-list");
if (!state.action || state.action.type !== "multi") {
state.action = { type: "multi", steps: [] };
}
const renderList = () => {
list.innerHTML = "";
state.action.steps.forEach((step, idx) => {
const card = document.createElement("div");
card.className = "step-card";
card.innerHTML = `
Шаг ${idx + 1}
`;
const body = document.createElement("div");
renderInlineEditor(body, step, (next) => {
state.action.steps[idx] = next;
updateAction(true);
});
card.appendChild(body);
card.querySelector("[data-up]").addEventListener("click", () => {
if (idx > 0) {
const tmp = state.action.steps[idx - 1];
state.action.steps[idx - 1] = state.action.steps[idx];
state.action.steps[idx] = tmp;
renderList();
updateAction(true);
}
});
card.querySelector("[data-down]").addEventListener("click", () => {
if (idx < state.action.steps.length - 1) {
const tmp = state.action.steps[idx + 1];
state.action.steps[idx + 1] = state.action.steps[idx];
state.action.steps[idx] = tmp;
renderList();
updateAction(true);
}
});
card.querySelector("[data-rm]").addEventListener("click", () => {
state.action.steps.splice(idx, 1);
renderList();
updateAction(true);
});
list.appendChild(card);
});
};
renderList();
f.querySelector("#add-step").addEventListener("click", () => {
state.action.steps.push({ type: "play_sound", name: "ok" });
renderList();
updateAction(true);
});
updateAction(true);
}
function renderInlineEditor(host, step, onChange) {
const typeSel = document.createElement("select");
const types = [
["play_sound", "Звук"],
["exe", ".exe"],
["url", "URL"],
["keys", "Клавиши"],
["shell", "Shell"],
["system", "Система"],
["sleep", "Пауза"],
];
for (const [v, label] of types) {
const o = document.createElement("option");
o.value = v;
o.textContent = label;
if (v === step.type) o.selected = true;
typeSel.appendChild(o);
}
host.appendChild(wrapField("Тип", typeSel));
const body = document.createElement("div");
host.appendChild(body);
const renderBody = () => {
body.innerHTML = "";
const t = typeSel.value;
if (t === "play_sound") {
const sel = document.createElement("select");
callApi("list_sound_names").then((list) => {
if (Array.isArray(list)) {
for (const name of list) {
const o = document.createElement("option");
o.value = name;
o.textContent = name;
if (step.name === name) o.selected = true;
sel.appendChild(o);
}
}
});
sel.addEventListener("change", () => onChange({ type: "play_sound", name: sel.value }));
body.appendChild(wrapField("Звук", sel));
} else if (t === "exe") {
const sel = document.createElement("select");
callApi("list_exes").then((list) => {
if (Array.isArray(list)) {
for (const name of list) {
const o = document.createElement("option");
o.value = `${name}.exe`;
o.textContent = name;
if (step.file === `${name}.exe`) o.selected = true;
sel.appendChild(o);
}
}
});
const wait = document.createElement("input");
wait.type = "checkbox";
wait.checked = !!step.wait;
const waitLabel = document.createElement("label");
waitLabel.className = "checkbox";
waitLabel.appendChild(wait);
waitLabel.appendChild(document.createTextNode("ждать"));
const delay = document.createElement("input");
delay.type = "number";
delay.value = step.delay_ms ?? "";
delay.placeholder = "delay_ms";
const apply = () => {
const next = { type: "exe", file: sel.value };
if (wait.checked) next.wait = true;
if (delay.value) next.delay_ms = parseInt(delay.value, 10);
onChange(next);
};
sel.addEventListener("change", apply);
wait.addEventListener("change", apply);
delay.addEventListener("input", apply);
body.appendChild(wrapField("Файл", sel));
body.appendChild(wrapField("Задержка (мс)", delay));
body.appendChild(waitLabel);
} else if (t === "url") {
const inp = document.createElement("input");
inp.type = "text";
inp.value = step.href || "";
inp.placeholder = "https://...";
const sel = document.createElement("select");
sel.innerHTML = `
`;
sel.value = step.browser || "";
const apply = () => {
const next = { type: "url", href: inp.value };
if (sel.value) next.browser = sel.value;
onChange(next);
};
inp.addEventListener("input", apply);
sel.addEventListener("change", apply);
body.appendChild(wrapField("URL", inp));
body.appendChild(wrapField("Браузер", sel));
} else if (t === "keys") {
const inp = document.createElement("input");
inp.type = "text";
inp.value = step.sequence || "";
inp.placeholder = "ctrl+shift+m";
inp.addEventListener("input", () => onChange({ type: "keys", sequence: inp.value }));
body.appendChild(wrapField("Сочетание", inp));
} else if (t === "shell") {
const inp = document.createElement("input");
inp.type = "text";
inp.value = step.cmd || "";
const wait = document.createElement("input");
wait.type = "checkbox";
wait.checked = !!step.wait;
const waitLabel = document.createElement("label");
waitLabel.className = "checkbox";
waitLabel.appendChild(wait);
waitLabel.appendChild(document.createTextNode("ждать"));
const apply = () => {
const next = { type: "shell", cmd: inp.value };
if (wait.checked) next.wait = true;
onChange(next);
};
inp.addEventListener("input", apply);
wait.addEventListener("change", apply);
body.appendChild(wrapField("Команда", inp));
body.appendChild(waitLabel);
} else if (t === "system") {
const sel = document.createElement("select");
for (const op of ["volume_mute", "volume_unmute", "exit"]) {
const o = document.createElement("option");
o.value = op;
o.textContent = op;
if (step.op === op) o.selected = true;
sel.appendChild(o);
}
sel.addEventListener("change", () => onChange({ type: "system", op: sel.value }));
body.appendChild(wrapField("Операция", sel));
} else if (t === "sleep") {
const inp = document.createElement("input");
inp.type = "number";
inp.value = step.ms ?? 500;
inp.addEventListener("input", () => onChange({ type: "sleep", ms: parseInt(inp.value, 10) || 0 }));
body.appendChild(wrapField("Миллисекунды", inp));
}
};
typeSel.addEventListener("change", () => {
const t = typeSel.value;
const fresh = ({
play_sound: { type: "play_sound", name: "ok" },
exe: { type: "exe", file: "" },
url: { type: "url", href: "" },
keys: { type: "keys", sequence: "" },
shell: { type: "shell", cmd: "" },
system: { type: "system", op: "volume_mute" },
sleep: { type: "sleep", ms: 500 },
})[t];
onChange(fresh);
step = fresh;
renderBody();
});
renderBody();
}
function wrapField(label, control) {
const w = document.createElement("div");
w.className = "field";
const l = document.createElement("label");
l.textContent = label;
w.appendChild(l);
w.appendChild(control);
return w;
}
function updateAction(skipRender) {
const t = state.actionType;
if (!t) return;
if (t === "exe") {
const file = document.getElementById("exe-file");
const delay = document.getElementById("exe-delay");
const wait = document.getElementById("exe-wait");
const a = { type: "exe", file: file ? file.value : "" };
if (wait && wait.checked) a.wait = true;
if (delay && delay.value) a.delay_ms = parseInt(delay.value, 10);
state.action = a;
} else if (t === "url") {
const a = { type: "url", href: (document.getElementById("url-href")||{}).value || "" };
const b = (document.getElementById("url-browser")||{}).value;
if (b) a.browser = b;
state.action = a;
} else if (t === "keys") {
const mode = (document.getElementById("keys-mode")||{}).value;
if (mode === "text") {
state.action = { type: "keys", text: (document.getElementById("keys-text")||{}).value || "" };
} else {
state.action = { type: "keys", sequence: (document.getElementById("keys-sequence")||{}).value || "" };
}
} else if (t === "play_sound") {
state.action = { type: "play_sound", name: (document.getElementById("snd-name")||{}).value || "ok" };
} else if (t === "system") {
state.action = { type: "system", op: (document.getElementById("sys-op")||{}).value || "volume_mute" };
} else if (t === "shell") {
const a = { type: "shell", cmd: (document.getElementById("shell-cmd")||{}).value || "" };
if ((document.getElementById("shell-wait")||{}).checked) a.wait = true;
state.action = a;
} else if (t === "sleep") {
state.action = { type: "sleep", ms: parseInt((document.getElementById("sleep-ms")||{}).value || "0", 10) };
}
}
function buildDraft() {
return {
cmd_id: state.cmdId,
phrases: state.phrases.map((p) => p.trim()).filter(Boolean),
action: state.action,
confirm_sound: state.confirmSound || undefined,
};
}
function renderPreview() {
const draft = buildDraft();
if (!draft.cmd_id || !draft.phrases.length || !draft.action) {
els.yamlPreview.textContent = "(черновик неполный)";
return;
}
const body = { phrases: draft.phrases, action: draft.action };
if (draft.confirm_sound) body.confirm_sound = draft.confirm_sound;
const obj = {};
obj[draft.cmd_id] = body;
try {
els.yamlPreview.textContent = jsyaml.dump(obj, { lineWidth: 4096, noRefs: true });
} catch (ex) {
els.yamlPreview.textContent = "Ошибка YAML: " + ex.message;
}
}
async function refreshExistingIds() {
const r = await callApi("list_existing_commands");
if (Array.isArray(r)) {
state.existingIds = r;
checkCmdIdCollision();
}
}
function bind() {
els.phrase0.addEventListener("input", () => {
state.phrases[0] = els.phrase0.value;
syncCmdId();
});
els.addPhrase.addEventListener("click", () => {
state.phrases.push("");
refreshExtraPhrases();
});
els.cmdId.addEventListener("input", () => {
state.cmdIdAuto = false;
state.cmdId = els.cmdId.value.trim();
checkCmdIdCollision();
});
els.actionGrid.addEventListener("click", (ev) => {
const card = ev.target.closest(".card");
if (!card) return;
const t = card.dataset.action;
els.actionGrid.querySelectorAll(".card").forEach((c) => c.classList.toggle("selected", c === card));
if (state.actionType !== t) {
state.actionType = t;
state.action = null;
}
});
els.toStep3.addEventListener("click", () => {
if (!state.actionType) {
showToast("Выберите тип действия", true);
return;
}
renderActionForm();
setStep(3);
});
document.querySelectorAll("[data-go]").forEach((b) => {
if (b.id === "to-step-3") return;
b.addEventListener("click", () => setStep(parseInt(b.dataset.go, 10)));
});
els.steps.forEach((s) => {
s.addEventListener("click", () => {
const target = parseInt(s.dataset.step, 10);
if (target === 3) renderActionForm();
setStep(target);
});
});
els.tabs.forEach((t) => {
t.addEventListener("click", () => {
els.tabs.forEach((x) => x.classList.toggle("active", x === t));
els.tabContents.forEach((c) => {
c.hidden = c.dataset.tabContent !== t.dataset.tab;
});
});
});
els.llmGenerate.addEventListener("click", async () => {
els.llmStatus.className = "row-hint";
els.llmStatus.textContent = "Запрос к модели...";
els.llmSuggestions.innerHTML = "";
const r = await callApi("llm_suggest", els.llmPrompt.value || "");
if (!r || !r.ok) {
els.llmStatus.className = "row-hint error";
els.llmStatus.textContent = (r && r.error) || "Не удалось получить ответ";
return;
}
els.llmStatus.className = "row-hint ok";
els.llmStatus.textContent = "Готово. Примите блок или отредактируйте вручную.";
renderLlmSuggestion(r.suggestion);
});
els.saveBtn.addEventListener("click", async () => {
const draft = buildDraft();
const v = await callApi("validate_draft", JSON.stringify(draft));
if (!v.ok) {
els.saveStatus.className = "row-hint error";
els.saveStatus.textContent = v.error;
return;
}
const overwrite = state.existingIds.includes(draft.cmd_id);
if (overwrite) {
if (!confirm(`Команда «${draft.cmd_id}» уже существует. Перезаписать?`)) return;
draft.overwrite = true;
}
const r = await callApi("save_command", JSON.stringify(draft));
if (!r.ok) {
els.saveStatus.className = "row-hint error";
els.saveStatus.textContent = r.error;
return;
}
els.saveStatus.className = "row-hint ok";
els.saveStatus.textContent = `Сохранено: ${r.id}`;
showToast(`Команда «${r.id}» сохранена`);
refreshExistingIds();
});
els.cancelBtn.addEventListener("click", () => {
if (!confirm("Сбросить черновик?")) return;
resetDraft();
});
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") {
if (state.step > 1) setStep(state.step - 1);
return;
}
if (ev.key === "Enter" && !ev.shiftKey && !ev.ctrlKey && !ev.altKey) {
const tag = (ev.target && ev.target.tagName) || "";
if (tag === "TEXTAREA") return;
if (tag === "BUTTON") return;
if (state.step < 4) {
if (state.step === 2 && state.actionType) renderActionForm();
setStep(state.step + 1);
ev.preventDefault();
}
}
});
}
function renderLlmSuggestion(suggestion) {
const host = els.llmSuggestions;
host.innerHTML = "";
if (!suggestion) return;
const block = document.createElement("div");
block.className = "suggested-block";
const pre = document.createElement("pre");
try {
pre.textContent = jsyaml.dump(suggestion, { lineWidth: 4096, noRefs: true });
} catch (ex) {
pre.textContent = JSON.stringify(suggestion, null, 2);
}
block.appendChild(pre);
const accept = document.createElement("button");
accept.type = "button";
accept.className = "primary";
accept.textContent = "Принять";
accept.addEventListener("click", () => {
if (suggestion.phrases) state.phrases = suggestion.phrases.slice();
if (suggestion.cmd_id) {
state.cmdId = suggestion.cmd_id;
state.cmdIdAuto = false;
els.cmdId.value = state.cmdId;
} else {
syncCmdId();
}
els.phrase0.value = state.phrases[0] || "";
refreshExtraPhrases();
if (suggestion.action) {
state.actionType = suggestion.action.type;
state.action = suggestion.action;
els.actionGrid.querySelectorAll(".card").forEach((c) => {
c.classList.toggle("selected", c.dataset.action === state.actionType);
});
els.tabs.forEach((x) => x.classList.toggle("active", x.dataset.tab === "manual"));
els.tabContents.forEach((c) => { c.hidden = c.dataset.tabContent !== "manual"; });
renderActionForm();
}
setStep(3);
});
const discard = document.createElement("button");
discard.type = "button";
discard.className = "ghost";
discard.textContent = "Отклонить";
discard.addEventListener("click", () => { host.innerHTML = ""; });
const actions = document.createElement("div");
actions.className = "actions";
actions.appendChild(discard);
actions.appendChild(accept);
block.appendChild(actions);
host.appendChild(block);
}
function resetDraft() {
state.phrases = [""];
state.cmdId = "";
state.cmdIdAuto = true;
state.actionType = null;
state.action = null;
state.confirmSound = null;
els.phrase0.value = "";
els.cmdId.value = "";
els.cmdIdHint.textContent = "";
refreshExtraPhrases();
els.actionGrid.querySelectorAll(".card").forEach((c) => c.classList.remove("selected"));
els.paramsForm.innerHTML = "";
els.yamlPreview.textContent = "";
els.saveStatus.textContent = "";
setStep(1);
}
function init() {
bind();
refreshExtraPhrases();
setStep(1);
const tryReady = () => {
if (api()) {
refreshExistingIds();
} else {
setTimeout(tryReady, 80);
}
};
tryReady();
}
document.addEventListener("DOMContentLoaded", init);
})();