Files
rclone_gui/webgui/web/assets/js/views/configure.js
T
2026-06-20 13:54:22 +08:00

433 lines
15 KiB
JavaScript

// views/configure.js — dynamic remote form builder.
//
// Two modes:
// #/configure/new → provider picker (searchable grid)
// #/configure/new/<provider> → form for that backend
// #/configure/edit/<remote> → form pre-filled from /config/get
//
// Pulls backend metadata from /config/providers (cached in app state).
// OAuth backends (option named "token" with IsPassword) get a banner
// and disabled submit — user must run `rclone config` in a terminal.
import { post } from "../rc.js?v=job-layout-1";
import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
import { t } from "../i18n.js?v=job-layout-1";
// --- Route entrypoints ---
export async function renderConfigureNew({ provider = "" }) {
if (!provider) {
return renderProviderPicker();
}
const providers = await ensureProviders();
const info = providers.find((p) => p.Name === provider);
if (!info) {
document.getElementById("app").innerHTML = `
<div class="empty">
<h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_provider", escapeHtml(provider))}</p>
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
</div>`;
return;
}
return renderForm({ provider: info, mode: "create" });
}
export async function renderConfigureEdit({ remote }) {
if (!remote) {
location.hash = "#/remotes";
return;
}
const app = document.getElementById("app");
app.innerHTML = `<div class="empty"><p>${t("loading.title")}…</p></div>`;
let typeName;
try {
const dump = await post("config/dump");
typeName = dump && dump[remote] && dump[remote].type;
} catch (e) {
toast(`${t("error.couldnt_load")}: ${e.message}`, "error");
return;
}
if (!typeName) {
app.innerHTML = `
<div class="empty">
<h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_remote", escapeHtml(remote))}</p>
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
</div>`;
return;
}
const providers = await ensureProviders();
const info = providers.find((p) => p.Name === typeName);
if (!info) {
toast(`${t("error.no_such_provider", typeName)}`, "error");
return;
}
let currentValues = {};
try {
currentValues = await post("config/get", { name: remote });
} catch (e) {
toast(`${t("error.couldnt_load")}: ${e.message}`, "error");
}
return renderForm({
provider: info,
mode: "edit",
remoteName: remote,
currentValues,
});
}
// --- Provider picker ---
async function renderProviderPicker() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="section-head">
<div>
<h2>${t("configure.new_title")}</h2>
<p class="subtitle">${t("configure.new_subtitle")}</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/remotes">${t("configure.cancel")}</a>
</div>
<div class="provider-search">
<input id="provider-filter" class="input" type="search" placeholder="${t("configure.filter_placeholder")}" autocomplete="off">
</div>
<div id="provider-grid" class="connector-grid">
<p class="empty" style="grid-column:1/-1">${t("loading.title")}…</p>
</div>
`;
const grid = document.getElementById("provider-grid");
const filter = document.getElementById("provider-filter");
let providers;
try {
providers = await ensureProviders();
} catch (e) {
grid.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>${t("error.couldnt_load_backends")}</h3><p>${escapeHtml(e.message)}</p></div>`;
return;
}
const visible = providers.filter(
(p) => !p.Hide && p.Name !== "all" && p.Name !== "alias",
);
function paint(list) {
if (list.length === 0) {
grid.innerHTML = `<p class="empty" style="grid-column:1/-1">${t("configure.no_match")}</p>`;
return;
}
grid.innerHTML = list
.map(
(p) => `
<a class="connector-tile" href="#/configure/new/${encodeURIComponent(p.Name)}">
<span class="tile-name">${escapeHtml(p.Name)}</span>
<span class="tile-type">${escapeHtml(p.Description || "")}</span>
</a>`,
)
.join("");
}
paint(visible);
filter.addEventListener("input", () => {
const q = filter.value.trim().toLowerCase();
if (!q) return paint(visible);
paint(
visible.filter((p) =>
(p.Name + " " + (p.Description || "")).toLowerCase().includes(q),
),
);
});
filter.focus();
}
// --- Dynamic form ---
async function renderForm({ provider, mode, remoteName = "", currentValues = {} }) {
const app = document.getElementById("app");
const isEdit = mode === "edit";
const requiresOAuth = provider.Options.some(
(o) => o.Name === "token" && (o.IsPassword || o.Sensitive),
);
const blocksCreate = requiresOAuth && !isEdit;
// Partition options into required, optional-basic, optional-advanced.
const required = provider.Options.filter((o) => o.Required && !o.Hide);
const basic = provider.Options.filter((o) => !o.Required && !o.Advanced && !o.Hide);
const advanced = provider.Options.filter((o) => o.Advanced && !o.Hide);
app.innerHTML = `
<div class="section-head">
<div>
<h2>${isEdit ? t("configure.edit_title", provider.Name) : t("configure.create_title", provider.Name)}</h2>
<p class="subtitle">${escapeHtml(provider.Description || "")}</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/remotes">${t("configure.cancel")}</a>
</div>
<form id="remote-form" class="card-outline" style="display:flex;flex-direction:column;gap:16px;max-width:760px">
${requiresOAuth ? oauthBanner(provider.Name) : ""}
<div class="form-grid">
<div class="field field-full">
<label>${t("configure.name")} <span class="field-required">*</span></label>
<input
class="input"
name="_remote_name"
type="text"
required
${isEdit ? `value="${escapeHtml(remoteName)}" readonly` : 'placeholder="my-remote"'}
autocomplete="off"
>
</div>
${required.length > 0 ? `<div class="form-section-title">${t("configure.section.required")}</div>` : ""}
${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
${basic.length > 0 ? `<div class="form-section-title">${t("configure.section.options")}</div>` : ""}
${basic.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
${advanced.length > 0 ? `
<div class="advanced-toggle-wrap">
<button type="button" class="btn btn-secondary btn-sm" id="advanced-toggle">
${t("configure.show_advanced", advanced.length)}
</button>
</div>
<div id="advanced-section" class="advanced-section hidden">
<div class="form-section-title">${t("configure.section.advanced")}</div>
${advanced.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
</div>
` : ""}
</div>
<div class="toolbar">
<button type="submit" class="btn btn-primary" ${blocksCreate ? "disabled" : ""}>
${isEdit ? t("configure.save") : t("configure.create")}
</button>
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
</div>
${isEdit ? `
<div class="danger-zone">
<h4>${t("configure.delete_section_title")}</h4>
<p>${t("configure.delete_section_body", escapeHtml(remoteName))}</p>
<button type="button" class="btn btn-danger" id="delete-btn">${t("configure.delete_btn")}</button>
</div>
` : ""}
</form>
`;
const form = document.getElementById("remote-form");
// Wire up "Custom…" reveal on <select> fields that have it.
wireCustomSelects(form);
// Advanced toggle
const advToggle = document.getElementById("advanced-toggle");
const advSection = document.getElementById("advanced-section");
if (advToggle && advSection) {
advToggle.addEventListener("click", () => {
const hidden = advSection.classList.toggle("hidden");
advToggle.textContent = hidden
? t("configure.show_advanced", advanced.length)
: t("configure.hide_advanced", advanced.length);
});
}
const deleteBtn = document.getElementById("delete-btn");
if (deleteBtn) {
deleteBtn.addEventListener("click", async () => {
if (!confirm(t("remotes.delete_confirm", remoteName))) return;
try {
await post("config/delete", { name: remoteName });
toast(t("remotes.deleted", remoteName), "success");
location.hash = "#/remotes";
} catch (e) {
toast(`${t("remotes.delete_failed")}: ${e.message}`, "error");
}
});
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (blocksCreate) return;
const name = form.elements._remote_name.value.trim();
if (!name) return;
const parameters = collectParameters(form, provider.Options, isEdit);
try {
const body = {
name,
parameters,
opt: { nonInteractive: true },
};
if (!isEdit) body.type = provider.Name;
const endpoint = isEdit ? "config/update" : "config/create";
await post(endpoint, body);
toast(isEdit ? t("configure.updated", name) : t("configure.created", name), "success");
location.hash = "#/remotes";
} catch (err) {
toast(`${t("configure.save_failed")}: ${err.message}`, "error");
}
});
}
// --- Helpers ---
function oauthBanner(providerName) {
return `
<div class="banner banner-warning">
<div>
<strong>${t("configure.oauth_title")}</strong>
${t("configure.oauth_body", escapeHtml(providerName))}
<code>rclone config</code>
${t("configure.oauth_hint")}
</div>
</div>
`;
}
function fieldHtml(opt, currentValues, isEdit) {
const isFull = opt.Type === "bool";
const classes = ["field"];
if (isFull) classes.push("field-bool");
if (opt.Examples && opt.Examples.length > 0 && opt.Type !== "bool") {
// Examples go full-width to fit the dropdown + custom override
classes.push("field-full");
} else if (opt.Help && opt.Help.length > 60) {
classes.push("field-full");
}
const labelHtml = `${escapeHtml(opt.Name)}${opt.Required ? '<span class="field-required">*</span>' : ""}`;
const helpHtml = opt.Help ? `<span class="field-help">${escapeHtml(opt.Help)}</span>` : "";
const inputHtml = inputHtmlFor(opt, currentValues[opt.Name], isEdit);
return `
<div class="${classes.join(" ")}">
<label>${labelHtml}</label>
${inputHtml}
${helpHtml}
</div>
`;
}
function inputHtmlFor(opt, value, isEdit) {
// In edit mode, never pre-fill password fields — server returns them
// already obscured and resubmitting would double-obscure. Show empty
// with a "(unchanged)" placeholder.
if (opt.IsPassword && isEdit) {
return `<input class="input" name="opt_${escapeHtml(opt.Name)}" type="password" autocomplete="off" placeholder="${t("configure.password_unchanged")}">`;
}
const name = `opt_${escapeHtml(opt.Name)}`;
const currentValue = value != null ? String(value) : "";
if (opt.Type === "bool") {
const checked = value === true || value === "true" ? "checked" : "";
return `<input type="checkbox" name="${name}" ${checked}>`;
}
if (opt.Type === "int" || opt.Type === "int64" || opt.Type === "Duration") {
return `<input class="input" name="${name}" type="number" value="${escapeAttr(currentValue)}" autocomplete="off">`;
}
if (opt.IsPassword) {
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="${t("configure.password_secret")}">`;
}
if (opt.Examples && opt.Examples.length > 0) {
const opts = [`<option value="">${t("configure.example_pick")}</option>`]
.concat(
opt.Examples.map(
(ex) =>
`<option value="${escapeAttr(ex.Value)}"${ex.Value === currentValue ? " selected" : ""}>${escapeHtml(ex.Help || ex.Value)}${ex.Provider ? ` (${escapeHtml(ex.Provider)})` : ""}</option>`,
),
)
.join("");
return `
<select class="select" name="${name}" data-has-custom="1">
${opts}
<option value="__custom__"${currentValue && !opt.Examples.some((e) => e.Value === currentValue) ? " selected" : ""}>${t("configure.example_custom")}</option>
</select>
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="${t("configure.example_custom_placeholder")}" autocomplete="off">
`;
}
return `<input class="input" name="${name}" type="text" value="${escapeAttr(currentValue)}" autocomplete="off">`;
}
function collectParameters(form, options, isEdit) {
const params = {};
for (const opt of options) {
if (opt.Hide) continue;
const baseName = `opt_${opt.Name}`;
const el = form.elements[baseName];
if (!el) continue;
let val;
if (opt.Type === "bool") {
val = el.checked ? "true" : "false";
} else if (el.dataset && el.dataset.hasCustom === "1" || el.getAttribute("data-has-custom") === "1") {
// It's a <select> with a sibling text override
if (el.value === "__custom__") {
const customEl = form.elements[`${baseName}__custom`];
val = customEl ? customEl.value.trim() : "";
} else {
val = el.value;
}
} else {
val = el.value.trim();
}
// In edit mode, skip empty password fields (leave unchanged).
if (isEdit && opt.IsPassword && val === "") continue;
// Skip empty non-required fields.
if (val === "" && !opt.Required) continue;
params[opt.Name] = val;
}
return params;
}
// Wire up the "Custom…" reveal on selects with data-has-custom.
// Called from app.js after every form render via a MutationObserver-
// free approach: we attach the listener at form-render time inside
// renderForm. Use event delegation for simplicity.
export function wireCustomSelects(container) {
container.querySelectorAll('select[data-has-custom="1"]').forEach((sel) => {
const customName = sel.name + "__custom";
const customEl = container.elements
? container.elements[customName]
: container.querySelector(`[name="${CSS.escape(customName)}"]`);
if (!customEl) return;
const sync = () => {
customEl.style.display = sel.value === "__custom__" ? "block" : "none";
};
sync();
sel.addEventListener("change", sync);
});
}
async function ensureProviders() {
const cached = getState().providers;
if (cached) return cached;
const res = await post("config/providers");
const providers = (res && res.providers) || [];
setState({ providers });
return providers;
}
function escapeAttr(s) {
return escapeHtml(s);
}