257 lines
9.0 KiB
JavaScript
257 lines
9.0 KiB
JavaScript
const $ = (selector) => document.querySelector(selector);
|
|
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
|
|
|
const ui = {
|
|
connection: $('#connection'),
|
|
inspect: $('#inspect'),
|
|
message: $('#inspectionMessage'),
|
|
options: $('#syncOptions'),
|
|
activity: $('#activity'),
|
|
activityTitle: $('#activityTitle'),
|
|
abort: $('#abort'),
|
|
progressBar: $('#progressBar'),
|
|
progressText: $('#progressText'),
|
|
progressCount: $('#progressCount'),
|
|
events: $('#events'),
|
|
};
|
|
|
|
let socket;
|
|
let jobType;
|
|
let jobCompleted = false;
|
|
let abortRequested = false;
|
|
let syncRunning = false;
|
|
|
|
function openJob(request) {
|
|
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
|
ui.message.textContent = 'Please wait for the current job to finish.';
|
|
return false;
|
|
}
|
|
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const jobSocket = new WebSocket(`${protocol}//${location.host}/ws`);
|
|
socket = jobSocket;
|
|
jobType = request.type;
|
|
jobCompleted = false;
|
|
abortRequested = false;
|
|
ui.inspect.disabled = true;
|
|
setConnection('connecting', 'Connecting');
|
|
|
|
jobSocket.addEventListener('open', () => {
|
|
setConnection('connected', request.type === 'inspect' ? 'Inspecting' : 'Synchronizing');
|
|
jobSocket.send(JSON.stringify(request));
|
|
});
|
|
jobSocket.addEventListener('message', ({ data }) => {
|
|
try { handle(JSON.parse(data)); } catch { addEvent('error', 'Received malformed service response'); }
|
|
});
|
|
jobSocket.addEventListener('close', () => {
|
|
if (socket !== jobSocket) return;
|
|
socket = undefined;
|
|
setConnection('idle', 'Idle');
|
|
ui.inspect.disabled = false;
|
|
if (abortRequested && !jobCompleted) {
|
|
syncRunning = false;
|
|
jobCompleted = true;
|
|
addEvent('aborted', 'Synchronization aborted');
|
|
setActivityProgress('Aborted', 0, 0);
|
|
} else if (!jobCompleted && jobType === 'sync') {
|
|
syncRunning = false;
|
|
addEvent('error', 'Socket disconnected — the server aborted this synchronization');
|
|
setActivityProgress('Aborted', 0, 0);
|
|
} else if (!jobCompleted && jobType === 'inspect') {
|
|
ui.message.textContent = 'Inspection stopped before it completed.';
|
|
}
|
|
jobType = undefined;
|
|
});
|
|
jobSocket.addEventListener('error', () => setConnection('disconnected', 'Connection error'));
|
|
return true;
|
|
}
|
|
|
|
function setConnection(state, text) {
|
|
ui.connection.className = `connection ${state}`;
|
|
ui.connection.querySelector('span').textContent = text;
|
|
}
|
|
|
|
function shareRequest(type) {
|
|
return {
|
|
type,
|
|
left: { url: $('#leftUrl').value, password: $('#leftPassword').value },
|
|
right: { url: $('#rightUrl').value, password: $('#rightPassword').value },
|
|
};
|
|
}
|
|
|
|
function inspect() {
|
|
const leftUrl = $('#leftUrl');
|
|
const rightUrl = $('#rightUrl');
|
|
if (!leftUrl.reportValidity() || !rightUrl.reportValidity()) return;
|
|
if (openJob(shareRequest('inspect'))) resetInspection();
|
|
}
|
|
|
|
function resetInspection() {
|
|
ui.options.classList.add('hidden');
|
|
ui.message.textContent = 'Inspecting both Immich shares…';
|
|
for (const side of ['left', 'right']) {
|
|
setSide(side, 'connecting', 'Starting inspection');
|
|
$(`#${side}Details`).classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function setSide(side, stage, message, share) {
|
|
const state = $(`#${side}State`);
|
|
const card = $(`#${side}Card`);
|
|
const status = $(`#${side}Status`);
|
|
const labels = {
|
|
idle: 'Idle', connecting: 'Connecting', authenticating: 'Password', ready: 'Ready',
|
|
'password-required': 'Password needed', error: 'Error',
|
|
};
|
|
state.className = `state ${stage}`;
|
|
state.textContent = labels[stage] || stage;
|
|
status.textContent = message;
|
|
card.classList.toggle('attention', stage === 'password-required');
|
|
card.classList.toggle('has-error', stage === 'error');
|
|
if (stage === 'password-required') $(`#${side}Password`).focus();
|
|
if (share) showShare(side, share);
|
|
}
|
|
|
|
function showShare(side, share) {
|
|
const details = $(`#${side}Details`);
|
|
details.replaceChildren(
|
|
detailRow('Album', share.name),
|
|
detailRow('Assets', String(share.assetCount)),
|
|
detailRow('Download', share.allowDownload ? 'Allowed' : 'Blocked', share.allowDownload),
|
|
detailRow('Upload', share.allowUpload ? 'Allowed' : 'Blocked', share.allowUpload),
|
|
detailRow('Server', share.origin),
|
|
);
|
|
details.classList.remove('hidden');
|
|
}
|
|
|
|
function detailRow(label, value, positive) {
|
|
const row = document.createElement('div');
|
|
const key = document.createElement('span');
|
|
const val = document.createElement('strong');
|
|
key.textContent = label;
|
|
val.textContent = value;
|
|
if (positive === true) val.className = 'positive';
|
|
if (positive === false) val.className = 'negative';
|
|
row.append(key, val);
|
|
return row;
|
|
}
|
|
|
|
function showOptions(options) {
|
|
for (const button of $$('#syncOptions button')) {
|
|
const option = options[button.dataset.direction];
|
|
button.disabled = !option.allowed;
|
|
button.title = option.reason || '';
|
|
button.querySelector('span').textContent = option.allowed
|
|
? `${option.missing} missing asset${option.missing === 1 ? '' : 's'}`
|
|
: option.reason;
|
|
}
|
|
ui.options.classList.remove('hidden');
|
|
}
|
|
|
|
function startSync(direction) {
|
|
const request = shareRequest('sync');
|
|
request.direction = direction;
|
|
if (!openJob(request)) return;
|
|
syncRunning = true;
|
|
ui.activity.classList.remove('hidden');
|
|
ui.activityTitle.textContent = direction === 'bidirectional' ? 'Bidirectional synchronization' :
|
|
direction === 'left-to-right' ? 'Synchronizing A → B' : 'Synchronizing B → A';
|
|
ui.events.replaceChildren();
|
|
ui.activity.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
function setActivityProgress(text, current, total) {
|
|
ui.progressText.textContent = text;
|
|
ui.progressCount.textContent = `${current} / ${total}`;
|
|
ui.progressBar.style.width = total > 0 ? `${Math.min(100, current / total * 100)}%` : '0%';
|
|
}
|
|
|
|
function addEvent(kind, message, detail = '') {
|
|
ui.activity.classList.remove('hidden');
|
|
const item = document.createElement('li');
|
|
item.className = kind;
|
|
const time = document.createElement('time');
|
|
const copy = document.createElement('div');
|
|
const main = document.createElement('strong');
|
|
time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
main.textContent = message;
|
|
copy.append(main);
|
|
if (detail) {
|
|
const sub = document.createElement('span');
|
|
sub.textContent = detail;
|
|
copy.append(sub);
|
|
}
|
|
item.append(time, copy);
|
|
ui.events.prepend(item);
|
|
while (ui.events.children.length > 250) ui.events.lastElementChild.remove();
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
if (!Number.isFinite(value) || value < 0) return 'unknown size';
|
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
let size = value;
|
|
let unit = 0;
|
|
while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit++; }
|
|
return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`;
|
|
}
|
|
|
|
function handle(message) {
|
|
switch (message.type) {
|
|
case 'hello':
|
|
break;
|
|
case 'inspection-status':
|
|
ui.message.textContent = message.message;
|
|
break;
|
|
case 'side-status':
|
|
setSide(message.side, message.stage, message.message, message.share);
|
|
break;
|
|
case 'inspection':
|
|
ui.message.textContent = message.message;
|
|
if (message.ready) showOptions(message.options);
|
|
if (jobType === 'inspect' || !message.ready) jobCompleted = true;
|
|
break;
|
|
case 'sync-status': {
|
|
const current = message.completed || 0;
|
|
const failed = message.failed || 0;
|
|
setActivityProgress(message.message, current + failed, message.total || 0);
|
|
addEvent(message.stage === 'complete' && failed === 0 ? 'success' : message.stage, message.message,
|
|
message.stage === 'complete' ? `${current} completed · ${failed} failed` : '');
|
|
if (['complete', 'aborted'].includes(message.stage)) {
|
|
syncRunning = false;
|
|
jobCompleted = true;
|
|
}
|
|
break;
|
|
}
|
|
case 'asset-status': {
|
|
const done = (message.completed || 0) + (message.failed || 0);
|
|
setActivityProgress(`${message.stage}: ${message.fileName}`, done, message.total || 0);
|
|
addEvent(message.stage, message.message, `${message.fileName} · ${message.direction}`);
|
|
break;
|
|
}
|
|
case 'asset-progress':
|
|
ui.progressText.textContent = `${message.phase}: ${message.fileName} · ${formatBytes(message.bytes)} / ${formatBytes(message.totalBytes)}`;
|
|
ui.progressCount.textContent = `${message.current} / ${message.total}`;
|
|
break;
|
|
case 'error':
|
|
ui.message.textContent = message.message;
|
|
addEvent('error', message.message);
|
|
syncRunning = false;
|
|
jobCompleted = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
ui.inspect.addEventListener('click', inspect);
|
|
for (const input of $$('input')) input.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter') inspect();
|
|
});
|
|
for (const button of $$('#syncOptions button')) button.addEventListener('click', () => startSync(button.dataset.direction));
|
|
ui.abort.addEventListener('click', () => {
|
|
if (jobType === 'sync' && socket && socket.readyState === WebSocket.OPEN && !jobCompleted) {
|
|
abortRequested = true;
|
|
socket.close(1000, 'User aborted synchronization');
|
|
}
|
|
});
|
|
|
|
setConnection('idle', 'Idle');
|