diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
21 files changed, 2079 insertions, 8 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js new file mode 100644 index 0000000..5145515 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js @@ -0,0 +1,21 @@ +// The Android Sync page: what this phone sends to its groups, on the Android +// application only (docs/MESHBAY_DESIGN.md §9.12, §9.13). Loaded on demand, +// like the sections it holds: no other client has it. + +import { html } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import * as platform from './platform.js'; +import { PhotoSyncSection } from './photo-sync-settings.js'; +import { DocSyncSection } from './phone-sync-settings.js'; + +export function AndroidSyncPage({ user, groups }) { + return html` + <div> + <h2>${t('androidsync.title')}</h2> + ${platform.photoSync.available && html` + <${PhotoSyncSection} user=${user} groups=${groups || []} />`} + ${platform.contactSync.available && html` + <${DocSyncSection} kind="contacts" user=${user} groups=${groups || []} />`} + </div> + `; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index e2d5742..72c52ac 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -463,6 +463,13 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, sh href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a> </div> `} + ${PHONE_SYNC && html` + <div class="sidebar-section"> + <div class="sidebar-heading">${t('sidebar.phone')}</div> + <a class="sidebar-item ${route === '/android-sync' ? 'active' : ''}" + href="#/android-sync"><${Icon} name="phone" /> ${t('androidsync.title')}</a> + </div> + `} <div class="sidebar-section"> <div class="sidebar-heading">${t('sidebar.my_groups')}</div> ${groups.length === 0 @@ -706,6 +713,14 @@ function SetupWelcome({ onDismiss }) { const SearchPage = lazy(() => import('./search-page.js'), 'SearchPage', html`<div class="page-content"><p class="page-message"><span class="spinner"></span></p></div>`); +// ── Lazy-loaded Android Sync page ──────────────────────────────────────────── +// +// The Android application's backups of its photos, contacts and messages +// (docs/MESHBAY_DESIGN.md §9.12, §9.13): no browser or desktop ever fetches it. +const PHONE_SYNC = platform.photoSync.available || platform.contactSync.available; +const AndroidSyncPage = lazy(() => import('./android-sync-page.js'), 'AndroidSyncPage', + html`<div class="page-content"><p class="page-message"><span class="spinner"></span></p></div>`); + // ── Lazy-loaded Create Group page ──────────────────────────────────────────── let _CreateGroupPage = null; @@ -904,6 +919,52 @@ 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]); + + // Contacts backup (§9.13), the same way. + useEffect(() => { + if (!syncAccount || !platform.contactSync.available) return undefined; + let stop = null; + let gone = false; + import('./phone-sync.js').then((m) => { + if (gone) return; + m.startPhoneSync({ + hub: HUB, + hubFetch, + getUser: () => userRef.current, + getBundleKey: async () => { + const key = session.bundleKey || await _loadBundleKey(); + if (key) session.bundleKey = key; + return key; + }, + }); + stop = m.stopPhoneSync; + }).catch((e) => console.warn('[MeshBay] contacts 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); @@ -1403,6 +1464,8 @@ function App() { allowPublicGroups=${allowPublicGroups} token=${user.token} onGroupsChanged=${reloadGroups} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; + } else if (route === '/android-sync' && PHONE_SYNC) { + page = html`<${AndroidSyncPage} user=${user} groups=${groups} />`; } else if (route === '/settings') { page = html`<${SettingsPage} user=${user} theme=${theme} onThemeChange=${setTheme} groups=${groups} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index 4d549f1..36db0fe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -87,6 +87,8 @@ const ICON_PATHS = { server: ['M4 6.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', 'M4 15.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', 'M8 7.5h.01', 'M8 16.5h.01'], + phone: ['M8 2.5h8a1.5 1.5 0 0 1 1.5 1.5v16a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 20V4A1.5 1.5 0 0 1 8 2.5z', + 'M11 18h2'], cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21', 'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6', 'M2 21h.01'], 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..18cdb40 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -542,6 +542,9 @@ export default { 'settings.node_key_placeholder': 'Öffentlichen Ed25519-Schlüssel des Nodes einfügen (base64)', 'settings.node_key_submit': 'Node verknüpfen', 'settings.node_key_success': 'Node erfolgreich verknüpft', + 'settings.node_key_unlink': "Trennen", + 'settings.node_key_unlink_confirm': "Den Node von Ihrem Konto trennen? Er stellt Ihre Gruppen nicht mehr bereit, bis wieder ein Node verknüpft ist.", + 'settings.node_key_unlinked': "Node getrennt", 'settings.node_key_current': 'Schlüssel des verknüpften Nodes', // Sidebar @@ -917,6 +920,8 @@ export default { 'lazy.reload': 'Seite neu laden', // Sidebar + 'sidebar.phone': 'Telefon', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -942,6 +947,7 @@ export default { 'node.startup_mode_service': 'Als Hintergrunddienst (startet beim Booten)', 'node.startup_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…', 'node.startup_mode_service_unavailable_hint': 'Der Hintergrunddienst-Modus erfordert eine installierte Version. Führen Sie zum lokalen Testen "meshbay-node service install" in einer PowerShell mit Administratorrechten aus.', + 'node.startup_mode_service_store_hint': 'Der Start beim Hochfahren, bevor sich jemand anmeldet, erfordert das MeshBay-Installationsprogramm (.exe) statt der Version aus dem Microsoft Store.', 'node.not_operator': 'Ihr Node konnte nicht erreicht werden. Stellen Sie sicher, dass er läuft.', 'node.offline': 'Node ist offline', 'node.retry': 'Erneut versuchen', @@ -950,6 +956,12 @@ export default { 'node.pair_button': 'Diesen Browser koppeln', 'node.pair_success': 'Erfolgreich gekoppelt.', 'node.pair_pending': 'Kopplungscode bereit: Er wird verwendet, wenn Sie das nächste Mal eine Gruppe dieses Nodes öffnen.', + 'node.link_other_node': "Ihr Konto ist mit dem Node eines anderen Rechners verknüpft, daher kann dieser Node Ihre Gruppen nicht bereitstellen.", + 'node.link_here': "Stattdessen diesen Node verknüpfen", + 'node.link_here_confirm': "Diesen Node mit Ihrem Konto verknüpfen? Der derzeit verknüpfte Node auf einem anderen Rechner stellt Ihre Gruppen nicht mehr bereit, bis er erneut verknüpft wird.", + 'node.link_other_account': "Dieser Node ist für {account} eingerichtet, nicht für Sie, daher kann er Ihre Gruppen nicht bereitstellen.", + 'node.link_take_over': "Diesen Node für mein Konto verwenden", + 'node.link_linking': "Verknüpfen…", 'node.reload': 'Konfiguration neu laden', 'node.reloaded': 'Konfiguration neu geladen. Verzeichnisänderungen an bestehenden Gruppen sind jetzt aktiv.', 'node.restart_needed': 'Nach dem Hinzufügen: Gruppenschlüssel initialisieren (meshbay-node gek-init --group <name>).', @@ -1282,4 +1294,70 @@ 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.", + 'contactsync.heading': "Kontakte-Sicherung", + 'contactsync.intro': "Einmal täglich, wenn sie sich geändert haben, eine Kopie der Kontakte dieses Telefons in einen Ordner einer Gruppe senden, deren einziges Mitglied du bist. Jede Kopie ist eine .vcf-Datei, die jedes Telefon importieren kann.", + 'contactsync.permission_needed': "MeshBay braucht Zugriff auf die Kontakte dieses Telefons.", + 'contactsync.confirm': "Eine Kopie deiner Kontakte wird an {group} in {dir} gesendet, danach eine neue an jedem Tag, an dem sie sich ändern.\n\nDu bist das einzige Mitglied dieser Gruppe. Tritt jemand bei, endet die Sicherung.", + 'phonesync.state_sending': "Wird gesendet…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Gesendete Kopien: {n}", + 'phonesync.last_file': "Letzte Kopie: {name}", + 'phonesync.checking_groups': "Suche nach Gruppen, deren einziges Mitglied du bist…", + 'phonesync.no_private_group': "Du hast keine eigene Gruppe. Erstelle eine Gruppe, deren einziges Mitglied du bist, und komm dann hierher zurück.", + 'phonesync.no_folder': "Diese Gruppe hat keinen Ordner, in den du hochladen kannst.", + 'phonesync.destination': "Kopien landen in {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Gestoppt: Der Datenträger des Nodes ist voll.", + 'phonesync.fail_read_only': "Gestoppt: Der Ordner nimmt keine Uploads mehr an.", + 'phonesync.fail_no_folder': "Gestoppt: Der Ordner existiert in der Gruppe nicht mehr.", + 'phonesync.fail_unavailable': "Gestoppt: Das Laufwerk des Ordners ist nicht mit dem Node verbunden.", + 'phonesync.fail_not_member': "Gestoppt: Du bist kein Mitglied dieser Gruppe mehr.", + 'phonesync.fail_not_private': "Gestoppt: Jemand anderes ist der Gruppe beigetreten. Wähle eine Gruppe, deren einziges Mitglied du bist.", + 'phonesync.fail_permission': "Gestoppt: MeshBay hat keinen Zugriff mehr.", + 'phonesync.fail_other': "Gestoppt.", }; 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..8f8af0d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -530,6 +530,9 @@ export default { 'settings.node_key_placeholder': 'Paste node Ed25519 public key (base64)', 'settings.node_key_submit': 'Link Node', 'settings.node_key_success': 'Node linked successfully', + 'settings.node_key_unlink': "Unlink", + 'settings.node_key_unlink_confirm': "Unlink the node from your account? It stops serving your groups until a node is linked again.", + 'settings.node_key_unlinked': "Node unlinked", 'settings.node_key_current': 'Linked node key', // Sidebar @@ -1018,6 +1021,8 @@ export default { 'usermenu.profile': 'Profile', // Node management (D5) + 'sidebar.phone': 'Phone', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', 'node.title': 'Node', 'node.service_checking': 'Checking node service…', @@ -1041,6 +1046,7 @@ export default { 'node.startup_mode_service': 'As a background service (starts at boot)', 'node.startup_mode_updating': 'Switching mode — check for an administrator prompt…', 'node.startup_mode_service_unavailable_hint': 'Background service mode needs an installed build. For local testing, run "meshbay-node service install" from an elevated PowerShell.', + 'node.startup_mode_service_store_hint': 'Starting at boot, before anyone signs in, needs the MeshBay installer (.exe) rather than the Microsoft Store version.', 'node.offline': 'Node is offline', 'node.no_groups': 'No groups configured on this node.', 'node.retry': 'Retry', @@ -1092,6 +1098,12 @@ export default { 'node.pair_button': 'Pair this browser', 'node.pair_success': 'Paired successfully.', 'node.pair_pending': "Pairing code ready: it is used the next time you open one of this node's groups.", + 'node.link_other_node': "Your account is linked to the node of another machine, so this node cannot serve your groups.", + 'node.link_here': "Link this node instead", + 'node.link_here_confirm': "Link this node to your account? The node linked now, on another machine, stops serving your groups until it is linked again.", + 'node.link_other_account': "This node is set up for {account}, not for you, so it cannot serve your groups.", + 'node.link_take_over': "Use this node for my account", + 'node.link_linking': "Linking…", 'node.reload': 'Reload config', 'node.reloaded': 'Config reloaded. Root changes on existing groups are now active.', 'node.attach_group': 'Add group', @@ -1263,4 +1275,70 @@ 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.", + 'contactsync.heading': "Contacts backup", + 'contactsync.intro': "Send a copy of this phone's contacts to a folder of a group only you belong to, once a day when they have changed. Each copy is a .vcf file any phone can import.", + 'contactsync.permission_needed': "MeshBay needs access to this phone's contacts.", + 'contactsync.confirm': "A copy of your contacts will be sent to {group} into {dir}, then a new one each day they change.\n\nYou are the only member of this group. If somebody joins it, the backup stops.", + 'phonesync.state_sending': "Sending…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Copies sent: {n}", + 'phonesync.last_file': "Last copy: {name}", + 'phonesync.checking_groups': "Looking for groups only you belong to…", + 'phonesync.no_private_group': "You have no group of your own. Create a group that only you belong to, then come back here.", + 'phonesync.no_folder': "This group has no folder you can add to.", + 'phonesync.destination': "Copies go into {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Stopped: the node's disk is full.", + 'phonesync.fail_read_only': "Stopped: the folder no longer accepts uploads.", + 'phonesync.fail_no_folder': "Stopped: the folder no longer exists in the group.", + 'phonesync.fail_unavailable': "Stopped: the folder's drive is not connected to the node.", + 'phonesync.fail_not_member': "Stopped: you are no longer a member of this group.", + 'phonesync.fail_not_private': "Stopped: somebody else has joined the group. Choose a group only you belong to.", + 'phonesync.fail_permission': "Stopped: MeshBay no longer has access.", + 'phonesync.fail_other': "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..586ff59 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -537,6 +537,9 @@ export default { 'settings.node_key_placeholder': 'Pegue la clave pública Ed25519 del node (base64)', 'settings.node_key_submit': 'Vincular el node', 'settings.node_key_success': 'Node vinculado correctamente', + 'settings.node_key_unlink': "Desvincular", + 'settings.node_key_unlink_confirm': "¿Desvincular el nodo de tu cuenta? Dejará de servir tus grupos hasta que se vuelva a vincular un nodo.", + 'settings.node_key_unlinked': "Nodo desvinculado", 'settings.node_key_current': 'Clave del node vinculado', // Sidebar @@ -911,6 +914,8 @@ export default { 'lazy.reload': 'Recargar la página', // Sidebar + 'sidebar.phone': 'Teléfono', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -936,6 +941,7 @@ export default { 'node.startup_mode_service': 'Como servicio en segundo plano (se inicia al arrancar)', 'node.startup_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…', 'node.startup_mode_service_unavailable_hint': 'El modo de servicio en segundo plano requiere una versión instalada. Para pruebas locales, ejecute "meshbay-node service install" desde una PowerShell con privilegios de administrador.', + 'node.startup_mode_service_store_hint': 'Iniciar al arrancar, antes de que nadie inicie sesión, requiere el instalador de MeshBay (.exe) en lugar de la versión de Microsoft Store.', 'node.not_operator': 'No se pudo contactar con su node. Asegúrese de que esté en ejecución.', 'node.offline': 'Node sin conexión', 'node.retry': 'Reintentar', @@ -944,6 +950,12 @@ export default { 'node.pair_button': 'Emparejar este navegador', 'node.pair_success': 'Emparejamiento exitoso.', 'node.pair_pending': 'Código de emparejamiento listo: se usará la próxima vez que abra uno de los grupos de este Node.', + 'node.link_other_node': "Tu cuenta está vinculada al nodo de otra máquina, así que este nodo no puede servir tus grupos.", + 'node.link_here': "Vincular este nodo en su lugar", + 'node.link_here_confirm': "¿Vincular este nodo a tu cuenta? El nodo vinculado ahora, en otra máquina, dejará de servir tus grupos hasta que se vuelva a vincular.", + 'node.link_other_account': "Este nodo está configurado para {account}, no para ti, así que no puede servir tus grupos.", + 'node.link_take_over': "Usar este nodo para mi cuenta", + 'node.link_linking': "Vinculando…", 'node.reload': 'Recargar configuración', 'node.reloaded': 'Configuración recargada. Los cambios de directorios en los grupos existentes ya están activos.', 'node.restart_needed': 'Después de añadir: inicialice la clave de grupo (meshbay-node gek-init --group <nombre>).', @@ -1276,4 +1288,70 @@ 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.", + 'contactsync.heading': "Copia de seguridad de contactos", + 'contactsync.intro': "Enviar una copia de los contactos de este teléfono a una carpeta de un grupo del que solo tú eres miembro, una vez al día si han cambiado. Cada copia es un archivo .vcf que cualquier teléfono puede importar.", + 'contactsync.permission_needed': "MeshBay necesita acceso a los contactos de este teléfono.", + 'contactsync.confirm': "Se enviará una copia de tus contactos a {group}, en {dir}, y luego una nueva cada día que cambien.\n\nEres el único miembro de este grupo. Si alguien se une, la copia de seguridad se detiene.", + 'phonesync.state_sending': "Enviando…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Copias enviadas: {n}", + 'phonesync.last_file': "Última copia: {name}", + 'phonesync.checking_groups': "Buscando grupos de los que solo tú eres miembro…", + 'phonesync.no_private_group': "No tienes ningún grupo propio. Crea un grupo del que solo tú seas miembro y vuelve aquí.", + 'phonesync.no_folder': "Este grupo no tiene ninguna carpeta en la que puedas añadir archivos.", + 'phonesync.destination': "Las copias van a {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Detenida: el disco del node está lleno.", + 'phonesync.fail_read_only': "Detenida: la carpeta ya no acepta envíos.", + 'phonesync.fail_no_folder': "Detenida: la carpeta ya no existe en el grupo.", + 'phonesync.fail_unavailable': "Detenida: la unidad de la carpeta no está conectada al node.", + 'phonesync.fail_not_member': "Detenida: ya no eres miembro de este grupo.", + 'phonesync.fail_not_private': "Detenida: otra persona se ha unido al grupo. Elige un grupo del que solo tú seas miembro.", + 'phonesync.fail_permission': "Detenida: MeshBay ya no tiene acceso.", + 'phonesync.fail_other': "Detenida.", }; 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..a234a45 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -540,6 +540,9 @@ export default { 'settings.node_key_placeholder': 'Collez la clé publique Ed25519 du node (base64)', 'settings.node_key_submit': 'Associer le node', 'settings.node_key_success': 'Node associé avec succès', + 'settings.node_key_unlink': "Délier", + 'settings.node_key_unlink_confirm': "Délier le node de votre compte ? Il cessera de servir vos groupes jusqu'à ce qu'un node soit lié de nouveau.", + 'settings.node_key_unlinked': "Node délié", 'settings.node_key_current': 'Clé du node associé', // Sidebar @@ -914,6 +917,8 @@ export default { 'lazy.reload': 'Recharger la page', // Sidebar + 'sidebar.phone': 'Téléphone', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -939,6 +944,7 @@ export default { 'node.startup_mode_service': 'Comme service en arrière-plan (démarre au boot)', 'node.startup_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…', 'node.startup_mode_service_unavailable_hint': 'Le mode service en arrière-plan nécessite une version installée. Pour un test local, exécutez "meshbay-node service install" depuis un PowerShell administrateur.', + 'node.startup_mode_service_store_hint': 'Démarrer au boot, avant toute ouverture de session, nécessite l\'installeur MeshBay (.exe) plutôt que la version du Microsoft Store.', 'node.not_operator': 'Impossible de joindre votre node. Vérifiez qu\'il est en cours d\'exécution.', 'node.offline': 'Node hors ligne', 'node.retry': 'Réessayer', @@ -947,6 +953,12 @@ export default { 'node.pair_button': 'Appairer ce navigateur', 'node.pair_success': 'Appairage réussi.', 'node.pair_pending': "Code d'appairage prêt : il sera utilisé à la prochaine ouverture d'un des groupes de ce node.", + 'node.link_other_node': "Votre compte est lié au node d'une autre machine : ce node-ci ne peut pas servir vos groupes.", + 'node.link_here': "Lier ce node à la place", + 'node.link_here_confirm': "Lier ce node à votre compte ? Le node lié actuellement, sur une autre machine, cessera de servir vos groupes jusqu'à ce qu'il soit lié de nouveau.", + 'node.link_other_account': "Ce node est configuré pour {account}, pas pour vous : il ne peut pas servir vos groupes.", + 'node.link_take_over': "Utiliser ce node pour mon compte", + 'node.link_linking': "Liaison…", 'node.reload': 'Recharger la configuration', 'node.reloaded': 'Configuration rechargée. Les modifications de répertoires sur les groupes existants sont maintenant actives.', 'node.restart_needed': 'Après l\'ajout : initialisez la clé de groupe (meshbay-node gek-init --group <name>).', @@ -1291,4 +1303,70 @@ 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.", + 'contactsync.heading': "Sauvegarde des contacts", + 'contactsync.intro': "Envoyer une copie des contacts de ce téléphone dans un dossier d'un groupe dont vous êtes le seul membre, une fois par jour s'ils ont changé. Chaque copie est un fichier .vcf que tout téléphone sait importer.", + 'contactsync.permission_needed': "MeshBay a besoin d'accéder aux contacts de ce téléphone.", + 'contactsync.confirm': "Une copie de vos contacts sera envoyée dans {group}, dans {dir}, puis une nouvelle chaque jour où ils changent.\n\nVous êtes le seul membre de ce groupe. Si quelqu'un le rejoint, la sauvegarde s'arrête.", + 'phonesync.state_sending': "Envoi…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Copies envoyées : {n}", + 'phonesync.last_file': "Dernière copie : {name}", + 'phonesync.checking_groups': "Recherche des groupes dont vous êtes le seul membre…", + 'phonesync.no_private_group': "Vous n'avez aucun groupe à vous seul. Créez un groupe dont vous êtes le seul membre, puis revenez ici.", + 'phonesync.no_folder': "Ce groupe n'a aucun dossier où vous pouvez ajouter des fichiers.", + 'phonesync.destination': "Les copies vont dans {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Arrêtée : le disque du node est plein.", + 'phonesync.fail_read_only': "Arrêtée : le dossier n'accepte plus d'envois.", + 'phonesync.fail_no_folder': "Arrêtée : le dossier n'existe plus dans le groupe.", + 'phonesync.fail_unavailable': "Arrêtée : le disque du dossier n'est pas branché au node.", + 'phonesync.fail_not_member': "Arrêtée : vous n'êtes plus membre de ce groupe.", + 'phonesync.fail_not_private': "Arrêtée : quelqu'un d'autre a rejoint le groupe. Choisissez un groupe dont vous êtes le seul membre.", + 'phonesync.fail_permission': "Arrêtée : MeshBay n'y a plus accès.", + 'phonesync.fail_other': "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..9019007 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -540,6 +540,9 @@ export default { 'settings.node_key_placeholder': 'Incolli la chiave pubblica Ed25519 del node (base64)', 'settings.node_key_submit': 'Collega il node', 'settings.node_key_success': 'Node collegato correttamente', + 'settings.node_key_unlink': "Scollega", + 'settings.node_key_unlink_confirm': "Scollegare il node dal tuo account? Smetterà di servire i tuoi gruppi finché non verrà collegato di nuovo un node.", + 'settings.node_key_unlinked': "Node scollegato", 'settings.node_key_current': 'Chiave del node collegato', // Sidebar @@ -913,6 +916,8 @@ export default { 'lazy.reload': 'Ricarica la pagina', // Sidebar + 'sidebar.phone': 'Telefono', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -938,6 +943,7 @@ export default { 'node.startup_mode_service': 'Come servizio in background (si avvia all\'avvio del sistema)', 'node.startup_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…', 'node.startup_mode_service_unavailable_hint': 'La modalità servizio in background richiede una build installata. Per test locali, eseguire "meshbay-node service install" da un PowerShell con privilegi di amministratore.', + 'node.startup_mode_service_store_hint': 'L\'avvio all\'accensione, prima che qualcuno acceda, richiede il programma di installazione di MeshBay (.exe) anziché la versione del Microsoft Store.', 'node.not_operator': 'Impossibile raggiungere il suo node. Si assicuri che sia in esecuzione.', 'node.offline': 'Node non in linea', 'node.retry': 'Riprova', @@ -946,6 +952,12 @@ export default { 'node.pair_button': 'Associa questo browser', 'node.pair_success': 'Associazione riuscita.', 'node.pair_pending': 'Codice di associazione pronto: verrà usato la prossima volta che apri uno dei gruppi di questo Node.', + 'node.link_other_node': "Il tuo account è collegato al node di un'altra macchina, quindi questo node non può servire i tuoi gruppi.", + 'node.link_here': "Collega invece questo node", + 'node.link_here_confirm': "Collegare questo node al tuo account? Il node collegato ora, su un'altra macchina, smetterà di servire i tuoi gruppi finché non verrà ricollegato.", + 'node.link_other_account': "Questo node è configurato per {account}, non per te, quindi non può servire i tuoi gruppi.", + 'node.link_take_over': "Usa questo node per il mio account", + 'node.link_linking': "Collegamento…", 'node.reload': 'Ricarica configurazione', 'node.reloaded': 'Configurazione ricaricata. Le modifiche alle directory dei gruppi esistenti sono ora attive.', 'node.restart_needed': "Dopo l'aggiunta: inizializzi la chiave di gruppo (meshbay-node gek-init --group <nome>).", @@ -1290,4 +1302,70 @@ 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.", + 'contactsync.heading': "Backup dei contatti", + 'contactsync.intro': "Invia una copia dei contatti di questo telefono in una cartella di un gruppo di cui sei l'unico membro, una volta al giorno se sono cambiati. Ogni copia è un file .vcf che qualsiasi telefono può importare.", + 'contactsync.permission_needed': "MeshBay ha bisogno di accedere ai contatti di questo telefono.", + 'contactsync.confirm': "Una copia dei tuoi contatti verrà inviata a {group}, in {dir}, poi una nuova ogni giorno in cui cambiano.\n\nSei l'unico membro di questo gruppo. Se qualcuno si unisce, il backup si ferma.", + 'phonesync.state_sending': "Invio…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Copie inviate: {n}", + 'phonesync.last_file': "Ultima copia: {name}", + 'phonesync.checking_groups': "Ricerca dei gruppi di cui sei l'unico membro…", + 'phonesync.no_private_group': "Non hai un gruppo tutto tuo. Crea un gruppo di cui sei l'unico membro, poi torna qui.", + 'phonesync.no_folder': "Questo gruppo non ha cartelle in cui puoi aggiungere file.", + 'phonesync.destination': "Le copie vanno in {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Interrotto: il disco del node è pieno.", + 'phonesync.fail_read_only': "Interrotto: la cartella non accetta più caricamenti.", + 'phonesync.fail_no_folder': "Interrotto: la cartella non esiste più nel gruppo.", + 'phonesync.fail_unavailable': "Interrotto: l'unità della cartella non è collegata al node.", + 'phonesync.fail_not_member': "Interrotto: non sei più membro di questo gruppo.", + 'phonesync.fail_not_private': "Interrotto: qualcun altro si è unito al gruppo. Scegli un gruppo di cui sei l'unico membro.", + 'phonesync.fail_permission': "Interrotto: MeshBay non ha più accesso.", + 'phonesync.fail_other': "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..043586f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -534,6 +534,9 @@ export default { 'settings.node_key_placeholder': 'node の Ed25519 公開鍵を貼り付け(base64)', 'settings.node_key_submit': 'node を連携', 'settings.node_key_success': 'node を連携しました', + 'settings.node_key_unlink': "リンク解除", + 'settings.node_key_unlink_confirm': "アカウントからノードのリンクを解除しますか?再びノードがリンクされるまで、グループを提供しなくなります。", + 'settings.node_key_unlinked': "ノードのリンクを解除しました", 'settings.node_key_current': '連携済みの node 鍵', // Sidebar @@ -901,6 +904,8 @@ export default { 'lazy.reload': 'ページを再読み込み', // Sidebar + 'sidebar.phone': 'スマートフォン', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -926,6 +931,7 @@ export default { 'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)', 'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…', 'node.startup_mode_service_unavailable_hint': 'バックグラウンドサービスモードにはインストール済みのビルドが必要です。ローカルでテストする場合は、管理者権限の PowerShell で "meshbay-node service install" を実行してください。', + 'node.startup_mode_service_store_hint': 'サインイン前の起動時に開始するには、Microsoft Store 版ではなく MeshBay インストーラー (.exe) が必要です。', 'node.not_operator': 'node に接続できませんでした。node が実行中であることをご確認ください。', 'node.offline': 'Node はオフラインです', 'node.retry': '再試行', @@ -934,6 +940,12 @@ export default { 'node.pair_button': 'このブラウザをペアリング', 'node.pair_success': 'ペアリングに成功しました。', 'node.pair_pending': 'ペアリングコードの準備ができました。この Node のグループを次に開いたときに使用されます。', + 'node.link_other_node': "アカウントは別のマシンのノードにリンクされているため、このノードではグループを提供できません。", + 'node.link_here': "代わりにこのノードをリンク", + 'node.link_here_confirm': "このノードをアカウントにリンクしますか?現在リンクされている別のマシンのノードは、再びリンクされるまでグループを提供しなくなります。", + 'node.link_other_account': "このノードはあなたではなく {account} 用に設定されているため、グループを提供できません。", + 'node.link_take_over': "このノードを自分のアカウントで使う", + 'node.link_linking': "リンク中…", 'node.reload': '設定を再読み込み', 'node.reloaded': '設定を再読み込みしました。既存グループのルート変更が反映されました。', 'node.restart_needed': '追加後、グループ鍵を初期化してください(meshbay-node gek-init --group <name>)。', @@ -1274,4 +1286,70 @@ 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': "写真のバックアップを停止しました。", + 'contactsync.heading': "連絡先のバックアップ", + 'contactsync.intro': "このスマートフォンの連絡先のコピーを、あなただけがメンバーのグループのフォルダーに、変更があった日に1日1回送ります。各コピーはどのスマートフォンでも読み込める .vcf ファイルです。", + 'contactsync.permission_needed': "MeshBay にこのスマートフォンの連絡先へのアクセスを許可してください。", + 'contactsync.confirm': "連絡先のコピーを {group} の {dir} に送り、その後は変更があった日ごとに新しいコピーを送ります。\n\nこのグループのメンバーはあなただけです。誰かが参加すると、バックアップは停止します。", + 'phonesync.state_sending': "送信中…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "送信したコピー: {n}", + 'phonesync.last_file': "最後のコピー: {name}", + 'phonesync.checking_groups': "あなただけがメンバーのグループを探しています…", + 'phonesync.no_private_group': "あなた専用のグループがありません。あなただけがメンバーのグループを作成してから、ここに戻ってください。", + 'phonesync.no_folder': "このグループには、ファイルを追加できるフォルダーがありません。", + 'phonesync.destination': "コピーの保存先: {dir}", + 'phonesync.notice': "{what}。{reason}", + 'phonesync.fail_disk_full': "停止しました: node のディスクがいっぱいです。", + 'phonesync.fail_read_only': "停止しました: フォルダーがアップロードを受け付けなくなりました。", + 'phonesync.fail_no_folder': "停止しました: フォルダーがグループにもうありません。", + 'phonesync.fail_unavailable': "停止しました: フォルダーのドライブが node に接続されていません。", + 'phonesync.fail_not_member': "停止しました: このグループのメンバーではなくなりました。", + 'phonesync.fail_not_private': "停止しました: 他の人がグループに参加しました。あなただけがメンバーのグループを選んでください。", + 'phonesync.fail_permission': "停止しました: MeshBay はアクセスできなくなりました。", + 'phonesync.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..5c1a0d2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -541,6 +541,9 @@ export default { 'settings.node_key_placeholder': 'Plak de openbare Ed25519-sleutel van de node (base64)', 'settings.node_key_submit': 'Node koppelen', 'settings.node_key_success': 'Node gekoppeld', + 'settings.node_key_unlink': "Ontkoppelen", + 'settings.node_key_unlink_confirm': "De node van je account ontkoppelen? Hij bedient je groepen niet meer tot er weer een node is gekoppeld.", + 'settings.node_key_unlinked': "Node ontkoppeld", 'settings.node_key_current': 'Sleutel van de gekoppelde node', // Sidebar @@ -915,6 +918,8 @@ export default { 'lazy.reload': 'Pagina opnieuw laden', // Sidebar + 'sidebar.phone': 'Telefoon', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -940,6 +945,7 @@ export default { 'node.startup_mode_service': 'Als achtergrondservice (start bij het opstarten)', 'node.startup_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…', 'node.startup_mode_service_unavailable_hint': 'Achtergrondservice-modus vereist een geïnstalleerde build. Voer voor lokaal testen "meshbay-node service install" uit vanuit een PowerShell met beheerdersrechten.', + 'node.startup_mode_service_store_hint': 'Starten bij het opstarten, voordat iemand zich aanmeldt, vereist het MeshBay-installatieprogramma (.exe) in plaats van de Microsoft Store-versie.', 'node.not_operator': 'Uw node is niet bereikbaar. Controleer of hij draait.', 'node.offline': 'Node is offline', 'node.retry': 'Opnieuw proberen', @@ -948,6 +954,12 @@ export default { 'node.pair_button': 'Deze browser koppelen', 'node.pair_success': 'Succesvol gekoppeld.', 'node.pair_pending': 'Koppelingscode klaar: die wordt gebruikt zodra u een van de groepen van deze Node opent.', + 'node.link_other_node': "Je account is gekoppeld aan de node van een andere computer, dus deze node kan je groepen niet bedienen.", + 'node.link_here': "Koppel in plaats daarvan deze node", + 'node.link_here_confirm': "Deze node aan je account koppelen? De nu gekoppelde node op een andere computer bedient je groepen niet meer tot hij opnieuw wordt gekoppeld.", + 'node.link_other_account': "Deze node is ingesteld voor {account}, niet voor jou, dus hij kan je groepen niet bedienen.", + 'node.link_take_over': "Deze node voor mijn account gebruiken", + 'node.link_linking': "Koppelen…", 'node.reload': 'Configuratie herladen', 'node.reloaded': 'Configuratie herladen. Mapwijzigingen op bestaande groepen zijn nu actief.', 'node.restart_needed': 'Na het toevoegen: initialiseer de groepssleutel (meshbay-node gek-init --group <naam>).', @@ -1292,4 +1304,70 @@ 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.", + 'contactsync.heading': "Back-up van contacten", + 'contactsync.intro': "Stuur eenmaal per dag, als ze veranderd zijn, een kopie van de contacten op deze telefoon naar een map van een groep waarvan alleen jij lid bent. Elke kopie is een .vcf-bestand dat elke telefoon kan importeren.", + 'contactsync.permission_needed': "MeshBay heeft toegang nodig tot de contacten op deze telefoon.", + 'contactsync.confirm': "Een kopie van je contacten wordt naar {group} gestuurd, in {dir}, en daarna een nieuwe op elke dag dat ze veranderen.\n\nJij bent het enige lid van deze groep. Als iemand lid wordt, stopt de back-up.", + 'phonesync.state_sending': "Bezig met verzenden…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Verzonden kopieën: {n}", + 'phonesync.last_file': "Laatste kopie: {name}", + 'phonesync.checking_groups': "Zoeken naar groepen waarvan alleen jij lid bent…", + 'phonesync.no_private_group': "Je hebt geen eigen groep. Maak een groep waarvan alleen jij lid bent en kom dan hier terug.", + 'phonesync.no_folder': "Deze groep heeft geen map waaraan je bestanden kunt toevoegen.", + 'phonesync.destination': "Kopieën gaan naar {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Gestopt: de schijf van de node is vol.", + 'phonesync.fail_read_only': "Gestopt: de map accepteert geen uploads meer.", + 'phonesync.fail_no_folder': "Gestopt: de map bestaat niet meer in de groep.", + 'phonesync.fail_unavailable': "Gestopt: de schijf van de map is niet aangesloten op de node.", + 'phonesync.fail_not_member': "Gestopt: je bent geen lid meer van deze groep.", + 'phonesync.fail_not_private': "Gestopt: iemand anders is lid geworden van de groep. Kies een groep waarvan alleen jij lid bent.", + 'phonesync.fail_permission': "Gestopt: MeshBay heeft geen toegang meer.", + 'phonesync.fail_other': "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..86d1d34 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -553,6 +553,9 @@ export default { 'settings.node_key_placeholder': 'Wklej klucz publiczny Ed25519 node (base64)', 'settings.node_key_submit': 'Powiąż node', 'settings.node_key_success': 'Node został powiązany', + 'settings.node_key_unlink': "Odłącz", + 'settings.node_key_unlink_confirm': "Odłączyć węzeł od twojego konta? Przestanie obsługiwać twoje grupy, dopóki nie zostanie ponownie powiązany węzeł.", + 'settings.node_key_unlinked': "Węzeł odłączony", 'settings.node_key_current': 'Klucz powiązanego node', // Sidebar @@ -933,6 +936,8 @@ export default { 'lazy.reload': 'Wczytaj stronę ponownie', // Sidebar (supplementary) + 'sidebar.phone': 'Telefon', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -958,6 +963,7 @@ export default { 'node.startup_mode_service': 'Jako usługa w tle (uruchamia się przy starcie systemu)', 'node.startup_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…', 'node.startup_mode_service_unavailable_hint': 'Tryb usługi w tle wymaga zainstalowanej wersji. Aby przetestować lokalnie, uruchom "meshbay-node service install" w PowerShell z uprawnieniami administratora.', + 'node.startup_mode_service_store_hint': 'Uruchamianie przy starcie systemu, zanim ktokolwiek się zaloguje, wymaga instalatora MeshBay (.exe) zamiast wersji ze sklepu Microsoft Store.', 'node.not_operator': 'Nie udało się połączyć z Pana/Pani node. Upewnij się, że działa.', 'node.offline': 'Node jest niedostępny', 'node.retry': 'Ponów', @@ -966,6 +972,12 @@ export default { 'node.pair_button': 'Sparuj tę przeglądarkę', 'node.pair_success': 'Parowanie zakończone sukcesem.', 'node.pair_pending': 'Kod parowania gotowy: zostanie użyty przy następnym otwarciu jednej z grup tego Node.', + 'node.link_other_node': "Twoje konto jest powiązane z węzłem innego komputera, więc ten węzeł nie może obsługiwać twoich grup.", + 'node.link_here': "Powiąż zamiast tego ten węzeł", + 'node.link_here_confirm': "Powiązać ten węzeł z twoim kontem? Węzeł powiązany teraz, na innym komputerze, przestanie obsługiwać twoje grupy, dopóki nie zostanie ponownie powiązany.", + 'node.link_other_account': "Ten węzeł jest skonfigurowany dla {account}, nie dla ciebie, więc nie może obsługiwać twoich grup.", + 'node.link_take_over': "Użyj tego węzła dla mojego konta", + 'node.link_linking': "Wiązanie…", 'node.reload': 'Przeładuj konfigurację', 'node.reloaded': 'Konfiguracja przeładowana. Zmiany katalogów w istniejących grupach są teraz aktywne.', 'node.restart_needed': 'Po dodaniu: zainicjalizuj klucz grupy (meshbay-node gek-init --group <nazwa>).', @@ -1318,4 +1330,70 @@ 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.", + 'contactsync.heading': "Kopia kontaktów", + 'contactsync.intro': "Wysyłaj kopię kontaktów z tego telefonu do folderu grupy, której jesteś jedynym członkiem, raz dziennie, jeśli się zmieniły. Każda kopia to plik .vcf, który zaimportuje każdy telefon.", + 'contactsync.permission_needed': "MeshBay potrzebuje dostępu do kontaktów w tym telefonie.", + 'contactsync.confirm': "Kopia Twoich kontaktów zostanie wysłana do {group}, do {dir}, a potem nowa każdego dnia, w którym się zmienią.\n\nJesteś jedynym członkiem tej grupy. Jeśli ktoś do niej dołączy, kopia zostanie zatrzymana.", + 'phonesync.state_sending': "Wysyłanie…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Wysłane kopie: {n}", + 'phonesync.last_file': "Ostatnia kopia: {name}", + 'phonesync.checking_groups': "Szukanie grup, których jesteś jedynym członkiem…", + 'phonesync.no_private_group': "Nie masz własnej grupy. Utwórz grupę, której jesteś jedynym członkiem, i wróć tutaj.", + 'phonesync.no_folder': "Ta grupa nie ma folderu, do którego możesz dodawać pliki.", + 'phonesync.destination': "Kopie trafiają do {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Zatrzymano: dysk node'a jest pełny.", + 'phonesync.fail_read_only': "Zatrzymano: folder nie przyjmuje już plików.", + 'phonesync.fail_no_folder': "Zatrzymano: folder nie istnieje już w grupie.", + 'phonesync.fail_unavailable': "Zatrzymano: dysk folderu nie jest podłączony do node'a.", + 'phonesync.fail_not_member': "Zatrzymano: nie jesteś już członkiem tej grupy.", + 'phonesync.fail_not_private': "Zatrzymano: ktoś inny dołączył do grupy. Wybierz grupę, której jesteś jedynym członkiem.", + 'phonesync.fail_permission': "Zatrzymano: MeshBay nie ma już dostępu.", + 'phonesync.fail_other': "Zatrzymano.", }; 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..e499de2 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 @@ -539,6 +539,9 @@ export default { 'settings.node_key_placeholder': 'Cole a chave pública Ed25519 do node (base64)', 'settings.node_key_submit': 'Vincular o node', 'settings.node_key_success': 'Node vinculado com sucesso', + 'settings.node_key_unlink': "Desvincular", + 'settings.node_key_unlink_confirm': "Desvincular o node da sua conta? Ele deixará de servir seus grupos até que um node seja vinculado novamente.", + 'settings.node_key_unlinked': "Node desvinculado", 'settings.node_key_current': 'Chave do node vinculado', // Sidebar @@ -912,6 +915,8 @@ export default { 'lazy.reload': 'Recarregar a página', // Sidebar + 'sidebar.phone': 'Celular', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -937,6 +942,7 @@ export default { 'node.startup_mode_service': 'Como serviço em segundo plano (inicia na inicialização)', 'node.startup_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…', 'node.startup_mode_service_unavailable_hint': 'O modo de serviço em segundo plano requer uma versão instalada. Para testes locais, execute "meshbay-node service install" em um PowerShell com privilégios de administrador.', + 'node.startup_mode_service_store_hint': 'Iniciar com o sistema, antes de qualquer login, exige o instalador do MeshBay (.exe) em vez da versão da Microsoft Store.', 'node.not_operator': 'Não foi possível alcançar seu node. Verifique se ele está em execução.', 'node.offline': 'Node está off-line', 'node.retry': 'Tentar novamente', @@ -945,6 +951,12 @@ export default { 'node.pair_button': 'Parear este navegador', 'node.pair_success': 'Pareamento realizado com sucesso.', 'node.pair_pending': 'Código de pareamento pronto: ele será usado na próxima vez que você abrir um dos grupos deste Node.', + 'node.link_other_node': "Sua conta está vinculada ao node de outra máquina, então este node não pode servir seus grupos.", + 'node.link_here': "Vincular este node no lugar", + 'node.link_here_confirm': "Vincular este node à sua conta? O node vinculado agora, em outra máquina, deixará de servir seus grupos até ser vinculado novamente.", + 'node.link_other_account': "Este node está configurado para {account}, não para você, então não pode servir seus grupos.", + 'node.link_take_over': "Usar este node na minha conta", + 'node.link_linking': "Vinculando…", 'node.reload': 'Recarregar configuração', 'node.reloaded': 'Configuração recarregada. Alterações de diretórios em grupos existentes estão ativas.', 'node.restart_needed': 'Após adicionar: inicialize a chave do grupo (meshbay-node gek-init --group <nome>).', @@ -1277,4 +1289,70 @@ 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.", + 'contactsync.heading': "Backup de contatos", + 'contactsync.intro': "Enviar uma cópia dos contatos deste celular para uma pasta de um grupo do qual só você é membro, uma vez por dia se tiverem mudado. Cada cópia é um arquivo .vcf que qualquer celular consegue importar.", + 'contactsync.permission_needed': "O MeshBay precisa de acesso aos contatos deste celular.", + 'contactsync.confirm': "Uma cópia dos seus contatos será enviada para {group}, em {dir}, e depois uma nova a cada dia em que mudarem.\n\nVocê é o único membro deste grupo. Se alguém entrar, o backup para.", + 'phonesync.state_sending': "Enviando…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "Cópias enviadas: {n}", + 'phonesync.last_file': "Última cópia: {name}", + 'phonesync.checking_groups': "Procurando grupos dos quais só você é membro…", + 'phonesync.no_private_group': "Você não tem nenhum grupo só seu. Crie um grupo do qual só você seja membro e volte aqui.", + 'phonesync.no_folder': "Este grupo não tem nenhuma pasta onde você possa adicionar arquivos.", + 'phonesync.destination': "As cópias vão para {dir}", + 'phonesync.notice': "{what}. {reason}", + 'phonesync.fail_disk_full': "Parado: o disco do node está cheio.", + 'phonesync.fail_read_only': "Parado: a pasta não aceita mais envios.", + 'phonesync.fail_no_folder': "Parado: a pasta não existe mais no grupo.", + 'phonesync.fail_unavailable': "Parado: o disco da pasta não está conectado ao node.", + 'phonesync.fail_not_member': "Parado: você não é mais membro deste grupo.", + 'phonesync.fail_not_private': "Parado: outra pessoa entrou no grupo. Escolha um grupo do qual só você seja membro.", + 'phonesync.fail_permission': "Parado: o MeshBay não tem mais acesso.", + 'phonesync.fail_other': "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..14595b1 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 @@ -528,6 +528,9 @@ export default { 'settings.node_key_placeholder': '粘贴 node 的 Ed25519 公钥(base64)', 'settings.node_key_submit': '关联 node', 'settings.node_key_success': 'node 关联成功', + 'settings.node_key_unlink': "取消关联", + 'settings.node_key_unlink_confirm': "要从你的账户取消关联该节点吗?在再次关联节点之前,它将停止为你的群组提供服务。", + 'settings.node_key_unlinked': "已取消关联节点", 'settings.node_key_current': '已关联的 node 密钥', // Sidebar @@ -889,6 +892,8 @@ export default { 'lazy.reload': '重新加载页面', // Sidebar + 'sidebar.phone': '手机', + 'androidsync.title': 'Android Sync', 'sidebar.node': 'Node', // Node admin @@ -914,6 +919,7 @@ export default { 'node.startup_mode_service': '作为后台服务(开机时启动)', 'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…', 'node.startup_mode_service_unavailable_hint': '后台服务模式需要已安装的版本。如需本地测试,请在具有管理员权限的 PowerShell 中运行 "meshbay-node service install"。', + 'node.startup_mode_service_store_hint': '在任何人登录之前随系统启动,需要使用 MeshBay 安装程序 (.exe),而不是 Microsoft Store 版本。', 'node.not_operator': '无法连接到您的 node。请确保它正在运行。', 'node.offline': 'Node 已离线', 'node.retry': '重试', @@ -922,6 +928,12 @@ export default { 'node.pair_button': '配对此浏览器', 'node.pair_success': '配对成功。', 'node.pair_pending': '配对码已就绪:下次打开此 Node 的任一群组时将自动使用。', + 'node.link_other_node': "你的账户已关联到另一台机器的节点,因此此节点无法为你的群组提供服务。", + 'node.link_here': "改为关联此节点", + 'node.link_here_confirm': "将此节点关联到你的账户?当前关联的另一台机器上的节点将停止为你的群组提供服务,直到再次关联。", + 'node.link_other_account': "此节点是为 {account} 而不是为你设置的,因此无法为你的群组提供服务。", + 'node.link_take_over': "将此节点用于我的账户", + 'node.link_linking': "正在关联…", 'node.reload': '重新加载配置', 'node.reloaded': '配置已重新加载。对现有群组的目录更改现已生效。', 'node.restart_needed': '添加后请初始化群组密钥(meshbay-node gek-init --group <名称>)。', @@ -1263,4 +1275,70 @@ 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': "照片备份已停止。", + 'contactsync.heading': "联系人备份", + 'contactsync.intro': "在联系人有变动的日子里,每天一次将本机联系人的副本发送到一个只有你一个成员的群组的文件夹。每份副本都是任何手机都能导入的 .vcf 文件。", + 'contactsync.permission_needed': "MeshBay 需要访问本机的联系人。", + 'contactsync.confirm': "你的联系人副本将发送到 {group} 的 {dir},之后每逢联系人有变动的日子再发送一份新的。\n\n你是这个群组的唯一成员。如果有人加入,备份将停止。", + 'phonesync.state_sending': "正在发送…", + 'phonesync.summary': "{group} → {dir}", + 'phonesync.copies_sent': "已发送副本:{n}", + 'phonesync.last_file': "最近一份副本:{name}", + 'phonesync.checking_groups': "正在查找只有你一个成员的群组…", + 'phonesync.no_private_group': "你没有自己专属的群组。请创建一个只有你一个成员的群组,然后回到这里。", + 'phonesync.no_folder': "这个群组没有你可以添加文件的文件夹。", + 'phonesync.destination': "副本保存到 {dir}", + 'phonesync.notice': "{what}。{reason}", + 'phonesync.fail_disk_full': "已停止:node 的磁盘已满。", + 'phonesync.fail_read_only': "已停止:该文件夹不再接受上传。", + 'phonesync.fail_no_folder': "已停止:该文件夹在群组中已不存在。", + 'phonesync.fail_unavailable': "已停止:该文件夹所在的磁盘未连接到 node。", + 'phonesync.fail_not_member': "已停止:你已不再是这个群组的成员。", + 'phonesync.fail_not_private': "已停止:有其他人加入了群组。请选择一个只有你一个成员的群组。", + 'phonesync.fail_permission': "已停止:MeshBay 已无法访问。", + 'phonesync.fail_other': "已停止。", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 04f4abc..56166c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -6,7 +6,7 @@ import { t } from './i18n.js'; import { ask } from './ask.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; -import { HUB, session } from './hub-client.js'; +import { HUB, session, hubFetch } from './hub-client.js'; // ── Node management (D5) ──────────────────────────────────────────────────── // @@ -22,18 +22,97 @@ export function pairedFrom(result) { return v === true || v === false ? v : null; } +// Why this machine's node cannot serve the signed-in account, if it cannot: +// 'other_account' when it is set up for another account or hub, 'other_node' +// when the account is linked to another node's key. Signing in links this +// node (main.js ensureNode) but never over either, since that would cut off +// the user's other machine or someone else's node; both left a node reading +// "Running" that the hub refused in a loop, with nothing here saying why +// (found on a real install, 2026-10-10). Worked out from the node and the hub +// each time the page looks, not kept from sign-in: a link made or removed on +// the Profile page or on the other machine changes the answer. +export function nodeLinkProblem(info, linkedKey, username, hub) { + if (!info || !username) return null; + const sameHub = (a, b) => String(a || '').replace(/\/+$/, '') === String(b || '').replace(/\/+$/, ''); + if ((info.username && info.username !== username) + || (info.hub_url && hub && !sameHub(info.hub_url, hub))) return 'other_account'; + if (linkedKey && info.pk_node_ed25519 && linkedKey !== info.pk_node_ed25519) return 'other_node'; + return null; +} + +function NodeLinkBanner({ info, token, username, onLinked }) { + const [linkedKey, setLinkedKey] = useState(null); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(''); + const nodeKey = info && info.pk_node_ed25519; + + useEffect(() => { + if (!username || !token) return; + let cancelled = false; + hubFetch(`/v1/users/${encodeURIComponent(username)}/pubkeys`, { token }) + .then((k) => { if (!cancelled) setLinkedKey((k && k.pk_node_ed25519) || null); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [username, token, nodeKey]); + + const problem = nodeLinkProblem(info, linkedKey, username, HUB); + if (!problem) return null; + + // Another node's key: this node's replaces it on the account, and a node + // waiting for its account signs in on its next attempt. Not node:start, + // which leaves a node that answers "running" alone, and one can (signed in + // before the account was linked elsewhere). Another account: node:start + // switches it, after asking, restarts it and links it. + const takeOver = async () => { + if (problem === 'other_node' && !await ask(t('node.link_here_confirm'))) return; + setBusy(true); + setErr(''); + try { + if (problem === 'other_node') { + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token, body: { pk_node_ed25519: nodeKey }, + }); + } else { + await platform.node.start({ hubUrl: HUB, username, token, takeOver: true }); + } + setLinkedKey(nodeKey); + if (onLinked) onLinked(); + } catch (e) { + setErr(platform.bridgeMessage(e)); + } finally { + setBusy(false); + } + }; + + return html` + <div class="node-pair-banner node-link-banner"> + <p>${problem === 'other_node' + ? t('node.link_other_node') + : t('node.link_other_account', { account: `${info.username} @ ${info.hub_url}` })}</p> + <button class="btn btn-primary btn-small" disabled=${busy} onClick=${takeOver}> + ${busy ? t('node.link_linking') + : problem === 'other_node' ? t('node.link_here') : t('node.link_take_over')}</button> + ${err && html`<p class="error-msg">${err}</p>`} + </div>`; +} + function NodeServicePanel({ onChanged, token, username }) { const [info, setInfo] = useState(null); const [busy, setBusy] = useState(''); - const [err, setErr] = useState(''); + // Two errors, not one: the status poll below cleared the message of an + // action that had just been refused, within five seconds and often before + // it was read (found switching startup mode on a real Store install). + const [pollErr, setPollErr] = useState(''); + const [actErr, setActErr] = useState(''); + const err = actErr || pollErr; const refresh = useCallback(async () => { try { const r = await platform.node.service.status(); setInfo(r); - setErr(''); + setPollErr(''); } catch (e) { - setErr(platform.bridgeMessage(e)); + setPollErr(platform.bridgeMessage(e)); } }, []); @@ -46,13 +125,13 @@ function NodeServicePanel({ onChanged, token, username }) { const act = useCallback(async (name, fn) => { setBusy(name); - setErr(''); + setActErr(''); try { await fn(); await refresh(); if (onChanged) onChanged(); } catch (e) { - setErr(platform.bridgeMessage(e)); + setActErr(platform.bridgeMessage(e)); } finally { setBusy(''); } @@ -162,7 +241,9 @@ function NodeServicePanel({ onChanged, token, username }) { ${busy === 'startupMode' && html` <p class="settings-hint">${t('node.startup_mode_updating')}</p>`} ${!info.canElevate && html` - <p class="settings-hint">${t('node.startup_mode_service_unavailable_hint')}</p>`} + <p class="settings-hint">${t(info.store + ? 'node.startup_mode_service_store_hint' + : 'node.startup_mode_service_unavailable_hint')}</p>`} `} </div>`; } @@ -718,6 +799,8 @@ export function NodePage({ groups, token, username }) { ${t('node.reload')}</button> </div> <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} /> + <${NodeLinkBanner} info=${nodeInfo} token=${token} username=${username} + onLinked=${fetchStatus} /> ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} ${operatorPaired === false && nodeGroups.length > 0 && html` <div class="node-pair-banner"> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js new file mode 100644 index 0000000..7df83b9 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js @@ -0,0 +1,210 @@ +// The contacts section of the Android Sync page, on the Android application +// only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other client has it. + +import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { ask } from './ask.js'; +import { + KINDS, subscribe, runDocSync, privateGroups, probeWritable, failureText, +} from './phone-sync.js'; + +// What each kind says in its own words; everything else is shared. +const TEXT = { + contacts: { + intro: 'contactsync.intro', + permission: 'contactsync.permission_needed', + confirm: 'contactsync.confirm', + }, +}; + +function stateLine(st, status) { + switch (st.phase) { + case 'connecting': return t('photosync.state_connecting'); + case 'sending': return t('phonesync.state_sending'); + 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') : ''; + } +} + +export function DocSyncSection({ kind, user, groups }) { + const { sync, heading } = KINDS[kind]; + const [status, setStatus] = useState(null); + const [run, setRun] = useState({ phase: 'idle' }); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(''); + + const refresh = useCallback(() => sync.status().then(setStatus).catch(() => {}), [sync]); + useEffect(() => { refresh(); }, [refresh]); + useEffect(() => subscribe(kind, (st) => { + setRun(st); + if (st.phase !== 'sending' && st.phase !== 'connecting') refresh(); + }), [kind, refresh]); + + if (!status) return null; + const cfg = status.config; + const busy = run.phase === 'sending' || run.phase === 'connecting'; + + const backUpNow = async () => { + setError(''); + try { await runDocSync(kind, { force: true }); } catch (e) { setError(e.message); } + }; + + const turnOff = async () => { + setError(''); + try { setStatus(await sync.configure(null)); } catch (e) { setError(e.message); } + }; + + return html` + <div class="settings-section"> + <h3 class="settings-heading">${t(heading)}</h3> + ${!cfg && !editing && html` + <p class="settings-hint">${t(TEXT[kind].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('phonesync.summary', { group: cfg.groupName, dir: status.dir })} + </span> + </div> + ${cfg.account !== user.username && html` + <p class="settings-hint">${t('photosync.other_account', { account: cfg.account })}</p>`} + <p class="settings-hint"> + ${t('phonesync.copies_sent', { n: status.sent })} + ${' · '} + ${status.lastCompleted + ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() }) + : t('photosync.never')} + </p> + ${status.lastSent && html` + <p class="settings-hint settings-name">${t('phonesync.last_file', { name: status.lastSent })}</p>`} + ${status.failure + ? html`<p class="error-msg">${failureText(status.failure)}</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=${busy} 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} kind=${kind} user=${user} groups=${groups} status=${status} + onPermission=${setStatus} + onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runDocSync(kind); } }} />`} + ${error && html`<p class="error-msg">${error}</p>`} + </div> + `; +} + +function Setup({ kind, user, groups, status, onPermission, onDone }) { + const { sync, suffix } = KINDS[kind]; + const cfg = status.config && status.config.account === user.username ? status.config : null; + const [candidates, setCandidates] = useState(null); // null while the hub is asked + const [groupId, setGroupId] = useState(cfg ? cfg.groupId : ''); + const [folders, setFolders] = useState(null); + const [probe, setProbe] = useState(''); // '', 'checking', 'offline' + const [folder, setFolder] = useState(cfg ? cfg.folder : ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const permitted = status.permission === 'granted'; + + // Only groups this account is alone in: what is sent is readable by every + // member, and an address book or a conversation is not the group's. + useEffect(() => { + let gone = false; + privateGroups(groups).then((list) => { + if (gone) return; + setCandidates(list); + setGroupId((g) => (list.some((x) => x.id === g) ? g : '')); + }); + return () => { gone = true; }; + }, [groups]); + + useEffect(() => { + setFolders(null); + if (!groupId) return undefined; + let gone = false; + setProbe('checking'); + probeWritable(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 sync.permit()); } catch (e) { setError(e.message); } + }; + + const dir = folder ? `${folder}/${user.username}-${suffix}` : ''; + + const start = async () => { + const group = (candidates || []).find((g) => g.id === groupId); + if (!group || !folder) return; + setBusy(true); + setError(''); + try { + if (!cfg || cfg.groupId !== groupId || cfg.folder !== folder) { + const yes = await ask(t(TEXT[kind].confirm, { group: group.name, dir })); + if (!yes) { setBusy(false); return; } + } + onDone(await sync.configure({ + account: user.username, groupId, groupName: group.name, + owner: group.owner_username || '', folder, + })); + } catch (e) { + setError(e.message); + setBusy(false); + } + }; + + return html` + <div> + ${!permitted && html` + <p class="settings-hint">${t(TEXT[kind].permission)}</p> + <div class="settings-row" style="gap:8px; justify-content:flex-start"> + <button class="admin-btn" onClick=${allow}>${t('photosync.permission_allow')}</button> + <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button> + </div> + `} + ${permitted && html` + ${candidates === null && html`<p class="settings-hint">${t('phonesync.checking_groups')}</p>`} + ${candidates && !candidates.length && html` + <p class="settings-hint">${t('phonesync.no_private_group')}</p>`} + ${candidates && candidates.length > 0 && 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> + ${candidates.map((g) => html`<option key=${g.id} value=${g.id}>${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('phonesync.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-hint settings-name">${t('phonesync.destination', { dir })}</p> + `} + <div class="settings-row" style="gap:8px; justify-content:flex-start"> + <button class="admin-btn" onClick=${start} + disabled=${busy || !groupId || !folder}>${t('photosync.start')}</button> + <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button> + </div> + `} + ${error && html`<p class="error-msg">${error}</p>`} + </div> + `; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js new file mode 100644 index 0000000..964f76c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js @@ -0,0 +1,249 @@ +// Contacts backup: a copy of this phone's address book sent to a folder of a +// group only its owner belongs to, once a day when it changed +// (docs/MESHBAY_DESIGN.md §9.13). +// +// The same split as photos (photo-sync.js): the phone writes what is new into +// one file and remembers what the node took; this module decides when a run +// is due and does the sending. Loaded only where the bridge offers it, the +// Android application. +// +// Additive by construction, like photos: every copy is a new dated file, and +// nothing here deletes, renames or replaces anything on the node. +// `test_phone_sync.py` reads this file for the operations that would. + +import { ConnectionPool } from './connection-pool.js'; +import * as platform from './platform.js'; +import { t } from './i18n.js'; +import { destinationProblem, makeDirs, upload } from './photo-sync.js'; + +const DAY_MS = 24 * 3600 * 1000; +// A run cut short by something passing is tried again at the next chance, +// but not more often than this. +const RETRY_MS = 15 * 60 * 1000; + +/** + * Each kind of data: the bridge object that reads it, its heading, and the + * folder it goes into under the chosen one (`<account>-<suffix>`, as the + * phone names it in DocPlan.kt). + */ +export const KINDS = { + contacts: { sync: platform.contactSync, heading: 'contactsync.heading', suffix: 'contacts' }, +}; + +// 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: 'phonesync.fail_disk_full', + root_read_only: 'phonesync.fail_read_only', + no_writable_root: 'phonesync.fail_read_only', + no_such_root: 'phonesync.fail_no_folder', + no_such_directory: 'phonesync.fail_no_folder', + root_unavailable: 'phonesync.fail_unavailable', + not_a_member: 'phonesync.fail_not_member', + not_private: 'phonesync.fail_not_private', + permission: 'phonesync.fail_permission', +}; + +export function failureText(code) { + return t(LASTING[code] || 'phonesync.fail_other'); +} + +// ── What the sections show ────────────────────────────────────────────────── + +const states = {}; +const listeners = {}; + +function setState(kind, patch) { + states[kind] = { ...(states[kind] || { phase: 'idle' }), ...patch }; + for (const fn of [...(listeners[kind] || [])]) { + try { fn(states[kind]); } catch (e) { console.error('[MeshBay] backup listener threw:', e); } + } +} + +export function subscribe(kind, fn) { + (listeners[kind] = listeners[kind] || new Set()).add(fn); + fn(states[kind] || { phase: 'idle' }); + return () => listeners[kind].delete(fn); +} + +// ── When it runs ──────────────────────────────────────────────────────────── + +let ctx = null; // { hub, hubFetch, getUser, getBundleKey } +const running = {}; +const timers = {}; +const lastAttempt = {}; + +function available() { + return Object.keys(KINDS).filter((k) => KINDS[k].sync.available); +} + +/** True when `status` says a scheduled run should go now. */ +export function isDue(status, now = Date.now()) { + if (!status || !status.config) return false; + 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, and once the next run falls due. */ +export function startPhoneSync(context) { + if (ctx || !available().length) return; + ctx = context; + document.addEventListener('visibilitychange', onVisible); + for (const kind of available()) runDocSync(kind); +} + +export function stopPhoneSync() { + if (!ctx) return; + ctx = null; + for (const kind of Object.keys(timers)) clearTimeout(timers[kind]); + document.removeEventListener('visibilitychange', onVisible); +} + +function onVisible() { + if (document.visibilityState !== 'visible') return; + for (const kind of available()) runDocSync(kind); +} + +function scheduleNext(kind, status) { + clearTimeout(timers[kind]); + 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(); + if (wait > 0) timers[kind] = setTimeout(() => runDocSync(kind), Math.min(wait, DAY_MS)); +} + +/** A run of one kind, if one is due; `force` is "Back up now". */ +export function runDocSync(kind, { force = false } = {}) { + if (!ctx || !KINDS[kind]) return Promise.resolve(); + if (running[kind]) return running[kind]; + const sync = KINDS[kind].sync; + running[kind] = (async () => { + let status = null; + try { + status = await sync.status(); + await run(kind, sync, status, force); + status = await sync.status(); + } catch (e) { + console.warn(`[MeshBay] ${kind} backup:`, e.message); + setState(kind, { phase: 'interrupted', detail: e.message }); + } finally { + running[kind] = null; + scheduleNext(kind, status); + } + })(); + return running[kind]; +} + +async function run(kind, sync, status, force) { + const cfg = status.config; + const user = ctx && ctx.getUser(); + if (!cfg) { setState(kind, { phase: 'off' }); return; } + if (!user || cfg.account !== user.username) { setState(kind, { phase: 'other_account' }); return; } + if (status.permission !== 'granted') { await lasting(kind, sync, 'permission'); return; } + if (!force && !isDue(status)) { + setState(kind, { phase: status.failure ? 'failed' : 'up_to_date', reason: status.failure || '' }); + return; + } + if (!force && Date.now() - (lastAttempt[kind] || 0) < RETRY_MS) return; + lastAttempt[kind] = Date.now(); + + // Asked of the hub at every run, not only at set-up: somebody who joins the + // group later would otherwise read every copy from then on. + setState(kind, { phase: 'connecting' }); + let alone; + try { + alone = await isPrivate(cfg.groupId, user); + } catch (e) { + if (/not a member|not found/i.test(e.message || '')) { await lasting(kind, sync, 'not_a_member'); return; } + setState(kind, { phase: 'offline' }); + return; + } + if (!alone) { await lasting(kind, sync, 'not_private'); return; } + + const { item } = await sync.plan(); + if (!item) { + await sync.completed(); + setState(kind, { phase: 'up_to_date' }); + return; + } + + 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(kind, sync, 'not_a_member'); return; } + setState(kind, { phase: 'offline' }); + return; + } + + try { + const index = await conn.transport.fetchIndex(); + const problem = destinationProblem(cfg.folder, index); + if (problem) { await lasting(kind, sync, problem); return; } + const dirs = new Set(index.dirs || []); + for (const r of index.roots || []) dirs.add(r.name); + setState(kind, { phase: 'sending', count: item.count }); + try { + await makeDirs(conn.transport, cfg.folder, item.dir, dirs); + const ack = await upload(conn.transport, item, (token, name) => sync.file(token, name)); + await sync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name); + } catch (e) { + if (e.code && LASTING[e.code]) { await lasting(kind, sync, e.code); return; } + throw e; + } + await sync.completed(); + setState(kind, { phase: 'up_to_date' }); + } finally { + pool.closeAll(); + } +} + +async function lasting(kind, sync, code) { + setState(kind, { phase: 'failed', reason: code }); + const text = t('phonesync.notice', { what: t(KINDS[kind].heading), reason: failureText(code) }); + await sync.failed(code, text).catch(() => {}); +} + +// ── For the sections ──────────────────────────────────────────────────────── + +/** True when this account is the group's one member and nobody is invited. */ +export async function isPrivate(groupId, user) { + const r = await ctx.hubFetch(`/v1/groups/${groupId}/members`, { token: user.token }); + const members = r.members || []; + return members.length === 1 && members[0].username === user.username + && !(r.invited || []).length; +} + +/** The groups among `groups` this account is alone in; a failed look leaves one out. */ +export async function privateGroups(groups) { + const user = ctx && ctx.getUser(); + if (!user) return []; + const alone = await Promise.all(groups.map((g) => isPrivate(g.id, user).catch(() => false))); + return groups.filter((_, i) => alone[i]); +} + +/** Every folder a member can add to: on a writable root whose drive is there. */ +export function writableFolders(index) { + const roots = new Set((index.roots || []) + .filter((r) => r.writable && r.available !== false).map((r) => r.name)); + const all = new Set([...(index.dirs || []), ...roots]); + return [...all].filter((d) => roots.has(d.split('/')[0])).sort((a, b) => a.localeCompare(b)); +} + +/** A one-off look at a group, for choosing a folder: connected, read, closed. */ +export async function probeWritable(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); + return { folders: writableFolders(await conn.transport.fetchIndex()) }; + } finally { + pool.closeAll(); + } +} 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..b5e9a30 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js @@ -0,0 +1,265 @@ +// The "Photo backup" section of the Android Sync page, 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..77d050e --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js @@ -0,0 +1,319 @@ +// 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. + * `file` is how its bytes come from the phone; contacts and messages pass theirs. + */ +export async function upload(transport, item, file = platform.photoSync.file) { + 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 bytes = await file(item.token, item.name); + return await transport.uploadFile(bytes, { + dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr, + }); + } finally { + lease.release(); + } +} + +/** `<folder>/YYYY/YYYY-MM`, one level at a time; a level that exists is fine. */ +export 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..1715d5f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -230,6 +230,58 @@ 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); + }, +}; + +/** + * Backing up one kind of this phone's data (the contacts) to a group of + * which the person is the only member (docs/MESHBAY_DESIGN.md §9.13). Android + * only, like `photoSync`. See phone-sync.js. + */ +function docSync(name) { + const b = bridge && bridge[name]; + return { + available: Boolean(b), + status() { return b.status(); }, + permit() { return b.permit(); }, + configure(settings) { return b.configure(settings); }, + plan() { return b.plan(); }, + sent(token, dir, n) { return b.sent(token, dir, n); }, + completed() { return b.completed(); }, + failed(code, text) { return b.failed(code, text); }, + /** The file the last plan wrote, as a File named for the node. */ + async file(token, fileName) { + const r = await fetch(`/phonesync/${encodeURIComponent(token)}`, { cache: 'no-store' }); + if (!r.ok) throw new Error('This file is no longer on the phone'); + return new File([await r.blob()], fileName); + }, + }; +} + +export const contactSync = docSync('contactSync'); + /** 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/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index aac3c14..b52a8c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -370,6 +370,25 @@ export function ProfilePage({ user, onLogout }) { } }, [nodeKey, user.token]); + // Unlinking was only on the linked machine's own Node page, so an account + // whose node was on a machine since wiped or lost had no way out here; and a + // new machine's node will not take over a key that is still linked + // (main.js ensureNode). The hub accepts it from any session of the account. + const unlinkNodeKey = useCallback(async () => { + if (!await ask(t('settings.node_key_unlink_confirm'))) return; + setNodeKeyLoading(true); + setNodeKeyStatus(''); + try { + await hubFetch('/v1/users/me/node_key', { method: 'DELETE', token: user.token }); + setCurrentNodeKey(null); + setNodeKeyStatus(t('settings.node_key_unlinked')); + } catch (e) { + setNodeKeyStatus(e.message); + } finally { + setNodeKeyLoading(false); + } + }, [user.token]); + return html` <div> <h2>${t('profile.title')}</h2> @@ -435,6 +454,9 @@ export function ProfilePage({ user, onLogout }) { <div class="settings-row" style="margin-top:8px"> <span class="settings-label">${t('settings.node_key_current')}</span> <code class="settings-value" style="font-size:0.8em;word-break:break-all">${currentNodeKey}</code> + <button class="admin-btn" onClick=${unlinkNodeKey} disabled=${nodeKeyLoading}> + ${t('settings.node_key_unlink')} + </button> </div> `} <div style="display:flex;gap:8px;margin-top:10px;align-items:center"> @@ -448,7 +470,7 @@ export function ProfilePage({ user, onLogout }) { </button> </div> ${nodeKeyStatus && html` - <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--success)' : 'var(--error)'}"> + <p style="margin-top:6px;font-size:0.85em;color:${[t('settings.node_key_success'), t('settings.node_key_unlinked')].includes(nodeKeyStatus) ? 'var(--success)' : 'var(--error)'}"> ${nodeKeyStatus} </p> `} 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. */ |