mirror of
https://github.com/jstrdev/ps5directo.git
synced 2026-10-29 04:01:51 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e642e0986 | ||
|
|
d8113534ea | ||
|
|
5128f49f8b |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
PS5 DIRECTO 1.18
|
||||
PS5 DIRECTO 1.19
|
||||
================
|
||||
(English: see README_EN.txt)
|
||||
|
||||
@@ -24,8 +24,15 @@ PESTAÑAS
|
||||
luego "Pegar aquí" en la carpeta de destino), borrar, bajar al PC
|
||||
(a Descargas\PS5 Directo), poner permisos 777 y subir archivos o
|
||||
carpetas tal cual a la carpeta en la que estás.
|
||||
Copiar, mover, borrar y calcular tamaños necesitan BFpilot; sin él, el
|
||||
explorador funciona en solo lectura por FTP.
|
||||
Clic derecho en un archivo o en el hueco para el menú; atajos: Supr,
|
||||
F2, Ctrl+C / Ctrl+X / Ctrl+V, Ctrl+A y Retroceso. "Mover a…" y "Copiar
|
||||
a…" abren un selector de carpetas con los destinos recientes. Si al pegar
|
||||
ya existe algo con el mismo nombre, eliges reemplazar/combinar, conservar
|
||||
ambos u omitir. "Extraer…" descomprime en la consola un .rar/.7z/.zip que
|
||||
ya esté allí. A la izquierda tienes las unidades (con su espacio libre) y
|
||||
accesos rápidos. Puedes arrastrar archivos del PC a la carpeta abierta.
|
||||
Sin BFpilot casi todo funciona por FTP (copiar pasa por el PC, más
|
||||
lento); descomprimir y ver el espacio libre sí necesitan BFpilot.
|
||||
- Espacio: gráfico de quesitos con lo que ocupa sitio en la consola, con
|
||||
el nombre de cada juego y su tamaño. Avisa de juegos repetidos (por
|
||||
ejemplo la carpeta y su versión convertida a imagen), envíos a medias y
|
||||
@@ -168,6 +175,36 @@ de quien es la culpa cuando va lento. Tambien hay uno solo PC -> PS5 en
|
||||
Ajustes.
|
||||
|
||||
|
||||
NOVEDADES 1.19
|
||||
- Explorador de archivos renovado: menú de clic derecho, atajos de teclado,
|
||||
ruta por tramos, unidades y accesos a la izquierda, "Mover a…" / "Copiar
|
||||
a…" con destinos recientes, aviso al pegar si ya existe (reemplazar,
|
||||
combinar, conservar ambos u omitir), "Extraer…" de comprimidos que ya
|
||||
están en la consola, y arrastrar archivos del PC a la carpeta abierta.
|
||||
- El explorador ya no necesita BFpilot para casi nada: renombrar, mover,
|
||||
borrar, crear carpetas, permisos, tamaños y copiar funcionan solo con FTP.
|
||||
- Campana de notificaciones arriba: transferencias terminadas o con error,
|
||||
operaciones de archivos, desconexiones de la consola y versiones nuevas.
|
||||
|
||||
NOVEDADES 1.18.4
|
||||
- Arreglado: si dejabas la ventana minimizada o en segundo plano un rato, el
|
||||
programa se cerraba solo y al volver salía "Failed to fetch". Ahora solo se
|
||||
cierra cuando cierras la ventana de verdad.
|
||||
|
||||
NOVEDADES 1.18.3
|
||||
- Arreglado "Failed to fetch" y la cola que aparecía y desaparecía: en Windows
|
||||
podían quedar dos PS5 Directo escuchando en el mismo puerto (por ejemplo al
|
||||
abrirlo dos veces seguidas). Ahora solo puede haber uno abierto.
|
||||
- Si la conexión con el programa falla un instante, se reintenta sola.
|
||||
|
||||
NOVEDADES 1.18.2
|
||||
- Crea automáticamente un acceso directo "PS5 Directo" en el escritorio (y en "Enviar a")
|
||||
con el icono del programa. Si lo borras no vuelve a aparecer; si apuntaba a
|
||||
una copia vieja del programa, se corrige.
|
||||
- La barra de tareas enseña el icono nuevo mientras el programa está abierto.
|
||||
- Si se había quedado abierta una versión antigua (por ejemplo una copia vieja
|
||||
en otra carpeta), al abrir esta se cierra la vieja sola en vez de enseñarla.
|
||||
|
||||
NOVEDADES 1.18.1
|
||||
- Al borrar algo grande se ve cuánto se ha liberado y el tiempo que lleva, y el
|
||||
espacio libre del explorador se actualiza mientras tanto.
|
||||
|
||||
@@ -10,7 +10,8 @@ A Windows file-transfer tool for jailbroken PS5 consoles. It sends files to the
|
||||
- ✅ Extracts on the console (BFpilot) or on the PC (7-Zip, every file CRC-checked)
|
||||
- ✅ Fixes the file permissions that stop BFpilot-extracted content from launching (CE-107750-0)
|
||||
- ✅ Pause, resume and retry where it left off, even after a dropped connection
|
||||
- ✅ Built-in console file browser: sizes, copy, move, rename, delete, download to PC, upload
|
||||
- ✅ Built-in console file browser (works over plain FTP, BFpilot optional): right-click menu, shortcuts, move/copy to…, extract archives on the console, drag & drop uploads, download to PC
|
||||
- ✅ Notification bell: finished or failed transfers, disconnections and new versions
|
||||
- ✅ Storage view: a pie chart of what fills the console, by game name and size, flagging duplicates and leftovers you can delete
|
||||
- ✅ Send whole PC folders over several connections, skipping what's already there and resuming what got cut off
|
||||
- ✅ Watches free space so the console never fills up
|
||||
|
||||
+33
-4
@@ -1,5 +1,5 @@
|
||||
PS5 DIRECTO 1.18 — USER GUIDE (English)
|
||||
=======================================
|
||||
PS5 DIRECTO 1.19 — USER GUIDE (English)
|
||||
=========================================
|
||||
|
||||
Paste links (or drag files from your PC) and the tool does the rest:
|
||||
|
||||
@@ -23,8 +23,15 @@ TABS
|
||||
destination folder), delete, download to the PC (to Downloads\PS5
|
||||
Directo), set 777 permissions, and upload files or folders as-is into
|
||||
the current folder.
|
||||
Copy, move, delete and folder sizes need BFpilot; without it the
|
||||
browser is read-only over FTP.
|
||||
Right-click a file or the empty space for the menu; shortcuts: Del,
|
||||
F2, Ctrl+C / Ctrl+X / Ctrl+V, Ctrl+A and Backspace. "Move to…" and
|
||||
"Copy to…" open a folder picker with recent destinations. If something
|
||||
with the same name already exists when pasting, you choose replace/merge,
|
||||
keep both or skip. "Extract…" unpacks a .rar/.7z/.zip that is already on
|
||||
the console. Drives (with free space) and quick places are on the left.
|
||||
You can drag files from the PC onto the open folder.
|
||||
Without BFpilot almost everything works over FTP (copying goes through
|
||||
the PC, slower); extracting and showing free space do need BFpilot.
|
||||
- Space: a pie chart of what takes up space on the console, with each
|
||||
game's name and size. It flags repeated games (for example a folder and
|
||||
its version converted to an image), half-sent transfers and leftovers,
|
||||
@@ -220,6 +227,28 @@ Settings and queue are stored in %USERPROFILE%\.ps5directo
|
||||
|
||||
WHAT'S NEW
|
||||
----------
|
||||
1.19 - Renewed file browser: right-click menu, keyboard shortcuts,
|
||||
clickable path, drives and places on the left, "Move to…" / "Copy
|
||||
to…" with recent destinations, a choice when pasting over existing
|
||||
items (replace, merge, keep both or skip), "Extract…" for archives
|
||||
already on the console, and dragging files from the PC onto the
|
||||
open folder.
|
||||
- The file browser no longer needs BFpilot for most things: rename,
|
||||
move, delete, new folder, permissions, sizes and copy work over FTP.
|
||||
- Notification bell at the top: finished or failed transfers, file
|
||||
operations, console disconnections and new versions.
|
||||
1.18.4 - Fixed: leaving the window minimized or in the background for a while
|
||||
made the program close itself, showing "Failed to fetch" on return.
|
||||
Now it only closes when you actually close the window.
|
||||
1.18.3 - Fixed "Failed to fetch" and the queue appearing and disappearing: on
|
||||
Windows two PS5 Directo could end up listening on the same port (e.g.
|
||||
when opened twice in a row). Now only one can be open. Brief
|
||||
connection hiccups are retried automatically.
|
||||
1.18.2 - Creates a "PS5 Directo" shortcut on the desktop (and in "Send to")
|
||||
with the program's icon. If you delete it, it won't come back; if it
|
||||
pointed to an old copy, it's fixed. The taskbar shows the new icon.
|
||||
If an old version was still open (e.g. an old copy in another folder),
|
||||
it's closed automatically instead of being shown.
|
||||
1.18.1 - When deleting something big you see how much has been freed and how
|
||||
long it has taken, and the browser's free space updates meanwhile.
|
||||
1.18 - "Space" tab: a pie chart and list of everything that takes up space
|
||||
|
||||
@@ -22,7 +22,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import engine as E # noqa: E402
|
||||
|
||||
VERSION = "1.18.1"
|
||||
VERSION = "1.19.0"
|
||||
# Repositorio de GitHub donde se publican las versiones (usuario/nombre). Si lo rellenas,
|
||||
# todas las copias buscan actualizaciones ahi sin tener que configurar nada.
|
||||
DEFAULT_REPO = "jstrdev/ps5directo"
|
||||
@@ -55,6 +55,8 @@ DEFAULTS = {
|
||||
"delete_archives": True,
|
||||
"tour_done": False, # la guia de bienvenida ya se vio
|
||||
"seen_version": "", # ultima version cuyas novedades ya se ensenaron
|
||||
"shortcut_done": False, # ya se creo el acceso directo del escritorio una vez
|
||||
"icon_ver": "", # version cuyo icono ya se dejo en la cache de la ventana
|
||||
}
|
||||
|
||||
LOCK = threading.RLock()
|
||||
@@ -79,6 +81,7 @@ def _save_json(path, obj):
|
||||
|
||||
def load_state():
|
||||
global JOBS
|
||||
load_notifs()
|
||||
try:
|
||||
with open(SETTINGS_FILE, encoding="utf-8") as f:
|
||||
SETTINGS.update(json.load(f))
|
||||
@@ -573,10 +576,49 @@ def record_history(job):
|
||||
"dest": job.get("dest") or "",
|
||||
})
|
||||
_save_json(HISTORY_FILE, hist[-500:])
|
||||
title = job.get("title") or "Transferencia"
|
||||
link = {"tab": "Transfers", "job": job["id"]}
|
||||
if job.get("status") == "hecho":
|
||||
notify("ok", "Terminado: %s" % title, ", ".join(job.get("result") or [])[:300], link)
|
||||
elif job.get("status") == "error":
|
||||
notify("err", "Error: %s" % title, job.get("error") or "", link)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
NOTIF_FILE = os.path.join(DATA_DIR, "notifications.json")
|
||||
NOTIF_LOCK = threading.Lock()
|
||||
NOTIFS = [] # lo mas nuevo primero
|
||||
|
||||
|
||||
def load_notifs():
|
||||
try:
|
||||
with open(NOTIF_FILE, encoding="utf-8") as f:
|
||||
NOTIFS[:] = json.load(f)[:100]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def save_notifs():
|
||||
with NOTIF_LOCK:
|
||||
data = list(NOTIFS)
|
||||
try:
|
||||
_save_json(NOTIF_FILE, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def notify(level, title, body="", link=None, key=None):
|
||||
"""Aviso para la campana. level: ok, info, warn, err. key evita repetir el mismo aviso."""
|
||||
with NOTIF_LOCK:
|
||||
if key and any(n.get("key") == key for n in NOTIFS):
|
||||
return
|
||||
NOTIFS.insert(0, {"id": secrets.token_hex(4), "t": time.time(), "level": level, "title": title,
|
||||
"body": (body or "")[:600], "link": link, "key": key, "read": False})
|
||||
del NOTIFS[100:]
|
||||
save_notifs()
|
||||
|
||||
|
||||
def read_history():
|
||||
try:
|
||||
with open(HISTORY_FILE, encoding="utf-8") as f:
|
||||
@@ -648,13 +690,177 @@ def fm_bf():
|
||||
return bf
|
||||
|
||||
|
||||
def _fm_start(op, items_total, fn):
|
||||
def fm_bf_opt():
|
||||
"""BFpilot si esta cargado, o None (entonces se hace por FTP)."""
|
||||
bf = E.BFpilot(cfg_snapshot())
|
||||
return bf if bf.alive() else None
|
||||
|
||||
|
||||
# ---- lo mismo por FTP, para cuando BFpilot no esta cargado -------------------
|
||||
|
||||
def _ftp_entries(f, path):
|
||||
"""[(nombre, es_carpeta, tamano)] de una carpeta remota."""
|
||||
out = []
|
||||
try:
|
||||
for name, facts in f.mlsd(path, facts=["type", "size"]):
|
||||
t = facts.get("type")
|
||||
if t in ("file", "dir"):
|
||||
out.append((name, t == "dir", int(facts.get("size") or 0)))
|
||||
finally:
|
||||
try:
|
||||
f.voidcmd("TYPE I")
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _ftp_kind(f, path):
|
||||
"""'dir', 'file' o None."""
|
||||
parent, name = posixpath.dirname(path) or "/", posixpath.basename(path)
|
||||
try:
|
||||
for n, d, _ in _ftp_entries(f, parent):
|
||||
if n == name:
|
||||
return "dir" if d else "file"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _ftp_walk(f, root, cancel=None):
|
||||
"""(archivos [(ruta, rel, tamano)], carpetas [rel]) de una carpeta, de arriba abajo."""
|
||||
files, dirs = [], []
|
||||
stack = [""]
|
||||
while stack:
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
rel = stack.pop()
|
||||
cur = root + ("/" + rel if rel else "")
|
||||
for n, d, sz in _ftp_entries(f, cur):
|
||||
r = (rel + "/" + n).lstrip("/")
|
||||
if d:
|
||||
dirs.append(r)
|
||||
stack.append(r)
|
||||
else:
|
||||
files.append((cur + "/" + n, r, sz))
|
||||
return files, dirs
|
||||
|
||||
|
||||
def ftp_du(cfg, path):
|
||||
def go(f):
|
||||
k = _ftp_kind(f, path)
|
||||
if k == "file":
|
||||
return int(f.size(path) or 0)
|
||||
files, _ = _ftp_walk(f, path)
|
||||
return sum(sz for _, _, sz in files)
|
||||
return E.Ftp(cfg).call(go)
|
||||
|
||||
|
||||
def ftp_rmtree(cfg, path, cancel, on_item=None):
|
||||
def go(f):
|
||||
k = _ftp_kind(f, path)
|
||||
if k is None:
|
||||
return
|
||||
if k == "file":
|
||||
f.delete(path)
|
||||
return
|
||||
files, dirs = _ftp_walk(f, path, cancel)
|
||||
for i, (rp, _, _) in enumerate(files):
|
||||
if cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
if on_item:
|
||||
on_item(rp, i, len(files))
|
||||
f.delete(rp)
|
||||
for d in sorted(dirs, key=lambda x: -x.count("/")):
|
||||
f.rmd(path + "/" + d)
|
||||
f.rmd(path)
|
||||
E.Ftp(cfg).call(go)
|
||||
|
||||
|
||||
def ftp_chmod_tree(cfg, path, cancel):
|
||||
def go(f):
|
||||
n = 1
|
||||
f.sendcmd("SITE CHMOD 777 " + path)
|
||||
if _ftp_kind(f, path) == "dir":
|
||||
files, dirs = _ftp_walk(f, path, cancel)
|
||||
for d in dirs:
|
||||
f.sendcmd("SITE CHMOD 777 %s/%s" % (path, d))
|
||||
for rp, _, _ in files:
|
||||
if cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
f.sendcmd("SITE CHMOD 777 " + rp)
|
||||
n += len(files) + len(dirs)
|
||||
return n
|
||||
return E.Ftp(cfg).call(go)
|
||||
|
||||
|
||||
def ftp_relay_copy(cfg, src, target, cancel, progress):
|
||||
"""Copia dentro de la consola pasando por el PC (sin BFpilot): lee por una conexion FTP
|
||||
y escribe por otra. progress(bytes_hechos, bytes_totales, ruta_actual)."""
|
||||
a = E.Ftp(cfg).connect()
|
||||
b = E.Ftp(cfg).connect()
|
||||
try:
|
||||
k = _ftp_kind(a, src)
|
||||
if k is None:
|
||||
raise E.PipeError("No existe %s" % src)
|
||||
if k == "file":
|
||||
files, dirs = [(src, "", int(a.size(src) or 0))], []
|
||||
else:
|
||||
files, dirs = _ftp_walk(a, src, cancel)
|
||||
E.Ftp.mkdirs(b, target)
|
||||
for d in dirs:
|
||||
E.Ftp.mkdirs(b, target + "/" + d)
|
||||
total = sum(sz for _, _, sz in files)
|
||||
done = 0
|
||||
for rp, rel, sz in files:
|
||||
if cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
dst = target + ("/" + rel if rel else "")
|
||||
E.Ftp.mkdirs(b, posixpath.dirname(dst))
|
||||
cr = a.transfercmd("RETR " + rp)
|
||||
cw = b.transfercmd("STOR " + dst)
|
||||
try:
|
||||
while True:
|
||||
if cancel.is_set():
|
||||
raise E.Cancelled()
|
||||
chunk = cr.recv(E.MiB)
|
||||
if not chunk:
|
||||
break
|
||||
cw.sendall(chunk)
|
||||
done += len(chunk)
|
||||
progress(done, total, rp)
|
||||
finally:
|
||||
cr.close()
|
||||
cw.close()
|
||||
a.voidresp()
|
||||
b.voidresp()
|
||||
got = E.Ftp.size(b, dst)
|
||||
if got is not None and got != sz:
|
||||
raise E.PipeError("La copia de %s salio incompleta (%s de %s)" % (rp, got, sz))
|
||||
finally:
|
||||
for f in (a, b):
|
||||
try:
|
||||
f.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- operaciones largas con progreso -----------------------------------------
|
||||
|
||||
OP_DONE = {"copy": "Copiado", "move": "Movido", "delete": "Borrado", "download": "Descargado al PC",
|
||||
"chmod": "Permisos puestos", "extract": "Extraído"}
|
||||
OP_FAIL = {"copy": "Error al copiar", "move": "Error al mover", "delete": "Error al borrar",
|
||||
"download": "Error al descargar", "chmod": "Error al poner permisos", "extract": "Error al extraer"}
|
||||
|
||||
|
||||
def _fm_start(op, items_total, fn, where=None, names=None):
|
||||
with LOCK:
|
||||
if FMOP["busy"]:
|
||||
raise E.PipeError("Ya hay una operacion en marcha en el explorador")
|
||||
FMOP.update(busy=True, op=op, current="", items_done=0, items_total=items_total, bytes_done=0,
|
||||
bytes_total=0, error="", message="", t0=time.time(), local="")
|
||||
FM_CANCEL.clear()
|
||||
what = ", ".join((names or [])[:3]) + ("…" if names and len(names) > 3 else "")
|
||||
link = {"tab": "Files", "path": where} if where else None
|
||||
|
||||
def run():
|
||||
try:
|
||||
@@ -662,6 +868,10 @@ def _fm_start(op, items_total, fn):
|
||||
with LOCK:
|
||||
if not FMOP["message"]:
|
||||
FMOP["message"] = "Hecho"
|
||||
msg = FMOP["message"]
|
||||
if time.time() - FMOP["t0"] > 8 or op in ("extract",):
|
||||
notify("ok", "%s: %s" % (OP_DONE.get(op, op), what) if what else OP_DONE.get(op, op),
|
||||
msg if msg != "Hecho" else "", link)
|
||||
except E.Cancelled:
|
||||
with LOCK:
|
||||
FMOP["error"] = "Cancelado"
|
||||
@@ -669,59 +879,143 @@ def _fm_start(op, items_total, fn):
|
||||
traceback.print_exc()
|
||||
with LOCK:
|
||||
FMOP["error"] = str(e)
|
||||
notify("err", "%s: %s" % (OP_FAIL.get(op, op), what) if what else OP_FAIL.get(op, op),
|
||||
str(e), link)
|
||||
finally:
|
||||
with LOCK:
|
||||
FMOP["busy"] = False
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def fm_paste(op, srcs, dst, overwrite=False):
|
||||
bf = fm_bf()
|
||||
def _names_in(path):
|
||||
try:
|
||||
return {e["name"]: e["dir"] for e in fm_list(path)["entries"]}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _free_name(name, taken):
|
||||
"""'juego' -> 'juego (2)'; 'a.zip' -> 'a (2).zip'."""
|
||||
base, ext = name, ""
|
||||
m = re.match(r"^(.*?)(\.[A-Za-z0-9]{1,5})$", name)
|
||||
if m and m.group(1):
|
||||
base, ext = m.group(1), m.group(2)
|
||||
k = 2
|
||||
while "%s (%d)%s" % (base, k, ext) in taken:
|
||||
k += 1
|
||||
return "%s (%d)%s" % (base, k, ext)
|
||||
|
||||
|
||||
def fm_conflicts(srcs, dst):
|
||||
dst = fm_clean(dst)
|
||||
have = _names_in(dst)
|
||||
out = []
|
||||
for s in srcs:
|
||||
s = fm_clean(s)
|
||||
n = posixpath.basename(s)
|
||||
if n in have:
|
||||
out.append({"name": n, "dir": have[n], "same": posixpath.dirname(s) == dst})
|
||||
return out
|
||||
|
||||
|
||||
def fm_paste(op, srcs, dst, mode="replace"):
|
||||
"""mode: replace (reemplaza archivos / combina carpetas), skip, keepboth."""
|
||||
dst = fm_clean(dst)
|
||||
srcs = [fm_clean(x) for x in srcs]
|
||||
if mode not in ("replace", "skip", "keepboth"):
|
||||
mode = "replace"
|
||||
for s in srcs:
|
||||
if dst == s or dst.startswith(s + "/"):
|
||||
raise E.PipeError("No se puede %s una carpeta dentro de si misma" % ("mover" if op == "move" else "copiar"))
|
||||
cfg = cfg_snapshot()
|
||||
bf = fm_bf_opt()
|
||||
|
||||
def bf_job(kind, s, d, overwrite):
|
||||
bf._req("POST", "/api/fs/" + kind, form={"src": s, "dst": d, "overwrite": "1" if overwrite else "0"})
|
||||
while True:
|
||||
time.sleep(0.7)
|
||||
if FM_CANCEL.is_set():
|
||||
try:
|
||||
bf._req("GET", "/api/fs/job/cancel")
|
||||
except Exception:
|
||||
pass
|
||||
j = bf._req("GET", "/api/fs/job/status")
|
||||
with LOCK:
|
||||
FMOP.update(bytes_done=int(j.get("copiedBytes") or 0), bytes_total=int(j.get("totalBytes") or 0),
|
||||
current=j.get("current") or s)
|
||||
if not j.get("busy"):
|
||||
if j.get("error"):
|
||||
raise E.PipeError(j["error"])
|
||||
break
|
||||
if FM_CANCEL.is_set():
|
||||
raise E.Cancelled()
|
||||
|
||||
def relay(s, target):
|
||||
def prog(done, total, cur):
|
||||
with LOCK:
|
||||
FMOP.update(bytes_done=done, bytes_total=total, current=cur)
|
||||
ftp_relay_copy(cfg, s, target, FM_CANCEL, prog)
|
||||
|
||||
def work():
|
||||
taken = set(_names_in(dst))
|
||||
for i, s in enumerate(srcs):
|
||||
if FM_CANCEL.is_set():
|
||||
raise E.Cancelled()
|
||||
name = posixpath.basename(s)
|
||||
same_dir = posixpath.dirname(s) == dst
|
||||
with LOCK:
|
||||
FMOP.update(current=s, items_done=i, bytes_done=0, bytes_total=0)
|
||||
if op == "move" and posixpath.dirname(s) == dst:
|
||||
if op == "move" and same_dir:
|
||||
continue
|
||||
bf._req("POST", "/api/fs/" + op, form={"src": s, "dst": dst, "overwrite": "1" if overwrite else "0"})
|
||||
while True:
|
||||
time.sleep(0.7)
|
||||
if FM_CANCEL.is_set():
|
||||
new = name
|
||||
if name in taken:
|
||||
if mode == "skip":
|
||||
continue
|
||||
if mode == "keepboth" or same_dir:
|
||||
new = _free_name(name, taken)
|
||||
taken.add(new)
|
||||
target = dst.rstrip("/") + "/" + new
|
||||
if bf is not None:
|
||||
if new == name:
|
||||
bf_job(op, s, dst, mode == "replace")
|
||||
else:
|
||||
# BFpilot copia con el mismo nombre: a una carpeta temporal y luego se renombra
|
||||
tmp = "%s/.ps5d-%s" % (dst.rstrip("/"), secrets.token_hex(3))
|
||||
bf.mkdir(tmp)
|
||||
try:
|
||||
bf._req("GET", "/api/fs/job/cancel")
|
||||
bf_job(op, s, tmp, False)
|
||||
bf.rename(tmp + "/" + name, target)
|
||||
finally:
|
||||
try:
|
||||
bf.delete(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if op == "move":
|
||||
try:
|
||||
E.Ftp(cfg).call(lambda f: f.rename(s, target))
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
j = bf._req("GET", "/api/fs/job/status")
|
||||
with LOCK:
|
||||
FMOP.update(bytes_done=int(j.get("copiedBytes") or 0), bytes_total=int(j.get("totalBytes") or 0),
|
||||
current=j.get("current") or s)
|
||||
if not j.get("busy"):
|
||||
if j.get("error"):
|
||||
raise E.PipeError(j["error"])
|
||||
break
|
||||
if FM_CANCEL.is_set():
|
||||
raise E.Cancelled()
|
||||
pass # otro disco o ya existe: copia y luego borra
|
||||
relay(s, target)
|
||||
if op == "move":
|
||||
ftp_rmtree(cfg, s, FM_CANCEL)
|
||||
with LOCK:
|
||||
FMOP["items_done"] = len(srcs)
|
||||
_fm_start(op, len(srcs), work)
|
||||
_fm_start(op, len(srcs), work, where=dst, names=[posixpath.basename(x) for x in srcs])
|
||||
|
||||
|
||||
def fm_delete(paths):
|
||||
bf = fm_bf()
|
||||
paths = [fm_clean(x) for x in paths]
|
||||
for p in paths:
|
||||
if p.count("/") < 2:
|
||||
raise E.PipeError("Por seguridad no borro carpetas de primer nivel (%s)" % p)
|
||||
bf = fm_bf_opt()
|
||||
cfg = cfg_snapshot()
|
||||
|
||||
def free_now():
|
||||
if bf is None:
|
||||
return None
|
||||
try:
|
||||
return int(bf._req("GET", "/api/fs/stat", {"path": paths[0].rsplit("/", 1)[0] or "/"},
|
||||
timeout=8).get("availableBytes") or 0)
|
||||
@@ -747,13 +1041,70 @@ def fm_delete(paths):
|
||||
raise E.Cancelled()
|
||||
with LOCK:
|
||||
FMOP.update(current=p, items_done=i)
|
||||
bf.delete(p, FM_CANCEL, on_tick=tick)
|
||||
if bf is not None:
|
||||
bf.delete(p, FM_CANCEL, on_tick=tick)
|
||||
else:
|
||||
ftp_rmtree(cfg, p, FM_CANCEL,
|
||||
lambda rp, k, n: FMOP.update(current=rp))
|
||||
tick()
|
||||
with LOCK:
|
||||
FMOP["items_done"] = len(paths)
|
||||
if FMOP["bytes_done"]:
|
||||
FMOP["message"] = "Hecho: liberados %s" % human(FMOP["bytes_done"])
|
||||
_fm_start("delete", len(paths), work)
|
||||
_fm_start("delete", len(paths), work, where=posixpath.dirname(paths[0]) if paths else None,
|
||||
names=[posixpath.basename(x) for x in paths])
|
||||
|
||||
|
||||
def fm_du(path):
|
||||
path = fm_clean(path)
|
||||
bf = fm_bf_opt()
|
||||
if bf is not None:
|
||||
return bf.du(path, timeout=300)
|
||||
return ftp_du(cfg_snapshot(), path)
|
||||
|
||||
|
||||
def fm_extract(src, folder, password="", exists="keepboth"):
|
||||
"""Descomprime un .rar/.7z/.zip que ya esta en la consola, en una carpeta a su lado."""
|
||||
src = fm_clean(src)
|
||||
bf = E.BFpilot(cfg_snapshot())
|
||||
if not bf.alive():
|
||||
raise E.PipeError("Descomprimir en la consola necesita BFpilot cargado en la PS5")
|
||||
parent, name = posixpath.dirname(src) or "/", posixpath.basename(src)
|
||||
if not E.is_archive(name):
|
||||
raise E.PipeError("Eso no es un comprimido (.rar, .7z o .zip)")
|
||||
have = _names_in(parent)
|
||||
# de un .part3.rar o un .7z.002 se extrae desde el primero
|
||||
group = [n for n in have if not have[n] and E.set_key(n) == E.set_key(name) and E.is_archive(n)]
|
||||
first = E.first_volume(group or [name]) or name
|
||||
src = parent.rstrip("/") + "/" + first
|
||||
folder = (folder or "").strip().strip("/")
|
||||
if not folder or "/" in folder or folder in (".", ".."):
|
||||
raise E.PipeError("Pon un solo nombre de carpeta, sin /")
|
||||
if folder in have: # BFpilot no extrae sobre una carpeta que ya existe
|
||||
folder = _free_name(folder, set(have))
|
||||
dst = parent.rstrip("/") + "/" + folder
|
||||
|
||||
def work():
|
||||
with LOCK:
|
||||
FMOP.update(current=first)
|
||||
|
||||
def st(j):
|
||||
with LOCK:
|
||||
FMOP.update(bytes_done=int(j.get("bytesWritten") or 0), bytes_total=int(j.get("totalBytes") or 0),
|
||||
current=j.get("current") or first)
|
||||
try:
|
||||
j = bf.extract(src, dst, password, FM_CANCEL, st)
|
||||
except E.PipeError as e:
|
||||
if E.is_password_error(str(e)):
|
||||
raise E.PipeError("Contraseña incorrecta o necesaria: vuelve a intentarlo con la contraseña")
|
||||
raise
|
||||
try:
|
||||
bf.fix_perms(dst, FM_CANCEL)
|
||||
except Exception:
|
||||
pass
|
||||
with LOCK:
|
||||
FMOP.update(items_done=1, message="Extraído en %s" % dst)
|
||||
_fm_start("extract", 1, work, where=parent, names=[first])
|
||||
|
||||
|
||||
def fm_download(paths):
|
||||
@@ -811,11 +1162,12 @@ def fm_download(paths):
|
||||
with LOCK:
|
||||
FMOP.update(items_done=len(files), bytes_done=done,
|
||||
message="Guardado en %s" % root_local)
|
||||
_fm_start("download", len(paths), work)
|
||||
_fm_start("download", len(paths), work, names=[posixpath.basename(x) for x in paths])
|
||||
|
||||
|
||||
def fm_chmod(paths):
|
||||
bf = fm_bf()
|
||||
bf = fm_bf_opt()
|
||||
cfg = cfg_snapshot()
|
||||
paths = [fm_clean(x) for x in paths]
|
||||
|
||||
def work():
|
||||
@@ -823,7 +1175,10 @@ def fm_chmod(paths):
|
||||
for i, p in enumerate(paths):
|
||||
with LOCK:
|
||||
FMOP.update(current=p, items_done=i)
|
||||
c, _, _ = bf.fix_perms(p, FM_CANCEL)
|
||||
if bf is not None:
|
||||
c, _, _ = bf.fix_perms(p, FM_CANCEL)
|
||||
else:
|
||||
c = ftp_chmod_tree(cfg, p, FM_CANCEL)
|
||||
changed += c
|
||||
with LOCK:
|
||||
FMOP.update(items_done=len(paths), message="Permisos 777 puestos en %d elementos" % changed)
|
||||
@@ -1004,7 +1359,32 @@ def fm_api(p, b):
|
||||
if p == "/api/fm/list":
|
||||
return fm_list(b.get("path") or "/data")
|
||||
if p == "/api/fm/du":
|
||||
return {"size": fm_bf().du(fm_clean(b.get("path")))}
|
||||
return {"size": fm_du(b.get("path"))}
|
||||
if p == "/api/fm/dirs":
|
||||
path = fm_clean(b.get("path") or "/data")
|
||||
return {"path": path, "dirs": sorted([e["name"] for e in fm_list(path)["entries"] if e["dir"]],
|
||||
key=str.lower)}
|
||||
if p == "/api/fm/conflicts":
|
||||
return {"conflicts": fm_conflicts(b.get("srcs") or [], b.get("dst") or "/")}
|
||||
if p == "/api/fm/extract":
|
||||
fm_extract(b.get("src"), b.get("folder"), str(b.get("password") or ""), b.get("exists") or "keepboth")
|
||||
return {}
|
||||
if p == "/api/fm/drives":
|
||||
bf = fm_bf_opt()
|
||||
out = [{"path": "/data", "name": "Almacenamiento interno"}]
|
||||
if bf is not None:
|
||||
for m in (bf.places() or {}).get("mounts") or []:
|
||||
mp = (m.get("path") or "").rstrip("/")
|
||||
if re.match(r"^/mnt/(usb|ext)\d+$", mp):
|
||||
out.append({"path": mp, "name": ("USB " if "/usb" in mp else "Disco externo ") + mp[-1]})
|
||||
for d in out:
|
||||
try:
|
||||
st = bf._req("GET", "/api/fs/stat", {"path": d["path"]}, timeout=6)
|
||||
d["free"] = st.get("availableBytes")
|
||||
d["total"] = st.get("usableTotalBytes") or st.get("totalBytes")
|
||||
except Exception:
|
||||
pass
|
||||
return {"drives": out, "bfpilot": bf is not None}
|
||||
if p == "/api/fm/mkdir":
|
||||
path = fm_clean(b.get("path"))
|
||||
bf = E.BFpilot(cfg_snapshot())
|
||||
@@ -1015,10 +1395,10 @@ def fm_api(p, b):
|
||||
return {}
|
||||
if p == "/api/fm/rename":
|
||||
src, dst = fm_clean(b.get("src")), fm_clean(b.get("dst"))
|
||||
if posixpath.basename(dst) in _names_in(posixpath.dirname(dst) or "/"):
|
||||
raise E.PipeError("Ya existe algo llamado %s en esa carpeta" % posixpath.basename(dst))
|
||||
bf = E.BFpilot(cfg_snapshot())
|
||||
if bf.alive():
|
||||
if bf.exists(dst):
|
||||
raise E.PipeError("Ya existe %s" % dst)
|
||||
bf.rename(src, dst)
|
||||
else:
|
||||
E.Ftp(cfg_snapshot()).call(lambda f: f.rename(src, dst))
|
||||
@@ -1027,7 +1407,7 @@ def fm_api(p, b):
|
||||
op = b.get("op")
|
||||
if op not in ("copy", "move"):
|
||||
raise E.PipeError("operacion no valida")
|
||||
fm_paste(op, b.get("srcs") or [], b.get("dst"), bool(b.get("overwrite")))
|
||||
fm_paste(op, b.get("srcs") or [], b.get("dst"), b.get("mode") or ("replace" if b.get("overwrite") else "replace"))
|
||||
return {}
|
||||
if p == "/api/fm/delete":
|
||||
fm_delete(b.get("paths") or [])
|
||||
@@ -1053,6 +1433,26 @@ def fm_api(p, b):
|
||||
return {"paths": pick_paths(b.get("kind") or "files", b.get("title") or "")}
|
||||
if p == "/api/history":
|
||||
return {"items": read_history()}
|
||||
if p == "/api/notifs":
|
||||
with NOTIF_LOCK:
|
||||
return {"items": list(NOTIFS), "unread": sum(1 for n in NOTIFS if not n.get("read"))}
|
||||
if p == "/api/notifs/read":
|
||||
with NOTIF_LOCK:
|
||||
for n in NOTIFS:
|
||||
if b.get("all") or n["id"] == b.get("id"):
|
||||
n["read"] = not b.get("unread")
|
||||
save_notifs()
|
||||
return {}
|
||||
if p == "/api/notifs/remove":
|
||||
with NOTIF_LOCK:
|
||||
NOTIFS[:] = [n for n in NOTIFS if n["id"] != b.get("id")]
|
||||
save_notifs()
|
||||
return {}
|
||||
if p == "/api/notifs/clear":
|
||||
with NOTIF_LOCK:
|
||||
NOTIFS[:] = []
|
||||
save_notifs()
|
||||
return {}
|
||||
if p == "/api/history/clear":
|
||||
_save_json(HISTORY_FILE, [])
|
||||
return {}
|
||||
@@ -2057,7 +2457,28 @@ def extract_worker():
|
||||
TOKEN = secrets.token_urlsafe(18)
|
||||
|
||||
|
||||
CONN_STATE = {"ftp": None, "bf": None}
|
||||
|
||||
|
||||
def _conn_change(kind, ok, detail):
|
||||
"""Avisa solo cuando algo que funcionaba deja de funcionar (y cuando vuelve)."""
|
||||
name = "el FTP" if kind == "ftp" else "BFpilot"
|
||||
prev = CONN_STATE[kind]
|
||||
CONN_STATE[kind] = ok
|
||||
if prev is True and not ok:
|
||||
notify("warn", "Se perdió la conexión con %s de la PS5" % name, detail)
|
||||
elif prev is False and ok:
|
||||
notify("info", "Otra vez conectado con %s de la PS5" % name)
|
||||
|
||||
|
||||
def test_connection(cfg):
|
||||
out = _test_connection(cfg)
|
||||
_conn_change("ftp", out["ftp"] == "ok", out["ftp"])
|
||||
_conn_change("bf", str(out["bf"]).startswith("ok"), out["bf"])
|
||||
return out
|
||||
|
||||
|
||||
def _test_connection(cfg):
|
||||
out = {"ftp": None, "bf": None, "free": None}
|
||||
try:
|
||||
f = E.Ftp(cfg).connect()
|
||||
@@ -2561,7 +2982,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.path == "/logo.svg":
|
||||
with open(os.path.join(HERE, "web", "logo.svg"), "rb") as f:
|
||||
return self._send(200, f.read(), "image/svg+xml")
|
||||
if self.path == "/favicon.ico":
|
||||
if self.path.startswith("/icon-") and self.path.split("?")[0].endswith(".png"):
|
||||
n = re.sub(r"\D", "", self.path.split("?")[0])
|
||||
fp = os.path.join(HERE, "web", "icon-%s.png" % n)
|
||||
if n and os.path.isfile(fp):
|
||||
with open(fp, "rb") as f:
|
||||
return self._send(200, f.read(), "image/png")
|
||||
if self.path.split("?")[0] == "/manifest.webmanifest":
|
||||
man = {"name": "PS5 Directo", "short_name": "PS5 Directo", "display": "standalone",
|
||||
"background_color": "#0f1115", "theme_color": "#0f1115", "start_url": "/",
|
||||
"icons": [{"src": "/icon-%d.png?v=%s" % (n, VERSION), "sizes": "%dx%d" % (n, n),
|
||||
"type": "image/png"} for n in (192, 256, 512)]}
|
||||
return self._send(200, json.dumps(man).encode(), "application/manifest+json")
|
||||
if self.path.split("?")[0] == "/favicon.ico":
|
||||
with open(os.path.join(HERE, "web", "ps5directo.ico"), "rb") as f:
|
||||
return self._send(200, f.read(), "image/x-icon")
|
||||
if self.path in ("/", "/index.html") or self.path.startswith("/?"):
|
||||
@@ -2574,6 +3007,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if self.headers.get("X-UI"):
|
||||
UI_SEEN["t"] = time.time()
|
||||
UI_SEEN["bye"] = 0.0
|
||||
with LOCK:
|
||||
jobs = []
|
||||
for j in JOBS:
|
||||
@@ -2581,7 +3015,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
d["has_password"] = bool(j.get("password"))
|
||||
d["pcx"] = (j.get("info") or {}).get("mode") == "pcx"
|
||||
jobs.append(d)
|
||||
st = {"ok": True, "version": VERSION, "settings": dict(SETTINGS), "jobs": jobs,
|
||||
st = {"ok": True, "version": VERSION, "home": HERE,
|
||||
"unread": sum(1 for n in NOTIFS if not n.get("read")), "settings": dict(SETTINGS), "jobs": jobs,
|
||||
"inbox": len(INBOX), "repo": SETTINGS.get("update_repo") or DEFAULT_REPO}
|
||||
st["settings"].pop("ftp_pass", None)
|
||||
return self._send(200, st)
|
||||
@@ -2593,6 +3028,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
or o.startswith("extension://")
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.split("?")[0] == "/api/bye":
|
||||
# la ventana se esta cerrando (sendBeacon no deja poner cabeceras: token en la URL)
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
if (q.get("t") or [""])[0] == TOKEN:
|
||||
UI_SEEN["bye"] = time.time()
|
||||
return self._send(200, {"ok": True})
|
||||
if self.path == "/api/ext/capture":
|
||||
if not self._from_extension():
|
||||
return self._send(403, {"ok": False, "error": "solo desde la extension"})
|
||||
@@ -2643,6 +3084,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
ver, url, notes = github_latest(update_repo())
|
||||
except Exception as e:
|
||||
return self._send(200, {"ok": False, "error": str(e)})
|
||||
if _ver_tuple(ver) > _ver_tuple(VERSION):
|
||||
notify("info", "Hay una versión nueva: %s" % ver, "Pulsa Actualizar arriba para instalarla.",
|
||||
{"action": "update"}, key="update:" + ver)
|
||||
return self._send(200, {"ok": True, "current": VERSION, "latest": ver, "notes": notes,
|
||||
"newer": _ver_tuple(ver) > _ver_tuple(VERSION)})
|
||||
if p == "/api/update/github":
|
||||
@@ -2700,7 +3144,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception:
|
||||
bases = ["/data/homebrew"]
|
||||
return self._send(200, {"ok": True, "dests": [b for b in bases if b.endswith("homebrew")]})
|
||||
if p.startswith("/api/fm/") or p.startswith("/api/space/") or p in ("/api/pick", "/api/history", "/api/history/clear"):
|
||||
if p.startswith("/api/fm/") or p.startswith("/api/space/") or p.startswith("/api/notifs") or p in ("/api/pick", "/api/history", "/api/history/clear"):
|
||||
try:
|
||||
return self._send(200, dict(ok=True, **fm_api(p, b)))
|
||||
except E.PipeError as e:
|
||||
@@ -2879,10 +3323,48 @@ def locate_files(files, budget=12.0, max_depth=6):
|
||||
return found
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
"""En Windows, SO_REUSEADDR deja que DOS programas escuchen en el mismo puerto y las
|
||||
peticiones se reparten al azar entre los dos ("Failed to fetch", la cola que aparece y
|
||||
desaparece). Aqui el puerto es exclusivo."""
|
||||
allow_reuse_address = os.name != "nt"
|
||||
daemon_threads = True
|
||||
|
||||
def server_bind(self):
|
||||
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
super().server_bind()
|
||||
|
||||
|
||||
LOCK_PORT = 8776
|
||||
_instance_lock = []
|
||||
|
||||
|
||||
def take_instance_lock(wait=0.0):
|
||||
"""Solo un PS5 Directo a la vez: se queda con un puerto fijo mientras funciona.
|
||||
Devuelve False si otro ya lo tiene (tras esperar hasta `wait` segundos)."""
|
||||
end = time.time() + wait
|
||||
while True:
|
||||
s = socket.socket()
|
||||
try:
|
||||
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
s.bind(("127.0.0.1", LOCK_PORT))
|
||||
_instance_lock.append(s)
|
||||
return True
|
||||
except OSError:
|
||||
s.close()
|
||||
if time.time() >= end:
|
||||
return False
|
||||
time.sleep(0.25)
|
||||
|
||||
|
||||
def pick_port():
|
||||
for port in (8777, 8778, 8779, 0):
|
||||
s = socket.socket()
|
||||
try:
|
||||
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
s.bind(("127.0.0.1", port))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
@@ -2907,20 +3389,45 @@ def dropped_paths():
|
||||
return out
|
||||
|
||||
|
||||
def running_session():
|
||||
"""Devuelve (url_base, token) si ya hay un PS5 Directo funcionando."""
|
||||
def running_session(full=False):
|
||||
"""Devuelve (url_base, token) si ya hay un PS5 Directo funcionando
|
||||
(con full=True, (url_base, token, estado))."""
|
||||
try:
|
||||
with open(SESSION_FILE, encoding="utf-8") as f:
|
||||
ses = json.load(f)
|
||||
base = "http://127.0.0.1:%d" % int(ses["port"])
|
||||
req = urllib.request.Request(base + "/api/state", headers={"X-Token": ses["token"]})
|
||||
with urllib.request.urlopen(req, timeout=3) as r:
|
||||
json.load(r)
|
||||
return base, ses["token"]
|
||||
st = json.load(r)
|
||||
return (base, ses["token"], st) if full else (base, ses["token"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def replace_other_version(ses):
|
||||
"""Si lo que ya esta abierto es OTRA version (o la de otra carpeta, por ejemplo una copia
|
||||
vieja del escritorio) y no tiene nada en marcha, la cierra para abrir esta.
|
||||
Devuelve True si se cerro."""
|
||||
base, token, st = ses
|
||||
if st.get("version") == VERSION and st.get("home", HERE) == HERE:
|
||||
return False
|
||||
for j in st.get("jobs") or []:
|
||||
if j.get("status") not in ("hecho", "error", "cancelado", "pausado"):
|
||||
return False # esta trabajando: no se le corta
|
||||
try:
|
||||
req = urllib.request.Request(base + "/api/quit", data=b"{}",
|
||||
headers={"X-Token": token, "Content-Type": "application/json"})
|
||||
urllib.request.urlopen(req, timeout=5).read()
|
||||
except Exception:
|
||||
pass
|
||||
for _ in range(40):
|
||||
time.sleep(0.25)
|
||||
if not running_session():
|
||||
print("Cerrada la version %s que seguia abierta" % st.get("version", "antigua"))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def forward_to_running(ses, paths):
|
||||
base, token = ses
|
||||
try:
|
||||
@@ -2965,21 +3472,93 @@ def open_window(url):
|
||||
return None
|
||||
|
||||
|
||||
def _powershell(script, timeout=30):
|
||||
import subprocess
|
||||
return subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
|
||||
|
||||
def ensure_shortcuts():
|
||||
"""Acceso directo "PS5 Directo" en el escritorio y en "Enviar a", con el icono del programa.
|
||||
Se crea la primera vez; despues solo se corrige si apunta a otra carpeta (una copia vieja)."""
|
||||
if os.name != "nt":
|
||||
return
|
||||
try:
|
||||
vbs = os.path.join(HERE, "PS5 Directo.vbs")
|
||||
ico = os.path.join(HERE, "web", "ps5directo.ico")
|
||||
if not os.path.isfile(vbs):
|
||||
return
|
||||
with LOCK:
|
||||
first = not SETTINGS.get("shortcut_done")
|
||||
q = lambda t: "'" + t.replace("'", "''") + "'"
|
||||
script = (
|
||||
"$w=New-Object -ComObject WScript.Shell; $first=$%s;"
|
||||
"foreach($f in @([Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('SendTo'))){"
|
||||
" $p=Join-Path $f 'PS5 Directo.lnk'; $ex=Test-Path -LiteralPath $p;"
|
||||
" $l=$w.CreateShortcut($p);"
|
||||
" $want='\"'+%s+'\"';"
|
||||
" if($ex -and $l.Arguments -eq $want -and $l.IconLocation -like ('*'+%s+'*')){continue}"
|
||||
" if(-not $ex -and -not $first){continue}"
|
||||
" if($ex -and $l.Arguments -notlike '*PS5 Directo.vbs*'){continue}"
|
||||
" $l.TargetPath=(Join-Path $env:WINDIR 'System32\\wscript.exe'); $l.Arguments=$want;"
|
||||
" $l.WorkingDirectory=%s; $l.IconLocation=%s+',0'; $l.Description='PS5 Directo'; $l.Save();"
|
||||
" 'ok '+$p }"
|
||||
) % ("true" if first else "false", q(vbs), q(ico), q(HERE), q(ico))
|
||||
r = _powershell(script)
|
||||
if r.stdout.strip():
|
||||
print("Acceso directo:", r.stdout.strip().replace("\n", " | "))
|
||||
if r.returncode == 0 and first:
|
||||
with LOCK:
|
||||
SETTINGS["shortcut_done"] = True
|
||||
save_settings()
|
||||
except Exception as e:
|
||||
print("No se pudo crear el acceso directo:", e)
|
||||
|
||||
|
||||
def refresh_window_icon():
|
||||
"""La ventana guarda el icono en su cache: tras actualizar se borra para que la barra
|
||||
de tareas enseñe el icono actual y no el de una version vieja."""
|
||||
with LOCK:
|
||||
if SETTINGS.get("icon_ver") == VERSION:
|
||||
return
|
||||
SETTINGS["icon_ver"] = VERSION
|
||||
save_settings()
|
||||
prof = os.path.join(DATA_DIR, "ventana")
|
||||
for sub in ("Default", ""):
|
||||
for name in ("Favicons", "Favicons-journal"):
|
||||
try:
|
||||
os.remove(os.path.join(prof, sub, name))
|
||||
except OSError:
|
||||
pass
|
||||
import shutil
|
||||
for d in ("Web Applications", os.path.join("Default", "Web Applications")):
|
||||
shutil.rmtree(os.path.join(prof, d), ignore_errors=True)
|
||||
|
||||
|
||||
def busy():
|
||||
with LOCK:
|
||||
return any(j["status"] not in ("hecho", "error", "cancelado", "pausado") for j in JOBS)
|
||||
|
||||
|
||||
UI_SEEN = {"t": 0.0}
|
||||
UI_SEEN = {"t": 0.0, "bye": 0.0}
|
||||
|
||||
|
||||
def watch_ui(grace=180):
|
||||
"""Si la ventana lleva un rato cerrada y no hay nada en marcha, se cierra el programa.
|
||||
Si hay trabajo, sigue en segundo plano; abre PS5 Directo otra vez para verlo."""
|
||||
def watch_ui(grace=30, silent=6 * 3600):
|
||||
"""Se cierra el programa cuando cierras la ventana (la ventana avisa al cerrarse) y no hay
|
||||
nada en marcha. Si la ventana solo esta minimizada, el navegador la "duerme" y deja de
|
||||
preguntar: eso NO cuenta como cerrada (antes el programa se apagaba y al volver salia
|
||||
"Failed to fetch"). Por si el navegador se cuelga sin avisar, tras `silent` segundos
|
||||
sin noticias tambien se cierra."""
|
||||
while True:
|
||||
time.sleep(10)
|
||||
seen = UI_SEEN["t"]
|
||||
if seen and time.time() - seen > grace and not busy():
|
||||
seen, bye = UI_SEEN["t"], UI_SEEN["bye"]
|
||||
if busy() or not seen:
|
||||
continue
|
||||
now = time.time()
|
||||
if bye and bye >= seen - 1 and now - bye > grace:
|
||||
os._exit(0)
|
||||
if now - seen > silent:
|
||||
os._exit(0)
|
||||
|
||||
|
||||
@@ -2997,8 +3576,21 @@ def main():
|
||||
if not running_session():
|
||||
break
|
||||
time.sleep(0.5)
|
||||
ses = running_session()
|
||||
ses = running_session(full=True)
|
||||
if ses and replace_other_version(ses):
|
||||
ses = None
|
||||
if not ses and not take_instance_lock(wait=30 if "--after-update" in sys.argv else 4):
|
||||
# otro PS5 Directo esta arrancando ahora mismo (doble clic, por ejemplo): uso ese
|
||||
for _ in range(40):
|
||||
ses = running_session(full=True)
|
||||
if ses:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not ses:
|
||||
print("Ya hay otro PS5 Directo abierto")
|
||||
sys.exit(3)
|
||||
if ses:
|
||||
ses = ses[:2]
|
||||
if paths:
|
||||
forward_to_running(ses, paths)
|
||||
print("Anadido a la cola de PS5 Directo (ya estaba abierto)")
|
||||
@@ -3014,13 +3606,15 @@ def main():
|
||||
for target in (transfer_worker, extract_worker):
|
||||
threading.Thread(target=target, daemon=True).start()
|
||||
port = pick_port()
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
srv = Server(("127.0.0.1", port), Handler)
|
||||
try:
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(SESSION_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"port": srv.server_address[1], "token": TOKEN}, f)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=ensure_shortcuts, daemon=True).start()
|
||||
refresh_window_icon()
|
||||
url = "http://127.0.0.1:%d/#t=%s" % (srv.server_address[1], TOKEN)
|
||||
print("PS5 Directo %s" % VERSION)
|
||||
print("Abre en el navegador: %s" % url)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
+487
-95
@@ -4,7 +4,10 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PS5 Directo</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="icon" href="/favicon.ico?v=1.18.2" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="/icon-256.png?v=1.18.2">
|
||||
<link rel="apple-touch-icon" href="/icon-192.png?v=1.18.2">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0e1116;--panel:#161b22;--panel2:#1c232c;--line:#2a323d;--text:#e6edf3;--muted:#8b97a5;
|
||||
@@ -136,6 +139,78 @@ tr:hover .rowact{opacity:1}
|
||||
@media(max-width:600px){#spListCard th:nth-child(4){width:76px}#spListCard th:nth-child(3){width:72px}}
|
||||
#spBody td{overflow-wrap:anywhere}
|
||||
@media(max-width:600px){#spBody td:last-child button{display:block;width:100%;margin:3px 0}#spBody td:last-child{white-space:normal}}
|
||||
.fm-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:18px;margin-top:14px}
|
||||
.fm-side{display:flex;flex-direction:column;gap:2px;border-right:1px solid var(--line);padding-right:12px}
|
||||
.side-h{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:10px 8px 4px}
|
||||
.side-h:first-child{margin-top:0}
|
||||
.side-i{display:flex;gap:9px;align-items:flex-start;text-align:left;background:none;border:1px solid transparent;border-radius:8px;padding:7px 8px;color:var(--text);font-size:13.5px;width:100%}
|
||||
.side-i:hover{background:rgba(255,255,255,.04);border-color:transparent}
|
||||
.side-i.on{background:rgba(61,139,253,.14);color:#dbe7ff}
|
||||
.side-i svg{flex:none;margin-top:2px;color:var(--muted)}
|
||||
.side-i>span{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
|
||||
.side-i.small-i>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.side-i small{color:var(--muted);font-size:11.5px}
|
||||
.side-i.small-i{font-size:12.5px;color:var(--muted);padding:5px 8px}
|
||||
.mini{display:block;height:4px;border-radius:3px;background:var(--bg);overflow:hidden}
|
||||
.mini b{display:block;height:100%;background:var(--accent)}
|
||||
.fm-main{min-width:0}
|
||||
.fm-main .fm-bar{margin-top:0}
|
||||
.crumbs{flex:1;min-width:200px;display:flex;align-items:center;gap:2px;flex-wrap:wrap;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:3px 6px;min-height:36px;cursor:text}
|
||||
.crumb{background:none;border:1px solid transparent;border-radius:6px;padding:3px 7px;font:13px Consolas,"Cascadia Mono",monospace;color:#bcd0ee}
|
||||
.crumb:hover{background:rgba(255,255,255,.06);border-color:transparent}
|
||||
.crumb.cur{color:var(--text);font-weight:600}
|
||||
.csep{color:var(--muted);font-size:12px}
|
||||
.selinfo{font-size:12.5px;color:var(--muted);margin-right:2px}
|
||||
.fmtable tr.cut td{opacity:.55}
|
||||
.fmtable th.sorted{color:var(--text)}
|
||||
.dots{padding:2px 9px;font-size:15px;line-height:1;opacity:.6}
|
||||
tr:hover .dots{opacity:1}
|
||||
.fm-drop{position:relative;border-radius:10px}
|
||||
.fm-drop.over{outline:2px dashed var(--accent);outline-offset:4px}
|
||||
.fm-drop.over::after{content:attr(data-drop);position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(14,17,22,.75);color:var(--text);font-weight:600;border-radius:10px;pointer-events:none}
|
||||
.keys{font-size:11.5px;opacity:.7}
|
||||
.ctxmenu{position:fixed;z-index:60;min-width:210px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:5px;box-shadow:0 12px 32px rgba(0,0,0,.5)}
|
||||
.ctxmenu button{display:flex;justify-content:space-between;gap:18px;width:100%;text-align:left;background:none;border:none;border-radius:6px;padding:7px 10px;font-size:13.5px}
|
||||
.ctxmenu button:hover,.ctxmenu button:focus{background:rgba(61,139,253,.18);outline:none}
|
||||
.ctxmenu button.danger{color:#ffb4ae}
|
||||
.ctxmenu kbd{font:11.5px "Segoe UI",system-ui,sans-serif;color:var(--muted)}
|
||||
.ctxmenu hr{border:none;border-top:1px solid var(--line);margin:4px 2px}
|
||||
.ctxmenu hr:first-child,.ctxmenu hr:last-child,.ctxmenu hr+hr{display:none}
|
||||
.pick-top{display:flex;gap:6px;align-items:center;margin-top:10px}
|
||||
.pick-cols{display:grid;grid-template-columns:200px minmax(0,1fr);gap:12px;margin-top:10px;height:min(360px,48vh)}
|
||||
.pick-side{overflow:auto;border-right:1px solid var(--line);padding-right:8px}
|
||||
.pick-list{overflow:auto;border:1px solid var(--line);border-radius:8px;padding:4px}
|
||||
.pick-i{display:flex;gap:9px;align-items:center;width:100%;text-align:left;background:none;border:none;border-radius:6px;padding:7px 9px;font-size:13.5px}
|
||||
.pick-i:hover{background:rgba(255,255,255,.05)}
|
||||
.pick-i[disabled]{opacity:.4;cursor:default}
|
||||
.pick-foot{display:flex;gap:8px;align-items:center;margin-top:12px}
|
||||
.cf-list{margin:8px 0 14px;padding:0;list-style:none;max-height:180px;overflow:auto}
|
||||
.cf-list li{display:flex;gap:8px;align-items:center;padding:4px 0;font-size:13.5px}
|
||||
.cf-opts{display:flex;gap:8px;flex-wrap:wrap}
|
||||
#dlgConflict,#dlgExtract{width:min(520px,94vw)}
|
||||
#dlgExtract input{margin-bottom:2px}
|
||||
.bell{position:relative;padding:5px 9px}
|
||||
.bell svg{display:block}
|
||||
.bell.has{color:#fff}
|
||||
.badge{position:absolute;top:-6px;right:-6px;min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:var(--err);color:#fff;font-size:10.5px;font-weight:700;display:flex;align-items:center;justify-content:center}
|
||||
.npanel{position:fixed;top:64px;right:16px;z-index:55;width:min(400px,calc(100vw - 32px));max-height:min(560px,75vh);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.55)}
|
||||
.nhead{display:flex;gap:6px;align-items:center;padding:12px 14px;border-bottom:1px solid var(--line)}
|
||||
.nhead h2{margin:0;font-size:15px}
|
||||
.nhead button{white-space:nowrap;padding:4px 8px;font-size:12px}
|
||||
#nList{overflow:auto;padding:6px}
|
||||
.nitem{display:flex;gap:10px;align-items:flex-start;padding:9px 10px;border-radius:8px}
|
||||
.nitem.link{cursor:pointer}
|
||||
.nitem:hover{background:rgba(255,255,255,.04)}
|
||||
.nitem.fresh{background:rgba(61,139,253,.09)}
|
||||
.ndot{flex:none;width:9px;height:9px;border-radius:50%;margin-top:5px;background:var(--muted)}
|
||||
.nitem.ok .ndot{background:var(--ok)}.nitem.err .ndot{background:var(--err)}.nitem.warn .ndot{background:var(--warn)}.nitem.info .ndot{background:var(--accent)}
|
||||
.nbody{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;font-size:13.5px}
|
||||
.nbody span{color:var(--muted);font-size:12.5px;word-break:break-word}
|
||||
.nbody small{color:var(--muted);font-size:11.5px;opacity:.8}
|
||||
.nx{background:none;border:none;color:var(--muted);font-size:17px;padding:0 4px;line-height:1;opacity:0}
|
||||
.nitem:hover .nx{opacity:1}
|
||||
.job.flash{outline:2px solid var(--accent);outline-offset:2px}
|
||||
@media(max-width:860px){.fm-layout{grid-template-columns:1fr}.fm-side{border-right:none;padding-right:0;flex-direction:row;flex-wrap:wrap;gap:4px}.side-h{display:none}.side-i{width:auto;padding:5px 9px;border:1px solid var(--line)}.side-i small,.side-i .mini{display:none}.pick-cols{grid-template-columns:1fr}.pick-side{display:none}}
|
||||
.acttable{width:100%;border-collapse:collapse;font-size:13.5px;margin-top:10px}
|
||||
.acttable th{color:var(--muted);font-weight:600;font-size:12px;text-align:left;padding:8px;border-bottom:1px solid var(--line)}
|
||||
.acttable td{padding:8px;border-bottom:1px solid rgba(42,50,61,.6);vertical-align:top}
|
||||
@@ -175,6 +250,7 @@ tr:hover .rowact{opacity:1}
|
||||
<span class="pill" id="pFtp"><span class="dot"></span>FTP</span>
|
||||
<span class="pill" id="pBf"><span class="dot"></span>BFpilot</span>
|
||||
<span class="pill" id="pFree" hidden></span>
|
||||
<button class="small bell" id="btnBell" title="Notificaciones"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg><span class="badge" id="bellN" hidden></span></button>
|
||||
<button class="small" id="btnLang" data-noi18n title="Idioma / Language"></button>
|
||||
<button class="small" id="btnTour" title="Guía rápida: para qué es cada cosa">Guía</button>
|
||||
<button class="small" id="btnExtHead" title="Capturar enlaces desde el navegador sin copiar y pegar">Extensión</button>
|
||||
@@ -182,6 +258,10 @@ tr:hover .rowact{opacity:1}
|
||||
<button class="small" id="btnSettings">Ajustes</button>
|
||||
<button class="small" id="btnQuit" title="Cierra PS5 Directo del todo">Salir</button>
|
||||
</header>
|
||||
<div class="npanel" id="nPanel" hidden>
|
||||
<div class="nhead"><h2>Notificaciones</h2><div class="spacer"></div><button class="small" id="nAllRead">Marcar leídos</button><button class="small" id="nClear">Borrar todo</button></div>
|
||||
<div id="nList"></div>
|
||||
</div>
|
||||
<nav class="tabs" id="tabs">
|
||||
<button data-tab="Transfers" class="on">Transferencias</button>
|
||||
<button data-tab="Files">Archivos de la consola</button>
|
||||
@@ -241,42 +321,51 @@ tr:hover .rowact{opacity:1}
|
||||
<div class="spacer"></div>
|
||||
<div class="store" id="fmStore"></div>
|
||||
</div>
|
||||
<div class="fm-bar">
|
||||
<button class="small iconbtn" id="fmBack" title="Atrás">←</button>
|
||||
<button class="small iconbtn" id="fmFwd" title="Adelante">→</button>
|
||||
<button class="small iconbtn" id="fmUp" title="Subir un nivel">↑</button>
|
||||
<input id="fmPath" value="/data/homebrew" spellcheck="false">
|
||||
<select id="fmPlaces" title="Ir a"><option value="">Ir a…</option></select>
|
||||
<input id="fmSearch" placeholder="Buscar aquí…">
|
||||
<button class="small iconbtn" id="fmRefresh" title="Actualizar">⟳</button>
|
||||
<div class="fm-layout">
|
||||
<aside class="fm-side" id="fmSide"></aside>
|
||||
<div class="fm-main">
|
||||
<div class="fm-bar">
|
||||
<button class="small iconbtn" id="fmBack" title="Atrás (Alt+←)">←</button>
|
||||
<button class="small iconbtn" id="fmFwd" title="Adelante (Alt+→)">→</button>
|
||||
<button class="small iconbtn" id="fmUp" title="Subir un nivel (Retroceso)">↑</button>
|
||||
<div class="crumbs" id="fmCrumbs" title="Haz clic en el hueco para escribir una ruta"></div>
|
||||
<input id="fmPath" value="/data/homebrew" spellcheck="false" hidden>
|
||||
<input id="fmSearch" placeholder="Buscar aquí…">
|
||||
<button class="small iconbtn" id="fmRefresh" title="Actualizar (F5)">⟳</button>
|
||||
</div>
|
||||
<div class="fm-actions">
|
||||
<button class="small" id="fmMkdir">Nueva carpeta</button>
|
||||
<button class="small" id="fmUpFiles">Subir archivos aquí</button>
|
||||
<button class="small" id="fmUpFolder">Subir carpeta aquí</button>
|
||||
<span class="sep"></span>
|
||||
<span class="selinfo" id="fmSelInfo"></span>
|
||||
<button class="small" id="fmCopy" disabled title="Ctrl+C">Copiar</button>
|
||||
<button class="small" id="fmCut" disabled title="Ctrl+X">Cortar</button>
|
||||
<button class="small" id="fmMoveTo" disabled>Mover a…</button>
|
||||
<button class="small" id="fmRename" disabled title="F2">Renombrar</button>
|
||||
<button class="small" id="fmDownload" disabled>Descargar al PC</button>
|
||||
<button class="small" id="fmMore" disabled>Más…</button>
|
||||
<button class="small danger" id="fmDelete" disabled title="Supr">Borrar</button>
|
||||
</div>
|
||||
<div class="clip" id="fmClip" hidden></div>
|
||||
<div class="fmop" id="fmOp" hidden></div>
|
||||
<div class="fm-drop" id="fmTableWrap">
|
||||
<table class="fmtable">
|
||||
<thead><tr>
|
||||
<th style="width:28px"><input type="checkbox" id="fmAll"></th>
|
||||
<th data-sort="name">Nombre</th>
|
||||
<th data-sort="size" class="num">Tamaño</th>
|
||||
<th data-sort="mtime" class="hide-s">Modificado</th>
|
||||
<th class="hide-s">Permisos</th>
|
||||
<th style="width:40px"></th>
|
||||
</tr></thead>
|
||||
<tbody id="fmBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint" id="fmHint" style="margin-top:10px"></p>
|
||||
<p class="hint keys">Atajos: doble clic abre · clic derecho para el menú · Supr borra · F2 renombra · Ctrl+C / Ctrl+X / Ctrl+V · Ctrl+A selecciona todo · Retroceso sube</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fm-actions">
|
||||
<button class="small" id="fmMkdir">Nueva carpeta</button>
|
||||
<button class="small" id="fmUpFiles">Subir archivos aquí</button>
|
||||
<button class="small" id="fmUpFolder">Subir carpeta aquí</button>
|
||||
<span class="sep"></span>
|
||||
<button class="small" id="fmCopy" disabled>Copiar</button>
|
||||
<button class="small" id="fmCut" disabled>Mover</button>
|
||||
<button class="small" id="fmRename" disabled>Renombrar</button>
|
||||
<button class="small" id="fmDownload" disabled>Descargar al PC</button>
|
||||
<button class="small" id="fmSize" disabled>Calcular tamaño</button>
|
||||
<button class="small" id="fmPerms" disabled>Permisos 777</button>
|
||||
<button class="small danger" id="fmDelete" disabled>Borrar</button>
|
||||
</div>
|
||||
<div class="clip" id="fmClip" hidden></div>
|
||||
<div class="fmop" id="fmOp" hidden></div>
|
||||
<table class="fmtable">
|
||||
<thead><tr>
|
||||
<th style="width:28px"><input type="checkbox" id="fmAll"></th>
|
||||
<th data-sort="name">Nombre</th>
|
||||
<th data-sort="size" class="num">Tamaño</th>
|
||||
<th data-sort="mtime" class="hide-s">Modificado</th>
|
||||
<th class="hide-s">Permisos</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody id="fmBody"></tbody>
|
||||
</table>
|
||||
<p class="hint" id="fmHint" style="margin-top:10px"></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -307,6 +396,44 @@ tr:hover .rowact{opacity:1}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="ctxmenu" id="fmMenu" hidden></div>
|
||||
<dialog id="dlgPick">
|
||||
<div class="head"><h2 id="pickTitle">Mover a…</h2><div class="spacer"></div><button class="small" id="pickX">Cerrar</button></div>
|
||||
<div class="body">
|
||||
<div class="pick-top"><button class="small iconbtn" id="pickUp" title="Subir un nivel">↑</button><div class="crumbs" id="pickCrumbs"></div></div>
|
||||
<div class="pick-cols">
|
||||
<div class="pick-side" id="pickSide"></div>
|
||||
<div class="pick-list" id="pickList"></div>
|
||||
</div>
|
||||
<p class="hint" id="pickNote"></p>
|
||||
<div class="pick-foot"><button class="small" id="pickNew">Nueva carpeta</button><div class="spacer"></div><button class="primary" id="pickGo">Mover aquí</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="dlgConflict">
|
||||
<div class="head"><h2 id="cfTitle">Ya existe</h2></div>
|
||||
<div class="body">
|
||||
<p id="cfBody"></p>
|
||||
<ul class="cf-list" id="cfList" data-noi18n></ul>
|
||||
<div class="cf-opts">
|
||||
<button class="primary" data-m="replace" id="cfReplace">Reemplazar / combinar</button>
|
||||
<button data-m="keepboth">Conservar ambos</button>
|
||||
<button data-m="skip">Omitir</button>
|
||||
<button data-m="">Cancelar</button>
|
||||
</div>
|
||||
<p class="hint" id="cfHint">Reemplazar sobrescribe los archivos con el mismo nombre. Si es una carpeta, se combina: lo que ya tenía y no estás pegando se queda. Conservar ambos le pone (2) al nuevo.</p>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="dlgExtract">
|
||||
<div class="head"><h2 id="exTitle">Extraer</h2></div>
|
||||
<div class="body">
|
||||
<p class="hint">La consola lo descomprime en una carpeta al lado del archivo (con BFpilot) y deja el comprimido como está. Nada pasa por el PC.</p>
|
||||
<label>Carpeta</label><input id="exFolder" spellcheck="false">
|
||||
<label style="margin-top:10px">Contraseña (solo si tiene)</label><input id="exPw" spellcheck="false" autocomplete="off">
|
||||
<p class="hint" id="exNote"></p>
|
||||
<div class="pick-foot"><div class="spacer"></div><button id="exCancel">Cancelar</button><button class="primary" id="exGo">Extraer</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="dlgNews">
|
||||
<div class="head"><h2 id="newsTitle">Novedades</h2><div class="spacer"></div></div>
|
||||
<div class="body"><div id="newsBody"></div>
|
||||
@@ -403,8 +530,8 @@ tr:hover .rowact{opacity:1}
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
const I18N_EXACT={"Novedades": "What's new", "Entendido": "Got it", "Se ha actualizado. Esto es lo nuevo:": "PS5 Directo has been updated. Here is what's new:", "Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Espacio te enseña en un gráfico qué ocupa más y qué puedes borrar. Actividad guarda el historial.": "Transfers is the queue. In Console files you browse the PS5: see sizes, copy, move, rename, delete, download to the PC or upload files and folders. Space shows in a chart what takes up the most room and what you can delete. Activity keeps the history.", "Espacio": "Space", "Almacenamiento interno": "Internal storage", "Analizar": "Analyze", "Mira qué ocupa sitio en la consola, con nombres y tamaños, para decidir qué borrar. Avisa de juegos repetidos (por ejemplo la carpeta y su versión convertida), envíos a medias y restos. Necesita BFpilot.": "See what takes up space on the console, with names and sizes, to decide what to delete. It flags repeated games (for example a folder and its converted version), half-sent transfers and leftovers. Needs BFpilot.", "Todo lo que ocupa sitio": "Everything that takes up space", "Borrar seleccionados": "Delete selected", "Disco externo": "External drive", "Libre": "Free", "Abrir": "Open", "Borrando…": "Deleting…", "¿Borrar de la consola? No se puede deshacer.": "Delete from the console? This cannot be undone.", "Juegos instalados desde la PS5": "Games installed from the PS5", "Sistema y otros datos de la consola": "System and other console data", "Otros": "Other", "Borrado. Vuelvo a medir el espacio…": "Deleted. Measuring the space again…", "Envía archivos a tu consola por la red local, sin pasar por el disco del PC": "Send files to your console over your local network, without going through your PC's disk", "Guía": "Guide", "Guía rápida: para qué es cada cosa": "Quick guide: what each part does", "Extensión": "Extension", "Capturar enlaces desde el navegador sin copiar y pegar": "Capture links from your browser without copy and paste", "Actualizar": "Update", "Buscar o instalar una versión nueva": "Check for or install a new version", "Ajustes": "Settings", "Salir": "Quit", "Cierra PS5 Directo del todo": "Closes PS5 Directo completely", "Nueva transferencia": "New transfer", "Enlaces directos (uno por línea). Pega todas las partes juntas: .part1.rar, .part2.rar… o .001, .002…": "Direct links (one per line). Paste all parts together: .part1.rar, .part2.rar… or .001, .002…", "https://servidor/archivo.part1.rar": "https://server/file.part1.rar", "https://servidor/archivo.part2.rar": "https://server/file.part2.rar", "También vale la ruta de un archivo de tu PC, por ejemplo: D:\\Descargas\\archivo.rar": "A file path on your PC also works, for example: D:\\Downloads\\file.rar", "Archivos de tu PC: suéltalos en esta página y los busco en tu PC, o arrástralos sobre el": "Files on your PC: drop them on this page and I'll find them on your PC, or drag them onto the", "icono de PS5 Directo": "PS5 Directo icon", ", o clic derecho →": ", or right-click →", "Enviar a → PS5 Directo": "Send to → PS5 Directo", "Contraseña del comprimido (si la tiene)": "Archive password (if any)", "Opcional. Si la dejas vacía prueba las de Ajustes y las del nombre": "Optional. If empty, it tries the ones in Settings and the one in the file name", "Destino en la PS5": "Destination on the PS5", "Dónde descomprimir": "Where to extract", "En la PS5 (BFpilot)": "On the PS5 (BFpilot)", "En el PC (7-Zip)": "On the PC (7-Zip)", "Añadir a la cola": "Add to queue", "Si pegas enlaces de varios archivos distintos, los separa solo. «En el PC» usa 7-Zip y manda el contenido ya descomprimido y comprobado (más datos por el Wi-Fi, pero sin BFpilot).": "If you paste links to several different files, it splits them automatically. \"On the PC\" uses 7-Zip and sends the content already extracted and verified (more data over Wi-Fi, but no BFpilot needed).", "Cola": "Queue", "Quitar terminados": "Clear finished", "No hay nada en la cola. Pega enlaces arriba.": "The queue is empty. Paste links above.", "Transferencia": "Transfer", "Pausar": "Pause", "Reanudar": "Resume", "Cancelar": "Cancel", "Reintentar": "Retry", "Quitar": "Remove", "Test de velocidad": "Speed test", "Mide descarga, envío y las dos cosas a la vez": "Measures download, upload and both at once", "Bajar al PC": "Download to PC", "Descomprimir y enviar": "Extract and send", "Colocar": "Place", "Listo": "Done", "Enviar": "Send", "Descomprimir": "Extract", "Enviado": "Sent", "de": "of", "Velocidad a la PS5": "Speed to PS5", "Quedan": "Remaining", "Descompresión en la PS5": "Extraction on the PS5", "Archivos y registro": "Files and log", "Copiar registro": "Copy log", "Registro copiado. Pégalo donde quieras (Ctrl+V).": "Log copied. Paste it anywhere (Ctrl+V).", "Registro copiado.": "Log copiLine truncated
|
||||
const I18N_RX=[["^Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo \\(solo el cifrado ZIP clasico\\)\\. Los archivos ya estan en la PS5, en ([\\s\\S]*?)\\. Puedes bajarlos al PC, extraerlos con 7-Zip y subir la carpeta\\.$", "This ZIP uses AES encryption and BFpilot can't open it yet (only classic ZIP encryption). The files are already on the PS5, in {0}. You can download them to the PC, extract them with 7-Zip and upload the folder."], ["^Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y quedan ([\\s\\S]*?) libres\\. Puede bastar, pero si el espacio baja del margen lo paro\\. Si puedes, libera espacio\\.$", "Warning: extracted content can take up to ~5 times the RAR (~{0}) and {1} is free. It may be enough, but if space drops below the margin I stop. Free up space if you can."], ["^Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y no se si cabra\\. Si al descomprimir baja del margen, lo paro y los RAR se quedan en la PS5\\.$", "Heads up: extracted content can take up to ~5 times the RAR (~{0}) and it may not fit. If space drops below the margin while extracting, I stop and the RARs stay on the PS5."], ["^AVISO: no pude poner los permisos de ([\\s\\S]*?) \\(([\\s\\S]*?)\\)\\. Si no arranca, en FileZilla: clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios\\.$", "WARNING: couldn't set permissions on {0} ({1}). If it doesn't launch, in FileZilla: right-click the folder > File permissions > 777 > recurse into subdirectories."], ["^No cabe en el PC: hay que bajar ([\\s\\S]*?) a ([\\s\\S]*?) y solo hay ([\\s\\S]*?) libres\\. Libera espacio, cambia la carpeta temporal en Ajustes o descomprime en la PS5\\.$", "Not enough space on the PC: {0} must be downloaded to {1} and only {2} is free. Free up space, change the temporary folder in Settings, or extract on the PS5."], ["^No cabe en la PS5: descomprimido ocupa ([\\s\\S]*?), faltan por mandar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: extracted it takes {0}, {1} left to send, {2} free and I keep {3} as margin. Free up space and click Retry."], ["^No hay sitio en la PS5: faltan por enviar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen \\(Ajustes\\)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} left to send, {1} free and I keep {2} as margin (Settings). Free up space and click Retry."], ["^La IP de la PS5 no es valida \\(([\\s\\S]*?)\\): en Ajustes escribe solo los numeros, por ejemplo 192\\.168\\.1\\.50, sin ftp:\\/\\/ ni puerto$", "The PS5 IP is not valid ({0}): in Settings type just the numbers, for example 192.168.1.50, without ftp:// or a port"], ["^Parado para no llenar la PS5 \\(([\\s\\S]*?)\\): quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar; sigue donde iba\\.$", "Stopped so the PS5 doesn't fill up ({0}): {1} free and the margin is {2}. Free up space and click Retry; it continues where it left off."], ["^BFpilot no abre ZIP partidos \\(\\.z01, \\.z02\\.\\.\\.\\)\\. Uno los RAR\\/7z partidos, pero este formato no\\. Los archivos estan en ([\\s\\S]*?)\\.$", "BFpilot can't open split ZIPs (.z01, .z02...). I can join split RAR/7z, but not this format. The files are in {0}."], ["^No cabe en la PS5: faltan ([\\s\\S]*?) por enviar, hay ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "It doesn't fit on the PS5: {0} left to send, {1} free and the margin is {2}. Free up space and click Retry."], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.([\\s\\S]*?)$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one).{2}"], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one)."], ["^El comprimido tiene enlaces \\(([\\s\\S]*?)\\); eso solo lo sabe extraer BFpilot\\. Descomprimelo en la PS5\\.$", "The archive contains links ({0}); only BFpilot can extract those. Extract it on the PS5."], ["^No hay sitio en la PS5: quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} free and the margin is {1}. Free up space and click Retry."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo extraido esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was extracted is in {1}"], ["^Recibido de la extensión: ([\\s\\S]*?)\\. Cuando estén todas las partes, pulsa \"Añadir a la cola\"\\.$", "Received from the extension: {0}. When all parts are iLine truncated
|
||||
const I18N_EXACT={"Atrás (Alt+←)": "Back (Alt+←)", "Adelante (Alt+→)": "Forward (Alt+→)", "Subir un nivel (Retroceso)": "Up one level (Backspace)", "Haz clic en el hueco para escribir una ruta": "Click the empty space to type a path", "Actualizar (F5)": "Refresh (F5)", "Cortar": "Cut", "Mover a…": "Move to…", "Copiar a…": "Copy to…", "Más…": "More…", "Más acciones": "More actions", "Atajos: doble clic abre · clic derecho para el menú · Supr borra · F2 renombra · Ctrl+C / Ctrl+X / Ctrl+V · Ctrl+A selecciona todo · Retroceso sube": "Shortcuts: double-click opens · right-click for the menu · Del deletes · F2 renames · Ctrl+C / Ctrl+X / Ctrl+V · Ctrl+A selects all · Backspace goes up", "Mover aquí": "Move here", "Copiar aquí": "Copy here", "Nueva carpeta": "New folder", "Ya existe": "Already exists", "Reemplazar / combinar": "Replace / merge", "Reemplazar": "Replace", "Combinar": "Merge", "Conservar ambos": "Keep both", "Omitir": "Skip", "Reemplazar sobrescribe los archivos con el mismo nombre. Si es una carpeta, se combina: lo que ya tenía y no estás pegando se queda. Conservar ambos le pone (2) al nuevo.": "Replace overwrites files with the same name. Folders are merged: whatever they already had that you are not pasting stays. Keep both adds (2) to the new one.", "¿Qué hago con los que ya existen en la carpeta de destino?": "What should I do with the ones that already exist in the destination folder?", "Extraer": "Extract", "Extraer…": "Extract…", "Carpeta": "Folder", "Contraseña (solo si tiene)": "Password (only if it has one)", "La consola lo descomprime en una carpeta al lado del archivo (con BFpilot) y deja el comprimido como está. Nada pasa por el PC.": "The console extracts it into a folder next to the archive (with BFpilot) and leaves the archive as it is. Nothing goes through the PC.", "Notificaciones": "Notifications", "Marcar leídos": "Mark all read", "Borrar todo": "Clear all", "Descartar": "Dismiss", "Unidades": "Drives", "Accesos": "Places", "Recientes": "Recent", "Destinos recientes": "Recent destinations", "libres": "free", "Almacenamiento interno": "Internal storage", "Aplicaciones (homebrew)": "Apps (homebrew)", "Datos (/data)": "Data (/data)", "Paquetes (/data/pkg)": "Packages (/data/pkg)", "Restos de PS5 Directo": "PS5 Directo leftovers", "BFpilot no está cargado: el explorador va por FTP. Copiar pasa por el PC (más lento), y para descomprimir o ver el espacio libre hace falta BFpilot.": "BFpilot is not loaded: the browser works over FTP. Copying goes through the PC (slower), and extracting or showing free space needs BFpilot.", "desde": "from", "Pegar dentro": "Paste inside", "Pegar aquí": "Paste here", "Subir archivos aquí": "Upload files here", "Subir carpeta aquí": "Upload folder here", "Actualizar la carpeta": "Refresh folder", "Supr": "Del", "Copiar ruta de esta carpeta": "Copy this folder's path", "Abrir": "Open", "Descargar al PC": "Download to PC", "Copiar": "Copy", "Renombrar": "Rename", "Calcular tamaño": "Calculate size", "Permisos 777": "Permissions 777", "Copiar ruta": "Copy path", "Copiar rutas": "Copy paths", "Borrar": "Delete", "Ve a la carpeta de destino y pulsa \"Pegar aquí\" (o Ctrl+V)": "Go to the destination folder and click \"Paste here\" (or Ctrl+V)", "Ruta copiada": "Path copied", "El nombre no puede llevar / ni \\": "The name cannot contain / or \\", "No se puede pegar una carpeta dentro de sí misma": "You cannot paste a folder inside itself", "No hay carpetas dentro.": "No folders inside.", "Una carpeta no se puede meter dentro de sí misma.": "A folder cannot go inside itself.", "Ya está en esta carpeta.": "It is already in this folder.", "Otra unidad sin BFpilot: se copia pasando por el PC y luego se borra el original.": "Another drive without BFpilot: it is copied through the PC and then the original is deleted.", "BFpilot no está cargado: cárgalo en la PS5 para poder extraer.": "BFpilot is not loaded: load it on the PS5 to extract.", "Ya hay una carpeta con ese nombre: se usará uno nuevo terminado en (2).": "There is already a folder with that name: a new one ending in (2) will be used.", "Pon un solo nombre de carpeta, sin /": "Use a single folder name, without /", "No los encuentro en tu PC: usa \"Subir archivos aquí\"": "I cannot find them on your PC: use \"Upload files here\"", "Extrayendo": "Extracting", "ahora": "now", "Aún no hay avisos. Aquí aparecerán las transferencias terminadas, los errores, las desconexiones y las versiones nuevas.": "No notifications yet. Finished transfers, errors, disconnections and new versions will show up here.", "Novedades": "What's new", "Entendido": "Got it", "Se ha actualizado. Esto es lo nuevo:": "PS5 Directo has been updated. Here is what's new:", "Transferencias es la cola. En Archivos de la consola navegas por la PS5: ves tamaños, copias, mueves, renombras, borras, bajas al PC o subes archivos y carpetas. Espacio te enseña en un gráfico qué ocupa Line truncated
|
||||
const I18N_RX=[["^Este ZIP usa cifrado AES y BFpilot todavia no sabe abrirlo \\(solo el cifrado ZIP clasico\\)\\. Los archivos ya estan en la PS5, en ([\\s\\S]*?)\\. Puedes bajarlos al PC, extraerlos con 7-Zip y subir la carpeta\\.$", "This ZIP uses AES encryption and BFpilot can't open it yet (only classic ZIP encryption). The files are already on the PS5, in {0}. You can download them to the PC, extract them with 7-Zip and upload the folder."], ["^Aviso: lo descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y quedan ([\\s\\S]*?) libres\\. Puede bastar, pero si el espacio baja del margen lo paro\\. Si puedes, libera espacio\\.$", "Warning: extracted content can take up to ~5 times the RAR (~{0}) and {1} is free. It may be enough, but if space drops below the margin I stop. Free up space if you can."], ["^Ojo: el contenido descomprimido puede ocupar hasta ~5 veces el RAR \\(~([\\s\\S]*?)\\) y no se si cabra\\. Si al descomprimir baja del margen, lo paro y los RAR se quedan en la PS5\\.$", "Heads up: extracted content can take up to ~5 times the RAR (~{0}) and it may not fit. If space drops below the margin while extracting, I stop and the RARs stay on the PS5."], ["^AVISO: no pude poner los permisos de ([\\s\\S]*?) \\(([\\s\\S]*?)\\)\\. Si no arranca, en FileZilla: clic derecho en la carpeta > Permisos > 777 > recurrir a subdirectorios\\.$", "WARNING: couldn't set permissions on {0} ({1}). If it doesn't launch, in FileZilla: right-click the folder > File permissions > 777 > recurse into subdirectories."], ["^No cabe en el PC: hay que bajar ([\\s\\S]*?) a ([\\s\\S]*?) y solo hay ([\\s\\S]*?) libres\\. Libera espacio, cambia la carpeta temporal en Ajustes o descomprime en la PS5\\.$", "Not enough space on the PC: {0} must be downloaded to {1} and only {2} is free. Free up space, change the temporary folder in Settings, or extract on the PS5."], ["^No cabe en la PS5: descomprimido ocupa ([\\s\\S]*?), faltan por mandar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: extracted it takes {0}, {1} left to send, {2} free and I keep {3} as margin. Free up space and click Retry."], ["^No hay sitio en la PS5: faltan por enviar ([\\s\\S]*?), hay ([\\s\\S]*?) libres y dejo ([\\s\\S]*?) de margen \\(Ajustes\\)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} left to send, {1} free and I keep {2} as margin (Settings). Free up space and click Retry."], ["^La IP de la PS5 no es valida \\(([\\s\\S]*?)\\): en Ajustes escribe solo los numeros, por ejemplo 192\\.168\\.1\\.50, sin ftp:\\/\\/ ni puerto$", "The PS5 IP is not valid ({0}): in Settings type just the numbers, for example 192.168.1.50, without ftp:// or a port"], ["^Parado para no llenar la PS5 \\(([\\s\\S]*?)\\): quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar; sigue donde iba\\.$", "Stopped so the PS5 doesn't fill up ({0}): {1} free and the margin is {2}. Free up space and click Retry; it continues where it left off."], ["^BFpilot no abre ZIP partidos \\(\\.z01, \\.z02\\.\\.\\.\\)\\. Uno los RAR\\/7z partidos, pero este formato no\\. Los archivos estan en ([\\s\\S]*?)\\.$", "BFpilot can't open split ZIPs (.z01, .z02...). I can join split RAR/7z, but not this format. The files are in {0}."], ["^No cabe en la PS5: faltan ([\\s\\S]*?) por enviar, hay ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "It doesn't fit on the PS5: {0} left to send, {1} free and the margin is {2}. Free up space and click Retry."], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.([\\s\\S]*?)$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one).{2}"], ["^Ojo: ya tienes ([\\s\\S]*?) en ([\\s\\S]*?)\\. Dos copias con el mismo Title ID pueden liar a ShadowMountPlus \\(deja solo una\\)\\.$", "Heads up: you already have {0} in {1}. Two copies with the same Title ID can confuse ShadowMountPlus (keep only one)."], ["^El comprimido tiene enlaces \\(([\\s\\S]*?)\\); eso solo lo sabe extraer BFpilot\\. Descomprimelo en la PS5\\.$", "The archive contains links ({0}); only BFpilot can extract those. Extract it on the PS5."], ["^No hay sitio en la PS5: quedan ([\\s\\S]*?) libres y el margen es ([\\s\\S]*?)\\. Libera espacio y pulsa Reintentar\\.$", "Not enough space on the PS5: {0} free and the margin is {1}. Free up space and click Retry."], ["^Ya existe ([\\s\\S]*?)\\. No lo piso: borralo o renombralo y pulsa Reintentar\\. Lo extraido esta en ([\\s\\S]*?)$", "{0} already exists. I won't overwrite it: delete or rename it and click Retry. What was extracted is in {1}"], ["^Recibido de la extensión: ([\\s\\S]*?)\\. Cuando estén todas las partes, pulsa \"Añadir a la cola\"\\.$", "Received from the extension: {0}. When all parts are iLine truncated
|
||||
// --- idioma: español (original) o inglés. Traduce el texto de la página y los mensajes del programa.
|
||||
const LANG=(()=>{try{const v=localStorage.getItem('ps5d_lang');if(v==='es'||v==='en')return v}catch(e){}
|
||||
return (navigator.language||'es').toLowerCase().startsWith('es')?'es':'en'})();
|
||||
@@ -469,7 +596,18 @@ let state = {jobs:[], settings:{}};
|
||||
let openLogs = new Set();
|
||||
|
||||
function toast(msg){const t=$('toast');t.textContent=msg;t.classList.add('show');clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove('show'),3500)}
|
||||
// Consultas que se pueden repetir sin efectos: si falla la conexion un instante, se reintenta
|
||||
const SAFE_API=new Set(['/api/state','/api/test','/api/settings','/api/places','/api/whatsnew','/api/space/state']);
|
||||
async function api(path, body){
|
||||
for(let i=0;;i++){
|
||||
try{ return await api1(path, body) }
|
||||
catch(e){
|
||||
if(!(e instanceof TypeError) || i>=2 || !(body===undefined || SAFE_API.has(path))) throw e;
|
||||
await new Promise(r=>setTimeout(r, 400*(i+1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
async function api1(path, body){
|
||||
const r = await fetch(path,{method: body===undefined?'GET':'POST',headers:{'X-Token':TOKEN,'X-UI':'1','Content-Type':'application/json'},body: body===undefined?undefined:JSON.stringify(body)});
|
||||
const j = await r.json().catch(()=>({ok:false,error:'HTTP '+r.status}));
|
||||
if(!r.ok || j.ok===false) throw new Error(j.error||('HTTP '+r.status));
|
||||
@@ -597,9 +735,11 @@ function paintConn(r){
|
||||
}
|
||||
async function checkConn(){try{paintConn(await api('/api/test',{}))}catch(e){}}
|
||||
|
||||
let refreshFails=0, refreshing=false;
|
||||
async function refresh(){
|
||||
try{const s=await api('/api/state');state=s;if(s.version){$('ver').textContent='v'+s.version;document.title='PS5 Directo v'+s.version}if(s.inbox>0)takeInbox();if(!$('dest').dataset.touched)$('dest').value=s.settings.dest||'/data/homebrew';if(!$('where').dataset.touched)$('where').value=s.settings.extract_where==='pc'?'pc':'ps5';render()}
|
||||
catch(e){$('jobs').innerHTML=`<div class="empty">No conecto con el programa. ¿Cerraste la ventana negra? (${esc(e.message)})</div>`}
|
||||
if(refreshing) return; refreshing=true;
|
||||
try{const s=await api('/api/state');refreshFails=0;state=s;paintBell(s.unread||0);if(s.version){$('ver').textContent='v'+s.version;document.title='PS5 Directo v'+s.version}if(s.inbox>0)takeInbox();if(!$('dest').dataset.touched)$('dest').value=s.settings.dest||'/data/homebrew';if(!$('where').dataset.touched)$('where').value=s.settings.extract_where==='pc'?'pc':'ps5';render()}
|
||||
catch(e){ if(++refreshFails<5) return; $('jobs').innerHTML=`<div class="empty">No conecto con el programa. Cierra esta ventana y abre PS5 Directo otra vez. (${esc(e.message)})</div>`}
|
||||
}
|
||||
$('dest').addEventListener('input',()=>$('dest').dataset.touched='1');
|
||||
$('where').addEventListener('change',()=>$('where').dataset.touched='1');
|
||||
@@ -680,6 +820,10 @@ refresh().then(()=>{
|
||||
checkUpdateQuiet();
|
||||
}); checkConn();
|
||||
setInterval(refresh, 1000); setInterval(checkConn, 20000);
|
||||
// Avisar al programa cuando se cierra la ventana (minimizarla no cuenta)
|
||||
addEventListener('pagehide',()=>{try{navigator.sendBeacon('/api/bye?t='+encodeURIComponent(TOKEN))}catch(e){}});
|
||||
// Al volver a la ventana, ponerse al dia enseguida
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden){refresh();checkConn();if(curTab==='Files'&&fm.loaded)fmGo(fm.path,true)}});
|
||||
</script>
|
||||
<script>
|
||||
['dragover','drop'].forEach(ev=>window.addEventListener(ev,async e=>{
|
||||
@@ -1029,117 +1173,331 @@ $('spDel').onclick=()=>spDelete([...sp.sel]);
|
||||
$('spRoot').onchange=spScan;
|
||||
|
||||
// ===================== explorador de la consola =====================
|
||||
const fm={path:'/data/homebrew',entries:[],back:[],fwd:[],sel:new Set(),sizes:{},sort:'name',asc:true,clip:null,loaded:false,busy:false};
|
||||
const fm={path:'/data/homebrew',entries:[],back:[],fwd:[],sel:new Set(),sizes:{},sort:'name',asc:true,clip:null,loaded:false,busy:false,via:'bfpilot',drives:[],anchor:null};
|
||||
const ICON_DIR='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7fb0ff" stroke-width="2" stroke-linejoin="round"><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.2l2 2.2h8.8A1.5 1.5 0 0 1 21 8.7v9.8A1.5 1.5 0 0 1 19.5 20h-15A1.5 1.5 0 0 1 3 18.5z"/></svg>';
|
||||
const ICON_FILE='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b97a5" stroke-width="2" stroke-linejoin="round"><path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/></svg>';
|
||||
const ICON_ARC='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#e0b25a" stroke-width="2" stroke-linejoin="round"><path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M10 6h2M10 9h2M10 12h2M10 15h2v3h-2z"/></svg>';
|
||||
const ICON_DRIVE='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M5 13l2.5-8h9L19 13"/><circle cx="17" cy="16.5" r="1" fill="currentColor"/></svg>';
|
||||
const ICON_PIN='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.2l2 2.2h8.8A1.5 1.5 0 0 1 21 8.7v9.8A1.5 1.5 0 0 1 19.5 20h-15A1.5 1.5 0 0 1 3 18.5z"/></svg>';
|
||||
const ARCH_RX=/\.(part0*1\.rar|rar|7z|7z\.0*1|zip)$/i;
|
||||
function joinPath(a,b){return (a.replace(/\/+$/,'')+'/'+b).replace(/\/+/g,'/')}
|
||||
function parentOf(p){const x=p.replace(/\/+$/,'').split('/');x.pop();return x.join('/')||'/'}
|
||||
function baseName(p){return p.replace(/\/+$/,'').split('/').pop()}
|
||||
function fmtDate(t){if(!t)return '';const d=new Date(t*1000);return d.toLocaleDateString()+' '+d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}
|
||||
function fmtMode(m){return m==null?'':(m&0o777).toString(8)}
|
||||
function lsGet(k,d){try{return JSON.parse(localStorage.getItem(k))??d}catch(e){return d}}
|
||||
function lsSet(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}}
|
||||
function entryOf(p){return fm.entries.find(x=>joinPath(fm.path,x.name)===p)}
|
||||
function rememberRecent(key,p){const r=lsGet(key,[]).filter(x=>x!==p); r.unshift(p); lsSet(key,r.slice(0,6))}
|
||||
|
||||
async function fmGo(path,noHist){
|
||||
path=(path||'/').trim()||'/';
|
||||
const hint=$('fmHint'); hint.textContent=tr('Cargando…');
|
||||
let r; try{r=await api('/api/fm/list',{path})}catch(e){hint.textContent=e.message;return}
|
||||
if(!r.ok){hint.textContent=r.error;return}
|
||||
if(!noHist && fm.path!==r.path){fm.back.push(fm.path);fm.fwd=[]}
|
||||
fm.path=r.path; fm.entries=r.entries; fm.sel.clear(); fm.loaded=true;
|
||||
$('fmPath').value=fm.path;
|
||||
fmPaintStore(r);
|
||||
hint.textContent=r.via==='ftp'?tr('BFpilot no responde: solo lectura por FTP. Cárgalo para copiar, mover, borrar o calcular tamaños.'):'';
|
||||
if(fm.path!==r.path){fm.sel.clear(); fm.anchor=null}
|
||||
fm.path=r.path; fm.entries=r.entries; fm.loaded=true; fm.via=r.via;
|
||||
for(const p of [...fm.sel]) if(!entryOf(p)) fm.sel.delete(p);
|
||||
$('fmPath').value=fm.path; rememberRecent('ps5d_fm_recent',fm.path);
|
||||
fmPaintStore(r); fmCrumbs(); fmSide();
|
||||
hint.textContent=r.via==='ftp'?tr('BFpilot no está cargado: el explorador va por FTP. Copiar pasa por el PC (más lento), y para descomprimir o ver el espacio libre hace falta BFpilot.'):'';
|
||||
fmRender();
|
||||
}
|
||||
function fmPaintStore(r){
|
||||
if(r.total){const used=r.total-r.free; $('fmStore').innerHTML=`<b>${human(r.free)}</b> ${esc(tr('libres de'))} ${human(r.total)}<div class="bar"><i style="width:${Math.min(100,used*100/r.total).toFixed(1)}%"></i></div>`}
|
||||
else $('fmStore').textContent='';
|
||||
}
|
||||
function fmRender(){
|
||||
const q=$('fmSearch').value.trim().toLowerCase();
|
||||
function crumbsHtml(path){
|
||||
const parts=path.split('/').filter(Boolean); let acc='';
|
||||
return `<button class="crumb" data-p="/">/</button>`+parts.map((x,i)=>{acc+='/'+x; return `${i?'<span class="csep">›</span>':''}<button class="crumb${i===parts.length-1?' cur':''}" data-p="${esc(acc)}" data-noi18n>${esc(x)}</button>`}).join('');
|
||||
}
|
||||
function fmCrumbs(){ $('fmCrumbs').innerHTML=crumbsHtml(fm.path) }
|
||||
$('fmCrumbs').addEventListener('click',e=>{const b=e.target.closest('.crumb'); if(b) return fmGo(b.dataset.p);
|
||||
$('fmCrumbs').hidden=true; const i=$('fmPath'); i.hidden=false; i.value=fm.path; i.focus(); i.select()});
|
||||
$('fmPath').addEventListener('keydown',e=>{if(e.key==='Enter'){$('fmPath').hidden=true;$('fmCrumbs').hidden=false;fmGo($('fmPath').value)} if(e.key==='Escape'){$('fmPath').hidden=true;$('fmCrumbs').hidden=false}});
|
||||
$('fmPath').addEventListener('blur',()=>{$('fmPath').hidden=true;$('fmCrumbs').hidden=false});
|
||||
|
||||
const FM_PLACES=[['/data/homebrew','Aplicaciones (homebrew)'],['/data','Datos (/data)'],['/data/pkg','Paquetes (/data/pkg)'],['/data/ps5directo','Restos de PS5 Directo']];
|
||||
async function fmLoadDrives(){try{const r=await api('/api/fm/drives',{}); fm.drives=r.drives||[]; fmSide()}catch(e){}}
|
||||
function fmSide(){
|
||||
const cur=fm.path, on=p=>cur===p||cur.startsWith(p+'/');
|
||||
const drives=fm.drives.length?fm.drives:[{path:'/data',name:'Almacenamiento interno'}];
|
||||
const best=drives.filter(d=>on(d.path)).sort((a,b)=>b.path.length-a.path.length)[0];
|
||||
let h=`<div class="side-h">${esc(tr('Unidades'))}</div>`+drives.map(d=>`<button class="side-i${best&&best.path===d.path?' on':''}" data-p="${esc(d.path)}">${ICON_DRIVE}<span><span>${esc(tr(d.name))}</span>${d.total?`<small>${esc(human(d.free))} ${esc(tr('libres'))}</small><i class="mini"><b style="width:${Math.min(100,(d.total-d.free)*100/d.total).toFixed(0)}%"></b></i>`:''}</span></button>`).join('');
|
||||
h+=`<div class="side-h">${esc(tr('Accesos'))}</div>`+FM_PLACES.map(([p,n])=>`<button class="side-i${cur===p?' on':''}" data-p="${esc(p)}">${ICON_PIN}<span>${esc(tr(n))}</span></button>`).join('');
|
||||
const rec=lsGet('ps5d_fm_recent',[]).filter(p=>p!==cur&&!FM_PLACES.some(x=>x[0]===p)).slice(0,5);
|
||||
if(rec.length) h+=`<div class="side-h">${esc(tr('Recientes'))}</div>`+rec.map(p=>`<button class="side-i small-i" data-p="${esc(p)}" title="${esc(p)}" data-noi18n>${ICON_PIN}<span>${esc(baseName(p)||'/')}</span></button>`).join('');
|
||||
$('fmSide').innerHTML=h;
|
||||
}
|
||||
$('fmSide').addEventListener('click',e=>{const b=e.target.closest('[data-p]'); if(b) fmGo(b.dataset.p)});
|
||||
|
||||
function fmVisible(){const q=$('fmSearch').value.trim().toLowerCase();
|
||||
let list=fm.entries.filter(e=>!q||e.name.toLowerCase().includes(q));
|
||||
const k=fm.sort, dir=fm.asc?1:-1;
|
||||
list.sort((a,b)=>{ if(a.dir!==b.dir) return a.dir?-1:1;
|
||||
let va=k==='size'?(a.dir?(fm.sizes[joinPath(fm.path,a.name)]??-1):a.size):k==='mtime'?a.mtime:a.name.toLowerCase();
|
||||
let vb=k==='size'?(b.dir?(fm.sizes[joinPath(fm.path,b.name)]??-1):b.size):k==='mtime'?b.mtime:b.name.toLowerCase();
|
||||
return (va>vb?1:va<vb?-1:0)*dir});
|
||||
const body=$('fmBody');
|
||||
return list}
|
||||
function fmRender(){
|
||||
const q=$('fmSearch').value.trim(), list=fmVisible(), body=$('fmBody');
|
||||
document.querySelectorAll('.fmtable th[data-sort]').forEach(th=>th.classList.toggle('sorted',th.dataset.sort===fm.sort));
|
||||
if(!list.length){body.innerHTML=`<tr><td colspan="6" class="hint" style="padding:18px;text-align:center">${esc(tr(q?'Nada coincide con la búsqueda.':'Carpeta vacía.'))}</td></tr>`}
|
||||
else body.innerHTML=list.map(e=>{
|
||||
const full=joinPath(fm.path,e.name), sz=e.dir?fm.sizes[full]:e.size;
|
||||
return `<tr data-p="${esc(full)}" data-dir="${e.dir?1:0}" class="${fm.sel.has(full)?'sel':''}">
|
||||
const full=joinPath(fm.path,e.name), sz=e.dir?fm.sizes[full]:e.size, cut=fm.clip&&fm.clip.op==='move'&&fm.clip.paths.includes(full);
|
||||
const icon=e.dir?ICON_DIR:(ARCH_RX.test(e.name)?ICON_ARC:ICON_FILE);
|
||||
return `<tr data-p="${esc(full)}" data-dir="${e.dir?1:0}" class="${fm.sel.has(full)?'sel':''}${cut?' cut':''}">
|
||||
<td><input type="checkbox" ${fm.sel.has(full)?'checked':''}></td>
|
||||
<td><div class="fname ${e.dir?'dir':''}" data-noi18n>${e.dir?ICON_DIR:ICON_FILE}<span>${esc(e.name)}</span></div></td>
|
||||
<td><div class="fname ${e.dir?'dir':''}" data-noi18n>${icon}<span>${esc(e.name)}</span></div></td>
|
||||
<td class="num">${sz!=null?human(sz):(e.dir?`<button class="linkbtn" data-a="du">${esc(tr('calcular'))}</button>`:'')}</td>
|
||||
<td class="hide-s mono">${fmtDate(e.mtime)}</td>
|
||||
<td class="hide-s mono">${fmtMode(e.mode)}</td>
|
||||
<td><div class="rowact"><button class="small" data-a="ren">${esc(tr('Renombrar'))}</button><button class="small" data-a="dl">${esc(tr('Descargar'))}</button><button class="small danger" data-a="del">${esc(tr('Borrar'))}</button></div></td></tr>`}).join('');
|
||||
<td><button class="small dots" data-a="menu" title="${esc(tr('Más acciones'))}">⋯</button></td></tr>`}).join('');
|
||||
$('fmAll').checked=list.length>0&&list.every(e=>fm.sel.has(joinPath(fm.path,e.name)));
|
||||
fmButtons();
|
||||
}
|
||||
function fmSelSize(){let t=0,unk=false; for(const p of fm.sel){const e=entryOf(p); if(!e) continue; const s=e.dir?fm.sizes[p]:e.size; if(s==null) unk=true; else t+=s} return [t,unk]}
|
||||
function fmButtons(){
|
||||
const n=fm.sel.size;
|
||||
['fmCopy','fmCut','fmDownload','fmSize','fmPerms','fmDelete'].forEach(id=>$(id).disabled=n===0||fm.busy);
|
||||
['fmCopy','fmCut','fmMoveTo','fmDownload','fmMore','fmDelete'].forEach(id=>$(id).disabled=n===0||fm.busy);
|
||||
$('fmRename').disabled=n!==1||fm.busy;
|
||||
const [t,unk]=fmSelSize();
|
||||
$('fmSelInfo').textContent=n?tr('{0} seleccionados').replace('{0}',n)+(t?' · '+human(t)+(unk?'+':''):''):'';
|
||||
const c=$('fmClip');
|
||||
if(fm.clip){c.hidden=false;c.innerHTML=`<span>${esc(tr(fm.clip.op==='copy'?'Para copiar:':'Para mover:'))} <b data-noi18n>${fm.clip.paths.length===1?esc(fm.clip.paths[0].split('/').pop()):fm.clip.paths.length+' '+esc(tr('elementos'))}</b></span><button class="small primary" id="fmPaste">${esc(tr('Pegar aquí'))}</button><button class="small" id="fmClipX">${esc(tr('Cancelar'))}</button>`;
|
||||
$('fmPaste').onclick=fmPaste; $('fmClipX').onclick=()=>{fm.clip=null;fmButtons()}}
|
||||
if(fm.clip){c.hidden=false;c.innerHTML=`<span>${esc(tr(fm.clip.op==='copy'?'Para copiar:':'Para mover:'))} <b data-noi18n>${fm.clip.paths.length===1?esc(baseName(fm.clip.paths[0])):fm.clip.paths.length+' '+esc(tr('elementos'))}</b> <span class="hint" data-noi18n>${esc(tr('desde'))} ${esc(parentOf(fm.clip.paths[0]))}</span></span><button class="small primary" id="fmPaste" ${fm.busy?'disabled':''}>${esc(tr('Pegar aquí'))}</button><button class="small" id="fmClipX">${esc(tr('Cancelar'))}</button>`;
|
||||
$('fmPaste').onclick=()=>fmPaste(); $('fmClipX').onclick=()=>{fm.clip=null;fmRender()}}
|
||||
else c.hidden=true;
|
||||
}
|
||||
function fmSelect(p,ev){
|
||||
const list=fmVisible().map(e=>joinPath(fm.path,e.name));
|
||||
if(ev&&ev.shiftKey&&fm.anchor&&list.includes(fm.anchor)){const a=list.indexOf(fm.anchor),b=list.indexOf(p); if(!(ev.ctrlKey||ev.metaKey)) fm.sel.clear(); list.slice(Math.min(a,b),Math.max(a,b)+1).forEach(x=>fm.sel.add(x))}
|
||||
else if(ev&&(ev.ctrlKey||ev.metaKey)){fm.sel.has(p)?fm.sel.delete(p):fm.sel.add(p); fm.anchor=p}
|
||||
else {fm.sel.clear(); fm.sel.add(p); fm.anchor=p}
|
||||
fmPaintSel();
|
||||
}
|
||||
// marca la seleccion sin volver a dibujar la tabla (si no, el doble clic no llega a la fila)
|
||||
function fmPaintSel(){
|
||||
document.querySelectorAll('#fmBody tr[data-p]').forEach(r=>{const on=fm.sel.has(r.dataset.p); r.classList.toggle('sel',on); const c=r.querySelector('input[type=checkbox]'); if(c) c.checked=on});
|
||||
const vis=fmVisible(); $('fmAll').checked=vis.length>0&&vis.every(e=>fm.sel.has(joinPath(fm.path,e.name)));
|
||||
fmButtons();
|
||||
}
|
||||
$('fmBody').addEventListener('click',async ev=>{
|
||||
const tr_=ev.target.closest('tr[data-p]'); if(!tr_) return;
|
||||
const p=tr_.dataset.p, isDir=tr_.dataset.dir==='1';
|
||||
const a=ev.target.closest('[data-a]');
|
||||
if(a){ev.stopPropagation();
|
||||
if(a.dataset.a==='du') return fmDu([p]);
|
||||
if(a.dataset.a==='ren') return fmRename(p);
|
||||
if(a.dataset.a==='dl') return fmOpStart('/api/fm/download',{paths:[p]});
|
||||
if(a.dataset.a==='del') return fmDelete([p]);
|
||||
if(a.dataset.a==='menu'){ if(!fm.sel.has(p)){fm.sel.clear();fm.sel.add(p);fm.anchor=p;fmPaintSel()} const r=a.getBoundingClientRect(); return fmOpenMenu(r.right,r.bottom,'sel')}
|
||||
}
|
||||
if(ev.target.type==='checkbox'){ ev.target.checked?fm.sel.add(p):fm.sel.delete(p); tr_.classList.toggle('sel',ev.target.checked); fmButtons(); return }
|
||||
if(ev.target.closest('.fname') && isDir) return fmGo(p);
|
||||
if(fm.sel.has(p)) fm.sel.delete(p); else fm.sel.add(p);
|
||||
fmRender();
|
||||
if(ev.target.type==='checkbox'){ ev.target.checked?fm.sel.add(p):fm.sel.delete(p); fm.anchor=p; tr_.classList.toggle('sel',ev.target.checked); fmButtons(); return }
|
||||
if(ev.target.closest('.fname') && isDir && !ev.ctrlKey && !ev.shiftKey) return fmGo(p);
|
||||
fmSelect(p,ev);
|
||||
});
|
||||
$('fmBody').addEventListener('dblclick',ev=>{const tr_=ev.target.closest('tr[data-p]'); if(tr_&&tr_.dataset.dir==='1') fmGo(tr_.dataset.p)});
|
||||
$('fmAll').onchange=()=>{const q=$('fmSearch').value.trim().toLowerCase();
|
||||
fm.entries.filter(e=>!q||e.name.toLowerCase().includes(q)).forEach(e=>{const p=joinPath(fm.path,e.name); $('fmAll').checked?fm.sel.add(p):fm.sel.delete(p)}); fmRender()};
|
||||
$('fmBody').addEventListener('dblclick',ev=>{const tr_=ev.target.closest('tr[data-p]'); if(!tr_) return; if(tr_.dataset.dir==='1') fmGo(tr_.dataset.p); else if(ARCH_RX.test(tr_.dataset.p)) fmExtract(tr_.dataset.p)});
|
||||
$('fmBody').addEventListener('contextmenu',ev=>{const tr_=ev.target.closest('tr[data-p]'); ev.preventDefault();
|
||||
if(!tr_) return fmOpenMenu(ev.clientX,ev.clientY,'bg');
|
||||
const p=tr_.dataset.p; if(!fm.sel.has(p)){fm.sel.clear();fm.sel.add(p);fm.anchor=p;fmPaintSel()}
|
||||
fmOpenMenu(ev.clientX,ev.clientY,'sel')});
|
||||
$('fmTableWrap').addEventListener('contextmenu',ev=>{if(ev.target.closest('tr[data-p]')||ev.target.closest('thead')) return; ev.preventDefault(); fm.sel.clear(); fmRender(); fmOpenMenu(ev.clientX,ev.clientY,'bg')});
|
||||
$('fmAll').onchange=()=>{fmVisible().forEach(e=>{const p=joinPath(fm.path,e.name); $('fmAll').checked?fm.sel.add(p):fm.sel.delete(p)}); fmRender()};
|
||||
document.querySelectorAll('.fmtable th[data-sort]').forEach(th=>th.onclick=()=>{const k=th.dataset.sort; fm.asc=fm.sort===k?!fm.asc:true; fm.sort=k; fmRender()});
|
||||
$('fmSearch').addEventListener('input',fmRender);
|
||||
$('fmPath').addEventListener('keydown',e=>{if(e.key==='Enter') fmGo($('fmPath').value)});
|
||||
$('fmRefresh').onclick=()=>fmGo(fm.path,true);
|
||||
$('fmUp').onclick=()=>fmGo(parentOf(fm.path));
|
||||
$('fmBack').onclick=()=>{if(!fm.back.length)return; fm.fwd.push(fm.path); fmGo(fm.back.pop(),true)};
|
||||
$('fmFwd').onclick=()=>{if(!fm.fwd.length)return; fm.back.push(fm.path); fmGo(fm.fwd.pop(),true)};
|
||||
$('fmPlaces').onchange=()=>{const v=$('fmPlaces').value; $('fmPlaces').value=''; if(v) fmGo(v)};
|
||||
async function fmPlaces(){
|
||||
const base=['/data/homebrew','/data','/data/pkg','/data/ps5directo','/mnt'];
|
||||
try{const r=await api('/api/places',{}); (r.dests||[]).forEach(d=>{if(!base.includes(d))base.push(d)})}catch(e){}
|
||||
$('fmPlaces').innerHTML=`<option value="">${esc(tr('Ir a…'))}</option>`+base.map(d=>`<option value="${esc(d)}">${esc(d)}</option>`).join('');
|
||||
}
|
||||
fmPlaces();
|
||||
function selList(){return [...fm.sel]}
|
||||
$('fmCopy').onclick=()=>{fm.clip={op:'copy',paths:selList()};fm.sel.clear();fmRender();toast(tr('Ve a la carpeta de destino y pulsa "Pegar aquí"'))};
|
||||
$('fmCut').onclick=()=>{fm.clip={op:'move',paths:selList()};fm.sel.clear();fmRender();toast(tr('Ve a la carpeta de destino y pulsa "Pegar aquí"'))};
|
||||
$('fmRename').onclick=()=>fmRename(selList()[0]);
|
||||
$('fmDownload').onclick=()=>fmOpStart('/api/fm/download',{paths:selList()});
|
||||
$('fmSize').onclick=()=>fmDu(selList().filter(p=>{const e=fm.entries.find(x=>joinPath(fm.path,x.name)===p);return e&&e.dir}));
|
||||
$('fmPerms').onclick=()=>fmOpStart('/api/fm/chmod',{paths:selList()});
|
||||
$('fmDelete').onclick=()=>fmDelete(selList());
|
||||
|
||||
// ---- menu contextual ----
|
||||
function fmOpenMenu(x,y,kind){
|
||||
const m=$('fmMenu'), sel=selList(), one=sel.length===1?entryOf(sel[0]):null, items=[];
|
||||
const it=(a,label,key,cls)=>items.push(`<button data-m="${a}" class="${cls||''}">${esc(tr(label))}${key?`<kbd>${esc(tr(key))}</kbd>`:''}</button>`);
|
||||
const sep=()=>items.push('<hr>');
|
||||
if(kind==='bg'){
|
||||
if(fm.clip) it('paste','Pegar aquí','Ctrl+V'), sep();
|
||||
it('mkdir','Nueva carpeta'); it('upf','Subir archivos aquí'); it('upd','Subir carpeta aquí'); sep();
|
||||
it('refresh','Actualizar la carpeta','F5'); it('copypath','Copiar ruta de esta carpeta');
|
||||
} else {
|
||||
if(one&&one.dir) it('open','Abrir','Enter');
|
||||
if(one&&!one.dir&&ARCH_RX.test(one.name)) it('extract','Extraer…');
|
||||
it('download','Descargar al PC'); sep();
|
||||
it('copy','Copiar','Ctrl+C'); it('cut','Cortar','Ctrl+X');
|
||||
if(fm.clip&&one&&one.dir) it('pasteinto','Pegar dentro');
|
||||
it('copyto','Copiar a…'); it('moveto','Mover a…'); sep();
|
||||
if(one) it('rename','Renombrar','F2');
|
||||
if(sel.some(p=>{const e=entryOf(p);return e&&e.dir})) it('du','Calcular tamaño');
|
||||
it('chmod','Permisos 777'); it('copypath',sel.length>1?'Copiar rutas':'Copiar ruta'); sep();
|
||||
it('delete','Borrar','Supr','danger');
|
||||
}
|
||||
m.innerHTML=items.join(''); m.hidden=false;
|
||||
const w=m.offsetWidth,h=m.offsetHeight;
|
||||
m.style.left=Math.max(6,Math.min(x,innerWidth-w-6))+'px'; m.style.top=Math.max(6,Math.min(y,innerHeight-h-6))+'px';
|
||||
m.querySelector('button')?.focus();
|
||||
}
|
||||
function fmMenuClose(){$('fmMenu').hidden=true}
|
||||
document.addEventListener('mousedown',e=>{if(!e.target.closest('#fmMenu')) fmMenuClose()});
|
||||
addEventListener('blur',fmMenuClose); addEventListener('resize',fmMenuClose); document.addEventListener('scroll',fmMenuClose,true);
|
||||
$('fmMenu').addEventListener('keydown',e=>{const bs=[...$('fmMenu').querySelectorAll('button')], i=bs.indexOf(document.activeElement);
|
||||
if(e.key==='ArrowDown'){e.preventDefault();bs[(i+1)%bs.length].focus()} else if(e.key==='ArrowUp'){e.preventDefault();bs[(i-1+bs.length)%bs.length].focus()} else if(e.key==='Escape') fmMenuClose()});
|
||||
$('fmMenu').addEventListener('click',e=>{const b=e.target.closest('[data-m]'); if(!b) return; fmMenuClose(); fmAct(b.dataset.m)});
|
||||
function fmAct(a){
|
||||
const sel=selList();
|
||||
switch(a){
|
||||
case 'open': return fmGo(sel[0]);
|
||||
case 'extract': return fmExtract(sel[0]);
|
||||
case 'download': return fmOpStart('/api/fm/download',{paths:sel});
|
||||
case 'copy': case 'cut': fm.clip={op:a==='copy'?'copy':'move',paths:sel}; fmRender(); return toast(tr('Ve a la carpeta de destino y pulsa "Pegar aquí" (o Ctrl+V)'));
|
||||
case 'paste': return fmPaste();
|
||||
case 'pasteinto': return fmPaste(sel[0]);
|
||||
case 'copyto': return fmPick('copy',sel);
|
||||
case 'moveto': return fmPick('move',sel);
|
||||
case 'rename': return fmRename(sel[0]);
|
||||
case 'du': return fmDu(sel.filter(p=>{const e=entryOf(p);return e&&e.dir}));
|
||||
case 'chmod': return fmOpStart('/api/fm/chmod',{paths:sel});
|
||||
case 'copypath': { const t=(sel.length?sel:[fm.path]).join('\n'); navigator.clipboard?.writeText(t).then(()=>toast(tr('Ruta copiada')),()=>toast(t)); return }
|
||||
case 'delete': return fmDelete(sel);
|
||||
case 'mkdir': return $('fmMkdir').click();
|
||||
case 'upf': return fmUpload('files');
|
||||
case 'upd': return fmUpload('folder');
|
||||
case 'refresh': return fmGo(fm.path,true);
|
||||
}
|
||||
}
|
||||
$('fmCopy').onclick=()=>fmAct('copy');
|
||||
$('fmCut').onclick=()=>fmAct('cut');
|
||||
$('fmMoveTo').onclick=()=>fmAct('moveto');
|
||||
$('fmRename').onclick=()=>fmAct('rename');
|
||||
$('fmDownload').onclick=()=>fmAct('download');
|
||||
$('fmDelete').onclick=()=>fmAct('delete');
|
||||
$('fmMore').onclick=()=>{const r=$('fmMore').getBoundingClientRect(); fmOpenMenu(r.left,r.bottom+4,'sel')};
|
||||
|
||||
// ---- atajos de teclado (solo en la pestaña de archivos y fuera de las casillas de texto) ----
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(curTab!=='Files'||tourEls||document.querySelector('dialog[open]')) return;
|
||||
if(!$('fmMenu').hidden) return;
|
||||
const t=e.target, typing=t&&(t.tagName==='INPUT'&&t.type!=='checkbox'||t.tagName==='TEXTAREA'||t.tagName==='SELECT');
|
||||
if(typing) return;
|
||||
const k=e.key, ctrl=e.ctrlKey||e.metaKey, sel=selList();
|
||||
if(k==='F5'){e.preventDefault(); return fmGo(fm.path,true)}
|
||||
if(k==='Backspace'||(e.altKey&&k==='ArrowUp')){e.preventDefault(); return fmGo(parentOf(fm.path))}
|
||||
if(e.altKey&&k==='ArrowLeft'){e.preventDefault(); return $('fmBack').click()}
|
||||
if(e.altKey&&k==='ArrowRight'){e.preventDefault(); return $('fmFwd').click()}
|
||||
if(ctrl&&k.toLowerCase()==='a'){e.preventDefault(); fmVisible().forEach(x=>fm.sel.add(joinPath(fm.path,x.name))); return fmPaintSel()}
|
||||
if(ctrl&&k.toLowerCase()==='v'){e.preventDefault(); return fmPaste()}
|
||||
if(k==='Escape'){fm.sel.clear(); return fmPaintSel()}
|
||||
if(!sel.length||fm.busy) return;
|
||||
if(k==='Delete'){e.preventDefault(); return fmAct('delete')}
|
||||
if(k==='F2'&&sel.length===1){e.preventDefault(); return fmAct('rename')}
|
||||
if(ctrl&&k.toLowerCase()==='c'&&!getSelection().toString()){e.preventDefault(); return fmAct('copy')}
|
||||
if(ctrl&&k.toLowerCase()==='x'){e.preventDefault(); return fmAct('cut')}
|
||||
if(k==='Enter'&&sel.length===1){const en=entryOf(sel[0]); if(en&&en.dir){e.preventDefault(); return fmGo(sel[0])}}
|
||||
});
|
||||
|
||||
// ---- acciones ----
|
||||
$('fmMkdir').onclick=async()=>{const n=prompt(tr('Nombre de la carpeta nueva:'),''); if(!n) return;
|
||||
if(/[\/\\]/.test(n)||n==='.'||n==='..') return toast(tr('El nombre no puede llevar / ni \\'));
|
||||
const r=await api('/api/fm/mkdir',{path:joinPath(fm.path,n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error); fmGo(fm.path,true)};
|
||||
async function fmRename(p){const old=p.split('/').pop(); const n=prompt(tr('Nuevo nombre:'),old); if(!n||n===old) return;
|
||||
const r=await api('/api/fm/rename',{src:p,dst:joinPath(parentOf(p),n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error); fmGo(fm.path,true)}
|
||||
async function fmRename(p){const old=baseName(p); const n=prompt(tr('Nuevo nombre:'),old); if(!n||n===old) return;
|
||||
if(/[\/\\]/.test(n)||n==='.'||n==='..') return toast(tr('El nombre no puede llevar / ni \\'));
|
||||
const r=await api('/api/fm/rename',{src:p,dst:joinPath(parentOf(p),n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error);
|
||||
fm.sel.clear(); fm.sel.add(joinPath(parentOf(p),n)); fmGo(fm.path,true)}
|
||||
async function fmDu(paths){for(const p of paths){fm.sizes[p]=undefined; try{const r=await api('/api/fm/du',{path:p}); if(r.ok) fm.sizes[p]=r.size; else toast(r.error)}catch(e){toast(e.message)} fmRender()}}
|
||||
function fmDelete(paths){if(!paths.length) return;
|
||||
const names=paths.map(p=>p.split('/').pop()); const list=names.slice(0,6).join('\n')+(names.length>6?'\n…':'');
|
||||
const names=paths.map(baseName); const list=names.slice(0,6).join('\n')+(names.length>6?'\n…':'');
|
||||
if(!confirm(tr('¿Borrar {0} elemento(s) de la consola? No se puede deshacer.').replace('{0}',paths.length)+'\n\n'+list)) return;
|
||||
fmOpStart('/api/fm/delete',{paths})}
|
||||
async function fmPaste(){if(!fm.clip) return; const c=fm.clip;
|
||||
const r=await fmOpStart('/api/fm/paste',{op:c.op,srcs:c.paths,dst:fm.path}); if(r) {fm.clip=null; fmButtons()}}
|
||||
async function fmUpload(kind){const ps=await pick(kind); if(!ps.length) return;
|
||||
fmOpStart('/api/fm/delete',{paths}).then(ok=>{if(ok){paths.forEach(p=>fm.sel.delete(p))}})}
|
||||
// pegar con aviso de conflictos
|
||||
function fmAskConflict(conf,op){
|
||||
return new Promise(res=>{
|
||||
const d=$('dlgConflict');
|
||||
$('cfTitle').textContent=conf.length===1?tr('"{0}" ya está aquí').replace('{0}',conf[0].name):tr('Ya hay {0} elementos con el mismo nombre').replace('{0}',conf.length);
|
||||
$('cfBody').textContent=tr(op==='copy'?'¿Qué hago con los que ya existen en la carpeta de destino?':'¿Qué hago con los que ya existen en la carpeta de destino?');
|
||||
$('cfList').innerHTML=conf.slice(0,8).map(c=>`<li>${c.dir?ICON_DIR:ICON_FILE} ${esc(c.name)}</li>`).join('')+(conf.length>8?'<li>…</li>':'');
|
||||
const allDirs=conf.every(c=>c.dir), allFiles=conf.every(c=>!c.dir);
|
||||
$('cfReplace').textContent=tr(allDirs?'Combinar':allFiles?'Reemplazar':'Reemplazar / combinar');
|
||||
$('cfReplace').hidden=conf.some(c=>c.same);
|
||||
d.querySelectorAll('.cf-opts button').forEach(b=>b.onclick=()=>{d.close(); res(b.dataset.m)});
|
||||
d.onclose=()=>res(''); d.showModal();
|
||||
});
|
||||
}
|
||||
async function fmDoPaste(op,srcs,dst){
|
||||
let conf=[]; try{conf=(await api('/api/fm/conflicts',{srcs,dst})).conflicts||[]}catch(e){}
|
||||
if(op==='move') conf=conf.filter(c=>!c.same);
|
||||
let mode='replace';
|
||||
if(conf.length){mode=await fmAskConflict(conf,op); if(!mode) return false}
|
||||
return fmOpStart('/api/fm/paste',{op,srcs,dst,mode});
|
||||
}
|
||||
async function fmPaste(into){if(!fm.clip||fm.busy) return; const c=fm.clip, dst=into||fm.path;
|
||||
if(c.paths.some(p=>dst===p||dst.startsWith(p+'/'))) return toast(tr('No se puede pegar una carpeta dentro de sí misma'));
|
||||
const ok=await fmDoPaste(c.op,c.paths,dst); if(ok){ if(c.op==='move') fm.clip=null; rememberRecent('ps5d_fm_dest',dst); fmButtons()}}
|
||||
async function fmUpload(kind){const ps=await pick(kind); if(!ps.length) return; fmUploadPaths(ps)}
|
||||
async function fmUploadPaths(ps){
|
||||
try{await api('/api/add',{lines:ps.join('\n'),dest:fm.path,raw:true}); toast(tr('Añadido a la cola: se sube tal cual a {0}').replace('{0}',fm.path)); refresh()}catch(e){toast(e.message)}}
|
||||
$('fmUpFiles').onclick=()=>fmUpload('files');
|
||||
$('fmUpFolder').onclick=()=>fmUpload('folder');
|
||||
// operaciones largas (copiar, mover, borrar, bajar, permisos) con progreso
|
||||
const OPNAME={copy:'Copiando',move:'Moviendo',delete:'Borrando',download:'Descargando al PC',chmod:'Poniendo permisos'};
|
||||
|
||||
// ---- Mover a… / Copiar a… ----
|
||||
const pk={op:'move',srcs:[],path:'/data'};
|
||||
async function fmPick(op,srcs){
|
||||
pk.op=op; pk.srcs=srcs;
|
||||
$('pickTitle').textContent=(op==='move'?tr('Mover {0} a…'):tr('Copiar {0} a…')).replace('{0}',srcs.length===1?'«'+baseName(srcs[0])+'»':tr('{0} elementos').replace('{0}',srcs.length));
|
||||
$('pickGo').textContent=tr(op==='move'?'Mover aquí':'Copiar aquí');
|
||||
const rec=lsGet('ps5d_fm_dest',[]);
|
||||
$('pickSide').innerHTML=`<div class="side-h">${esc(tr('Unidades'))}</div>`+(fm.drives.length?fm.drives:[{path:'/data',name:'Almacenamiento interno'}]).map(d=>`<button class="side-i" data-p="${esc(d.path)}">${ICON_DRIVE}<span>${esc(tr(d.name))}</span></button>`).join('')
|
||||
+`<div class="side-h">${esc(tr('Accesos'))}</div>`+FM_PLACES.map(([p,n])=>`<button class="side-i" data-p="${esc(p)}">${ICON_PIN}<span>${esc(tr(n))}</span></button>`).join('')
|
||||
+(rec.length?`<div class="side-h">${esc(tr('Destinos recientes'))}</div>`+rec.map(p=>`<button class="side-i small-i" data-p="${esc(p)}" title="${esc(p)}" data-noi18n>${ICON_PIN}<span>${esc(p)}</span></button>`).join(''):'');
|
||||
$('dlgPick').showModal(); pickGo(rec[0]||parentOf(srcs[0]));
|
||||
}
|
||||
async function pickGo(path){
|
||||
$('pickList').innerHTML=`<p class="hint">${esc(tr('Cargando…'))}</p>`;
|
||||
let r; try{r=await api('/api/fm/dirs',{path})}catch(e){$('pickList').innerHTML=`<p class="err">${esc(e.message)}</p>`;return}
|
||||
pk.path=r.path; $('pickCrumbs').innerHTML=crumbsHtml(r.path);
|
||||
$('pickList').innerHTML=r.dirs.length?r.dirs.map(d=>{const full=joinPath(r.path,d), bad=pk.srcs.includes(full);
|
||||
return `<button class="pick-i" data-p="${esc(full)}" ${bad?'disabled':''} data-noi18n>${ICON_DIR}<span>${esc(d)}</span></button>`}).join(''):`<p class="hint">${esc(tr('No hay carpetas dentro.'))}</p>`;
|
||||
const into=pk.srcs.some(p=>r.path===p||r.path.startsWith(p+'/')), here=pk.op==='move'&&pk.srcs.every(p=>parentOf(p)===r.path);
|
||||
$('pickGo').disabled=into||here;
|
||||
$('pickNote').textContent=into?tr('Una carpeta no se puede meter dentro de sí misma.'):here?tr('Ya está en esta carpeta.'):
|
||||
(pk.op==='move'&&fm.via==='ftp'&&!sameDrive(pk.srcs[0],r.path)?tr('Otra unidad sin BFpilot: se copia pasando por el PC y luego se borra el original.'):'');
|
||||
}
|
||||
function driveOf(p){const m=p.match(/^\/mnt\/(usb|ext)\d+/); return m?m[0]:'/data'}
|
||||
function sameDrive(a,b){return driveOf(a)===driveOf(b)}
|
||||
$('pickCrumbs').addEventListener('click',e=>{const b=e.target.closest('.crumb'); if(b) pickGo(b.dataset.p)});
|
||||
$('pickList').addEventListener('click',e=>{const b=e.target.closest('[data-p]'); if(b&&!b.disabled) pickGo(b.dataset.p)});
|
||||
$('pickSide').addEventListener('click',e=>{const b=e.target.closest('[data-p]'); if(b) pickGo(b.dataset.p)});
|
||||
$('pickUp').onclick=()=>pickGo(parentOf(pk.path));
|
||||
$('pickX').onclick=()=>$('dlgPick').close();
|
||||
$('pickNew').onclick=async()=>{const n=prompt(tr('Nombre de la carpeta nueva:'),''); if(!n) return; if(/[\/\\]/.test(n)) return toast(tr('El nombre no puede llevar / ni \\'));
|
||||
const r=await api('/api/fm/mkdir',{path:joinPath(pk.path,n)}).catch(e=>({ok:false,error:e.message})); if(!r.ok) return toast(r.error); pickGo(joinPath(pk.path,n))};
|
||||
$('pickGo').onclick=async()=>{const dst=pk.path; $('dlgPick').close();
|
||||
const ok=await fmDoPaste(pk.op,pk.srcs,dst); if(ok){rememberRecent('ps5d_fm_dest',dst); if(pk.op==='move') fm.sel.clear()}};
|
||||
|
||||
// ---- Extraer… ----
|
||||
let exSrc='';
|
||||
function fmExtract(p){
|
||||
exSrc=p; const name=baseName(p);
|
||||
$('exTitle').textContent=tr('Extraer «{0}»').replace('{0}',name);
|
||||
$('exFolder').value=name.replace(/\.part0*1\.rar$/i,'').replace(/\.7z\.0*1$/i,'').replace(/\.(rar|7z|zip)$/i,'');
|
||||
$('exPw').value=''; const taken=fm.entries.some(e=>e.name===$('exFolder').value);
|
||||
$('exNote').textContent=fm.via==='ftp'?tr('BFpilot no está cargado: cárgalo en la PS5 para poder extraer.'):taken?tr('Ya hay una carpeta con ese nombre: se usará uno nuevo terminado en (2).'):'';
|
||||
$('exGo').disabled=fm.via==='ftp';
|
||||
$('dlgExtract').showModal(); $('exFolder').focus();
|
||||
}
|
||||
$('exCancel').onclick=()=>$('dlgExtract').close();
|
||||
$('exGo').onclick=async()=>{const folder=$('exFolder').value.trim(); if(!folder||/[\/\\]/.test(folder)) return toast(tr('Pon un solo nombre de carpeta, sin /'));
|
||||
$('dlgExtract').close(); fmOpStart('/api/fm/extract',{src:exSrc,folder,password:$('exPw').value})};
|
||||
|
||||
// ---- arrastrar archivos del PC a la carpeta abierta ----
|
||||
$('tabFiles').addEventListener('dragover',e=>{if(curTab!=='Files'||!e.dataTransfer||![...e.dataTransfer.types].includes('Files')) return; e.preventDefault(); e.stopPropagation(); $('fmTableWrap').classList.add('over'); $('fmTableWrap').dataset.drop=tr('Suelta para subir a {0}').replace('{0}',fm.path)});
|
||||
$('tabFiles').addEventListener('dragleave',e=>{if(!$('tabFiles').contains(e.relatedTarget)) $('fmTableWrap').classList.remove('over')});
|
||||
$('tabFiles').addEventListener('drop',async e=>{if(curTab!=='Files'||!e.dataTransfer||!e.dataTransfer.files.length) return; e.preventDefault(); e.stopPropagation(); $('fmTableWrap').classList.remove('over');
|
||||
const files=[...e.dataTransfer.files].map(f=>({name:f.name,size:f.size,lastModified:f.lastModified}));
|
||||
toast(tr('Buscando {0} archivo(s) en tu PC…').replace('{0}',files.length));
|
||||
let r; try{r=await api('/api/locate',{files})}catch(err){return toast(err.message)}
|
||||
const found=Object.values(r.found||{}); if(!found.length) return toast(tr('No los encuentro en tu PC: usa "Subir archivos aquí"'));
|
||||
fmUploadPaths(found)});
|
||||
|
||||
// operaciones largas (copiar, mover, borrar, bajar, permisos, extraer) con progreso
|
||||
const OPNAME={copy:'Copiando',move:'Moviendo',delete:'Borrando',download:'Descargando al PC',chmod:'Poniendo permisos',extract:'Extrayendo'};
|
||||
let fmPoll=null;
|
||||
async function fmOpStart(url,body){
|
||||
try{const r=await api(url,body); if(!r.ok){toast(r.error);return false}}catch(e){toast(e.message);return false}
|
||||
@@ -1159,19 +1517,53 @@ async function fmPollOp(){
|
||||
if(o.busy){
|
||||
box.innerHTML=`<b>${esc(tr(OPNAME[o.op]||o.op))}</b> <span class="hint" data-noi18n>${esc((o.current||'').split('/').pop())}</span>
|
||||
<div class="bar"><i style="width:${pct.toFixed(1)}%"></i></div>
|
||||
<span class="hint">${o.bytes_total?human(o.bytes_done)+' / '+human(o.bytes_total):(o.items_done+' / '+o.items_total)}${o.op==='delete'?' · '+esc(tr('liberados {0}').replace('{0}',human(o.bytes_done||0)))+' · '+fmtSecs(Date.now()/1000-(o.t0||Date.now()/1000)):''}</span>
|
||||
<span class="hint">${o.bytes_total?human(o.bytes_done)+' / '+human(o.bytes_total):(o.items_done+' / '+o.items_total)}${o.op==='delete'&&o.bytes_done?' · '+esc(tr('liberados {0}').replace('{0}',human(o.bytes_done||0))):''} · ${fmtSecs(Date.now()/1000-(o.t0||Date.now()/1000))}</span>
|
||||
<button class="small" id="fmOpCancel">${esc(tr('Cancelar'))}</button>`;
|
||||
$('fmOpCancel').onclick=()=>api('/api/fm/cancel',{});
|
||||
} else {
|
||||
clearInterval(fmPoll); fm.busy=false; fmButtons();
|
||||
box.innerHTML=o.error?`<span class="err" style="margin:0">${esc(o.error)}</span><button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`
|
||||
:`<span>✔ ${esc(o.message||tr('Hecho'))}</span>${o.op==='download'?`<button class="small" id="fmOpenLocal">${esc(tr('Abrir carpeta'))}</button>`:''}<button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`;
|
||||
const was=fm.busy; clearInterval(fmPoll); fm.busy=false; fmButtons();
|
||||
box.innerHTML=o.error?`<span class="err" style="margin:0">${esc(tr(o.error))}</span><button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`
|
||||
:`<span>✔ ${esc(tr(o.message||'Hecho'))}</span>${o.op==='download'?`<button class="small" id="fmOpenLocal">${esc(tr('Abrir carpeta'))}</button>`:''}<button class="small" id="fmOpOk">${esc(tr('Cerrar'))}</button>`;
|
||||
$('fmOpOk').onclick=()=>{box.hidden=true};
|
||||
if($('fmOpenLocal')) $('fmOpenLocal').onclick=()=>api('/api/fm/open_local',{});
|
||||
if(o.op!=='download') fmGo(fm.path,true);
|
||||
if(o.op!=='download'&&was) fmGo(fm.path,true);
|
||||
}
|
||||
}
|
||||
fmWatch();
|
||||
fmWatch(); fmLoadDrives(); setInterval(()=>{if(curTab==='Files')fmLoadDrives()},60000);
|
||||
|
||||
// ===================== campana de notificaciones =====================
|
||||
let notifs=[], nFresh=new Set();
|
||||
function paintBell(n){const b=$('bellN'); b.hidden=!n; b.textContent=n>99?'99+':String(n); $('btnBell').classList.toggle('has',!!n)}
|
||||
function ago(t){const s=Math.max(0,Date.now()/1000-t); if(s<60) return tr('ahora'); if(s<3600) return tr('hace {0} min').replace('{0}',Math.floor(s/60)); if(s<86400) return tr('hace {0} h').replace('{0}',Math.floor(s/3600)); return new Date(t*1000).toLocaleDateString()}
|
||||
async function loadNotifs(){try{const r=await api('/api/notifs',{}); notifs=r.items||[]; paintBell(r.unread||0); renderNotifs()}catch(e){}}
|
||||
function renderNotifs(){
|
||||
const box=$('nList');
|
||||
if(!notifs.length){box.innerHTML=`<p class="hint" style="padding:18px;text-align:center">${esc(tr('Aún no hay avisos. Aquí aparecerán las transferencias terminadas, los errores, las desconexiones y las versiones nuevas.'))}</p>`;return}
|
||||
box.innerHTML=notifs.map(n=>`<div class="nitem ${n.level}${nFresh.has(n.id)?' fresh':''}${n.link?' link':''}" data-id="${esc(n.id)}">
|
||||
<span class="ndot"></span>
|
||||
<div class="nbody"><b>${esc(tr(n.title))}</b>${n.body?`<span>${esc(tr(n.body))}</span>`:''}<small>${esc(ago(n.t))}</small></div>
|
||||
<button class="nx" data-x title="${esc(tr('Descartar'))}">×</button></div>`).join('');
|
||||
}
|
||||
async function openNotifs(){
|
||||
const p=$('nPanel'); if(!p.hidden){p.hidden=true;return}
|
||||
await loadNotifs(); nFresh=new Set(notifs.filter(n=>!n.read).map(n=>n.id)); renderNotifs();
|
||||
p.hidden=false;
|
||||
if(nFresh.size) api('/api/notifs/read',{all:true}).then(()=>paintBell(0)).catch(()=>{});
|
||||
}
|
||||
$('btnBell').onclick=e=>{e.stopPropagation(); openNotifs()};
|
||||
document.addEventListener('mousedown',e=>{if(!e.target.closest('#nPanel')&&!e.target.closest('#btnBell')) $('nPanel').hidden=true});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape') $('nPanel').hidden=true});
|
||||
$('nAllRead').onclick=async()=>{await api('/api/notifs/read',{all:true}).catch(()=>{}); nFresh.clear(); notifs.forEach(n=>n.read=true); paintBell(0); renderNotifs()};
|
||||
$('nClear').onclick=async()=>{if(!notifs.length) return; await api('/api/notifs/clear',{}).catch(()=>{}); notifs=[]; paintBell(0); renderNotifs()};
|
||||
$('nList').addEventListener('click',async e=>{
|
||||
const it=e.target.closest('.nitem'); if(!it) return; const n=notifs.find(x=>x.id===it.dataset.id); if(!n) return;
|
||||
if(e.target.closest('[data-x]')){await api('/api/notifs/remove',{id:n.id}).catch(()=>{}); notifs=notifs.filter(x=>x!==n); renderNotifs(); return}
|
||||
const l=n.link; if(!l) return; $('nPanel').hidden=true;
|
||||
if(l.action==='update') return $('btnUpdHead').click();
|
||||
if(l.tab==='Files'){showTab('Files'); if(l.path) fmGo(l.path); return}
|
||||
if(l.tab==='Transfers'){showTab('Transfers'); setTimeout(()=>{const c=document.querySelector(`.job[data-id="${CSS.escape(l.job||'')}"]`); if(c){c.scrollIntoView({block:'center',behavior:'smooth'});c.classList.add('flash');setTimeout(()=>c.classList.remove('flash'),1600)} else showTab('Activity')},150)}
|
||||
});
|
||||
setInterval(()=>{const n=$('nList'); if(!$('nPanel').hidden) renderNotifs()},30000);
|
||||
|
||||
// ===================== actividad =====================
|
||||
async function loadActivity(){
|
||||
|
||||
Reference in new issue
Block a user