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 = {
|
DEFAULT_CONFIG = {
|
||||||
# USB
|
# USB
|
||||||
'source_port': None, 'source_label': '',
|
'source_ports': [], # [{port, label}, ...]
|
||||||
|
'source_port': None, 'source_label': '', # Migration legacy
|
||||||
'dest_port': None, 'dest_label': '',
|
'dest_port': None, 'dest_label': '',
|
||||||
'folder_format': '%Y-%m-%d', 'add_time': True,
|
'folder_format': '%Y-%m-%d', 'add_time': True,
|
||||||
'subfolder': True, 'auto_copy': True,
|
'subfolder': True, 'auto_copy': True,
|
||||||
@@ -572,9 +573,19 @@ def _fmt_bytes(b):
|
|||||||
return f'{b/1024**3:.2f} GB'
|
return f'{b/1024**3:.2f} GB'
|
||||||
|
|
||||||
|
|
||||||
def do_copy(src_dev, dst_dev, cfg):
|
def _resolve_source_ports(cfg) -> list:
|
||||||
src_mp = dst_mp = None
|
"""Gibt source_ports als [{port, label}]-Liste zurück. Migriert altes source_port-Feld."""
|
||||||
src_owned = dst_owned = False
|
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:
|
try:
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state.update(running=True, progress=0, error=None,
|
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,
|
start_ts=time.time(), eta_sec=None, speed_bps=0,
|
||||||
phase='copy')
|
phase='copy')
|
||||||
save_state()
|
save_state()
|
||||||
add_log('Kopiervorgang gestartet')
|
n = len(src_devs)
|
||||||
|
add_log(f'Kopiervorgang gestartet ({n} Quelle{"n" if n != 1 else ""})')
|
||||||
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"]})')
|
|
||||||
|
|
||||||
dst_mp, dst_owned = ensure_mount(dst_dev)
|
dst_mp, dst_owned = ensure_mount(dst_dev)
|
||||||
if not dst_mp:
|
if not dst_mp:
|
||||||
@@ -599,100 +606,120 @@ def do_copy(src_dev, dst_dev, cfg):
|
|||||||
date_str = ts.strftime(cfg['folder_format'])
|
date_str = ts.strftime(cfg['folder_format'])
|
||||||
if cfg.get('add_time'):
|
if cfg.get('add_time'):
|
||||||
date_str += '_' + ts.strftime('%H%M%S')
|
date_str += '_' + ts.strftime('%H%M%S')
|
||||||
label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source'))
|
|
||||||
|
|
||||||
dst_dir = Path(dst_mp) / date_str
|
# -- Alle Quellen mounten & Dateien sammeln -------------------------
|
||||||
if cfg.get('subfolder'):
|
# source_data: [(src_dev, src_path, files, dst_dir, incomplete_marker)]
|
||||||
dst_dir = dst_dir / label
|
source_data = []
|
||||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
total = 0
|
||||||
add_log(f'Zielordner: {dst_dir}')
|
bytes_total = 0
|
||||||
|
|
||||||
# Halbkopierte .picopy_tmp-Dateien aus vorherigen Unterbrechungen entfernen
|
for src_dev in src_devs:
|
||||||
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):
|
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
cancelled = not copy_state['running']
|
cancelled = not copy_state['running']
|
||||||
if cancelled:
|
if cancelled:
|
||||||
add_log('Abgebrochen')
|
add_log('Abgebrochen')
|
||||||
return
|
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:
|
try:
|
||||||
dst_f.parent.mkdir(parents=True, exist_ok=True)
|
dst_f.parent.mkdir(parents=True, exist_ok=True)
|
||||||
except OSError as mkdir_err:
|
except OSError as mkdir_err:
|
||||||
io_errors += 1
|
io_errors += 1
|
||||||
add_log(f'⚠ Verzeichnis nicht erstellbar ({dst_f.parent.name}): {mkdir_err}')
|
add_log(f'⚠ Verzeichnis nicht erstellbar ({dst_f.parent.name}): {mkdir_err}')
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state.update(done=i+1,
|
copy_state.update(done=global_done,
|
||||||
progress=int((i+1)/total*100) if total else 100,
|
progress=int(global_done/total*100) if total else 100,
|
||||||
current=str(f.name))
|
current=str(f.name))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if dst_f.exists():
|
if dst_f.exists():
|
||||||
if dup_mode == 'skip':
|
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:
|
if dst_f.stat().st_size == f.stat().st_size:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state.update(done=i+1,
|
copy_state.update(done=global_done,
|
||||||
progress=int((i+1)/total*100) if total else 100,
|
progress=int(global_done/total*100) if total else 100,
|
||||||
current=str(f.name))
|
current=str(f.name))
|
||||||
continue
|
continue
|
||||||
else:
|
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':
|
elif dup_mode == 'rename':
|
||||||
dst_f = _unique_path(dst_f)
|
dst_f = _unique_path(dst_f)
|
||||||
# overwrite: einfach weitermachen
|
|
||||||
|
|
||||||
fsize = f.stat().st_size
|
fsize = f.stat().st_size
|
||||||
tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp')
|
tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp')
|
||||||
try:
|
try:
|
||||||
shutil.copy2(f, tmp_f) # Erst in Temp-Datei kopieren
|
shutil.copy2(f, tmp_f)
|
||||||
os.replace(str(tmp_f), str(dst_f)) # Dann atomar umbenennen
|
os.replace(str(tmp_f), str(dst_f))
|
||||||
except OSError as copy_err:
|
except OSError as copy_err:
|
||||||
try: tmp_f.unlink(missing_ok=True)
|
try: tmp_f.unlink(missing_ok=True)
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
io_errors += 1
|
io_errors += 1
|
||||||
add_log(f'⚠ Fehler bei {f.name}: {copy_err}')
|
add_log(f'⚠ Fehler bei {f.name}: {copy_err}')
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state.update(done=i+1,
|
copy_state.update(done=global_done,
|
||||||
progress=int((i+1)/total*100) if total else 100,
|
progress=int(global_done/total*100) if total else 100,
|
||||||
current=str(f.name))
|
current=str(f.name))
|
||||||
continue
|
continue
|
||||||
copied_pairs.append((f, dst_f))
|
all_copied_pairs.append((f, dst_f))
|
||||||
|
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state['bytes_done'] += fsize
|
copy_state['bytes_done'] += fsize
|
||||||
@@ -701,13 +728,13 @@ def do_copy(src_dev, dst_dev, cfg):
|
|||||||
elapsed = time.time() - copy_state['start_ts']
|
elapsed = time.time() - copy_state['start_ts']
|
||||||
speed = bd / elapsed if elapsed > 1 else 0
|
speed = bd / elapsed if elapsed > 1 else 0
|
||||||
eta = int((bt - bd) / speed) if speed > 0 and bt > bd else 0
|
eta = int((bt - bd) / speed) if speed > 0 and bt > bd else 0
|
||||||
copy_state.update(done=i+1,
|
copy_state.update(done=global_done,
|
||||||
progress=int((i+1)/total*100) if total else 100,
|
progress=int(global_done/total*100) if total else 100,
|
||||||
current=str(f.name), speed_bps=int(speed), eta_sec=eta)
|
current=str(f.name), speed_bps=int(speed), eta_sec=eta)
|
||||||
if (i+1) % 20 == 0:
|
if global_done % 20 == 0:
|
||||||
save_state()
|
save_state()
|
||||||
|
|
||||||
msg_parts = [f'{len(copied_pairs)} kopiert']
|
msg_parts = [f'{len(all_copied_pairs)} kopiert']
|
||||||
if skipped:
|
if skipped:
|
||||||
msg_parts.append(f'{skipped} übersprungen')
|
msg_parts.append(f'{skipped} übersprungen')
|
||||||
if io_errors:
|
if io_errors:
|
||||||
@@ -715,22 +742,22 @@ def do_copy(src_dev, dst_dev, cfg):
|
|||||||
|
|
||||||
# -- Phase 2: Verifizieren ------------------------------------------
|
# -- Phase 2: Verifizieren ------------------------------------------
|
||||||
verify_errors = 0
|
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:
|
with copy_lock:
|
||||||
copy_state.update(phase='verify', progress=0, done=0,
|
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)
|
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 = []
|
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:
|
with copy_lock:
|
||||||
cancelled = not copy_state['running']
|
cancelled = not copy_state['running']
|
||||||
if not cancelled:
|
if not cancelled:
|
||||||
copy_state.update(done=i+1,
|
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)
|
current=src_f.name)
|
||||||
if cancelled:
|
if cancelled:
|
||||||
add_log('Abgebrochen')
|
add_log('Abgebrochen')
|
||||||
@@ -769,16 +796,17 @@ def do_copy(src_dev, dst_dev, cfg):
|
|||||||
else:
|
else:
|
||||||
add_log('Quelle geleert ✓')
|
add_log('Quelle geleert ✓')
|
||||||
|
|
||||||
# Alle Daten auf den Datenträger schreiben bevor wir abmelden
|
|
||||||
subprocess.run(['sync'], capture_output=True)
|
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
|
except Exception: pass
|
||||||
|
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
copy_state['last_copy'] = datetime.now().isoformat()
|
copy_state['last_copy'] = datetime.now().isoformat()
|
||||||
add_log('Fertig! ' + ', '.join(msg_parts))
|
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:
|
except Exception as e:
|
||||||
log.exception('Copy failed')
|
log.exception('Copy failed')
|
||||||
@@ -787,9 +815,10 @@ def do_copy(src_dev, dst_dev, cfg):
|
|||||||
add_log(f'Fehler: {e}')
|
add_log(f'Fehler: {e}')
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(['sync'], capture_output=True) # Sicherheits-Sync vor Unmount
|
subprocess.run(['sync'], capture_output=True)
|
||||||
if src_owned and src_mp:
|
for _, src_mp_i, src_owned_i in src_mounts:
|
||||||
subprocess.run(['umount', src_mp], capture_output=True)
|
if src_owned_i and src_mp_i:
|
||||||
|
subprocess.run(['umount', src_mp_i], capture_output=True)
|
||||||
if dst_owned and dst_mp:
|
if dst_owned and dst_mp:
|
||||||
subprocess.run(['umount', dst_mp], capture_output=True)
|
subprocess.run(['umount', dst_mp], capture_output=True)
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
@@ -800,17 +829,19 @@ def do_copy(src_dev, dst_dev, cfg):
|
|||||||
|
|
||||||
def check_auto_copy():
|
def check_auto_copy():
|
||||||
cfg = load_cfg()
|
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
|
return
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
if copy_state['running'] or copy_state['error']:
|
if copy_state['running'] or copy_state['error']:
|
||||||
return
|
return
|
||||||
devs = usb_devices()
|
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)
|
dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None)
|
||||||
if src and dst:
|
if srcs and dst:
|
||||||
log.info('Auto-Copy: beide Geräte verbunden')
|
log.info(f'Auto-Copy: {len(srcs)} Quelle(n) und Ziel verbunden')
|
||||||
threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True).start()
|
threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True).start()
|
||||||
|
|
||||||
def usb_monitor():
|
def usb_monitor():
|
||||||
try:
|
try:
|
||||||
@@ -952,11 +983,13 @@ def r_start():
|
|||||||
return jsonify(error='Abbruch wird noch abgeschlossen - bitte kurz warten und erneut versuchen.'), 400
|
return jsonify(error='Abbruch wird noch abgeschlossen - bitte kurz warten und erneut versuchen.'), 400
|
||||||
cfg = load_cfg()
|
cfg = load_cfg()
|
||||||
devs = usb_devices()
|
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)
|
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
|
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()
|
_copy_thread.start()
|
||||||
return jsonify(ok=True)
|
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 -->
|
<!-- Quelle + Ziel in eigenem gleichbreiten Grid -->
|
||||||
<div class="port-pair">
|
<div class="port-pair">
|
||||||
|
|
||||||
<!-- Quelle -->
|
<!-- Quellen (dynamisch) -->
|
||||||
<div class="port-slot" id="slot-src">
|
<div id="slot-src">
|
||||||
<div class="role-tag src">▲ Quelle</div>
|
<div id="sources-list"></div>
|
||||||
<div class="port-display">
|
<div style="margin-top:.6rem">
|
||||||
<div class="dot off" id="src-dot"></div>
|
<div class="role-tag src" style="margin-bottom:.5rem">+ Quelle hinzufügen</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>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Port lernen - Gerät wählen</label>
|
<label>Port lernen - Gerät wählen</label>
|
||||||
<select id="src-select">
|
<select id="src-select">
|
||||||
<option value="">- Gerät einstecken, dann hier wählen -</option>
|
<option value="">- Gerät einstecken, dann hier wählen -</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 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>
|
</div>
|
||||||
|
|
||||||
<!-- Ziel -->
|
<!-- Ziel -->
|
||||||
@@ -1969,18 +1998,41 @@ function swTab(show,hide){
|
|||||||
// -- Port Slots ----------------------------------------------------------------
|
// -- Port Slots ----------------------------------------------------------------
|
||||||
async function refreshDevices(){
|
async function refreshDevices(){
|
||||||
devs = await api('/devices');
|
devs = await api('/devices');
|
||||||
renderSlot('src', cfg.source_port, cfg.source_label);
|
renderSources();
|
||||||
renderSlot('dst', cfg.dest_port, cfg.dest_label);
|
renderSlot('dst', cfg.dest_port, cfg.dest_label);
|
||||||
renderUnassigned();
|
renderUnassigned();
|
||||||
populateSel();
|
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){
|
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 dot=$(r+'-dot'), pp=$(r+'-port-path'), pi=$(r+'-dev-info');
|
||||||
const sl=$('slot-'+r), lb=$(r+'-label');
|
const sl=$('slot-'+r), lb=$(r+'-label');
|
||||||
sl.classList.toggle('src-on', isSrc && !!port);
|
sl.classList.toggle('dst-on', !!port);
|
||||||
sl.classList.toggle('dst-on', !isSrc && !!port);
|
|
||||||
if(port){
|
if(port){
|
||||||
pp.textContent='Port '+port+(label?' | '+label:'');
|
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:''); }
|
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(){
|
function populateSel(){
|
||||||
const opts=devs.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} - ${d.label||d.device} (${d.size})</option>`).join('');
|
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
|
||||||
['src-select','dst-select'].forEach(id=>{
|
const mkOpts = filter => devs.filter(filter)
|
||||||
const el=$(id),prev=el.value;
|
.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} - ${d.label||d.device} (${d.size})</option>`)
|
||||||
el.innerHTML='<option value="">- Gerät einstecken, dann hier wählen -</option>'+opts;
|
.join('');
|
||||||
if(prev && devs.find(d=>d.usb_port===prev)) el.value=prev;
|
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(){
|
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');
|
const w=$('unassigned-wrap');
|
||||||
if(!list.length){w.style.display='none';return;}
|
if(!list.length){w.style.display='none';return;}
|
||||||
w.style.display='block';
|
w.style.display='block';
|
||||||
@@ -2013,21 +2075,37 @@ function renderUnassigned(){
|
|||||||
</div>`).join('');
|
</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){
|
async function assignPort(role){
|
||||||
const s=role==='source', sid=s?'src-select':'dst-select', lid=s?'src-label':'dst-label';
|
const sid='dst-select', lid='dst-label';
|
||||||
const fid=s?'src-flash':'dst-flash', pk=s?'source_port':'dest_port', lk=s?'source_label':'dest_label';
|
const fid='dst-flash', pk='dest_port', lk='dest_label';
|
||||||
const port=$(sid).value, label=$(lid).value.trim();
|
const port=$(sid).value, label=$(lid).value.trim();
|
||||||
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
|
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
|
||||||
const other=s?cfg.dest_port:cfg.source_port;
|
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err','Port bereits als Quelle konfiguriert!');return;}
|
||||||
if(port===other){flash(fid,'err','Port bereits als '+(s?'Ziel':'Quelle')+' konfiguriert!');return;}
|
|
||||||
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
|
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
|
||||||
await api('/config','POST',cfg);
|
await api('/config','POST',cfg);
|
||||||
flash(fid,'ok','✓ Port '+port+' als '+(s?'Quelle':'Ziel')+' gespeichert.');
|
flash(fid,'ok','✓ Port '+port+' als Ziel gespeichert.');
|
||||||
renderSlot('src',cfg.source_port,cfg.source_label);
|
|
||||||
renderSlot('dst',cfg.dest_port,cfg.dest_label);
|
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');
|
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 --------------------------------------------------------------------
|
// -- Config --------------------------------------------------------------------
|
||||||
async function loadCfg(){
|
async function loadCfg(){
|
||||||
cfg=await api('/config');
|
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-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-time').checked=!!cfg.add_time; $('c-sub').checked=!!cfg.subfolder; $('c-auto').checked=!!cfg.auto_copy;
|
||||||
$('c-filter').value=cfg.file_filter||'';
|
$('c-filter').value=cfg.file_filter||'';
|
||||||
@@ -2179,7 +2261,7 @@ const expl={
|
|||||||
reload(){this.load(this.paths[this.role]);},
|
reload(){this.load(this.paths[this.role]);},
|
||||||
navigate(p){this.load(p);},
|
navigate(p){this.load(p);},
|
||||||
async load(path=''){
|
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');
|
const body=$('expl-body'), bread=$('expl-bread');
|
||||||
if(!port){body.innerHTML='<div class="expl-empty">Kein Port konfiguriert</div>';bread.innerHTML='';return;}
|
if(!port){body.innerHTML='<div class="expl-empty">Kein Port konfiguriert</div>';bread.innerHTML='';return;}
|
||||||
const dev=devs.find(d=>d.usb_port===port);
|
const dev=devs.find(d=>d.usb_port===port);
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.0.22
|
1.0.23
|
||||||
Reference in New Issue
Block a user