Mã nguồn đã công bố
Tải mã nguồn dạng ZIP
CH-J Tools
Duyệt thư mục và tệp của một bản phát hành ứng dụng cụ thể.
CH-J / Server Manager
1704 tệp
0.1.2-beta
Ngày thêm: · Ngày dựng:
1
"use strict";3
// Rsync Manager: a client of the rsync engine in the Core, like the File Manager. All text from servers is inserted as plain text.4
const api = window.chjPlugin;5
const i18n = window.chjPluginI18n.createI18n("cs");6
const t = i18n.t;7
const byId = (id) => document.getElementById(id);8
const LOCAL = "local", NEW = "new";10
let sessions = [];11
let sshProfiles = [];12
let plan = null;13
let activeJob = null;14
let shownItems = [];15
let busy = false;16
// Every change of the job counts, so a preview that finishes later cannot be started for what the form no longer says.17
let revision = 0;18
const choice = { source: LOCAL, target: "" };20
const MODE_INFO = {21
copy: "Přenese nové a změněné soubory. Co je jen v cíli, zůstane. Nejbezpečnější režim.",22
mirror: "Cíl bude přesně podle zdroje. Soubory, které ve zdroji nejsou, se z cíle smažou, a jen po potvrzení přesného počtu.",23
versioned: "Přepsané a odstraněné soubory se přesunou do .chj-versions/<čas>, takže je lze obnovit.",24
snapshot: "Každý běh vytvoří novou úplnou složku s časem. Nezměněné soubory sdílí místo na disku přes pevné odkazy. Odkaz latest ukazuje na poslední.",25
compare: "Jen ukáže rozdíly mezi složkami. Nic se nepřenese ani nesmaže.",26
};27
const CODE_MESSAGES = {28
RSYNC_DELETE_NOT_ACCEPTED: "Počet souborů ke smazání je potřeba potvrdit.",29
RSYNC_PLAN_EXPIRED: "Náhled už neplatí. Spusťte analýzu znovu.",30
};32
// The Core reports in English like every module API; the user reads the state.33
const STATE_MESSAGES = {34
ok: "Hotovo.", warning: "Hotovo, ale některé soubory ve zdroji během přenosu zmizely.", partial: "Dokončeno jen částečně: některé soubory se nepřenesly. Podrobnosti jsou ve výstupu.",35
stopped: "Zastaveno: ke smazání bylo víc souborů, než ukázal náhled. Spusťte analýzu znovu.", cancelled: "Zrušeno.",36
};37
function jobMessage(job) {38
if (job.errorCode === "RSYNC_SOURCE_MISSING") return t("Zdroj už neexistuje, proto se nic nezměnilo.");39
if (job.errorCode === "RSYNC_SOURCE_EMPTY") return t("Zdroj je prázdný, proto se nic nesmazalo. Spusťte analýzu znovu.");40
if (job.errorCode === "RSYNC_SOURCE_UNKNOWN") return t("Zdroj se nepodařilo ověřit, proto se nic nezměnilo.");41
return STATE_MESSAGES[job.state] ? t(STATE_MESSAGES[job.state]) : `${job.message || t("Přenos selhal.")} ${job.state === "failed" && job.output ? t("Podrobnosti jsou ve výstupu.") : ""}`.trim();42
}44
function errorText(error) { return String(error?.message || error).replace(/^Error invoking remote method '[^']*': Error: /, ""); }45
function setStatus(message, error = false) { const node = byId("status"); node.textContent = message; node.classList.toggle("error", error); }46
function setBusy(value) { busy = value; syncControls(); }47
function node(tag, className, text) { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; }48
function formatBytes(bytes) {49
const value = Number(bytes) || 0, units = ["B", "KiB", "MiB", "GiB", "TiB"];50
const power = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0;51
return `${(value / 1024 ** power).toLocaleString(i18n.language?.() || undefined, { maximumFractionDigits: power ? 1 : 0 })} ${units[power]}`;52
}53
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));54
function fill(list, rows, empty) { list.replaceChildren(...(rows.length ? rows : [node("p", "muted", empty)])); }55
function row(title, detail, actions = []) {56
const item = node("div", "site-row"), text = node("div");57
text.append(node("strong", "", title));58
if (detail) text.append(node("small", "muted", detail));59
const buttons = node("div", "actions");60
for (const action of actions) buttons.append(action);61
item.append(text, buttons);62
return item;63
}64
function button(label, action, className = "button") { const element = node("button", className, label); element.type = "button"; element.addEventListener("click", action); return element; }66
// --- where things are --------------------------------------------------------------------------------68
async function loadPlaces() {69
sessions = (await api.sessions.list()).filter((session) => session.state === "connected");70
try { sshProfiles = await api.rsync.sshProfiles(); } catch { sshProfiles = []; }71
renderPlaces();72
}74
function placeLabel(value) {75
if (value === LOCAL) return t("Tento počítač");76
if (value.startsWith("session:")) { const session = sessions.find((item) => item.sessionId === value.slice(8)); return session ? api.sessions.displayName(session) : value; }77
if (value.startsWith("profile:")) { const profile = sshProfiles.find((item) => item.id === value.slice(8)); return profile ? `${profile.label} (${profile.username}@${profile.host})` : value; }78
return "";79
}81
function renderPlaces() {82
for (const side of ["source", "target"]) {83
const select = byId(`${side}Place`);84
// A saved server that got connected in the meantime is still the same place.85
const saved = choice[side].startsWith("profile:") ? sessions.find((session) => session.profileId === choice[side].slice(8)) : null;86
if (saved) choice[side] = `session:${saved.sessionId}`;87
const wanted = choice[side];88
const options = [new Option(t("Tento počítač"), LOCAL)];89
for (const session of sessions) options.push(new Option(api.sessions.displayName(session), `session:${session.sessionId}`));90
const live = new Set(sessions.map((session) => session.profileId));91
for (const profile of sshProfiles) if (!live.has(profile.id)) options.push(new Option(`${profile.label} (${profile.username}@${profile.host}) · ${t("připojí se")}`, `profile:${profile.id}`));92
options.push(new Option(t("Nové SSH připojení (heslo nebo klíč)…"), NEW));93
if (side === "target") options.unshift(new Option(t("Vyberte cíl…"), ""));94
select.replaceChildren(...options);95
select.value = [...select.options].some((option) => option.value === wanted) ? wanted : (side === "target" ? "" : LOCAL);96
if (choice[side] !== select.value) { revision += 1; plan = null; byId("previewBlock").hidden = true; }97
choice[side] = select.value;98
}99
const server = byId("scheduleServer"), current = server.value;100
server.replaceChildren(...sessions.map((session) => new Option(api.sessions.displayName(session), session.sessionId)));101
if ([...server.options].some((option) => option.value === current)) server.value = current;102
if (server.value !== current) clearSchedules();103
syncControls();104
}106
function endpoint(side) {107
const value = byId(`${side}Place`).value, path = byId(`${side}Path`).value.trim(), privileged = byId(`${side}Sudo`).checked;108
if (value === LOCAL) return { kind: "local", path };109
if (value.startsWith("session:")) {110
const session = sessions.find((item) => item.sessionId === value.slice(8));111
const profileId = session?.profileId && !String(session.profileId).startsWith("adhoc:") ? session.profileId : "";112
return { kind: "ssh", sessionId: value.slice(8), profileId, path, privileged };113
}114
if (value.startsWith("profile:")) return { kind: "ssh", profileId: value.slice(8), path, privileged };115
return { kind: "", path };116
}118
function readOptions() {119
const on = (id) => byId(id).checked, number = (id) => Number(byId(id).value) || 0, lines = (id) => byId(id).value.split("\n").map((line) => line.trim()).filter(Boolean);120
return {121
permissions: on("optPermissions"), times: on("optTimes"), owner: on("optOwner"), group: on("optGroup"), acls: on("optAcls"), xattrs: on("optXattrs"),122
hardlinks: on("optHardlinks"), devices: on("optDevices"), numericIds: on("optNumericIds"), compress: on("optCompress"), checksum: on("optChecksum"), sizeOnly: on("optSizeOnly"),123
resume: on("optResume"), inplace: on("optInplace"), oneFileSystem: on("optOneFs"), updateOnly: on("optUpdate"), existingOnly: on("optExisting"), ignoreExisting: on("optIgnoreExisting"),124
deleteExcluded: on("optDeleteExcluded"), symlinks: byId("optSymlinks").value, bandwidthKiB: number("optBandwidth"), timeoutSeconds: number("optTimeout"),125
maxSize: byId("optMaxSize").value.trim(), minSize: byId("optMinSize").value.trim(), keepDays: number("optKeepDays"), keepCount: number("optKeepCount"),126
excludes: lines("optExcludes"), includes: lines("optIncludes"), extra: byId("optExtra").value.split(/\s+/).filter(Boolean),127
};128
}130
function isBetweenServers() {131
const source = endpoint("source"), target = endpoint("target");132
return source.kind === "ssh" && target.kind === "ssh" && (source.sessionId || source.profileId) !== (target.sessionId || target.profileId);133
}135
function readTransport() {136
if (!isBetweenServers() || byId("transportKind").value !== "direct") return { kind: "relay" };137
return { kind: "direct", host: byId("directHost").value.trim(), port: Number(byId("directPort").value) || 22, username: byId("directUser").value.trim(), method: byId("directMethod").value, keyPath: byId("directKey").value.trim(), acceptNewHostKey: byId("directAcceptNew").checked };138
}140
function readSpec() {141
return { mode: byId("mode").value, contentsOnly: byId("contentsOnly").value === "1", source: endpoint("source"), target: endpoint("target"), options: readOptions(), transport: readTransport() };142
}144
function withSecrets(spec) {145
return { ...spec, sourceSudo: byId("sourceSudoPassword").value, targetSudo: byId("targetSudoPassword").value, directPassword: byId("directPassword").value };146
}148
function writeSpec(spec) {149
const place = (value) => {150
if (value?.kind === "local") return LOCAL;151
if (value?.sessionId && sessions.some((session) => session.sessionId === value.sessionId)) return `session:${value.sessionId}`;152
if (value?.profileId) {153
const live = sessions.find((session) => session.profileId === value.profileId);154
return live ? `session:${live.sessionId}` : `profile:${value.profileId}`;155
}156
return "";157
};158
for (const side of ["source", "target"]) {159
choice[side] = place(spec[side]);160
byId(`${side}Path`).value = spec[side]?.path || "";161
byId(`${side}Sudo`).checked = spec[side]?.privileged === true;162
}163
renderPlaces();164
byId("mode").value = spec.mode || "copy";165
byId("contentsOnly").value = spec.contentsOnly === false ? "0" : "1";166
const options = spec.options || {};167
const flags = { optPermissions: "permissions", optTimes: "times", optOwner: "owner", optGroup: "group", optAcls: "acls", optXattrs: "xattrs", optHardlinks: "hardlinks", optDevices: "devices", optNumericIds: "numericIds", optCompress: "compress", optChecksum: "checksum", optSizeOnly: "sizeOnly", optResume: "resume", optInplace: "inplace", optOneFs: "oneFileSystem", optUpdate: "updateOnly", optExisting: "existingOnly", optIgnoreExisting: "ignoreExisting", optDeleteExcluded: "deleteExcluded" };168
for (const [id, name] of Object.entries(flags)) if (name in options) byId(id).checked = options[name] === true;169
byId("optSymlinks").value = options.symlinks || "preserve";170
byId("optBandwidth").value = options.bandwidthKiB || 0;171
byId("optTimeout").value = options.timeoutSeconds || 0;172
byId("optMaxSize").value = options.maxSize || "";173
byId("optMinSize").value = options.minSize || "";174
byId("optKeepDays").value = options.keepDays || 0;175
byId("optKeepCount").value = options.keepCount || 0;176
byId("optExcludes").value = (options.excludes || []).join("\n");177
byId("optIncludes").value = (options.includes || []).join("\n");178
byId("optExtra").value = (options.extra || []).join(" ");179
const transport = spec.transport || {};180
byId("transportKind").value = transport.kind === "direct" ? "direct" : "relay";181
byId("directHost").value = transport.host || "";182
byId("directPort").value = transport.port || 22;183
byId("directUser").value = transport.username || "";184
byId("directMethod").value = transport.method || "key";185
byId("directKey").value = transport.keyPath || "";186
byId("directAcceptNew").checked = transport.acceptNewHostKey === true;187
invalidatePlan();188
syncControls();189
}191
function syncControls() {192
const direct = byId("transportKind").value === "direct";193
const between = isBetweenServers();194
byId("transportBlock").hidden = !between;195
byId("directFields").hidden = !direct;196
byId("directKeyField").hidden = byId("directMethod").value !== "key";197
byId("directPasswordField").hidden = byId("directMethod").value !== "password";198
for (const side of ["source", "target"]) {199
const remote = byId(`${side}Place`).value !== LOCAL;200
byId(`${side}Sudo`).closest("label").hidden = !remote;201
byId(`${side}SudoField`).hidden = !remote || !byId(`${side}Sudo`).checked;202
}203
byId("modeInfo").textContent = t(MODE_INFO[byId("mode").value]);204
const path = byId("sourcePath").value.trim().replace(/\/+$/, "") || "/srv/www", into = byId("targetPath").value.trim().replace(/\/+$/, "") || "/backup";205
const name = path.split("/").pop();206
byId("slashInfo").textContent = byId("contentsOnly").value === "1" ? t("Obsah složky {from} se přenese přímo do {to}.", { from: path, to: into }) : t("Složka se přenese celá: vznikne {to}/{name}.", { to: into, name });207
byId("previewButton").disabled = busy;208
byId("runButton").disabled = busy || !plan || byId("mode").value === "compare";209
byId("cancelButton").hidden = !activeJob;210
byId("saveProfileButton").disabled = busy;211
byId("scheduleInstall").disabled = busy;212
}214
function invalidatePlan() {215
revision += 1;216
plan = null;217
byId("previewBlock").hidden = true;218
syncControls();219
}221
// --- connections -------------------------------------------------------------------------------------223
let connectSide = "";224
function openConnect(side) { connectSide = side; byId("connectDialog").hidden = false; byId("connectHost").focus(); }225
function closeConnect() {226
byId("connectDialog").hidden = true;227
byId("connectPassword").value = ""; byId("connectPassphrase").value = "";228
if (byId(`${connectSide}Place`).value === NEW) { byId(`${connectSide}Place`).value = connectSide === "target" ? "" : LOCAL; choice[connectSide] = byId(`${connectSide}Place`).value; }229
syncControls();230
}232
async function connectNew() {233
const method = byId("connectMethod").value;234
const adhoc = { host: byId("connectHost").value.trim(), port: Number(byId("connectPort").value) || 22, username: byId("connectUser").value.trim(), authMethod: method, ...(method === "privateKey" ? { privateKeyPath: byId("connectKey").value.trim() } : {}) };235
const payload = { adhoc, ...(method === "privateKey" ? { passphrase: byId("connectPassphrase").value } : { password: byId("connectPassword").value }) };236
try {237
setStatus(t("Připojuji…"));238
for (let attempt = 0; attempt < 2; attempt += 1) {239
const result = await api.rsync.connect(payload);240
if (result.hostKey) {241
const changed = result.hostKey.code === "HOST_KEY_MISMATCH";242
const question = changed ? t("POZOR: host key serveru {host} se změnil. Otisk: {fingerprint}. Pokračujte, jen pokud změnu očekáváte.", { host: adhoc.host, fingerprint: result.hostKey.fingerprint }) : t("Server {host} zatím není známý. Otisk host key: {fingerprint}. Důvěřovat mu?", { host: adhoc.host, fingerprint: result.hostKey.fingerprint });243
if (!window.confirm(question)) { setStatus(t("Připojení bylo zrušeno."), true); return; }244
await api.rsync.trustHost({ sessionId: result.sessionId, profileId: "", fingerprint: result.hostKey.fingerprint, ...(changed ? { knownFingerprint: result.hostKey.knownFingerprint, replaceKnown: true } : {}) });245
payload.sessionId = result.sessionId;246
continue;247
}248
choice[connectSide] = `session:${result.sessionId}`;249
invalidatePlan();250
byId("connectDialog").hidden = true;251
byId("connectPassword").value = ""; byId("connectPassphrase").value = "";252
await loadPlaces();253
setStatus(t("Připojeno."));254
return;255
}256
} catch (error) { setStatus(errorText(error), true); }257
}259
// --- preview and run ---------------------------------------------------------------------------------261
function showProgress(job) {262
const bar = byId("progress"), percent = job?.progress?.percent;263
bar.hidden = !job || job.state !== "running";264
bar.classList.toggle("moving", percent === null || percent === undefined);265
bar.querySelector("i").style.width = typeof percent === "number" ? `${percent}%` : "";266
const total = job?.itemsTotal || 0;267
const parts = [];268
if (typeof percent === "number") parts.push(`${percent} %`);269
if (job?.progress?.rate) parts.push(job.progress.rate);270
if (job?.progress?.remaining) parts.push(t("zbývá {time}", { time: job.progress.remaining }));271
if (total) parts.push(t("{count} položek", { count: total }));272
byId("progressText").textContent = job?.state === "running" ? parts.join(" · ") : "";273
byId("output").textContent = job?.output || "—";274
}276
async function follow(id) {277
activeJob = id; syncControls();278
for (;;) {279
const job = await api.rsync.job({ id });280
showProgress(job);281
if (job.state !== "running") { activeJob = null; syncControls(); return job; }282
await sleep(400);283
}284
}286
function renderItems() {287
const filter = byId("itemFilter").value.trim().toLowerCase();288
const rows = shownItems.filter((item) => !filter || item.path.toLowerCase().includes(filter)).slice(0, 1500);289
const labels = { new: t("nové"), changed: t("změna"), delete: t("smazat"), metadata: t("metadata"), directory: t("složka"), link: t("odkaz") };290
byId("itemList").replaceChildren(...rows.map((item) => {291
const line = node("div", item.change);292
line.append(node("b", "", labels[item.change] || item.change), node("span", "", item.path));293
if (item.change === "new" || item.change === "changed") line.append(node("small", "", formatBytes(item.size)));294
return line;295
}));296
}298
function renderPreview(job) {299
const summary = job.summary || {};300
byId("previewBlock").hidden = false;301
byId("statNew").textContent = summary.new || 0;302
byId("statChanged").textContent = summary.changed || 0;303
byId("statDelete").textContent = summary.delete || 0;304
byId("statMeta").textContent = summary.metadata || 0;305
byId("statBytes").textContent = formatBytes(summary.bytes);306
byId("statDeleteLabel").textContent = job.mode === "versioned" ? t("Přesune se do verzí") : t("K odstranění");307
const warnings = [];308
if (job.mode === "compare" && summary.delete) warnings.push(t("Počet souborů, které jsou jen v cíli: {count}. Porovnání nic nesmaže.", { count: summary.delete }));309
if (job.mode === "mirror" && summary.delete) warnings.push(t("Počet souborů, které se z cíle smažou: {count}. Bez zálohy je nelze vrátit.", { count: summary.delete }));310
if (job.emptySource) warnings.push(t("Zdroj je prázdný. Pokud nejde o záměr, je cesta nejspíš špatně."));311
if (job.protectedTarget && ["mirror", "versioned"].includes(job.mode)) warnings.push(t("Cíl je systémová nebo domovská složka. Mazání v ní vyžaduje opsání cesty."));312
byId("previewWarnings").replaceChildren(...warnings.map((text) => node("p", "warning-box", text)));313
shownItems = job.items || [];314
byId("itemsNote").textContent = job.itemsTotal > shownItems.length ? t("Zobrazeno prvních {shown} z {total} položek.", { shown: shownItems.length, total: job.itemsTotal }) : "";315
renderItems();316
}318
async function offerInstall(job) {319
const sessionId = job.errorDetails?.sessionId;320
if (!sessionId) return false;321
if (job.errorDetails.installer === "none") { setStatus(t("Na serveru chybí rsync a není tam podporovaný správce balíčků."), true); return false; }322
const session = sessions.find((item) => item.sessionId === sessionId);323
if (!window.confirm(`${session ? api.sessions.displayName(session) : sessionId}\n\n${t("Na tomto serveru chybí rsync. Nainstalovat ho přes správce balíčků serveru?")}`)) return false;324
const password = endpoint("source").sessionId === sessionId ? byId("sourceSudoPassword").value : byId("targetSudoPassword").value;325
const entered = password || window.prompt(t("Heslo pro sudo (prázdné pro NOPASSWD)")) || "";326
setBusy(true); setStatus(t("Instaluji rsync…"));327
try {328
const result = await api.rsync.install({ sessionId, sudoPassword: entered, confirm: true });329
byId("output").textContent = result.output || "—";330
setStatus(t("rsync byl nainstalován."));331
return true;332
} catch (error) { setStatus(errorText(error), true); return false; } finally { setBusy(false); }333
}335
async function preview() {336
const spec = readSpec();337
if (!spec.target.kind && spec.transport.kind !== "direct") { setStatus(t("Vyberte cíl."), true); return; }338
plan = null; byId("previewBlock").hidden = true;339
const startedRevision = revision;340
setBusy(true); setStatus(t("Analyzuji rozdíly, nic se nezmění…"));341
let job;342
try {343
const started = await api.rsync.preview(withSecrets(spec));344
await loadPlaces().catch(() => {});345
job = await follow(started.jobId);346
} catch (error) { setBusy(false); setStatus(errorText(error), true); return; }347
setBusy(false);348
if (startedRevision !== revision) { setStatus(t("Úloha se mezitím změnila. Spusťte analýzu znovu."), true); return; }349
if (job.state === "failed" && job.errorCode === "RSYNC_MISSING") { if (await offerInstall(job)) await preview(); return; }350
if (job.state === "failed" && job.errorCode === "RSYNC_LOCAL_MISSING") { setStatus(t("Na tomto počítači chybí rsync. Nainstalujte ho (macOS: Homebrew, Linux: balíček rsync)."), true); return; }351
if (!["ok", "warning"].includes(job.state)) { setStatus(jobMessage(job), true); return; }352
plan = job;353
renderPreview(job);354
byId("previewBlock").scrollIntoView?.({ block: "nearest", behavior: "smooth" });355
const summary = job.summary || {};356
setStatus(t("Analýza je hotová. Nové: {new}, změněné: {changed}, k odstranění: {delete}.", { new: summary.new || 0, changed: summary.changed || 0, delete: summary.delete || 0 }));357
syncControls();358
}360
async function run() {361
if (!plan) return;362
const summary = plan.summary || {};363
const payload = { planId: plan.planId, confirm: true };364
const remove = (summary.delete || 0) > 0 && ["mirror", "versioned"].includes(plan.mode);365
const where = (side) => `${side.kind === "local" ? t("Tento počítač") : side.sessionId === "direct-target" ? byId("directHost").value : placeLabel(`session:${side.sessionId}`)}: ${side.path}`;366
const lines = [t("Spustit synchronizaci?"), `${where(plan.source)} → ${where(plan.target)}`, t("Nové: {new}, změněné: {changed}, objem {bytes}.", { new: summary.new || 0, changed: summary.changed || 0, bytes: formatBytes(summary.bytes) })];367
if (remove) lines.push(plan.mode === "mirror" ? t("Z cíle se trvale smaže souborů: {count}.", { count: summary.delete }) : t("Do verzí se přesune souborů: {count}.", { count: summary.delete }));368
if (!window.confirm(lines.join("\n"))) return;369
if (remove) payload.acceptDeletes = summary.delete;370
if (remove && plan.protectedTarget) {371
const typed = window.prompt(t("Cíl {path} je systémová nebo domovská složka. Pro pokračování opište přesnou cestu.", { path: plan.target.path }));372
if (typed === null) return;373
payload.confirmProtected = typed;374
}375
if (remove && plan.emptySource) {376
if (!window.confirm(t("Zdroj je prázdný, takže se z cíle smaže všechno. Opravdu pokračovat?"))) return;377
payload.allowEmptySource = true;378
}379
setBusy(true); setStatus(t("Synchronizuji…"));380
let job;381
try {382
const started = await api.rsync.run(payload);383
job = await follow(started.jobId);384
} catch (error) { setBusy(false); const text = errorText(error); const code = text.match(/RSYNC_[A-Z_]+/)?.[0]; setStatus(CODE_MESSAGES[code] ? t(CODE_MESSAGES[code]) : text, true); return; }385
setBusy(false);386
plan = null;387
syncControls();388
const stats = job.stats || {};389
const saved = stats.matchedBytes ? t(" Díky delta přenosu se {bytes} nemuselo přenášet.", { bytes: formatBytes(stats.matchedBytes) }) : "";390
setStatus(`${jobMessage(job)}${["ok", "warning"].includes(job.state) ? saved : ""}`, !["ok", "warning"].includes(job.state));391
void renderHistory();392
}394
async function cancelJob() {395
if (!activeJob) return;396
try { await api.rsync.cancel({ id: activeJob }); setStatus(t("Ruším přenos…")); } catch (error) { setStatus(errorText(error), true); }397
}399
async function showCommand() {400
const box = byId("commandPreview");401
try {402
const spec = readSpec();403
const check = JSON.parse(JSON.stringify(spec));404
for (const side of ["source", "target"]) if (check[side].kind === "ssh" && !check[side].sessionId) check[side].sessionId = "profile";405
const described = await api.rsync.describe(check);406
box.textContent = `rsync ${described.arguments.join(" ")}`;407
box.hidden = false;408
} catch (error) { setStatus(errorText(error), true); }409
}411
// --- profiles ----------------------------------------------------------------------------------------413
async function saveProfile() {414
const name = window.prompt(t("Název profilu"));415
if (!name) return;416
const every = window.prompt(t("Spouštět automaticky v této aplikaci po kolika minutách? Prázdné = jen ručně. Běží jen, když je aplikace zapnutá."), "");417
const payload = { name, spec: readSpec() };418
if (every && every.trim()) {419
payload.schedule = { kind: "interval", minutes: Number(every) };420
payload.scheduleInApp = true;421
if (payload.spec.mode === "mirror") payload.maxDeletes = Number(window.prompt(t("Kolik souborů smí automatický běh nejvýše smazat? Bez dohledu nikdo nevidí náhled."), "0")) || 0;422
}423
try { await api.rsync.saveProfile(payload); setStatus(t("Profil byl uložen.")); await renderProfiles(); }424
catch (error) { setStatus(errorText(error), true); }425
}427
async function renderProfiles() {428
const list = await api.rsync.profiles();429
const describe = (side) => side?.kind === "local" ? `${t("Tento počítač")}:${side.path}` : `${sshProfiles.find((item) => item.id === side?.profileId)?.label || side?.host || "SSH"}:${side?.path || ""}`;430
fill(byId("profileList"), list.map((profile) => row(profile.name, `${describe(profile.spec.source)} → ${describe(profile.spec.target)} · ${t(MODE_NAMES[profile.spec.mode] || "Kopírování")}${profile.scheduleInApp ? ` · ${t("v aplikaci každých {minutes} min", { minutes: profile.schedule?.minutes })}` : ""}`, [431
button(t("Načíst"), () => { writeSpec(profile.spec); switchTab("sync"); setStatus(t("Profil {name} je načtený. Spusťte analýzu.", { name: profile.name })); }),432
button(t("Smazat"), async () => { if (window.confirm(t("Smazat profil {name}? Na serverech se nic nezmění.", { name: profile.name }))) { await api.rsync.deleteProfile({ id: profile.id }); await renderProfiles(); } }),433
])), t("Zatím žádný uložený profil."));434
}435
const MODE_NAMES = { copy: "Kopírování", mirror: "Zrcadlení", versioned: "Verzovaná záloha", snapshot: "Snapshoty", compare: "Porovnání bez přenosu" };437
// --- schedules on servers ----------------------------------------------------------------------------439
async function installSchedule() {440
const kind = byId("scheduleKind").value;441
const schedule = kind === "interval" ? { kind, minutes: Number(byId("scheduleMinutes").value) } : { kind, time: byId("scheduleTime").value, ...(kind === "weekly" ? { day: Number(byId("scheduleDay").value) } : {}) };442
const spec = readSpec();443
if (!window.confirm(t("Nainstalovat tuto úlohu na časovač serveru? Poběží i bez této aplikace, pod účtem root."))) return;444
setBusy(true); setStatus(t("Instaluji úlohu…"));445
try {446
await api.rsync.scheduleInstall({ confirm: true, label: byId("scheduleLabel").value, spec, schedule, maxDeletes: Number(byId("scheduleMaxDeletes").value) || 0, sudoPassword: byId("scheduleSudo").value });447
setStatus(t("Úloha je nainstalovaná."));448
if (spec.source.sessionId) byId("scheduleServer").value = spec.source.sessionId;449
await loadSchedules();450
} catch (error) { setStatus(errorText(error), true); } finally { setBusy(false); }451
}453
// A late answer about another server must not replace the list of the one shown.454
let scheduleRequest = 0;455
function clearSchedules() { scheduleRequest += 1; byId("scheduleList").replaceChildren(); byId("discoveredList").replaceChildren(); byId("scheduleLog").hidden = true; }457
async function loadSchedules() {458
const sessionId = byId("scheduleServer").value;459
if (!sessionId) { setStatus(t("Vyberte server."), true); return; }460
const sudoPassword = byId("scheduleSudo").value;461
const request = ++scheduleRequest;462
try {463
const list = await api.rsync.scheduleList({ sessionId, sudoPassword });464
if (request !== scheduleRequest || byId("scheduleServer").value !== sessionId) return;465
fill(byId("scheduleList"), list.map((job) => row(job.label || job.id, `${job.mode || ""} · ${job.cron || ""} · ${job.lastRun ? t("poslední běh {time}, kód {code}", { time: job.lastRun, code: job.lastExit }) : t("ještě neběžela")}`, [466
button(t("Spustit teď"), async () => { try { await api.rsync.scheduleStart({ sessionId, id: job.id, sudoPassword, confirm: true }); setStatus(t("Úloha byla spuštěna.")); } catch (error) { setStatus(errorText(error), true); } }),467
button(t("Log"), async () => { try { const log = await api.rsync.scheduleLog({ sessionId, id: job.id, sudoPassword }); const box = byId("scheduleLog"); box.textContent = log.output || "—"; box.hidden = false; } catch (error) { setStatus(errorText(error), true); } }),468
button(t("Odebrat"), async () => { if (!window.confirm(t("Odebrat úlohu {name} ze serveru včetně jejího logu? Přenesená data zůstanou.", { name: job.label || job.id }))) return; try { await api.rsync.scheduleRemove({ sessionId, id: job.id, sudoPassword, confirm: true }); await loadSchedules(); } catch (error) { setStatus(errorText(error), true); } }),469
])), t("Na tomto serveru nejsou žádné naplánované úlohy."));470
const found = await api.rsync.discover({ sessionId, sudoPassword });471
if (request !== scheduleRequest || byId("scheduleServer").value !== sessionId) return;472
const kinds = { cron: "cron", systemd: "systemd", daemon: t("Démon rsync"), running: t("Běží teď"), script: t("Skript") };473
fill(byId("discoveredList"), found.map((item) => row(`${kinds[item.kind] || item.kind}${item.place === "-" ? "" : ` · ${item.place}`}`, item.detail)), t("Na serveru nebyl nalezen žádný další rsync."));474
} catch (error) { setStatus(errorText(error), true); }475
}477
// --- backups -----------------------------------------------------------------------------------------479
function targetForVersions() {480
const target = endpoint("target");481
return { ...target, sudoPassword: byId("targetSudoPassword").value };482
}484
async function loadVersions() {485
try {486
const target = targetForVersions();487
if (!target.kind || !target.path) { setStatus(t("Vyberte cíl."), true); return; }488
const result = await api.rsync.versions(target);489
fill(byId("versionList"), result.versions.map((version) => row(version.name, `${version.kind === "." ? t("Snapshot") : t("Starší verze souborů")}${result.latest === version.name ? ` · ${t("poslední")}` : ""}`, [490
button(t("Obnovit…"), () => {491
byId("sourcePath").value = version.path;492
choice.source = byId("targetPlace").value; renderPlaces();493
byId("mode").value = "copy"; byId("contentsOnly").value = "1";494
invalidatePlan(); switchTab("sync");495
setStatus(t("Připraveno k obnově. Zkontrolujte cíl a spusťte analýzu."));496
}),497
])), t("V cíli nejsou žádné verze ani snapshoty."));498
} catch (error) { setStatus(errorText(error), true); }499
}501
async function pruneVersions() {502
const target = targetForVersions();503
const keepDays = Number(byId("pruneDays").value) || 0, keepCount = Number(byId("pruneCount").value) || 0;504
if (!window.confirm(t("Odstranit verze starší než {days} dní v {path}? Vždy zůstane nejméně {count} nejnovějších a poslední snapshot.", { days: keepDays, path: target.path, count: keepCount }))) return;505
try {506
const result = await api.rsync.pruneVersions({ ...target, keepDays, keepCount, confirm: true });507
byId("output").textContent = result.output || "—";508
setStatus(t("Staré verze byly odstraněny."));509
await loadVersions();510
} catch (error) { setStatus(errorText(error), true); }511
}513
// --- diagnostics -------------------------------------------------------------------------------------515
async function loadDiagnostics() {516
const rows = [];517
const local = await api.rsync.localProbe();518
if (local.reason === "windows") rows.push(row(t("Tento počítač"), t("Přenosy z disku Windows zatím nejsou podporované. Přenosy mezi servery fungují.")));519
else if (!local.available) rows.push(row(t("Tento počítač"), t("rsync nebyl nalezen. macOS: Homebrew, Linux: balíček rsync.")));520
for (const candidate of local.candidates || []) rows.push(row(`${t("Tento počítač")}: ${candidate.path}`, `${candidate.version}${candidate.realRsync ? "" : ` · ${t("omezená náhrada bez ACL a celkového průběhu")}`}`));521
for (const session of sessions) {522
try {523
const probe = await api.rsync.remoteProbe({ sessionId: session.sessionId });524
const actions = probe.available || probe.installer === "none" ? [] : [button(t("Nainstalovat rsync"), async () => { if (await offerInstall({ errorDetails: { sessionId: session.sessionId, installer: probe.installer } })) await loadDiagnostics(); })];525
rows.push(row(api.sessions.displayName(session), probe.available ? `${probe.version} · ACL ${probe.acls ? "✓" : "—"} · xattr ${probe.xattrs ? "✓" : "—"} · ${t("plánovač")}: ${probe.scheduler} · ${probe.ssh || ""}` : t("rsync není nainstalovaný."), actions));526
} catch (error) { rows.push(row(api.sessions.displayName(session), errorText(error))); }527
}528
fill(byId("diagList"), rows, t("Žádné údaje."));529
}531
async function renderHistory() {532
const list = (await api.rsync.history()).slice().reverse().slice(0, 100);533
fill(byId("historyList"), list.map((entry) => {534
const stats = entry.stats || {};535
return row(`${entry.label || `${entry.source?.path || ""} → ${entry.target?.path || ""}`}`, `${entry.finished || ""} · ${entry.state || ""} · ${entry.message || ""}${stats.transferredBytes ? ` · ${formatBytes(stats.transferredBytes)}` : ""}`);536
}), t("Zatím žádný dokončený přenos."));537
}539
// --- wiring ------------------------------------------------------------------------------------------541
function switchTab(name) {542
for (const tab of document.querySelectorAll(".tab")) tab.classList.toggle("active", tab.dataset.tab === name);543
for (const panel of document.querySelectorAll(".tab-panel")) panel.hidden = panel.dataset.panel !== name;544
if (name === "schedule" && byId("scheduleServer").value) void loadSchedules();545
if (name === "profiles") void renderProfiles().catch((error) => setStatus(errorText(error), true));546
if (name === "diag") { void renderHistory().catch(() => {}); void loadDiagnostics().catch((error) => setStatus(errorText(error), true)); }547
}549
for (const tab of document.querySelectorAll(".tab")) tab.addEventListener("click", () => switchTab(tab.dataset.tab));550
for (const side of ["source", "target"]) {551
byId(`${side}Place`).addEventListener("change", (event) => {552
invalidatePlan();553
if (event.target.value === NEW) { openConnect(side); return; }554
choice[side] = event.target.value;555
});556
byId(`${side}Path`).addEventListener("input", invalidatePlan);557
byId(`${side}Sudo`).addEventListener("change", invalidatePlan);558
}559
document.querySelector("[data-panel=sync]").addEventListener("change", (event) => { if (event.target.closest(".options, #transportBlock") || ["mode", "contentsOnly"].includes(event.target.id)) invalidatePlan(); });560
byId("previewButton").addEventListener("click", () => void preview());561
byId("runButton").addEventListener("click", () => void run());562
byId("cancelButton").addEventListener("click", () => void cancelJob());563
byId("showCommandButton").addEventListener("click", () => void showCommand());564
byId("saveProfileButton").addEventListener("click", () => void saveProfile());565
byId("itemFilter").addEventListener("input", renderItems);566
byId("connectMethod").addEventListener("change", () => { const key = byId("connectMethod").value === "privateKey"; byId("connectKeyField").hidden = !key; byId("connectPassphraseField").hidden = !key; byId("connectPasswordField").hidden = key; });567
byId("connectCancel").addEventListener("click", closeConnect);568
byId("connectGo").addEventListener("click", () => void connectNew());569
byId("scheduleKind").addEventListener("change", () => { const kind = byId("scheduleKind").value; byId("scheduleMinutesField").hidden = kind !== "interval"; byId("scheduleTimeField").hidden = kind === "interval"; byId("scheduleDayField").hidden = kind !== "weekly"; });570
byId("scheduleInstall").addEventListener("click", () => void installSchedule());571
byId("scheduleLoad").addEventListener("click", () => void loadSchedules());572
byId("scheduleServer").addEventListener("change", () => { clearSchedules(); void loadSchedules(); });573
byId("versionsLoad").addEventListener("click", () => void loadVersions());574
byId("pruneButton").addEventListener("click", () => void pruneVersions());575
byId("diagLoad").addEventListener("click", () => void loadDiagnostics());576
byId("historyClear").addEventListener("click", async () => { if (window.confirm(t("Vymazat historii přenosů?"))) { await api.rsync.clearHistory(); await renderHistory(); } });577
byId("closeButton").addEventListener("click", () => api.close());579
function fillStaticChoices() {580
const minutes = byId("scheduleMinutes"), keep = minutes.value || "30";581
minutes.replaceChildren(...[5, 10, 15, 20, 30, 60, 120, 180, 240, 360, 720, 1440].map((value) => new Option(value < 60 ? t("každých {count} min", { count: value }) : t("každých {count} h", { count: value / 60 }), String(value))));582
minutes.value = keep;583
const day = byId("scheduleDay"), keepDay = day.value || "1";584
day.replaceChildren(...["Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"].map((name, index) => new Option(t(name), String(index))));585
day.value = keepDay;586
}588
async function takeHandoff() {589
const spec = await api.rsync.takeHandoff();590
if (spec) { writeSpec(spec); switchTab("sync"); setStatus(t("Úloha z File Manageru je připravená. Upravte ji a spusťte analýzu.")); }591
}593
(async () => {594
try { i18n.setLanguage(await api.ui.getLanguage()); } catch {}595
i18n.apply(document);596
fillStaticChoices();597
api.ui.onLanguageChanged((language) => { i18n.setLanguage(language); i18n.apply(document); fillStaticChoices(); renderPlaces(); syncControls(); });598
const info = await api.getInfo(); byId("pluginVersion").textContent = `${info.name} v${info.version} · ${info.publisher}`;599
await loadPlaces();600
await takeHandoff();601
syncControls();602
})().catch((error) => setStatus(errorText(error), true));604
setInterval(() => { if (!document.hidden && byId("connectDialog").hidden) { void loadPlaces().catch(() => {}); void takeHandoff().catch(() => {}); } }, 3000);SHA-256
47704a0ee0521282ccab9caf3ee97fbffb86734ccf8d8d28d3640a31f6468d4aCH-J Proprietary Software License 1.14 · Mã nguồn được cung cấp theo CH-J Proprietary Software License 1.14. Việc công khai mã nguồn không thay đổi các điều khoản giấy phép và không cấp thêm quyền nào.
SHA-256 của tệp nén
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f