feat: Unterstützung für mehrere Quellgeräte hinzugefügt und Versionsnummer auf 1.0.23 erhöht
This commit is contained in:
334
app.py
334
app.py
@@ -51,7 +51,8 @@ WIFI_BOOT_WAIT = 25 # Sekunden warten beim Start bevor AP gestartet wird
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
# USB
|
||||
'source_port': None, 'source_label': '',
|
||||
'source_ports': [], # [{port, label}, ...]
|
||||
'source_port': None, 'source_label': '', # Migration legacy
|
||||
'dest_port': None, 'dest_label': '',
|
||||
'folder_format': '%Y-%m-%d', 'add_time': True,
|
||||
'subfolder': True, 'auto_copy': True,
|
||||
@@ -572,9 +573,19 @@ def _fmt_bytes(b):
|
||||
return f'{b/1024**3:.2f} GB'
|
||||
|
||||
|
||||
def do_copy(src_dev, dst_dev, cfg):
|
||||
src_mp = dst_mp = None
|
||||
src_owned = dst_owned = False
|
||||
def _resolve_source_ports(cfg) -> list:
|
||||
"""Gibt source_ports als [{port, label}]-Liste zurück. Migriert altes source_port-Feld."""
|
||||
ports = cfg.get('source_ports') or []
|
||||
if not ports and cfg.get('source_port'):
|
||||
ports = [{'port': cfg['source_port'], 'label': cfg.get('source_label', '')}]
|
||||
return ports
|
||||
|
||||
|
||||
def do_copy(src_devs, dst_dev, cfg):
|
||||
"""Kopiert von einer oder mehreren Quellen auf ein Ziel."""
|
||||
dst_mp = None
|
||||
dst_owned = False
|
||||
src_mounts = [] # [(src_dev, src_mp, src_owned)]
|
||||
try:
|
||||
with copy_lock:
|
||||
copy_state.update(running=True, progress=0, error=None,
|
||||
@@ -583,12 +594,8 @@ def do_copy(src_dev, dst_dev, cfg):
|
||||
start_ts=time.time(), eta_sec=None, speed_bps=0,
|
||||
phase='copy')
|
||||
save_state()
|
||||
add_log('Kopiervorgang gestartet')
|
||||
|
||||
src_mp, src_owned = ensure_mount(src_dev)
|
||||
if not src_mp:
|
||||
raise RuntimeError(f'Quelle nicht mountbar: {src_dev["device"]}')
|
||||
add_log(f'Quelle: {src_mp} ({src_dev["label"]})')
|
||||
n = len(src_devs)
|
||||
add_log(f'Kopiervorgang gestartet ({n} Quelle{"n" if n != 1 else ""})')
|
||||
|
||||
dst_mp, dst_owned = ensure_mount(dst_dev)
|
||||
if not dst_mp:
|
||||
@@ -599,100 +606,120 @@ def do_copy(src_dev, dst_dev, cfg):
|
||||
date_str = ts.strftime(cfg['folder_format'])
|
||||
if cfg.get('add_time'):
|
||||
date_str += '_' + ts.strftime('%H%M%S')
|
||||
label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source'))
|
||||
|
||||
dst_dir = Path(dst_mp) / date_str
|
||||
if cfg.get('subfolder'):
|
||||
dst_dir = dst_dir / label
|
||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||
add_log(f'Zielordner: {dst_dir}')
|
||||
# -- Alle Quellen mounten & Dateien sammeln -------------------------
|
||||
# source_data: [(src_dev, src_path, files, dst_dir, incomplete_marker)]
|
||||
source_data = []
|
||||
total = 0
|
||||
bytes_total = 0
|
||||
|
||||
# Halbkopierte .picopy_tmp-Dateien aus vorherigen Unterbrechungen entfernen
|
||||
for stale in dst_dir.rglob('*.picopy_tmp'):
|
||||
log.info(f'Bereinige Temp-Datei: {stale}')
|
||||
stale.unlink(missing_ok=True)
|
||||
|
||||
# Incomplete-Marker: existiert dieser nach Neustart, war die letzte Kopie unterbrochen
|
||||
incomplete_marker = dst_dir / '.picopy_incomplete'
|
||||
incomplete_marker.write_text(json.dumps({
|
||||
'started': datetime.now().isoformat(),
|
||||
'source': src_dev.get('label', ''),
|
||||
}))
|
||||
|
||||
# -- Dateien sammeln & filtern --------------------------------------
|
||||
src_path = Path(src_mp)
|
||||
all_files = [f for f in src_path.rglob('*') if f.is_file()]
|
||||
files = [f for f in all_files if _should_copy(f, cfg)]
|
||||
n_filtered = len(all_files) - len(files)
|
||||
if n_filtered:
|
||||
add_log(f'{n_filtered} Dateien durch Filter ausgeschlossen')
|
||||
|
||||
total = len(files)
|
||||
bytes_total = sum(f.stat().st_size for f in files)
|
||||
with copy_lock:
|
||||
copy_state['total'] = total
|
||||
copy_state['bytes_total'] = bytes_total
|
||||
add_log(f'{total} Dateien ({_fmt_bytes(bytes_total)})')
|
||||
save_state()
|
||||
|
||||
dup_mode = cfg.get('duplicate_handling', 'skip')
|
||||
copied_pairs = [] # [(src, dst)] erfolgreich kopiert
|
||||
skipped = 0
|
||||
io_errors = 0
|
||||
|
||||
# -- Phase 1: Kopieren ----------------------------------------------
|
||||
for i, f in enumerate(files):
|
||||
for src_dev in src_devs:
|
||||
with copy_lock:
|
||||
cancelled = not copy_state['running']
|
||||
if cancelled:
|
||||
add_log('Abgebrochen')
|
||||
return
|
||||
rel = f.relative_to(src_path)
|
||||
dst_f = dst_dir / rel
|
||||
|
||||
src_mp_i, src_owned_i = ensure_mount(src_dev)
|
||||
src_mounts.append((src_dev, src_mp_i, src_owned_i))
|
||||
if not src_mp_i:
|
||||
add_log(f'Quelle nicht mountbar: {src_dev["device"]} - übersprungen')
|
||||
continue
|
||||
|
||||
add_log(f'Quelle: {src_mp_i} ({src_dev["label"]})')
|
||||
src_path = Path(src_mp_i)
|
||||
all_files = [f for f in src_path.rglob('*') if f.is_file()]
|
||||
files = [f for f in all_files if _should_copy(f, cfg)]
|
||||
n_filtered = len(all_files) - len(files)
|
||||
if n_filtered:
|
||||
add_log(f'{n_filtered} Dateien gefiltert ({src_dev["label"]})')
|
||||
|
||||
label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source'))
|
||||
dst_dir_i = Path(dst_mp) / date_str
|
||||
if cfg.get('subfolder'):
|
||||
dst_dir_i = dst_dir_i / label
|
||||
dst_dir_i.mkdir(parents=True, exist_ok=True)
|
||||
add_log(f'Zielordner: {dst_dir_i}')
|
||||
|
||||
for stale in dst_dir_i.rglob('*.picopy_tmp'):
|
||||
stale.unlink(missing_ok=True)
|
||||
|
||||
incomplete_marker_i = dst_dir_i / '.picopy_incomplete'
|
||||
incomplete_marker_i.write_text(json.dumps({
|
||||
'started': datetime.now().isoformat(),
|
||||
'source': src_dev.get('label', ''),
|
||||
}))
|
||||
|
||||
total += len(files)
|
||||
bytes_total += sum(f.stat().st_size for f in files)
|
||||
source_data.append((src_dev, src_path, files, dst_dir_i, incomplete_marker_i))
|
||||
|
||||
with copy_lock:
|
||||
copy_state['total'] = total
|
||||
copy_state['bytes_total'] = bytes_total
|
||||
add_log(f'{total} Dateien gesamt ({_fmt_bytes(bytes_total)})')
|
||||
save_state()
|
||||
|
||||
# -- Phase 1: Kopieren (alle Quellen) --------------------------------
|
||||
dup_mode = cfg.get('duplicate_handling', 'skip')
|
||||
all_copied_pairs = []
|
||||
skipped = 0
|
||||
io_errors = 0
|
||||
global_done = 0
|
||||
|
||||
for src_dev_i, src_path_i, files_i, dst_dir_i, _ in source_data:
|
||||
if len(src_devs) > 1:
|
||||
add_log(f'Kopiere: {src_dev_i["label"]}')
|
||||
for f in files_i:
|
||||
with copy_lock:
|
||||
cancelled = not copy_state['running']
|
||||
if cancelled:
|
||||
add_log('Abgebrochen')
|
||||
return
|
||||
global_done += 1
|
||||
rel = f.relative_to(src_path_i)
|
||||
dst_f = dst_dir_i / rel
|
||||
try:
|
||||
dst_f.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as mkdir_err:
|
||||
io_errors += 1
|
||||
add_log(f'⚠ Verzeichnis nicht erstellbar ({dst_f.parent.name}): {mkdir_err}')
|
||||
with copy_lock:
|
||||
copy_state.update(done=i+1,
|
||||
progress=int((i+1)/total*100) if total else 100,
|
||||
copy_state.update(done=global_done,
|
||||
progress=int(global_done/total*100) if total else 100,
|
||||
current=str(f.name))
|
||||
continue
|
||||
|
||||
if dst_f.exists():
|
||||
if dup_mode == 'skip':
|
||||
# Größenvergleich: stimmt die Größe nicht überein, war die Datei
|
||||
# beim letzten Kopieren möglicherweise durch Stromausfall abgeschnitten
|
||||
if dst_f.stat().st_size == f.stat().st_size:
|
||||
skipped += 1
|
||||
with copy_lock:
|
||||
copy_state.update(done=i+1,
|
||||
progress=int((i+1)/total*100) if total else 100,
|
||||
copy_state.update(done=global_done,
|
||||
progress=int(global_done/total*100) if total else 100,
|
||||
current=str(f.name))
|
||||
continue
|
||||
else:
|
||||
add_log(f'Unvollständige Datei gefunden, wird neu kopiert: {f.name}')
|
||||
add_log(f'Unvollständige Datei, wird neu kopiert: {f.name}')
|
||||
elif dup_mode == 'rename':
|
||||
dst_f = _unique_path(dst_f)
|
||||
# overwrite: einfach weitermachen
|
||||
|
||||
fsize = f.stat().st_size
|
||||
tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp')
|
||||
try:
|
||||
shutil.copy2(f, tmp_f) # Erst in Temp-Datei kopieren
|
||||
os.replace(str(tmp_f), str(dst_f)) # Dann atomar umbenennen
|
||||
shutil.copy2(f, tmp_f)
|
||||
os.replace(str(tmp_f), str(dst_f))
|
||||
except OSError as copy_err:
|
||||
try: tmp_f.unlink(missing_ok=True)
|
||||
except Exception: pass
|
||||
io_errors += 1
|
||||
add_log(f'⚠ Fehler bei {f.name}: {copy_err}')
|
||||
with copy_lock:
|
||||
copy_state.update(done=i+1,
|
||||
progress=int((i+1)/total*100) if total else 100,
|
||||
copy_state.update(done=global_done,
|
||||
progress=int(global_done/total*100) if total else 100,
|
||||
current=str(f.name))
|
||||
continue
|
||||
copied_pairs.append((f, dst_f))
|
||||
all_copied_pairs.append((f, dst_f))
|
||||
|
||||
with copy_lock:
|
||||
copy_state['bytes_done'] += fsize
|
||||
@@ -701,13 +728,13 @@ def do_copy(src_dev, dst_dev, cfg):
|
||||
elapsed = time.time() - copy_state['start_ts']
|
||||
speed = bd / elapsed if elapsed > 1 else 0
|
||||
eta = int((bt - bd) / speed) if speed > 0 and bt > bd else 0
|
||||
copy_state.update(done=i+1,
|
||||
progress=int((i+1)/total*100) if total else 100,
|
||||
copy_state.update(done=global_done,
|
||||
progress=int(global_done/total*100) if total else 100,
|
||||
current=str(f.name), speed_bps=int(speed), eta_sec=eta)
|
||||
if (i+1) % 20 == 0:
|
||||
if global_done % 20 == 0:
|
||||
save_state()
|
||||
|
||||
msg_parts = [f'{len(copied_pairs)} kopiert']
|
||||
msg_parts = [f'{len(all_copied_pairs)} kopiert']
|
||||
if skipped:
|
||||
msg_parts.append(f'{skipped} übersprungen')
|
||||
if io_errors:
|
||||
@@ -715,22 +742,22 @@ def do_copy(src_dev, dst_dev, cfg):
|
||||
|
||||
# -- Phase 2: Verifizieren ------------------------------------------
|
||||
verify_errors = 0
|
||||
verified_pairs = list(copied_pairs)
|
||||
verified_pairs = list(all_copied_pairs)
|
||||
|
||||
if cfg.get('verify_checksum') and copied_pairs:
|
||||
if cfg.get('verify_checksum') and all_copied_pairs:
|
||||
with copy_lock:
|
||||
copy_state.update(phase='verify', progress=0, done=0,
|
||||
total=len(copied_pairs), current='',
|
||||
total=len(all_copied_pairs), current='',
|
||||
eta_sec=None, speed_bps=0)
|
||||
add_log(f'Verifiziere {len(copied_pairs)} Dateien...')
|
||||
add_log(f'Verifiziere {len(all_copied_pairs)} Dateien...')
|
||||
verified_pairs = []
|
||||
|
||||
for i, (src_f, dst_f) in enumerate(copied_pairs):
|
||||
for i, (src_f, dst_f) in enumerate(all_copied_pairs):
|
||||
with copy_lock:
|
||||
cancelled = not copy_state['running']
|
||||
if not cancelled:
|
||||
copy_state.update(done=i+1,
|
||||
progress=int((i+1)/len(copied_pairs)*100),
|
||||
progress=int((i+1)/len(all_copied_pairs)*100),
|
||||
current=src_f.name)
|
||||
if cancelled:
|
||||
add_log('Abgebrochen')
|
||||
@@ -769,16 +796,17 @@ def do_copy(src_dev, dst_dev, cfg):
|
||||
else:
|
||||
add_log('Quelle geleert ✓')
|
||||
|
||||
# Alle Daten auf den Datenträger schreiben bevor wir abmelden
|
||||
subprocess.run(['sync'], capture_output=True)
|
||||
try: incomplete_marker.unlink(missing_ok=True)
|
||||
for _, _, _, _, incomplete_marker_i in source_data:
|
||||
try: incomplete_marker_i.unlink(missing_ok=True)
|
||||
except Exception: pass
|
||||
|
||||
with copy_lock:
|
||||
copy_state['last_copy'] = datetime.now().isoformat()
|
||||
add_log('Fertig! ' + ', '.join(msg_parts))
|
||||
|
||||
threading.Thread(target=run_uploads, args=(dst_dir, cfg), daemon=True).start()
|
||||
dst_dir_root = Path(dst_mp) / date_str
|
||||
threading.Thread(target=run_uploads, args=(dst_dir_root, cfg), daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
log.exception('Copy failed')
|
||||
@@ -787,9 +815,10 @@ def do_copy(src_dev, dst_dev, cfg):
|
||||
add_log(f'Fehler: {e}')
|
||||
|
||||
finally:
|
||||
subprocess.run(['sync'], capture_output=True) # Sicherheits-Sync vor Unmount
|
||||
if src_owned and src_mp:
|
||||
subprocess.run(['umount', src_mp], capture_output=True)
|
||||
subprocess.run(['sync'], capture_output=True)
|
||||
for _, src_mp_i, src_owned_i in src_mounts:
|
||||
if src_owned_i and src_mp_i:
|
||||
subprocess.run(['umount', src_mp_i], capture_output=True)
|
||||
if dst_owned and dst_mp:
|
||||
subprocess.run(['umount', dst_mp], capture_output=True)
|
||||
with copy_lock:
|
||||
@@ -800,17 +829,19 @@ def do_copy(src_dev, dst_dev, cfg):
|
||||
|
||||
def check_auto_copy():
|
||||
cfg = load_cfg()
|
||||
if not cfg.get('auto_copy') or not cfg.get('source_port') or not cfg.get('dest_port'):
|
||||
src_ports = _resolve_source_ports(cfg)
|
||||
if not cfg.get('auto_copy') or not src_ports or not cfg.get('dest_port'):
|
||||
return
|
||||
with copy_lock:
|
||||
if copy_state['running'] or copy_state['error']:
|
||||
return
|
||||
devs = usb_devices()
|
||||
src = next((d for d in devs if d['usb_port'] == cfg['source_port']), None)
|
||||
srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports]
|
||||
srcs = [s for s in srcs if s is not None]
|
||||
dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None)
|
||||
if src and dst:
|
||||
log.info('Auto-Copy: beide Geräte verbunden')
|
||||
threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True).start()
|
||||
if srcs and dst:
|
||||
log.info(f'Auto-Copy: {len(srcs)} Quelle(n) und Ziel verbunden')
|
||||
threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True).start()
|
||||
|
||||
def usb_monitor():
|
||||
try:
|
||||
@@ -952,11 +983,13 @@ def r_start():
|
||||
return jsonify(error='Abbruch wird noch abgeschlossen - bitte kurz warten und erneut versuchen.'), 400
|
||||
cfg = load_cfg()
|
||||
devs = usb_devices()
|
||||
src = next((d for d in devs if d['usb_port'] == cfg.get('source_port')), None)
|
||||
src_ports = _resolve_source_ports(cfg)
|
||||
srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports]
|
||||
srcs = [s for s in srcs if s is not None]
|
||||
if not srcs: return jsonify(error='Keine Quellgeräte gefunden (Ports nicht verbunden)'), 400
|
||||
dst = next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None)
|
||||
if not src: return jsonify(error='Quellgerät nicht gefunden'), 400
|
||||
if not dst: return jsonify(error='Zielgerät nicht gefunden'), 400
|
||||
_copy_thread = threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True)
|
||||
_copy_thread = threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True)
|
||||
_copy_thread.start()
|
||||
return jsonify(ok=True)
|
||||
|
||||
@@ -1642,29 +1675,25 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
|
||||
<!-- Quelle + Ziel in eigenem gleichbreiten Grid -->
|
||||
<div class="port-pair">
|
||||
|
||||
<!-- Quelle -->
|
||||
<div class="port-slot" id="slot-src">
|
||||
<div class="role-tag src">▲ Quelle</div>
|
||||
<div class="port-display">
|
||||
<div class="dot off" id="src-dot"></div>
|
||||
<div style="min-width:0">
|
||||
<div class="port-path" id="src-port-path">-</div>
|
||||
<div class="port-info" id="src-dev-info">Kein Port konfiguriert</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bezeichnung</label>
|
||||
<input type="text" id="src-label" placeholder="z.B. linker blauer Port">
|
||||
</div>
|
||||
<!-- Quellen (dynamisch) -->
|
||||
<div id="slot-src">
|
||||
<div id="sources-list"></div>
|
||||
<div style="margin-top:.6rem">
|
||||
<div class="role-tag src" style="margin-bottom:.5rem">+ Quelle hinzufügen</div>
|
||||
<div class="field">
|
||||
<label>Port lernen - Gerät wählen</label>
|
||||
<select id="src-select">
|
||||
<option value="">- Gerät einstecken, dann hier wählen -</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn grn" style="width:100%" onclick="assignPort('source')">✓ Als feste Quelle speichern</button>
|
||||
<div class="field">
|
||||
<label>Bezeichnung</label>
|
||||
<input type="text" id="src-label" placeholder="z.B. Kamera 1 / linker Port">
|
||||
</div>
|
||||
<button class="btn grn" style="width:100%" onclick="addSource()">+ Quelle hinzufügen</button>
|
||||
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
|
||||
<div class="hint-box">Gerät in den gewünschten Port → aus Liste wählen → Speichern. PiCopy merkt sich den physischen Port dauerhaft.</div>
|
||||
<div class="hint-box">Gerät einstecken → aus Liste wählen → Hinzufügen. Mehrere Quellen werden nacheinander auf dasselbe Ziel kopiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ziel -->
|
||||
@@ -1969,18 +1998,41 @@ function swTab(show,hide){
|
||||
// -- Port Slots ----------------------------------------------------------------
|
||||
async function refreshDevices(){
|
||||
devs = await api('/devices');
|
||||
renderSlot('src', cfg.source_port, cfg.source_label);
|
||||
renderSources();
|
||||
renderSlot('dst', cfg.dest_port, cfg.dest_label);
|
||||
renderUnassigned();
|
||||
populateSel();
|
||||
}
|
||||
|
||||
function renderSources(){
|
||||
const ports = cfg.source_ports || [];
|
||||
$('sources-list').innerHTML = ports.map((sp, i) => {
|
||||
const dev = devs.find(d => d.usb_port === sp.port);
|
||||
const info = dev
|
||||
? (dev.label||dev.device) + (dev.size ? ' | '+dev.size : '')
|
||||
: 'Gerät nicht verbunden';
|
||||
return `<div class="port-slot ${dev?'src-on':''}" style="margin-bottom:.5rem">
|
||||
<div class="role-tag src">▲ Quelle ${i+1}${sp.label?' – '+sp.label:''}</div>
|
||||
<div class="port-display">
|
||||
<div class="dot ${dev?'on':'off'}"></div>
|
||||
<div style="min-width:0">
|
||||
<div class="port-path">Port ${sp.port}</div>
|
||||
<div class="port-info">${info}</div>
|
||||
</div>
|
||||
<button class="btn sm danger" style="margin-left:auto;flex-shrink:0"
|
||||
onclick="removeSource('${sp.port}')">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') + (ports.length === 0
|
||||
? '<div style="color:var(--sub);font-size:.83rem;margin-bottom:.5rem">Noch keine Quelle konfiguriert.</div>'
|
||||
: '');
|
||||
}
|
||||
|
||||
function renderSlot(r, port, label){
|
||||
const isSrc=r==='src', dev=devs.find(d=>d.usb_port===port);
|
||||
const dev=devs.find(d=>d.usb_port===port);
|
||||
const dot=$(r+'-dot'), pp=$(r+'-port-path'), pi=$(r+'-dev-info');
|
||||
const sl=$('slot-'+r), lb=$(r+'-label');
|
||||
sl.classList.toggle('src-on', isSrc && !!port);
|
||||
sl.classList.toggle('dst-on', !isSrc && !!port);
|
||||
sl.classList.toggle('dst-on', !!port);
|
||||
if(port){
|
||||
pp.textContent='Port '+port+(label?' | '+label:'');
|
||||
if(dev){ dot.className='dot on'; pi.textContent=(dev.label||dev.device)+(dev.size?' | '+dev.size:'')+(dev.mount?' | '+dev.mount:''); }
|
||||
@@ -1992,16 +2044,26 @@ function renderSlot(r, port, label){
|
||||
}
|
||||
|
||||
function populateSel(){
|
||||
const opts=devs.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} - ${d.label||d.device} (${d.size})</option>`).join('');
|
||||
['src-select','dst-select'].forEach(id=>{
|
||||
const el=$(id),prev=el.value;
|
||||
el.innerHTML='<option value="">- Gerät einstecken, dann hier wählen -</option>'+opts;
|
||||
if(prev && devs.find(d=>d.usb_port===prev)) el.value=prev;
|
||||
});
|
||||
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
|
||||
const mkOpts = filter => devs.filter(filter)
|
||||
.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} - ${d.label||d.device} (${d.size})</option>`)
|
||||
.join('');
|
||||
const blank = v => `<option value="">- ${v} -</option>`;
|
||||
|
||||
const srcEl=$('src-select'), srcPrev=srcEl.value;
|
||||
srcEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
|
||||
+ mkOpts(d => !srcSet.has(d.usb_port) && d.usb_port !== cfg.dest_port);
|
||||
if(srcPrev && devs.find(d=>d.usb_port===srcPrev)) srcEl.value=srcPrev;
|
||||
|
||||
const dstEl=$('dst-select'), dstPrev=dstEl.value;
|
||||
dstEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
|
||||
+ mkOpts(d => !srcSet.has(d.usb_port));
|
||||
if(dstPrev && devs.find(d=>d.usb_port===dstPrev)) dstEl.value=dstPrev;
|
||||
}
|
||||
|
||||
function renderUnassigned(){
|
||||
const list=devs.filter(d=>d.usb_port!==cfg.source_port&&d.usb_port!==cfg.dest_port);
|
||||
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
|
||||
const list=devs.filter(d=>!srcSet.has(d.usb_port)&&d.usb_port!==cfg.dest_port);
|
||||
const w=$('unassigned-wrap');
|
||||
if(!list.length){w.style.display='none';return;}
|
||||
w.style.display='block';
|
||||
@@ -2013,21 +2075,37 @@ function renderUnassigned(){
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function addSource(){
|
||||
const port=$('src-select').value, label=$('src-label').value.trim();
|
||||
if(!port){flash('src-flash','err','Bitte zuerst ein Gerät wählen.');return;}
|
||||
if(port===cfg.dest_port){flash('src-flash','err','Port bereits als Ziel konfiguriert!');return;}
|
||||
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash('src-flash','err','Port bereits als Quelle hinzugefügt!');return;}
|
||||
cfg.source_ports = [...(cfg.source_ports||[]), {port, label}];
|
||||
await api('/config','POST',cfg);
|
||||
$('src-label').value='';
|
||||
flash('src-flash','ok','✓ Quelle Port '+port+' hinzugefügt.');
|
||||
renderSources(); populateSel(); renderUnassigned();
|
||||
}
|
||||
|
||||
async function removeSource(port){
|
||||
cfg.source_ports = (cfg.source_ports||[]).filter(sp=>sp.port!==port);
|
||||
await api('/config','POST',cfg);
|
||||
renderSources(); populateSel(); renderUnassigned();
|
||||
}
|
||||
|
||||
async function assignPort(role){
|
||||
const s=role==='source', sid=s?'src-select':'dst-select', lid=s?'src-label':'dst-label';
|
||||
const fid=s?'src-flash':'dst-flash', pk=s?'source_port':'dest_port', lk=s?'source_label':'dest_label';
|
||||
const sid='dst-select', lid='dst-label';
|
||||
const fid='dst-flash', pk='dest_port', lk='dest_label';
|
||||
const port=$(sid).value, label=$(lid).value.trim();
|
||||
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
|
||||
const other=s?cfg.dest_port:cfg.source_port;
|
||||
if(port===other){flash(fid,'err','Port bereits als '+(s?'Ziel':'Quelle')+' konfiguriert!');return;}
|
||||
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err','Port bereits als Quelle konfiguriert!');return;}
|
||||
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
|
||||
await api('/config','POST',cfg);
|
||||
flash(fid,'ok','✓ Port '+port+' als '+(s?'Quelle':'Ziel')+' gespeichert.');
|
||||
renderSlot('src',cfg.source_port,cfg.source_label);
|
||||
flash(fid,'ok','✓ Port '+port+' als Ziel gespeichert.');
|
||||
renderSlot('dst',cfg.dest_port,cfg.dest_label);
|
||||
renderUnassigned();
|
||||
populateSel(); renderUnassigned();
|
||||
}
|
||||
['src-label','dst-label'].forEach(id=>window.addEventListener('DOMContentLoaded',()=>{
|
||||
['dst-label'].forEach(id=>window.addEventListener('DOMContentLoaded',()=>{
|
||||
const el=$(id); if(el) el.addEventListener('input',()=>el.dataset.dirty='1');
|
||||
}));
|
||||
|
||||
@@ -2044,6 +2122,10 @@ async function cancelCopy(){ await api('/copy/cancel','POST'); }
|
||||
// -- Config --------------------------------------------------------------------
|
||||
async function loadCfg(){
|
||||
cfg=await api('/config');
|
||||
// Migration: altes source_port-Feld -> source_ports-Array
|
||||
if(!cfg.source_ports) cfg.source_ports=[];
|
||||
if(cfg.source_ports.length===0 && cfg.source_port)
|
||||
cfg.source_ports=[{port:cfg.source_port, label:cfg.source_label||''}];
|
||||
$('c-fmt').value=cfg.folder_format||'%Y-%m-%d';
|
||||
$('c-time').checked=!!cfg.add_time; $('c-sub').checked=!!cfg.subfolder; $('c-auto').checked=!!cfg.auto_copy;
|
||||
$('c-filter').value=cfg.file_filter||'';
|
||||
@@ -2179,7 +2261,7 @@ const expl={
|
||||
reload(){this.load(this.paths[this.role]);},
|
||||
navigate(p){this.load(p);},
|
||||
async load(path=''){
|
||||
const port=this.role==='src'?cfg.source_port:cfg.dest_port;
|
||||
const port=this.role==='src'?(cfg.source_ports&&cfg.source_ports[0]?.port):cfg.dest_port;
|
||||
const body=$('expl-body'), bread=$('expl-bread');
|
||||
if(!port){body.innerHTML='<div class="expl-empty">Kein Port konfiguriert</div>';bread.innerHTML='';return;}
|
||||
const dev=devs.find(d=>d.usb_port===port);
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.22
|
||||
1.0.23
|
||||
Reference in New Issue
Block a user