aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-09 18:23:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-09 18:23:56 +0200
commit25152ddb61a89ea3ea29d3aef5de13343429d32a (patch)
treeebf8f11f66146caaa3c01270cd6cfcb0ee51723c /packages/meshbay-hub/src/meshbay_hub
parentc85c7f48e8f29923038d4c90cc1a6f9b8bcd7673 (diff)
downloadmeshbay-25152ddb61a89ea3ea29d3aef5de13343429d32a.tar.gz
feat: back up the phone's photos to a group, once a day on Wi-FiHEADmain
The Android application sends the photos taken on the phone to one folder of one group chosen by the member (docs/MESHBAY_DESIGN.md §9.12). The phone lists MediaStore, keeps a ledger of what was sent and hands each photo's bytes to the page by an opaque token on the packaged origin; the page decides when a run is due and uploads through the existing path, one photo at a time under a slot. - Once a day from the last finished run, on an unmetered network only; "Back up now" asks first on mobile data. Leaving Wi-Fi stops after the file in flight. - Photos already on the phone are sent by default, newest first, under <folder>/YYYY/MM; edits are sent beside the original as -edited-<date>. - Additive by construction: nothing is ever deleted, renamed or replaced on the node, and a photo deleted on the node is not sent again. - A confirmation names the group, owner, members, folder and size when the destination or starting point changes; a lasting refusal (disk full, folder read-only or gone, no longer a member) is said once and retried a day later. - No ACCESS_MEDIA_LOCATION, so the platform redacts photo locations. - A dataSync foreground service keeps a run going with the screen off. HEIC/HEIF photos are sent but not shown in Photos yet (§15.2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js264
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js316
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css5
16 files changed, 1092 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index e2d5742..41a608f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -904,6 +904,30 @@ function App() {
pullOnce(user.userId).catch(() => {});
}, [user, groups.length]);
+ // Photo backup (docs/MESHBAY_DESIGN.md §9.12), on the Android application
+ // only: loaded and started once signed in, stopped on sign-out. A run reads
+ // the live user and bundle key when it starts, never ones captured here.
+ const syncAccount = user ? user.username : null;
+ useEffect(() => {
+ if (!syncAccount || !platform.photoSync.available) return undefined;
+ let stop = null;
+ let gone = false;
+ import('./photo-sync.js').then((m) => {
+ if (gone) return;
+ m.startPhotoSync({
+ hub: HUB,
+ getUser: () => userRef.current,
+ getBundleKey: async () => {
+ const key = session.bundleKey || await _loadBundleKey();
+ if (key) session.bundleKey = key;
+ return key;
+ },
+ });
+ stop = m.stopPhotoSync;
+ }).catch((e) => console.warn('[MeshBay] photo backup did not load:', e.message));
+ return () => { gone = true; if (stop) stop(); };
+ }, [syncAccount]);
+
// Saving the queue is a shell-level action because the queue is: the player
// bar outlives every page, and the account it belongs to is here.
const [saveQueue, setSaveQueue] = useState(null);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 07d2622..f3c3b79 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1282,4 +1282,49 @@ export default {
'native.node_account_confirm': 'Der Node auf diesem Computer ist für {current} eingerichtet. Stattdessen für {next} einrichten? Er stellt dann die Gruppen, die er für {current} hostet, nicht mehr bereit.',
'native.browser_access_confirm': 'Die Anmeldung über einen Browser erlauben? Die Anwendung legt Ihre Identität auf jedem genutzten Node ab, so versiegelt, dass nur Ihre Passphrase zusammen mit Ihrem Hub-Konto sie öffnen kann.',
'native.declined': 'Abgebrochen — nichts wurde geändert.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Foto-Sicherung",
+ 'photosync.intro': "Die mit diesem Telefon aufgenommenen Fotos einmal täglich über WLAN in einen Ordner einer Ihrer Gruppen senden. Auf dem Telefon gelöschte Fotos bleiben in der Gruppe.",
+ 'photosync.setup': "Einrichten",
+ 'photosync.change': "Ändern",
+ 'photosync.turn_off': "Ausschalten",
+ 'photosync.now': "Jetzt sichern",
+ 'photosync.permission_needed': "MeshBay braucht Zugriff auf die Fotos dieses Telefons.",
+ 'photosync.permission_allow': "Zugriff erlauben",
+ 'photosync.permission_partial': "Der Zugriff ist auf die ausgewählten Fotos beschränkt: Neue Fotos werden erst gesichert, wenn Sie den Zugriff auf alle erlauben.",
+ 'photosync.group': "Gruppe",
+ 'photosync.choose_group': "Gruppe wählen",
+ 'photosync.checking_group': "Ordner der Gruppe werden gelesen…",
+ 'photosync.group_offline': "Gerade ist kein Node online, der diese Gruppe hostet. Versuchen Sie es erneut, sobald einer online ist.",
+ 'photosync.no_folder': "Diese Gruppe hat keinen Ordner, in den Sie hochladen können und den ihr Fotos-Tab anzeigt.",
+ 'photosync.folder': "Ordner",
+ 'photosync.albums': "Alben auf diesem Telefon",
+ 'photosync.album_camera': "Kamera",
+ 'photosync.album_count': "{n} Fotos · {size}",
+ 'photosync.scope_existing': "Die Fotos, die schon auf diesem Telefon sind, dann neue",
+ 'photosync.scope_new': "Nur Fotos, die ab jetzt aufgenommen werden",
+ 'photosync.start': "Sicherung starten",
+ 'photosync.confirm': "Ihre Fotos werden an {group} (gehostet von @{owner}, {members} Mitglieder) in den Ordner {folder} gesendet. Alle Mitglieder dieser Gruppe können sie sehen und herunterladen.\n\n{count} Fotos ({size}) werden gesendet, danach neue einmal täglich.\n\nSie können später löschen, was Sie gesendet haben, aber keine Kopien, die andere bereits heruntergeladen haben.",
+ 'photosync.confirm_new': "Ihre Fotos werden an {group} (gehostet von @{owner}, {members} Mitglieder) in den Ordner {folder} gesendet. Alle Mitglieder dieser Gruppe können sie sehen und herunterladen.\n\nNeue Fotos werden einmal täglich gesendet.\n\nSie können später löschen, was Sie gesendet haben, aber keine Kopien, die andere bereits heruntergeladen haben.",
+ 'photosync.metered_confirm': "Sie sind mit mobilen Daten verbunden — {count} Fotos, {size}. Trotzdem senden?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Eingerichtet für @{account}. Melden Sie sich als @{account} an, um sie fortzusetzen, oder richten Sie sie hier neu ein.",
+ 'photosync.sent_total': "Gesendete Fotos: {n}",
+ 'photosync.last_run': "Letzte Sicherung: {when}",
+ 'photosync.never': "Noch nicht gesichert",
+ 'photosync.state_waiting_wifi': "Wartet auf WLAN",
+ 'photosync.state_connecting': "Verbindung zur Gruppe…",
+ 'photosync.state_sending': "Sende {done} / {total}",
+ 'photosync.state_offline': "Kein Node der Gruppe ist online; später neuer Versuch",
+ 'photosync.state_interrupted': "Unterbrochen — wird bei nächster Gelegenheit fortgesetzt",
+ 'photosync.state_up_to_date': "Aktuell",
+ 'photosync.notif_progress': "Fotos werden gesichert — {done} / {total}",
+ 'photosync.fail_disk_full': "Foto-Sicherung angehalten: Die Festplatte des Nodes ist voll.",
+ 'photosync.fail_read_only': "Foto-Sicherung angehalten: Der Ordner nimmt keine Uploads mehr an.",
+ 'photosync.fail_no_folder': "Foto-Sicherung angehalten: Der Ordner existiert in der Gruppe nicht mehr.",
+ 'photosync.fail_unavailable': "Foto-Sicherung angehalten: Das Laufwerk des Ordners ist nicht mit dem Node verbunden.",
+ 'photosync.fail_not_member': "Foto-Sicherung angehalten: Sie sind kein Mitglied dieser Gruppe mehr.",
+ 'photosync.fail_permission': "Foto-Sicherung angehalten: MeshBay hat keinen Zugriff mehr auf die Fotos.",
+ 'photosync.fail_other': "Foto-Sicherung angehalten.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a25fb2e..013a2a5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1263,4 +1263,49 @@ export default {
'native.node_account_confirm': 'The node on this computer is set up for {current}. Set it up for {next} instead? It will stop serving the groups it hosts for {current}.',
'native.browser_access_confirm': 'Allow signing in from a browser? The application will leave your identity on every node you use, sealed so that only your passphrase together with your hub account can open it.',
'native.declined': 'Cancelled — nothing was changed.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Photo backup",
+ 'photosync.intro': "Send the photos taken on this phone to a folder of one of your groups, once a day, on Wi-Fi. Photos deleted from the phone stay in the group.",
+ 'photosync.setup': "Set up",
+ 'photosync.change': "Change",
+ 'photosync.turn_off': "Turn off",
+ 'photosync.now': "Back up now",
+ 'photosync.permission_needed': "MeshBay needs access to this phone's photos.",
+ 'photosync.permission_allow': "Allow access",
+ 'photosync.permission_partial': "Access is limited to the photos you selected: new photos will not be backed up until you allow access to all of them.",
+ 'photosync.group': "Group",
+ 'photosync.choose_group': "Choose a group",
+ 'photosync.checking_group': "Looking at the group's folders…",
+ 'photosync.group_offline': "No node hosting this group is online right now. Try again when it is.",
+ 'photosync.no_folder': "This group has no folder you can add to that its Photos tab shows.",
+ 'photosync.folder': "Folder",
+ 'photosync.albums': "Albums on this phone",
+ 'photosync.album_camera': "Camera",
+ 'photosync.album_count': "{n} photos · {size}",
+ 'photosync.scope_existing': "The photos already on this phone, then new ones",
+ 'photosync.scope_new': "Only photos taken from now on",
+ 'photosync.start': "Start backup",
+ 'photosync.confirm': "Your photos will be sent to {group} (hosted by @{owner}, {members} members) into {folder}. Every member of this group will be able to see and download them.\n\n{count} photos ({size}) will be sent, then new ones once a day.\n\nYou can delete what you sent later, but not copies others have already downloaded.",
+ 'photosync.confirm_new': "Your photos will be sent to {group} (hosted by @{owner}, {members} members) into {folder}. Every member of this group will be able to see and download them.\n\nNew photos will be sent once a day.\n\nYou can delete what you sent later, but not copies others have already downloaded.",
+ 'photosync.metered_confirm': "You are on mobile data — {count} photos, {size}. Send them anyway?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Set up for @{account}. Sign in as @{account} to continue it, or set it up again here.",
+ 'photosync.sent_total': "Photos sent: {n}",
+ 'photosync.last_run': "Last backup: {when}",
+ 'photosync.never': "Not backed up yet",
+ 'photosync.state_waiting_wifi': "Waiting for Wi-Fi",
+ 'photosync.state_connecting': "Connecting to the group…",
+ 'photosync.state_sending': "Sending {done} / {total}",
+ 'photosync.state_offline': "No node hosting the group is online; it will try again later",
+ 'photosync.state_interrupted': "Interrupted — it will carry on at the next chance",
+ 'photosync.state_up_to_date': "Up to date",
+ 'photosync.notif_progress': "Backing up photos — {done} / {total}",
+ 'photosync.fail_disk_full': "Photo backup stopped: the node's disk is full.",
+ 'photosync.fail_read_only': "Photo backup stopped: the folder no longer accepts uploads.",
+ 'photosync.fail_no_folder': "Photo backup stopped: the folder no longer exists in the group.",
+ 'photosync.fail_unavailable': "Photo backup stopped: the folder's drive is not connected to the node.",
+ 'photosync.fail_not_member': "Photo backup stopped: you are no longer a member of this group.",
+ 'photosync.fail_permission': "Photo backup stopped: MeshBay no longer has access to the photos.",
+ 'photosync.fail_other': "Photo backup stopped.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 0731b76..284f454 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1276,4 +1276,49 @@ export default {
'native.node_account_confirm': 'El node de este ordenador está configurado para {current}. ¿Configurarlo para {next} en su lugar? Dejará de servir los grupos que aloja para {current}.',
'native.browser_access_confirm': '¿Permitir el acceso desde un navegador? La aplicación dejará su identidad en cada node que use, sellada de modo que solo su frase de contraseña, junto con su cuenta del hub, pueda abrirla.',
'native.declined': 'Cancelado: no se ha cambiado nada.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Copia de seguridad de fotos",
+ 'photosync.intro': "Envía las fotos tomadas con este teléfono a una carpeta de uno de tus grupos, una vez al día, por Wi-Fi. Las fotos borradas del teléfono se quedan en el grupo.",
+ 'photosync.setup': "Configurar",
+ 'photosync.change': "Cambiar",
+ 'photosync.turn_off': "Desactivar",
+ 'photosync.now': "Hacer copia ahora",
+ 'photosync.permission_needed': "MeshBay necesita acceso a las fotos de este teléfono.",
+ 'photosync.permission_allow': "Permitir acceso",
+ 'photosync.permission_partial': "El acceso está limitado a las fotos que seleccionaste: las fotos nuevas no se copiarán hasta que permitas el acceso a todas.",
+ 'photosync.group': "Grupo",
+ 'photosync.choose_group': "Elegir un grupo",
+ 'photosync.checking_group': "Leyendo las carpetas del grupo…",
+ 'photosync.group_offline': "Ningún node que aloje este grupo está en línea ahora. Vuelve a intentarlo cuando lo esté.",
+ 'photosync.no_folder': "Este grupo no tiene ninguna carpeta en la que puedas subir archivos y que muestre su pestaña Fotos.",
+ 'photosync.folder': "Carpeta",
+ 'photosync.albums': "Álbumes de este teléfono",
+ 'photosync.album_camera': "Cámara",
+ 'photosync.album_count': "{n} fotos · {size}",
+ 'photosync.scope_existing': "Las fotos que ya están en este teléfono y luego las nuevas",
+ 'photosync.scope_new': "Solo las fotos tomadas a partir de ahora",
+ 'photosync.start': "Iniciar la copia",
+ 'photosync.confirm': "Tus fotos se enviarán a {group} (alojado por @{owner}, {members} miembros), a la carpeta {folder}. Todos los miembros de este grupo podrán verlas y descargarlas.\n\nSe enviarán {count} fotos ({size}) y después las nuevas una vez al día.\n\nPodrás borrar lo que enviaste, pero no las copias que otros ya hayan descargado.",
+ 'photosync.confirm_new': "Tus fotos se enviarán a {group} (alojado por @{owner}, {members} miembros), a la carpeta {folder}. Todos los miembros de este grupo podrán verlas y descargarlas.\n\nLas fotos nuevas se enviarán una vez al día.\n\nPodrás borrar lo que enviaste, pero no las copias que otros ya hayan descargado.",
+ 'photosync.metered_confirm': "Estás usando datos móviles — {count} fotos, {size}. ¿Enviarlas de todos modos?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Configurada para @{account}. Inicia sesión como @{account} para continuarla, o vuelve a configurarla aquí.",
+ 'photosync.sent_total': "Fotos enviadas: {n}",
+ 'photosync.last_run': "Última copia: {when}",
+ 'photosync.never': "Aún sin copia",
+ 'photosync.state_waiting_wifi': "Esperando Wi-Fi",
+ 'photosync.state_connecting': "Conectando con el grupo…",
+ 'photosync.state_sending': "Enviando {done} / {total}",
+ 'photosync.state_offline': "Ningún node del grupo está en línea; se volverá a intentar más tarde",
+ 'photosync.state_interrupted': "Interrumpida — continuará en la próxima ocasión",
+ 'photosync.state_up_to_date': "Al día",
+ 'photosync.notif_progress': "Copia de fotos — {done} / {total}",
+ 'photosync.fail_disk_full': "La copia de fotos se detuvo: el disco del node está lleno.",
+ 'photosync.fail_read_only': "La copia de fotos se detuvo: la carpeta ya no acepta subidas.",
+ 'photosync.fail_no_folder': "La copia de fotos se detuvo: la carpeta ya no existe en el grupo.",
+ 'photosync.fail_unavailable': "La copia de fotos se detuvo: el disco de la carpeta no está conectado al node.",
+ 'photosync.fail_not_member': "La copia de fotos se detuvo: ya no eres miembro de este grupo.",
+ 'photosync.fail_permission': "La copia de fotos se detuvo: MeshBay ya no tiene acceso a las fotos.",
+ 'photosync.fail_other': "La copia de fotos se detuvo.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index e67f50d..4554ffb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1291,4 +1291,49 @@ export default {
'native.node_account_confirm': 'Le node de cet ordinateur est configuré pour {current}. Le configurer pour {next} à la place ? Il cessera de servir les groupes qu\'il héberge pour {current}.',
'native.browser_access_confirm': 'Autoriser la connexion depuis un navigateur ? L\'application déposera votre identité sur chaque node que vous utilisez, scellée de sorte que seule votre phrase secrète, avec votre compte sur le hub, puisse l\'ouvrir.',
'native.declined': 'Annulé — rien n\'a été modifié.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Sauvegarde des photos",
+ 'photosync.intro': "Envoyer les photos prises avec ce téléphone dans un dossier d'un de vos groupes, une fois par jour, en Wi-Fi. Les photos supprimées du téléphone restent dans le groupe.",
+ 'photosync.setup': "Configurer",
+ 'photosync.change': "Modifier",
+ 'photosync.turn_off': "Désactiver",
+ 'photosync.now': "Sauvegarder maintenant",
+ 'photosync.permission_needed': "MeshBay a besoin d'accéder aux photos de ce téléphone.",
+ 'photosync.permission_allow': "Autoriser l'accès",
+ 'photosync.permission_partial': "L'accès est limité aux photos que vous avez sélectionnées : les nouvelles photos ne seront pas sauvegardées tant que vous n'autorisez pas l'accès à toutes.",
+ 'photosync.group': "Groupe",
+ 'photosync.choose_group': "Choisir un groupe",
+ 'photosync.checking_group': "Lecture des dossiers du groupe…",
+ 'photosync.group_offline': "Aucun node hébergeant ce groupe n'est en ligne pour le moment. Réessayez quand il le sera.",
+ 'photosync.no_folder': "Ce groupe n'a aucun dossier où vous pouvez déposer des fichiers et que son onglet Photos affiche.",
+ 'photosync.folder': "Dossier",
+ 'photosync.albums': "Albums de ce téléphone",
+ 'photosync.album_camera': "Appareil photo",
+ 'photosync.album_count': "{n} photos · {size}",
+ 'photosync.scope_existing': "Les photos déjà sur ce téléphone, puis les nouvelles",
+ 'photosync.scope_new': "Seulement les photos prises à partir de maintenant",
+ 'photosync.start': "Lancer la sauvegarde",
+ 'photosync.confirm': "Vos photos seront envoyées dans {group} (hébergé par @{owner}, {members} membres), dans le dossier {folder}. Tous les membres de ce groupe pourront les voir et les télécharger.\n\n{count} photos ({size}) seront envoyées, puis les nouvelles une fois par jour.\n\nVous pourrez supprimer ce que vous avez envoyé, mais pas les copies que d'autres auront déjà téléchargées.",
+ 'photosync.confirm_new': "Vos photos seront envoyées dans {group} (hébergé par @{owner}, {members} membres), dans le dossier {folder}. Tous les membres de ce groupe pourront les voir et les télécharger.\n\nLes nouvelles photos seront envoyées une fois par jour.\n\nVous pourrez supprimer ce que vous avez envoyé, mais pas les copies que d'autres auront déjà téléchargées.",
+ 'photosync.metered_confirm': "Vous êtes en données mobiles — {count} photos, {size}. Les envoyer quand même ?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Configurée pour @{account}. Connectez-vous en tant que @{account} pour la poursuivre, ou configurez-la à nouveau ici.",
+ 'photosync.sent_total': "Photos envoyées : {n}",
+ 'photosync.last_run': "Dernière sauvegarde : {when}",
+ 'photosync.never': "Pas encore sauvegardé",
+ 'photosync.state_waiting_wifi': "En attente du Wi-Fi",
+ 'photosync.state_connecting': "Connexion au groupe…",
+ 'photosync.state_sending': "Envoi {done} / {total}",
+ 'photosync.state_offline': "Aucun node hébergeant le groupe n'est en ligne ; nouvel essai plus tard",
+ 'photosync.state_interrupted': "Interrompue — elle reprendra à la prochaine occasion",
+ 'photosync.state_up_to_date': "À jour",
+ 'photosync.notif_progress': "Sauvegarde des photos — {done} / {total}",
+ 'photosync.fail_disk_full': "Sauvegarde des photos arrêtée : le disque du node est plein.",
+ 'photosync.fail_read_only': "Sauvegarde des photos arrêtée : le dossier n'accepte plus les dépôts.",
+ 'photosync.fail_no_folder': "Sauvegarde des photos arrêtée : le dossier n'existe plus dans le groupe.",
+ 'photosync.fail_unavailable': "Sauvegarde des photos arrêtée : le disque du dossier n'est pas branché sur le node.",
+ 'photosync.fail_not_member': "Sauvegarde des photos arrêtée : vous n'êtes plus membre de ce groupe.",
+ 'photosync.fail_permission': "Sauvegarde des photos arrêtée : MeshBay n'a plus accès aux photos.",
+ 'photosync.fail_other': "Sauvegarde des photos arrêtée.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index d88b8d5..bc091e3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1290,4 +1290,49 @@ export default {
'native.node_account_confirm': 'Il node di questo computer è configurato per {current}. Configurarlo invece per {next}? Smetterà di servire i gruppi che ospita per {current}.',
'native.browser_access_confirm': 'Consentire l\'accesso da un browser? L\'applicazione lascerà la tua identità su ogni node che usi, sigillata in modo che solo la tua passphrase, insieme al tuo account sull\'hub, possa aprirla.',
'native.declined': 'Annullato: non è stato modificato nulla.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Backup delle foto",
+ 'photosync.intro': "Invia le foto scattate con questo telefono in una cartella di uno dei tuoi gruppi, una volta al giorno, in Wi-Fi. Le foto eliminate dal telefono restano nel gruppo.",
+ 'photosync.setup': "Configura",
+ 'photosync.change': "Modifica",
+ 'photosync.turn_off': "Disattiva",
+ 'photosync.now': "Esegui ora il backup",
+ 'photosync.permission_needed': "MeshBay ha bisogno di accedere alle foto di questo telefono.",
+ 'photosync.permission_allow': "Consenti l'accesso",
+ 'photosync.permission_partial': "L'accesso è limitato alle foto che hai selezionato: le nuove foto non verranno salvate finché non consenti l'accesso a tutte.",
+ 'photosync.group': "Gruppo",
+ 'photosync.choose_group': "Scegli un gruppo",
+ 'photosync.checking_group': "Lettura delle cartelle del gruppo…",
+ 'photosync.group_offline': "Nessun node che ospita questo gruppo è online ora. Riprova quando lo sarà.",
+ 'photosync.no_folder': "Questo gruppo non ha nessuna cartella in cui puoi caricare file e che la sua scheda Foto mostri.",
+ 'photosync.folder': "Cartella",
+ 'photosync.albums': "Album su questo telefono",
+ 'photosync.album_camera': "Fotocamera",
+ 'photosync.album_count': "{n} foto · {size}",
+ 'photosync.scope_existing': "Le foto già su questo telefono, poi quelle nuove",
+ 'photosync.scope_new': "Solo le foto scattate da ora in poi",
+ 'photosync.start': "Avvia il backup",
+ 'photosync.confirm': "Le tue foto verranno inviate a {group} (ospitato da @{owner}, {members} membri), nella cartella {folder}. Tutti i membri di questo gruppo potranno vederle e scaricarle.\n\nVerranno inviate {count} foto ({size}), poi quelle nuove una volta al giorno.\n\nPotrai eliminare ciò che hai inviato, ma non le copie che altri hanno già scaricato.",
+ 'photosync.confirm_new': "Le tue foto verranno inviate a {group} (ospitato da @{owner}, {members} membri), nella cartella {folder}. Tutti i membri di questo gruppo potranno vederle e scaricarle.\n\nLe nuove foto verranno inviate una volta al giorno.\n\nPotrai eliminare ciò che hai inviato, ma non le copie che altri hanno già scaricato.",
+ 'photosync.metered_confirm': "Stai usando i dati mobili — {count} foto, {size}. Inviarle comunque?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Configurato per @{account}. Accedi come @{account} per continuarlo, oppure configuralo di nuovo qui.",
+ 'photosync.sent_total': "Foto inviate: {n}",
+ 'photosync.last_run': "Ultimo backup: {when}",
+ 'photosync.never': "Nessun backup ancora",
+ 'photosync.state_waiting_wifi': "In attesa del Wi-Fi",
+ 'photosync.state_connecting': "Connessione al gruppo…",
+ 'photosync.state_sending': "Invio {done} / {total}",
+ 'photosync.state_offline': "Nessun node del gruppo è online; nuovo tentativo più tardi",
+ 'photosync.state_interrupted': "Interrotto — riprenderà alla prossima occasione",
+ 'photosync.state_up_to_date': "Aggiornato",
+ 'photosync.notif_progress': "Backup delle foto — {done} / {total}",
+ 'photosync.fail_disk_full': "Backup delle foto interrotto: il disco del node è pieno.",
+ 'photosync.fail_read_only': "Backup delle foto interrotto: la cartella non accetta più caricamenti.",
+ 'photosync.fail_no_folder': "Backup delle foto interrotto: la cartella non esiste più nel gruppo.",
+ 'photosync.fail_unavailable': "Backup delle foto interrotto: il disco della cartella non è collegato al node.",
+ 'photosync.fail_not_member': "Backup delle foto interrotto: non sei più membro di questo gruppo.",
+ 'photosync.fail_permission': "Backup delle foto interrotto: MeshBay non ha più accesso alle foto.",
+ 'photosync.fail_other': "Backup delle foto interrotto.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index edf134d..0522f4c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1274,4 +1274,49 @@ export default {
'native.node_account_confirm': 'このコンピューターの node は {current} 用に設定されています。代わりに {next} 用に設定しますか?{current} のためにホストしているグループは提供されなくなります。',
'native.browser_access_confirm': 'ブラウザーからのサインインを許可しますか?アプリは、利用中のすべての node にあなたの ID を残します。これは、パスフレーズと hub のアカウントの両方がそろった場合にのみ開けるよう封印されます。',
'native.declined': 'キャンセルしました。何も変更されていません。',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "写真のバックアップ",
+ 'photosync.intro': "この電話で撮った写真を、1 日 1 回 Wi-Fi で、グループのいずれかのフォルダーに送ります。電話から削除した写真はグループに残ります。",
+ 'photosync.setup': "設定する",
+ 'photosync.change': "変更",
+ 'photosync.turn_off': "オフにする",
+ 'photosync.now': "今すぐバックアップ",
+ 'photosync.permission_needed': "MeshBay がこの電話の写真にアクセスする必要があります。",
+ 'photosync.permission_allow': "アクセスを許可",
+ 'photosync.permission_partial': "アクセスは選択した写真に限られています。すべての写真へのアクセスを許可するまで、新しい写真はバックアップされません。",
+ 'photosync.group': "グループ",
+ 'photosync.choose_group': "グループを選択",
+ 'photosync.checking_group': "グループのフォルダーを確認しています…",
+ 'photosync.group_offline': "このグループをホストしている node は現在オンラインではありません。オンラインになってから再度お試しください。",
+ 'photosync.no_folder': "このグループには、アップロードでき、かつ写真タブに表示されるフォルダーがありません。",
+ 'photosync.folder': "フォルダー",
+ 'photosync.albums': "この電話のアルバム",
+ 'photosync.album_camera': "カメラ",
+ 'photosync.album_count': "{n} 枚 · {size}",
+ 'photosync.scope_existing': "この電話にすでにある写真と、これからの写真",
+ 'photosync.scope_new': "これから撮る写真のみ",
+ 'photosync.start': "バックアップを開始",
+ 'photosync.confirm': "写真は {group}(ホスト: @{owner}、メンバー {members} 人)のフォルダー {folder} に送られます。このグループのメンバー全員が写真を見てダウンロードできます。\n\n{count} 枚({size})を送信し、その後は新しい写真を 1 日 1 回送信します。\n\n送信したものは後で削除できますが、他の人がすでにダウンロードしたコピーは削除できません。",
+ 'photosync.confirm_new': "写真は {group}(ホスト: @{owner}、メンバー {members} 人)のフォルダー {folder} に送られます。このグループのメンバー全員が写真を見てダウンロードできます。\n\n新しい写真を 1 日 1 回送信します。\n\n送信したものは後で削除できますが、他の人がすでにダウンロードしたコピーは削除できません。",
+ 'photosync.metered_confirm': "モバイルデータ通信中です — {count} 枚、{size}。それでも送信しますか?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "@{account} 用に設定されています。続けるには @{account} でサインインするか、ここで設定し直してください。",
+ 'photosync.sent_total': "送信済みの写真: {n}",
+ 'photosync.last_run': "前回のバックアップ: {when}",
+ 'photosync.never': "まだバックアップしていません",
+ 'photosync.state_waiting_wifi': "Wi-Fi を待っています",
+ 'photosync.state_connecting': "グループに接続しています…",
+ 'photosync.state_sending': "送信中 {done} / {total}",
+ 'photosync.state_offline': "グループの node がオンラインではありません。後で再試行します",
+ 'photosync.state_interrupted': "中断しました — 次の機会に続行します",
+ 'photosync.state_up_to_date': "最新です",
+ 'photosync.notif_progress': "写真をバックアップ中 — {done} / {total}",
+ 'photosync.fail_disk_full': "写真のバックアップを停止しました: node のディスクがいっぱいです。",
+ 'photosync.fail_read_only': "写真のバックアップを停止しました: フォルダーがアップロードを受け付けなくなりました。",
+ 'photosync.fail_no_folder': "写真のバックアップを停止しました: フォルダーがグループに存在しなくなりました。",
+ 'photosync.fail_unavailable': "写真のバックアップを停止しました: フォルダーのドライブが node に接続されていません。",
+ 'photosync.fail_not_member': "写真のバックアップを停止しました: このグループのメンバーではなくなりました。",
+ 'photosync.fail_permission': "写真のバックアップを停止しました: MeshBay が写真にアクセスできなくなりました。",
+ 'photosync.fail_other': "写真のバックアップを停止しました。",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 0eaedbb..b258b04 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1292,4 +1292,49 @@ export default {
'native.node_account_confirm': 'De node op deze computer is ingesteld voor {current}. In plaats daarvan instellen voor {next}? Hij stopt dan met het aanbieden van de groepen die hij voor {current} host.',
'native.browser_access_confirm': 'Aanmelden vanuit een browser toestaan? De toepassing laat je identiteit achter op elke node die je gebruikt, zo verzegeld dat alleen je wachtzin samen met je hub-account haar kan openen.',
'native.declined': 'Geannuleerd — er is niets gewijzigd.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Fotoback-up",
+ 'photosync.intro': "Stuur de foto's die met deze telefoon zijn gemaakt eenmaal per dag via wifi naar een map van een van je groepen. Foto's die van de telefoon worden verwijderd, blijven in de groep.",
+ 'photosync.setup': "Instellen",
+ 'photosync.change': "Wijzigen",
+ 'photosync.turn_off': "Uitschakelen",
+ 'photosync.now': "Nu back-uppen",
+ 'photosync.permission_needed': "MeshBay heeft toegang nodig tot de foto's op deze telefoon.",
+ 'photosync.permission_allow': "Toegang toestaan",
+ 'photosync.permission_partial': "De toegang is beperkt tot de foto's die je hebt geselecteerd: nieuwe foto's worden pas geback-upt als je toegang tot alle foto's toestaat.",
+ 'photosync.group': "Groep",
+ 'photosync.choose_group': "Kies een groep",
+ 'photosync.checking_group': "Mappen van de groep worden gelezen…",
+ 'photosync.group_offline': "Er is nu geen node online die deze groep host. Probeer het opnieuw als dat wel zo is.",
+ 'photosync.no_folder': "Deze groep heeft geen map waarin je kunt uploaden en die het tabblad Foto's toont.",
+ 'photosync.folder': "Map",
+ 'photosync.albums': "Albums op deze telefoon",
+ 'photosync.album_camera': "Camera",
+ 'photosync.album_count': "{n} foto's · {size}",
+ 'photosync.scope_existing': "De foto's die al op deze telefoon staan, daarna nieuwe",
+ 'photosync.scope_new': "Alleen foto's die vanaf nu worden gemaakt",
+ 'photosync.start': "Back-up starten",
+ 'photosync.confirm': "Je foto's worden naar {group} (gehost door @{owner}, {members} leden) gestuurd, in de map {folder}. Alle leden van deze groep kunnen ze zien en downloaden.\n\nEr worden {count} foto's ({size}) gestuurd, daarna eenmaal per dag de nieuwe.\n\nJe kunt wat je hebt gestuurd later verwijderen, maar niet de kopieën die anderen al hebben gedownload.",
+ 'photosync.confirm_new': "Je foto's worden naar {group} (gehost door @{owner}, {members} leden) gestuurd, in de map {folder}. Alle leden van deze groep kunnen ze zien en downloaden.\n\nNieuwe foto's worden eenmaal per dag gestuurd.\n\nJe kunt wat je hebt gestuurd later verwijderen, maar niet de kopieën die anderen al hebben gedownload.",
+ 'photosync.metered_confirm': "Je gebruikt mobiele data — {count} foto's, {size}. Toch versturen?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Ingesteld voor @{account}. Log in als @{account} om verder te gaan, of stel het hier opnieuw in.",
+ 'photosync.sent_total': "Verstuurde foto's: {n}",
+ 'photosync.last_run': "Laatste back-up: {when}",
+ 'photosync.never': "Nog geen back-up",
+ 'photosync.state_waiting_wifi': "Wacht op wifi",
+ 'photosync.state_connecting': "Verbinden met de groep…",
+ 'photosync.state_sending': "Versturen {done} / {total}",
+ 'photosync.state_offline': "Geen node van de groep is online; later nieuwe poging",
+ 'photosync.state_interrupted': "Onderbroken — gaat bij de volgende gelegenheid verder",
+ 'photosync.state_up_to_date': "Bijgewerkt",
+ 'photosync.notif_progress': "Foto's back-uppen — {done} / {total}",
+ 'photosync.fail_disk_full': "Fotoback-up gestopt: de schijf van de node is vol.",
+ 'photosync.fail_read_only': "Fotoback-up gestopt: de map accepteert geen uploads meer.",
+ 'photosync.fail_no_folder': "Fotoback-up gestopt: de map bestaat niet meer in de groep.",
+ 'photosync.fail_unavailable': "Fotoback-up gestopt: de schijf van de map is niet aangesloten op de node.",
+ 'photosync.fail_not_member': "Fotoback-up gestopt: je bent geen lid meer van deze groep.",
+ 'photosync.fail_permission': "Fotoback-up gestopt: MeshBay heeft geen toegang meer tot de foto's.",
+ 'photosync.fail_other': "Fotoback-up gestopt.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 6efebb8..a750c2f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1318,4 +1318,49 @@ export default {
'native.node_account_confirm': 'Node na tym komputerze jest skonfigurowany dla {current}. Skonfigurować go zamiast tego dla {next}? Przestanie obsługiwać grupy, które hostuje dla {current}.',
'native.browser_access_confirm': 'Zezwolić na logowanie z przeglądarki? Aplikacja pozostawi Twoją tożsamość na każdym używanym node, zapieczętowaną tak, by otworzyć ją mogło tylko Twoje hasło wraz z kontem na hubie.',
'native.declined': 'Anulowano — nic nie zostało zmienione.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Kopia zdjęć",
+ 'photosync.intro': "Wysyłaj zdjęcia zrobione tym telefonem do folderu jednej z Twoich grup, raz dziennie, przez Wi-Fi. Zdjęcia usunięte z telefonu zostają w grupie.",
+ 'photosync.setup': "Skonfiguruj",
+ 'photosync.change': "Zmień",
+ 'photosync.turn_off': "Wyłącz",
+ 'photosync.now': "Utwórz kopię teraz",
+ 'photosync.permission_needed': "MeshBay potrzebuje dostępu do zdjęć na tym telefonie.",
+ 'photosync.permission_allow': "Zezwól na dostęp",
+ 'photosync.permission_partial': "Dostęp jest ograniczony do wybranych zdjęć: nowe zdjęcia nie będą kopiowane, dopóki nie zezwolisz na dostęp do wszystkich.",
+ 'photosync.group': "Grupa",
+ 'photosync.choose_group': "Wybierz grupę",
+ 'photosync.checking_group': "Odczytywanie folderów grupy…",
+ 'photosync.group_offline': "Żaden node hostujący tę grupę nie jest teraz dostępny. Spróbuj ponownie, gdy będzie.",
+ 'photosync.no_folder': "Ta grupa nie ma folderu, do którego możesz przesyłać pliki i który pokazuje jej karta Zdjęcia.",
+ 'photosync.folder': "Folder",
+ 'photosync.albums': "Albumy na tym telefonie",
+ 'photosync.album_camera': "Aparat",
+ 'photosync.album_count': "Zdjęcia: {n} · {size}",
+ 'photosync.scope_existing': "Zdjęcia, które już są na tym telefonie, a potem nowe",
+ 'photosync.scope_new': "Tylko zdjęcia zrobione od teraz",
+ 'photosync.start': "Rozpocznij kopię",
+ 'photosync.confirm': "Twoje zdjęcia zostaną wysłane do grupy {group} (hostowanej przez @{owner}, członkowie: {members}), do folderu {folder}. Wszyscy członkowie tej grupy będą mogli je oglądać i pobierać.\n\nZdjęcia do wysłania: {count} ({size}), a potem nowe raz dziennie.\n\nMożesz później usunąć to, co wysłałeś, ale nie kopie, które inni już pobrali.",
+ 'photosync.confirm_new': "Twoje zdjęcia zostaną wysłane do grupy {group} (hostowanej przez @{owner}, członkowie: {members}), do folderu {folder}. Wszyscy członkowie tej grupy będą mogli je oglądać i pobierać.\n\nNowe zdjęcia będą wysyłane raz dziennie.\n\nMożesz później usunąć to, co wysłałeś, ale nie kopie, które inni już pobrali.",
+ 'photosync.metered_confirm': "Korzystasz z danych komórkowych — zdjęcia: {count}, {size}. Wysłać mimo to?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Skonfigurowano dla @{account}. Zaloguj się jako @{account}, aby ją kontynuować, albo skonfiguruj ją tu ponownie.",
+ 'photosync.sent_total': "Wysłane zdjęcia: {n}",
+ 'photosync.last_run': "Ostatnia kopia: {when}",
+ 'photosync.never': "Jeszcze bez kopii",
+ 'photosync.state_waiting_wifi': "Oczekiwanie na Wi-Fi",
+ 'photosync.state_connecting': "Łączenie z grupą…",
+ 'photosync.state_sending': "Wysyłanie {done} / {total}",
+ 'photosync.state_offline': "Żaden node grupy nie jest dostępny; kolejna próba później",
+ 'photosync.state_interrupted': "Przerwano — będzie kontynuowana przy następnej okazji",
+ 'photosync.state_up_to_date': "Aktualna",
+ 'photosync.notif_progress': "Kopia zdjęć — {done} / {total}",
+ 'photosync.fail_disk_full': "Kopia zdjęć zatrzymana: dysk, na którym działa node, jest pełny.",
+ 'photosync.fail_read_only': "Kopia zdjęć zatrzymana: folder nie przyjmuje już plików.",
+ 'photosync.fail_no_folder': "Kopia zdjęć zatrzymana: folder już nie istnieje w grupie.",
+ 'photosync.fail_unavailable': "Kopia zdjęć zatrzymana: dysk z folderem nie jest podłączony do node.",
+ 'photosync.fail_not_member': "Kopia zdjęć zatrzymana: nie jesteś już członkiem tej grupy.",
+ 'photosync.fail_permission': "Kopia zdjęć zatrzymana: MeshBay nie ma już dostępu do zdjęć.",
+ 'photosync.fail_other': "Kopia zdjęć zatrzymana.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index df2d1f8..bd91cee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1277,4 +1277,49 @@ export default {
'native.node_account_confirm': 'O node deste computador está configurado para {current}. Configurá-lo para {next} em vez disso? Ele deixará de servir os grupos que hospeda para {current}.',
'native.browser_access_confirm': 'Permitir o acesso por um navegador? O aplicativo deixará sua identidade em cada node que você usa, selada de forma que apenas sua frase secreta, junto com sua conta no hub, possa abri-la.',
'native.declined': 'Cancelado — nada foi alterado.',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "Backup de fotos",
+ 'photosync.intro': "Envie as fotos tiradas com este celular para uma pasta de um dos seus grupos, uma vez por dia, pelo Wi-Fi. Fotos apagadas do celular continuam no grupo.",
+ 'photosync.setup': "Configurar",
+ 'photosync.change': "Alterar",
+ 'photosync.turn_off': "Desativar",
+ 'photosync.now': "Fazer backup agora",
+ 'photosync.permission_needed': "O MeshBay precisa de acesso às fotos deste celular.",
+ 'photosync.permission_allow': "Permitir acesso",
+ 'photosync.permission_partial': "O acesso está limitado às fotos que você selecionou: novas fotos não serão salvas até você permitir o acesso a todas.",
+ 'photosync.group': "Grupo",
+ 'photosync.choose_group': "Escolha um grupo",
+ 'photosync.checking_group': "Lendo as pastas do grupo…",
+ 'photosync.group_offline': "Nenhum node que hospeda este grupo está on-line agora. Tente de novo quando estiver.",
+ 'photosync.no_folder': "Este grupo não tem nenhuma pasta onde você possa enviar arquivos e que a aba Fotos mostre.",
+ 'photosync.folder': "Pasta",
+ 'photosync.albums': "Álbuns deste celular",
+ 'photosync.album_camera': "Câmera",
+ 'photosync.album_count': "{n} fotos · {size}",
+ 'photosync.scope_existing': "As fotos que já estão neste celular, depois as novas",
+ 'photosync.scope_new': "Somente as fotos tiradas a partir de agora",
+ 'photosync.start': "Iniciar backup",
+ 'photosync.confirm': "Suas fotos serão enviadas para {group} (hospedado por @{owner}, {members} membros), na pasta {folder}. Todos os membros deste grupo poderão vê-las e baixá-las.\n\nSerão enviadas {count} fotos ({size}) e, depois, as novas uma vez por dia.\n\nVocê poderá apagar o que enviou, mas não as cópias que outros já tiverem baixado.",
+ 'photosync.confirm_new': "Suas fotos serão enviadas para {group} (hospedado por @{owner}, {members} membros), na pasta {folder}. Todos os membros deste grupo poderão vê-las e baixá-las.\n\nAs novas fotos serão enviadas uma vez por dia.\n\nVocê poderá apagar o que enviou, mas não as cópias que outros já tiverem baixado.",
+ 'photosync.metered_confirm': "Você está usando dados móveis — {count} fotos, {size}. Enviar mesmo assim?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "Configurado para @{account}. Entre como @{account} para continuar, ou configure de novo aqui.",
+ 'photosync.sent_total': "Fotos enviadas: {n}",
+ 'photosync.last_run': "Último backup: {when}",
+ 'photosync.never': "Ainda sem backup",
+ 'photosync.state_waiting_wifi': "Aguardando Wi-Fi",
+ 'photosync.state_connecting': "Conectando ao grupo…",
+ 'photosync.state_sending': "Enviando {done} / {total}",
+ 'photosync.state_offline': "Nenhum node do grupo está on-line; nova tentativa mais tarde",
+ 'photosync.state_interrupted': "Interrompido — continuará na próxima oportunidade",
+ 'photosync.state_up_to_date': "Atualizado",
+ 'photosync.notif_progress': "Backup de fotos — {done} / {total}",
+ 'photosync.fail_disk_full': "Backup de fotos parado: o disco do node está cheio.",
+ 'photosync.fail_read_only': "Backup de fotos parado: a pasta não aceita mais envios.",
+ 'photosync.fail_no_folder': "Backup de fotos parado: a pasta não existe mais no grupo.",
+ 'photosync.fail_unavailable': "Backup de fotos parado: o disco da pasta não está conectado ao node.",
+ 'photosync.fail_not_member': "Backup de fotos parado: você não é mais membro deste grupo.",
+ 'photosync.fail_permission': "Backup de fotos parado: o MeshBay não tem mais acesso às fotos.",
+ 'photosync.fail_other': "Backup de fotos parado.",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 709eb65..d7b0aeb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1263,4 +1263,49 @@ export default {
'native.node_account_confirm': '这台电脑上的 node 已为 {current} 设置。改为为 {next} 设置吗?它将不再为 {current} 提供其托管的群组。',
'native.browser_access_confirm': '允许从浏览器登录吗?应用会在您使用的每个 node 上留下您的身份,并加以封存,只有您的密码短语配合您的 hub 账户才能打开。',
'native.declined': '已取消,未做任何更改。',
+
+ // Photo backup, Android only (photo-sync.js, photo-sync-settings.js).
+ 'photosync.heading': "照片备份",
+ 'photosync.intro': "每天一次,通过 Wi-Fi 将这部手机拍摄的照片发送到您某个群组的文件夹。从手机删除的照片会保留在群组中。",
+ 'photosync.setup': "设置",
+ 'photosync.change': "更改",
+ 'photosync.turn_off': "关闭",
+ 'photosync.now': "立即备份",
+ 'photosync.permission_needed': "MeshBay 需要访问这部手机上的照片。",
+ 'photosync.permission_allow': "允许访问",
+ 'photosync.permission_partial': "访问权限仅限于您选择的照片:在您允许访问全部照片之前,新照片不会被备份。",
+ 'photosync.group': "群组",
+ 'photosync.choose_group': "选择群组",
+ 'photosync.checking_group': "正在读取群组的文件夹…",
+ 'photosync.group_offline': "目前没有托管此群组的 node 在线。请在其上线后重试。",
+ 'photosync.no_folder': "此群组没有您可以上传、且其照片标签页会显示的文件夹。",
+ 'photosync.folder': "文件夹",
+ 'photosync.albums': "这部手机上的相册",
+ 'photosync.album_camera': "相机",
+ 'photosync.album_count': "{n} 张照片 · {size}",
+ 'photosync.scope_existing': "手机上已有的照片,然后是新照片",
+ 'photosync.scope_new': "仅从现在起拍摄的照片",
+ 'photosync.start': "开始备份",
+ 'photosync.confirm': "您的照片将发送到 {group}(由 @{owner} 托管,{members} 位成员)的文件夹 {folder}。此群组的所有成员都可以查看和下载这些照片。\n\n将发送 {count} 张照片({size}),之后每天发送一次新照片。\n\n您之后可以删除已发送的内容,但无法删除他人已下载的副本。",
+ 'photosync.confirm_new': "您的照片将发送到 {group}(由 @{owner} 托管,{members} 位成员)的文件夹 {folder}。此群组的所有成员都可以查看和下载这些照片。\n\n新照片将每天发送一次。\n\n您之后可以删除已发送的内容,但无法删除他人已下载的副本。",
+ 'photosync.metered_confirm': "您正在使用移动数据 — {count} 张照片,{size}。仍要发送吗?",
+ 'photosync.summary': "{group} (@{owner}) → {folder}",
+ 'photosync.other_account': "已为 @{account} 设置。请以 @{account} 登录以继续,或在此重新设置。",
+ 'photosync.sent_total': "已发送照片:{n}",
+ 'photosync.last_run': "上次备份:{when}",
+ 'photosync.never': "尚未备份",
+ 'photosync.state_waiting_wifi': "正在等待 Wi-Fi",
+ 'photosync.state_connecting': "正在连接群组…",
+ 'photosync.state_sending': "正在发送 {done} / {total}",
+ 'photosync.state_offline': "群组的 node 均不在线;稍后重试",
+ 'photosync.state_interrupted': "已中断 — 将在下次有机会时继续",
+ 'photosync.state_up_to_date': "已是最新",
+ 'photosync.notif_progress': "正在备份照片 — {done} / {total}",
+ 'photosync.fail_disk_full': "照片备份已停止:node 的磁盘已满。",
+ 'photosync.fail_read_only': "照片备份已停止:该文件夹不再接受上传。",
+ 'photosync.fail_no_folder': "照片备份已停止:该文件夹已不在群组中。",
+ 'photosync.fail_unavailable': "照片备份已停止:该文件夹所在的磁盘未连接到 node。",
+ 'photosync.fail_not_member': "照片备份已停止:您已不再是此群组的成员。",
+ 'photosync.fail_permission': "照片备份已停止:MeshBay 已无法访问照片。",
+ 'photosync.fail_other': "照片备份已停止。",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js
new file mode 100644
index 0000000..1ec68d3
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js
@@ -0,0 +1,264 @@
+// The "Photo backup" section of Settings, on the Android application only
+// (docs/MESHBAY_DESIGN.md §9.12). Loaded on demand: no other client has it.
+
+import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import * as platform from './platform.js';
+import { hubFetch } from './hub-client.js';
+import { formatSize } from './file-utils.js';
+import { ask } from './ask.js';
+import { subscribe, runPhotoSync, probeGroup } from './photo-sync.js';
+
+function stateLine(st, status) {
+ switch (st.phase) {
+ case 'waiting_wifi': return t('photosync.state_waiting_wifi');
+ case 'connecting': return t('photosync.state_connecting');
+ case 'sending': return t('photosync.state_sending', { done: st.done, total: st.total });
+ case 'offline': return t('photosync.state_offline');
+ case 'interrupted': return t('photosync.state_interrupted');
+ case 'up_to_date': return t('photosync.state_up_to_date');
+ default: return status && status.lastCompleted ? t('photosync.state_up_to_date') : '';
+ }
+}
+
+const FAILURES = {
+ disk_full: 'photosync.fail_disk_full',
+ root_read_only: 'photosync.fail_read_only',
+ no_writable_root: 'photosync.fail_read_only',
+ no_such_root: 'photosync.fail_no_folder',
+ no_such_directory: 'photosync.fail_no_folder',
+ root_unavailable: 'photosync.fail_unavailable',
+ not_a_member: 'photosync.fail_not_member',
+ permission: 'photosync.fail_permission',
+};
+
+export function PhotoSyncSection({ user, groups }) {
+ const [status, setStatus] = useState(null);
+ const [run, setRun] = useState({ phase: 'idle' });
+ const [editing, setEditing] = useState(false);
+ const [error, setError] = useState('');
+
+ const refresh = useCallback(() => platform.photoSync.status().then(setStatus).catch(() => {}), []);
+ useEffect(() => { refresh(); }, [refresh]);
+ // A run that ends changes what the phone says (sent, last backup, a
+ // failure); read it again then rather than on a timer.
+ useEffect(() => subscribe((st) => {
+ setRun(st);
+ if (st.phase !== 'sending' && st.phase !== 'connecting') refresh();
+ }), [refresh]);
+
+ if (!status) return null;
+ const cfg = status.config;
+
+ const backUpNow = async () => {
+ setError('');
+ try {
+ if (!status.unmetered) {
+ const est = await platform.photoSync.estimate(cfg);
+ if (est.count === 0) { await runPhotoSync({ force: true, metered: true }); return; }
+ const yes = await ask(t('photosync.metered_confirm', {
+ count: est.count, size: formatSize(est.bytes) }));
+ if (!yes) return;
+ await runPhotoSync({ force: true, metered: true });
+ } else {
+ await runPhotoSync({ force: true });
+ }
+ } catch (e) { setError(e.message); }
+ };
+
+ const turnOff = async () => {
+ setError('');
+ try { setStatus(await platform.photoSync.configure(null)); } catch (e) { setError(e.message); }
+ };
+
+ return html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('photosync.heading')}</h3>
+ ${!cfg && !editing && html`
+ <p class="settings-hint">${t('photosync.intro')}</p>
+ <button class="admin-btn" onClick=${() => setEditing(true)}>${t('photosync.setup')}</button>
+ `}
+ ${cfg && !editing && html`
+ <div class="settings-row">
+ <span class="settings-label settings-name">
+ ${t('photosync.summary', { group: cfg.groupName, owner: cfg.owner, folder: cfg.folder })}
+ </span>
+ </div>
+ ${cfg.account !== user.username && html`
+ <p class="settings-hint">${t('photosync.other_account', { account: cfg.account })}</p>`}
+ <p class="settings-hint">
+ ${t('photosync.sent_total', { n: status.sent })}
+ ${' · '}
+ ${status.lastCompleted
+ ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() })
+ : t('photosync.never')}
+ </p>
+ ${status.permission === 'partial' && html`
+ <p class="settings-hint">${t('photosync.permission_partial')}</p>`}
+ ${status.failure
+ ? html`<p class="error-msg">${t(FAILURES[status.failure] || 'photosync.fail_other')}</p>`
+ : stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`}
+ <div class="settings-row" style="gap:8px; justify-content:flex-start; flex-wrap:wrap">
+ <button class="admin-btn" disabled=${run.phase === 'sending' || run.phase === 'connecting'}
+ onClick=${backUpNow}>${t('photosync.now')}</button>
+ <button class="btn-secondary" onClick=${() => setEditing(true)}>${t('photosync.change')}</button>
+ <button class="btn-secondary" onClick=${turnOff}>${t('photosync.turn_off')}</button>
+ </div>
+ `}
+ ${editing && html`
+ <${Setup} user=${user} groups=${groups} status=${status}
+ onPermission=${setStatus}
+ onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runPhotoSync(); } }} />`}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}
+
+function Setup({ user, groups, status, onPermission, onDone }) {
+ const cfg = status.config && status.config.account === user.username ? status.config : null;
+ const [groupId, setGroupId] = useState(cfg ? cfg.groupId : '');
+ const [folders, setFolders] = useState(null); // null while unknown
+ const [probe, setProbe] = useState(''); // '', 'checking', 'offline'
+ const [folder, setFolder] = useState(cfg ? cfg.folder : '');
+ const [albums, setAlbums] = useState(null);
+ const [chosen, setChosen] = useState(() => new Set(cfg ? cfg.albums : []));
+ const [existing, setExisting] = useState(cfg ? cfg.includeExisting : true);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const permitted = status.permission !== 'denied';
+
+ useEffect(() => {
+ if (!permitted) return;
+ platform.photoSync.albums().then((list) => {
+ setAlbums(list);
+ // The camera, and only the camera, unless the person chose before:
+ // screenshots and saved images are where photos nobody meant to share live.
+ if (!cfg) setChosen(new Set(list.filter((a) => a.camera).map((a) => a.id)));
+ }).catch((e) => setError(e.message));
+ }, [permitted]);
+
+ useEffect(() => {
+ setFolders(null);
+ if (!groupId) return;
+ let gone = false;
+ setProbe('checking');
+ probeGroup(groupId).then((r) => {
+ if (gone) return;
+ setProbe('');
+ setFolders(r.folders);
+ setFolder((f) => (r.folders.includes(f) ? f : (r.folders[0] || '')));
+ }).catch(() => { if (!gone) setProbe('offline'); });
+ return () => { gone = true; };
+ }, [groupId]);
+
+ const allow = async () => {
+ setError('');
+ try { onPermission(await platform.photoSync.permit()); } catch (e) { setError(e.message); }
+ };
+
+ const toggle = (id) => setChosen((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id); else next.add(id);
+ return next;
+ });
+
+ const start = async () => {
+ const group = groups.find((g) => g.id === groupId);
+ if (!group || !folder || !chosen.size) return;
+ setBusy(true);
+ setError('');
+ try {
+ const settings = {
+ account: user.username, groupId, groupName: group.name,
+ owner: group.owner_username || '', folder, albums: [...chosen], includeExisting: existing,
+ };
+ // Asked when the photos would go somewhere new, or from somewhere new in
+ // time — never for a change of albums alone, which the person just made.
+ const same = cfg && cfg.groupId === groupId && cfg.folder === folder
+ && cfg.includeExisting === existing;
+ if (!same) {
+ const [est, members] = await Promise.all([
+ platform.photoSync.estimate(settings),
+ hubFetch(`/v1/groups/${groupId}/members`, { token: user.token })
+ .then((r) => (r.members || []).length).catch(() => null),
+ ]);
+ const params = {
+ group: group.name, owner: group.owner_username || '', folder,
+ members: members == null ? '?' : members,
+ count: est.count, size: formatSize(est.bytes),
+ };
+ const yes = await ask(t(existing && est.count > 0 ? 'photosync.confirm'
+ : 'photosync.confirm_new', params));
+ if (!yes) { setBusy(false); return; }
+ }
+ onDone(await platform.photoSync.configure(settings));
+ } catch (e) {
+ setError(e.message);
+ setBusy(false);
+ }
+ };
+
+ return html`
+ <div>
+ ${!permitted && html`
+ <p class="settings-hint">${t('photosync.permission_needed')}</p>
+ <button class="admin-btn" onClick=${allow}>${t('photosync.permission_allow')}</button>
+ `}
+ ${status.permission === 'partial' && html`
+ <p class="settings-hint">${t('photosync.permission_partial')}</p>
+ <button class="btn-secondary" onClick=${allow}>${t('photosync.permission_allow')}</button>
+ `}
+ ${permitted && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('photosync.group')}</span>
+ <select class="settings-select" value=${groupId}
+ onChange=${(e) => setGroupId(e.target.value)}>
+ <option value="">${t('photosync.choose_group')}</option>
+ ${groups.map((g) => html`<option key=${g.id} value=${g.id}>
+ ${g.owner_username ? `${g.name} (@${g.owner_username})` : g.name}</option>`)}
+ </select>
+ </div>
+ ${probe === 'checking' && html`<p class="settings-hint">${t('photosync.checking_group')}</p>`}
+ ${probe === 'offline' && html`<p class="settings-hint">${t('photosync.group_offline')}</p>`}
+ ${folders && !folders.length && html`<p class="settings-hint">${t('photosync.no_folder')}</p>`}
+ ${folders && folders.length > 0 && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('photosync.folder')}</span>
+ <select class="settings-select" value=${folder} onChange=${(e) => setFolder(e.target.value)}>
+ ${folders.map((f) => html`<option key=${f} value=${f}>${f}</option>`)}
+ </select>
+ </div>
+ `}
+ <p class="settings-label" style="margin-top:10px">${t('photosync.albums')}</p>
+ ${(albums || []).map((a) => html`
+ <label class="settings-choice" key=${a.id}>
+ <input type="checkbox" checked=${chosen.has(a.id)} onChange=${() => toggle(a.id)} />
+ <span class="settings-name">
+ <strong>${a.camera ? t('photosync.album_camera') : a.name}</strong>
+ <span class="settings-hint">
+ ${t('photosync.album_count', { n: a.count, size: formatSize(a.bytes) })}</span>
+ </span>
+ </label>
+ `)}
+ <label class="settings-choice">
+ <input type="radio" name="photosync-scope" checked=${existing}
+ onChange=${() => setExisting(true)} />
+ <span><strong>${t('photosync.scope_existing')}</strong></span>
+ </label>
+ <label class="settings-choice">
+ <input type="radio" name="photosync-scope" checked=${!existing}
+ onChange=${() => setExisting(false)} />
+ <span><strong>${t('photosync.scope_new')}</strong></span>
+ </label>
+ <div class="settings-row" style="gap:8px; justify-content:flex-start">
+ <button class="admin-btn" onClick=${start}
+ disabled=${busy || !groupId || !folder || !chosen.size}>${t('photosync.start')}</button>
+ <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button>
+ </div>
+ `}
+ ${!permitted && html`
+ <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button>`}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
new file mode 100644
index 0000000..154a009
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
@@ -0,0 +1,316 @@
+// Photo backup: this phone's photos sent to one folder of one group, once a
+// day, on an unmetered network (docs/MESHBAY_DESIGN.md §9.12).
+//
+// The phone (platform.photoSync) lists the photos, keeps the ledger of what was
+// sent and hands each photo's bytes over; this module decides when a run is
+// due and does the sending, because the transport and the group key are here.
+// Loaded only where the bridge offers it — the Android application — so no
+// browser and no desktop pays for it.
+//
+// Additive by construction: nothing here deletes, renames or replaces anything
+// on the node. `test_photo_sync.py` reads this file for the operations that
+// would, and refuses them.
+
+import { ConnectionPool } from './connection-pool.js';
+import * as platform from './platform.js';
+import { t } from './i18n.js';
+
+const DAY_MS = 24 * 3600 * 1000;
+// A run cut short by something passing — the node offline, the network gone —
+// is tried again at the next chance, but not more often than this: every
+// return to the foreground would otherwise be a connection attempt.
+const RETRY_MS = 15 * 60 * 1000;
+// A slot the node keeps queued this long means it is busy with others; the
+// backup can wait for tomorrow's run, they cannot.
+const LEASE_WAIT_MS = 10 * 60 * 1000;
+
+// Refusals that will hold at the next run too. Said once (a notification, from
+// the phone) and retried a day later, never at every opening.
+const LASTING = {
+ disk_full: 'photosync.fail_disk_full',
+ root_read_only: 'photosync.fail_read_only',
+ no_writable_root: 'photosync.fail_read_only',
+ no_such_root: 'photosync.fail_no_folder',
+ no_such_directory: 'photosync.fail_no_folder',
+ root_unavailable: 'photosync.fail_unavailable',
+ not_a_member: 'photosync.fail_not_member',
+ permission: 'photosync.fail_permission',
+};
+
+// ── What the settings section shows ─────────────────────────────────────────
+
+let state = { phase: 'idle', done: 0, total: 0 };
+const listeners = new Set();
+
+function setState(patch) {
+ state = { ...state, ...patch };
+ for (const fn of [...listeners]) {
+ try { fn(state); } catch (e) { console.error('[MeshBay] photo backup listener threw:', e); }
+ }
+}
+
+export function subscribe(fn) {
+ listeners.add(fn);
+ fn(state);
+ return () => listeners.delete(fn);
+}
+
+// ── When it runs ────────────────────────────────────────────────────────────
+
+let ctx = null; // { hub, getUser, getBundleKey }
+let running = null;
+let timer = 0;
+let lastAttempt = 0;
+let unmeteredNow = null;
+
+/** True when `status` says a scheduled run should go now. */
+export function isDue(status, now = Date.now()) {
+ if (!status || !status.config) return false;
+ // A lasting refusal waits a day, like a finished run; anything else is
+ // decided by the last run that finished.
+ if (status.failure && status.failureAt && now - status.failureAt < DAY_MS) return false;
+ const last = status.lastCompleted;
+ return last == null || now - last >= DAY_MS || now < last;
+}
+
+/**
+ * Checked at start, on coming back to the foreground, when the network turns
+ * unmetered, and once the next run falls due. Nothing polls.
+ */
+export function startPhotoSync(context) {
+ if (!platform.photoSync.available || ctx) return;
+ ctx = context;
+ document.addEventListener('visibilitychange', onVisible);
+ window.addEventListener('meshbay-network', onNetwork);
+ runPhotoSync();
+}
+
+export function stopPhotoSync() {
+ if (!ctx) return;
+ ctx = null;
+ clearTimeout(timer);
+ document.removeEventListener('visibilitychange', onVisible);
+ window.removeEventListener('meshbay-network', onNetwork);
+}
+
+function onVisible() {
+ if (document.visibilityState === 'visible') runPhotoSync();
+}
+
+function onNetwork(e) {
+ unmeteredNow = !!(e.detail && e.detail.unmetered);
+ if (unmeteredNow) runPhotoSync();
+}
+
+function scheduleNext(status) {
+ clearTimeout(timer);
+ if (!ctx || !status || !status.config) return;
+ const base = Math.max(status.lastCompleted || 0, status.failure ? (status.failureAt || 0) : 0);
+ const wait = base + DAY_MS - Date.now();
+ // A page left open past midnight runs on time; the cap keeps a timer from
+ // being parked for days on a clock that may move.
+ if (wait > 0) timer = setTimeout(runPhotoSync, Math.min(wait, DAY_MS));
+}
+
+/**
+ * A run, if one is due. `force` is "Back up now": due or not. `metered` is the
+ * person's yes to sending over mobile data, asked by the caller.
+ */
+export function runPhotoSync({ force = false, metered = false } = {}) {
+ if (!ctx) return Promise.resolve();
+ if (running) return running;
+ running = (async () => {
+ let status = null;
+ try {
+ status = await platform.photoSync.status();
+ await run(status, force, metered);
+ status = await platform.photoSync.status();
+ } catch (e) {
+ console.warn('[MeshBay] photo backup:', e.message);
+ setState({ phase: 'interrupted', detail: e.message });
+ } finally {
+ running = null;
+ scheduleNext(status);
+ }
+ })();
+ return running;
+}
+
+async function run(status, force, metered) {
+ const cfg = status.config;
+ const user = ctx && ctx.getUser();
+ if (!cfg) { setState({ phase: 'off' }); return; }
+ if (!user || cfg.account !== user.username) { setState({ phase: 'other_account' }); return; }
+ if (status.permission === 'denied') { await lasting('permission'); return; }
+ if (!force && !isDue(status)) {
+ setState({ phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' });
+ return;
+ }
+ unmeteredNow = status.unmetered;
+ if (!status.unmetered && !metered) { setState({ phase: 'waiting_wifi' }); return; }
+ if (!force && Date.now() - lastAttempt < RETRY_MS) return;
+ lastAttempt = Date.now();
+
+ const { items } = await platform.photoSync.plan();
+ if (!items.length) {
+ await platform.photoSync.completed();
+ setState({ phase: 'up_to_date', done: 0, total: 0 });
+ return;
+ }
+
+ setState({ phase: 'connecting', done: 0, total: items.length });
+ const pool = new ConnectionPool(ctx.hub);
+ let conn;
+ try {
+ const bundleKey = await ctx.getBundleKey();
+ conn = await pool.connect(cfg.groupId, user.token, bundleKey, user.username, user.userId);
+ } catch (e) {
+ pool.closeAll();
+ if (e.reason === 'not_a_member') { await lasting('not_a_member'); return; }
+ setState({ phase: 'offline' });
+ return;
+ }
+
+ try {
+ await send(conn.transport, cfg, items, metered);
+ } finally {
+ platform.photoSync.keepAlive(false).catch(() => {});
+ pool.closeAll();
+ }
+}
+
+async function send(transport, cfg, items, metered) {
+ const index = await transport.fetchIndex();
+ const problem = destinationProblem(cfg.folder, index);
+ if (problem) { await lasting(problem); return; }
+
+ // What is already in each folder, by name and size: after a reinstall the
+ // ledger is empty, and a photo already there is recorded, not sent again.
+ const present = new Map();
+ for (const e of index.entries || []) {
+ if (!present.has(e.path)) present.set(e.path, new Map());
+ present.get(e.path).set(e.name, e.size);
+ }
+ const dirs = new Set(index.dirs || []);
+ for (const r of index.roots || []) dirs.add(r.name);
+
+ let done = 0;
+ const progress = () => {
+ setState({ phase: 'sending', done, total: items.length });
+ return t('photosync.notif_progress', { done, total: items.length });
+ };
+ await platform.photoSync.keepAlive(true, progress());
+
+ for (const item of items) {
+ // Off Wi-Fi mid-run: stop after the file in flight. The node keeps the
+ // partial upload, and the next unmetered moment carries on.
+ if (!metered && unmeteredNow === false) { setState({ phase: 'waiting_wifi' }); return; }
+ if (!ctx) return;
+
+ const there = present.get(item.dir);
+ const known = there && [item.name, item.alsoKnownAs]
+ .find((n) => n && there.get(n) === item.size);
+ if (known) {
+ await platform.photoSync.sent(item.token, item.dir, known);
+ } else {
+ try {
+ await makeDirs(transport, cfg.folder, item.dir, dirs);
+ const ack = await upload(transport, item);
+ await platform.photoSync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name);
+ } catch (e) {
+ if (e.code && LASTING[e.code]) { await lasting(e.code); return; }
+ throw e;
+ }
+ }
+ done += 1;
+ await platform.photoSync.keepAlive(true, progress());
+ }
+ await platform.photoSync.completed();
+ setState({ phase: 'up_to_date', done, total: items.length });
+}
+
+/** One photo, under a slot the node granted — the backup queues like anyone. */
+async function upload(transport, item) {
+ const lease = transport.openTransfer({ kind: 'upload', bytes: item.size });
+ try {
+ let timeout;
+ await Promise.race([
+ lease.acquire(),
+ new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(new Error('The node is busy')), LEASE_WAIT_MS);
+ }),
+ ]).finally(() => clearTimeout(timeout));
+ const file = await platform.photoSync.file(item.token, item.name);
+ return await transport.uploadFile(file, {
+ dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr,
+ });
+ } finally {
+ lease.release();
+ }
+}
+
+/** `<folder>/YYYY/MM`, one level at a time; a level that exists is fine. */
+async function makeDirs(transport, folder, dir, dirs) {
+ let at = folder;
+ for (const name of dir.slice(folder.length + 1).split('/').filter(Boolean)) {
+ const next = `${at}/${name}`;
+ if (!dirs.has(next)) {
+ try {
+ await transport.createDirectory(at, name);
+ } catch (e) {
+ // Somebody else's backup made it a moment ago: what was wanted.
+ if (!/already exists/i.test(e.message || '')) throw e;
+ }
+ dirs.add(next);
+ }
+ at = next;
+ }
+}
+
+/** A reason the chosen folder cannot take photos now, or null. */
+export function destinationProblem(folder, index) {
+ const root = (index.roots || []).find((r) => r.name === folder.split('/')[0]);
+ if (!root) return 'no_such_root';
+ if (!root.writable) return 'root_read_only';
+ if (root.available === false) return 'root_unavailable';
+ if (folder !== root.name && !(index.dirs || []).includes(folder)) return 'no_such_directory';
+ return null;
+}
+
+async function lasting(code) {
+ setState({ phase: 'failed', reason: code });
+ await platform.photoSync.failed(code, t(LASTING[code] || 'photosync.fail_other')).catch(() => {});
+}
+
+// ── For the settings section ────────────────────────────────────────────────
+
+/**
+ * The folders of a group this member can back up into: writable, available,
+ * and shown by the group's Photos tab — a folder outside those would take the
+ * photos and show them nowhere, which reads as a backup that failed.
+ */
+export function backupFolders(ack, index) {
+ const photoDirs = (ack && ack.photo_directories) || [];
+ const roots = new Map((index.roots || [])
+ .filter((r) => r.writable && r.available !== false).map((r) => [r.name, r]));
+ const under = (d) => photoDirs.some((p) => d === p || d.startsWith(`${p}/`));
+ const all = new Set([...(index.dirs || []), ...roots.keys()]);
+ return [...all]
+ .filter((d) => roots.has(d.split('/')[0]) && under(d))
+ .sort((a, b) => a.localeCompare(b));
+}
+
+/** A one-off look at a group, for choosing a folder: connected, read, closed. */
+export async function probeGroup(groupId) {
+ const user = ctx && ctx.getUser();
+ if (!user) throw new Error('Not signed in');
+ const pool = new ConnectionPool(ctx.hub);
+ try {
+ const bundleKey = await ctx.getBundleKey();
+ const conn = await pool.connect(groupId, user.token, bundleKey, user.username, user.userId);
+ const index = await conn.transport.fetchIndex();
+ return { folders: backupFolders(conn.ack, index) };
+ } finally {
+ pool.closeAll();
+ }
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index c70b03a..c5ae9ee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -230,6 +230,31 @@ export const push = {
},
};
+/**
+ * Backing up this phone's photos to a group (docs/MESHBAY_DESIGN.md §9.12).
+ * Only the Android application has a camera roll to read; `available` is false
+ * in a browser and on a desktop. See photo-sync.js.
+ */
+export const photoSync = {
+ available: Boolean(bridge && bridge.photoSync),
+ status() { return bridge.photoSync.status(); },
+ permit() { return bridge.photoSync.permit(); },
+ albums() { return bridge.photoSync.albums(); },
+ configure(settings) { return bridge.photoSync.configure(settings); },
+ estimate(settings) { return bridge.photoSync.estimate(settings); },
+ plan() { return bridge.photoSync.plan(); },
+ sent(token, dir, name) { return bridge.photoSync.sent(token, dir, name); },
+ completed() { return bridge.photoSync.completed(); },
+ failed(code, text) { return bridge.photoSync.failed(code, text); },
+ keepAlive(on, text) { return bridge.photoSync.keepAlive(on, text); },
+ /** The bytes of a photo the last plan issued, as a File named for the node. */
+ async file(token, name) {
+ const r = await fetch(`/photosync/${encodeURIComponent(token)}`, { cache: 'no-store' });
+ if (!r.ok) throw new Error('This photo is no longer on the phone');
+ return new File([await r.blob()], name);
+ },
+};
+
/** Pick a directory to add as a group root. Returns { path, name } or null. */
export const rootPicker = {
available: Boolean(bridge && bridge.rootPicker),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
index 985b219..8826520 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
@@ -7,10 +7,15 @@ import * as platform from './platform.js';
import { hubFetch } from './hub-client.js';
import { syncPush, enablePush, disablePush } from './push.js';
import { APPS } from './apps.js';
+import { lazy } from './lazy.js';
import {
PAGE_SIZE_PREF, PAGE_SIZE_DEFAULT, PAGE_SIZE_STEP, PAGE_SIZE_MAX, pageSizeFrom,
} from './pager.js';
+// Photo backup exists on the Android application only, so its module is
+// fetched there and nowhere else (docs/MESHBAY_DESIGN.md §9.12).
+const PhotoSyncSection = lazy(() => import('./photo-sync-settings.js'), 'PhotoSyncSection', null);
+
const PAGE_SIZES = Array.from(
{ length: PAGE_SIZE_MAX / PAGE_SIZE_STEP }, (_, i) => (i + 1) * PAGE_SIZE_STEP);
@@ -296,6 +301,9 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange
`}
</div>
+ ${platform.photoSync.available && html`
+ <${PhotoSyncSection} user=${user} groups=${groups || []} />`}
+
<div class="settings-section">
<h3 class="settings-heading">${t('settings.defaults')}</h3>
<div class="settings-row">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 539ac12..ea017fb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2688,6 +2688,11 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-choice input { margin-top: 3px; flex-shrink: 0; }
.settings-choice strong { display: block; font-size: 0.88em; color: var(--text); }
.settings-choice .settings-hint { display: block; margin-top: 2px; }
+/* A group, a folder or an album is named by somebody else and often has no
+ space to break at: without this, one such name widens the whole page on a
+ phone, and everything pinned goes with it (CLAUDE.md). `min-width: 0` lets a
+ flex item shrink below its longest word. */
+.settings-name { overflow-wrap: anywhere; min-width: 0; }
/* A pasted address can be longer than the bubble; breaking it is better than
a message that scrolls sideways. */