aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/drive-sync-settings.js122
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js48
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js42
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js406
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js67
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js83
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/whatsapp-sync-settings.js125
22 files changed, 1207 insertions, 240 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
index b4d85d0..9fac9e4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js
@@ -11,6 +11,9 @@ import { ask } from './ask.js';
import * as platform from './platform.js';
import { PhotoSyncSection } from './photo-sync-settings.js';
import { DocSyncSection } from './phone-sync-settings.js';
+import { DriveSyncSection } from './drive-sync-settings.js';
+import { WhatsAppSyncSection } from './whatsapp-sync-settings.js';
+import { runDriveSync, runWhatsAppSync } from './drive-sync.js';
import { runPhotoSync } from './photo-sync.js';
import { runDocSync, KINDS } from './phone-sync.js';
import {
@@ -22,6 +25,8 @@ function kindDirs(folder, account) {
const suffixes = [
...(platform.photoSync.available ? ['photos'] : []),
...Object.values(KINDS).filter((k) => k.sync.available).map((k) => k.suffix),
+ ...(platform.driveSync.available ? ['drive'] : []),
+ ...(platform.whatsappSync.available ? ['whatsapp'] : []),
];
return suffixes.map((s) => `${folder}/${account}-${s}`);
}
@@ -39,9 +44,13 @@ export function AndroidSyncPage({ user, groups }) {
const moved = (next) => {
setDest(next);
- // Each kind that is on starts again in the new place, now.
- if (platform.photoSync.available) runPhotoSync();
- for (const kind of Object.keys(KINDS)) if (KINDS[kind].sync.available) runDocSync(kind);
+ // Each kind that is on starts again in the new place, now: forced, as a
+ // run asked for by a change, not left to the 15-minute retry spacing.
+ const now = { force: true };
+ if (platform.photoSync.available) runPhotoSync(now);
+ for (const kind of Object.keys(KINDS)) if (KINDS[kind].sync.available) runDocSync(kind, now);
+ if (platform.driveSync.available) runDriveSync(now);
+ if (platform.whatsappSync.available) runWhatsAppSync(now);
};
return html`
@@ -52,6 +61,10 @@ export function AndroidSyncPage({ user, groups }) {
<${PhotoSyncSection} key=${'photos' + place} destination=${mine} />`}
${Object.keys(KINDS).filter((k) => KINDS[k].sync.available).map((kind) => html`
<${DocSyncSection} key=${kind + place} kind=${kind} destination=${mine} />`)}
+ ${platform.driveSync.available && html`
+ <${DriveSyncSection} key=${'drive' + place} destination=${mine} />`}
+ ${platform.whatsappSync.available && html`
+ <${WhatsAppSyncSection} key=${'whatsapp' + place} destination=${mine} />`}
</div>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 41cc5f2..579d1dd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -919,6 +919,19 @@ function App() {
pullOnce(user.userId).catch(() => {});
}, [user, groups.length]);
+ // What every phone backup runs with: the live user and bundle key, read
+ // when a run starts, never ones captured here.
+ const backupContext = () => ({
+ hub: HUB,
+ hubFetch,
+ getUser: () => userRef.current,
+ getBundleKey: async () => {
+ const key = session.bundleKey || await _loadBundleKey();
+ if (key) session.bundleKey = key;
+ return key;
+ },
+ });
+
// 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.
@@ -929,42 +942,40 @@ function App() {
let gone = false;
import('./photo-sync.js').then((m) => {
if (gone) return;
- m.startPhotoSync({
- hub: HUB,
- hubFetch,
- getUser: () => userRef.current,
- getBundleKey: async () => {
- const key = session.bundleKey || await _loadBundleKey();
- if (key) session.bundleKey = key;
- return key;
- },
- });
+ m.startPhotoSync(backupContext());
stop = m.stopPhotoSync;
}).catch((e) => console.warn('[MeshBay] photo backup did not load:', e.message));
return () => { gone = true; if (stop) stop(); };
}, [syncAccount]);
- // Contacts and messages backup (§9.13), the same way.
+ // Files and WhatsApp backups (§9.13), the same way.
+ useEffect(() => {
+ if (!syncAccount || !(platform.driveSync.available || platform.whatsappSync.available)) {
+ return undefined;
+ }
+ let stop = null;
+ let gone = false;
+ import('./drive-sync.js').then((m) => {
+ if (gone) return;
+ m.startDriveSync(backupContext());
+ stop = m.stopDriveSync;
+ }).catch((e) => console.warn('[MeshBay] files backup did not load:', e.message));
+ return () => { gone = true; if (stop) stop(); };
+ }, [syncAccount]);
+
+ // Contacts, calendar and messages backup (§9.13), the same way.
useEffect(() => {
- if (!syncAccount || !(platform.contactSync.available || platform.messageSync.available)) {
+ if (!syncAccount || !(platform.contactSync.available || platform.calendarSync.available
+ || platform.messageSync.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;
- },
- });
+ m.startPhoneSync(backupContext());
stop = m.stopPhoneSync;
- }).catch((e) => console.warn('[MeshBay] contacts and messages backup did not load:', e.message));
+ }).catch((e) => console.warn('[MeshBay] phone backups did not load:', e.message));
return () => { gone = true; if (stop) stop(); };
}, [syncAccount]);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/drive-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync-settings.js
new file mode 100644
index 0000000..8fa9a3e
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync-settings.js
@@ -0,0 +1,122 @@
+// The files 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.
+// Where the files go is the page's destination section; this one chooses the
+// folders, through the system's own picker.
+
+import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ask } from './ask.js';
+import * as platform from './platform.js';
+import { subscribe, runDriveSync } from './drive-sync.js';
+import { failureText } from './phone-sync.js';
+
+// What the phone says when it refuses a folder, in the person's words.
+const REFUSED = {
+ media_folder: 'drivesync.refused_media',
+ whole_storage: 'drivesync.refused_root',
+};
+
+function refusal(message) {
+ const code = /Refused: (\w+)/.exec(message || '');
+ return code && REFUSED[code[1]] ? t(REFUSED[code[1]]) : message;
+}
+
+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') : '';
+ }
+}
+
+export function DriveSyncSection({ destination }) {
+ const sync = platform.driveSync;
+ const [status, setStatus] = useState(null);
+ const [run, setRun] = useState({ phase: 'idle' });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+
+ const refresh = useCallback(() => sync.status().then(setStatus).catch(() => {}), [sync]);
+ useEffect(() => { refresh(); }, [refresh]);
+ useEffect(() => subscribe((st) => {
+ setRun(st);
+ if (st.phase !== 'sending' && st.phase !== 'connecting') refresh();
+ }), [refresh]);
+
+ if (!status) return null;
+ const folders = status.folders || [];
+ const running = run.phase === 'sending' || run.phase === 'connecting';
+
+ const act = async (fn) => {
+ setBusy(true);
+ setError('');
+ try { const next = await fn(); if (next) setStatus(next); } catch (e) { setError(refusal(e.message)); }
+ finally { setBusy(false); }
+ };
+
+ const backUpNow = () => act(async () => {
+ if (!status.unmetered && !await ask(t('drivesync.metered_confirm'))) return null;
+ await runDriveSync({ force: true, metered: !status.unmetered });
+ return null;
+ });
+
+ return html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('drivesync.heading')}</h3>
+ ${!status.on && html`<p class="settings-hint">${t('drivesync.intro')}</p>`}
+ ${!destination && html`<p class="settings-hint">${t('androidsync.dest_needed')}</p>`}
+ ${destination && html`
+ <p class="settings-label" style="margin-top:6px">${t('drivesync.folders')}</p>
+ ${!folders.length && html`<p class="settings-hint">${t('drivesync.no_folders')}</p>`}
+ ${folders.map((f) => html`
+ <div class="settings-row" key=${f.id}>
+ <span class="settings-label settings-name">${f.name}</span>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.removeFolder(f.id))}>${t('drivesync.remove')}</button>
+ </div>
+ `)}
+ <p class="settings-hint">${t('drivesync.folders_hint')}</p>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.addFolder())}>${t('drivesync.add_folder')}</button>
+ `}
+ ${destination && !status.on && html`
+ <div class="settings-row" style="gap:8px; justify-content:flex-start; margin-top:8px">
+ <button class="admin-btn" disabled=${busy || !folders.length}
+ onClick=${() => act(async () => {
+ const next = await sync.configure({});
+ runDriveSync({ force: true });
+ return next;
+ })}>${t('phonesync.turn_on')}</button>
+ </div>
+ `}
+ ${destination && status.on && html`
+ <div class="settings-row" style="margin-top:8px">
+ <span class="settings-label settings-name">
+ ${t('phonesync.summary', { group: destination.groupName, dir: status.dir })}
+ </span>
+ </div>
+ <p class="settings-hint">
+ ${t('drivesync.sent_total', { n: status.sent })}
+ ${' · '}
+ ${status.lastCompleted
+ ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() })
+ : t('photosync.never')}
+ </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=${running || busy || !folders.length}
+ onClick=${backUpNow}>${t('photosync.now')}</button>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.configure(null))}>${t('photosync.turn_off')}</button>
+ </div>
+ `}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js
new file mode 100644
index 0000000..3887e4f
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/drive-sync.js
@@ -0,0 +1,48 @@
+// Files and WhatsApp backups: folders chosen through the system's picker,
+// sent into `<folder>/<account>-drive/<chosen folder>/…` of the destination
+// every backup shares (sync-destination.js), once a day, on an unmetered
+// network, like photos (docs/MESHBAY_DESIGN.md §9.13).
+//
+// The phone (platform.driveSync) lists the files, leaves out what the photo
+// backup sends, keeps the ledger and hands each file over a range at a time;
+// the running is the photo backup's own (createBackup). Additive by
+// construction: a new version of a file goes beside the one sent, and nothing
+// here deletes, renames or replaces anything on the node.
+
+import * as platform from './platform.js';
+import { t } from './i18n.js';
+import { createBackup } from './photo-sync.js';
+import { LASTING, failureText } from './phone-sync.js';
+
+const drive = createBackup({
+ name: 'files',
+ sync: platform.driveSync,
+ configured: (status) => Boolean(status.on) && (status.folders || []).length > 0,
+ progress: 'drivesync.notif_progress',
+ failures: LASTING,
+ say: (code) => t('phonesync.notice', { what: t('drivesync.heading'), reason: failureText(code) }),
+});
+
+// WhatsApp's own folder: the same runner over its bridge, into
+// `<folder>/<account>-whatsapp/` (DriveKind.kt says what is sent).
+const whatsapp = createBackup({
+ name: 'WhatsApp',
+ sync: platform.whatsappSync,
+ configured: (status) => Boolean(status.on) && (status.folders || []).length > 0,
+ progress: 'whatsappsync.notif_progress',
+ failures: LASTING,
+ say: (code) => t('phonesync.notice', { what: t('whatsappsync.heading'), reason: failureText(code) }),
+});
+
+export function startDriveSync(context) {
+ drive.start(context);
+ whatsapp.start(context);
+}
+export function stopDriveSync() {
+ drive.stop();
+ whatsapp.stop();
+}
+export const runDriveSync = (opts) => drive.run(opts);
+export const subscribe = (fn) => drive.subscribe(fn);
+export const runWhatsAppSync = (opts) => whatsapp.run(opts);
+export const subscribeWhatsApp = (fn) => whatsapp.subscribe(fn);
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 086f3a9..17b8c2b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -380,6 +380,11 @@ export default {
one: '{n} Foto',
other: '{n} Fotos',
},
+ 'photo.n_videos': {
+ one: "{n} Video",
+ other: "{n} Videos",
+ },
+ 'photo.play_video': "Video abspielen",
'photo.back': 'Zurück',
'photo.prev': 'Vorherige (←)',
'photo.next': 'Nächste (→)',
@@ -1313,6 +1318,13 @@ export default {
'photosync.albums': "Alben auf diesem Telefon",
'photosync.album_camera': "Kamera",
'photosync.album_count': "{n} Fotos · {size}",
+ 'photosync.videos_not_included': "Videos werden nicht gesichert: „Ändern“, um sie hinzuzufügen.",
+ 'photosync.videos_included': "Videos werden ebenfalls gesichert.",
+ 'photosync.include_videos': "Auch Videos",
+ 'photosync.include_videos_hint': "Videos sind groß: Sie werden wie Fotos über WLAN gesendet, und die erste Sicherung kann dauern.",
+ 'photosync.album_count_videos': "{n} Fotos, {v} Videos · {size}",
+ 'photosync.confirm_videos': "{count} Fotos und Videos ({size}), davon {videos} Videos ({videoSize}), werden nach {dir} gesendet, danach einmal täglich die neuen.",
+ 'photosync.video_permission_needed': "MeshBay braucht auch Zugriff auf die Videos dieses Telefons.",
'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",
@@ -1340,9 +1352,13 @@ export default {
'contactsync.heading': "Kontakte-Sicherung",
'contactsync.intro': "Einmal täglich, wenn sie sich geändert haben, eine Kopie der Kontakte dieses Telefons senden. Jede Kopie ist eine .vcf-Datei, die jedes Telefon importieren kann.",
'contactsync.permission_needed': "MeshBay braucht Zugriff auf die Kontakte dieses Telefons.",
+ 'calendarsync.heading': "Kalender-Sicherung",
+ 'calendarsync.intro': "Einmal täglich, wenn sie sich geändert haben, eine Kopie der Kalender dieses Telefons senden: die, die du bearbeiten kannst, nicht die nur abonnierten. Jede Kopie ist eine .ics-Datei, die jeder Kalender importieren kann.",
+ 'calendarsync.permission_needed': "MeshBay braucht Zugriff auf die Kalender dieses Telefons.",
'messagesync.heading': "SMS-Sicherung",
'messagesync.intro': "Die SMS dieses Telefons einmal täglich senden: Die erste Kopie enthält alle, jede weitere nur die neuen. Die Dateien sind im XML-Format, das SMS-Sicherungs-Apps wiederherstellen können. MMS sind nicht enthalten.",
'messagesync.permission_needed': "MeshBay braucht Zugriff auf die SMS dieses Telefons.",
+ 'messagesync.restricted_hint': "Wenn Android von einer eingeschränkten Einstellung spricht: Öffne die App-Einstellungen, tippe oben auf ⋮, „Eingeschränkte Einstellungen zulassen“, und schalte es dann hier wieder ein. Android verlangt das von Apps, die nicht aus einem Store installiert wurden.",
'phonesync.state_sending': "Wird gesendet…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Gesendete Kopien: {n}",
@@ -1359,6 +1375,30 @@ export default {
'phonesync.fail_not_private': "Gestoppt: Jemand anderes ist der Gruppe beigetreten.",
'phonesync.fail_permission': "Gestoppt: MeshBay hat keinen Zugriff mehr.",
'phonesync.fail_other': "Gestoppt.",
+ 'phonesync.open_settings': "App-Einstellungen öffnen",
+ 'drivesync.heading': "Datei-Sicherung",
+ 'drivesync.intro': "Die Dateien der Ordner, die du auf diesem Telefon auswählst, einmal täglich über WLAN senden. Eine auf dem Telefon geänderte Datei wird neben der früheren Kopie erneut gesendet; nichts wird je ersetzt.",
+ 'drivesync.folders': "Gesicherte Ordner",
+ 'drivesync.no_folders': "Noch kein Ordner ausgewählt.",
+ 'drivesync.folders_hint': "Fotos und Videos übernimmt die Foto-Sicherung: DCIM, Pictures und Movies können nicht gewählt werden. Android lässt eine App nicht den ganzen Download-Ordner nehmen, nur Ordner darin.",
+ 'drivesync.add_folder': "Ordner hinzufügen",
+ 'drivesync.remove': "Entfernen",
+ 'drivesync.refused_media': "Dieser Ordner enthält Fotos und Videos, die die Foto-Sicherung sendet: Wähle einen anderen.",
+ 'drivesync.refused_root': "Android erlaubt nicht, einen ganzen Speicher zu wählen: Wähle einen Ordner darin.",
+ 'drivesync.notif_progress': "Dateien werden gesichert: {done} / {total}",
+ 'drivesync.metered_confirm': "Du bist im Mobilfunknetz. Die Dateien trotzdem senden?",
+ 'drivesync.sent_total': "Gesendete Dateien: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Die Chat-Sicherungen von WhatsApp einmal täglich über WLAN kopieren, damit sie auf einem neuen Telefon wiederhergestellt werden können. Sie bleiben von WhatsApp verschlüsselt: Hier lassen sie sich nicht lesen, nur wiederherstellen.",
+ 'whatsappsync.folder_needed': "Der WhatsApp-Ordner ist noch nicht ausgewählt.",
+ 'whatsappsync.folder_chosen': "Der WhatsApp-Ordner ist ausgewählt.",
+ 'whatsappsync.folder_choose': "WhatsApp-Ordner wählen",
+ 'whatsappsync.folder_hint': "Die Auswahl öffnet sich darauf: Android/media/com.whatsapp/WhatsApp. Tippe auf „Diesen Ordner verwenden“.",
+ 'whatsappsync.media': "Auch Medien",
+ 'whatsappsync.media_hint': "Empfangene und gesendete Fotos, Videos, Sprachnachrichten und Dokumente: oft mehrere Gigabyte. Was die Foto-Sicherung schon sendet, wird nicht doppelt gesendet.",
+ 'whatsappsync.refused': "Das ist nicht der WhatsApp-Ordner: Wähle Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Wiederherstellen auf einem neuen Telefon: Kopiere die Ordner Databases und Backups der letzten Kopie nach Android/media/com.whatsapp/WhatsApp, bevor du WhatsApp installierst, und stelle dann mit derselben Telefonnummer wieder her (und dem Passwort oder 64-stelligen Schlüssel, wenn die Ende-zu-Ende-Sicherung aktiv ist).",
+ 'whatsappsync.notif_progress': "WhatsApp wird gesichert: {done} / {total}",
'androidsync.dest_heading': "Ziel der Sicherungen",
'androidsync.dest_intro': "Sicherungen landen in einem Ordner einer Gruppe, die dir gehört und deren einziges Mitglied du bist. Jede Art von Sicherung bekommt dort einen eigenen Ordner.",
'androidsync.dest_summary': "{group} → {folder}",
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 66cd23b..a4e07a1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -378,6 +378,11 @@ export default {
one: '{n} photo',
other: '{n} photos',
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} videos",
+ },
+ 'photo.play_video': "Play the video",
'photo.back': 'Back',
'photo.prev': 'Previous (←)',
'photo.next': 'Next (→)',
@@ -1294,6 +1299,13 @@ export default {
'photosync.albums': "Albums on this phone",
'photosync.album_camera': "Camera",
'photosync.album_count': "{n} photos · {size}",
+ 'photosync.videos_not_included': "Videos are not backed up: “Change” to add them.",
+ 'photosync.videos_included': "Videos are backed up too.",
+ 'photosync.include_videos': "Videos too",
+ 'photosync.include_videos_hint': "Videos are large: they go on Wi-Fi like photos, and the first backup can take a while.",
+ 'photosync.album_count_videos': "{n} photos, {v} videos · {size}",
+ 'photosync.confirm_videos': "{count} photos and videos ({size}), including {videos} videos ({videoSize}), will be sent to {dir}, then new ones once a day.",
+ 'photosync.video_permission_needed': "MeshBay needs access to this phone's videos too.",
'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",
@@ -1321,9 +1333,13 @@ export default {
'contactsync.heading': "Contacts backup",
'contactsync.intro': "Send a copy of this phone's contacts 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.",
+ 'calendarsync.heading': "Calendar backup",
+ 'calendarsync.intro': "Send a copy of this phone's calendars once a day when they have changed: the ones you can edit, not those you only subscribe to. Each copy is an .ics file any calendar can import.",
+ 'calendarsync.permission_needed': "MeshBay needs access to this phone's calendars.",
'messagesync.heading': "Messages backup",
'messagesync.intro': "Send the text messages on this phone once a day: the first copy holds them all, each next one only the new ones. The files are in the XML format SMS backup applications restore. Multimedia messages are not included.",
'messagesync.permission_needed': "MeshBay needs access to this phone's text messages.",
+ 'messagesync.restricted_hint': "If Android says this is a restricted setting: open the application's settings, tap ⋮ at the top, “Allow restricted settings”, then come back and turn this on again. Android asks this of applications installed outside a store.",
'phonesync.state_sending': "Sending…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Copies sent: {n}",
@@ -1340,6 +1356,30 @@ export default {
'phonesync.fail_not_private': "Stopped: somebody else has joined the group.",
'phonesync.fail_permission': "Stopped: MeshBay no longer has access.",
'phonesync.fail_other': "Stopped.",
+ 'phonesync.open_settings': "Open the application's settings",
+ 'drivesync.heading': "Files backup",
+ 'drivesync.intro': "Send the files of folders you choose on this phone, once a day, on Wi-Fi. A file changed on the phone is sent again beside the earlier copy; nothing is ever replaced.",
+ 'drivesync.folders': "Folders backed up",
+ 'drivesync.no_folders': "No folder chosen yet.",
+ 'drivesync.folders_hint': "Photos and videos are left to the photo backup: DCIM, Pictures and Movies cannot be chosen. Android does not let an application take the whole of Download, only folders inside it.",
+ 'drivesync.add_folder': "Add a folder",
+ 'drivesync.remove': "Remove",
+ 'drivesync.refused_media': "This folder holds photos and videos, which the photo backup sends: choose another one.",
+ 'drivesync.refused_root': "Android does not let a whole storage be chosen: choose a folder inside it.",
+ 'drivesync.notif_progress': "Backing up files: {done} / {total}",
+ 'drivesync.metered_confirm': "You are on mobile data. Send the files anyway?",
+ 'drivesync.sent_total': "Files sent: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copy WhatsApp's own chat backups, once a day on Wi-Fi, so they can be restored onto a new phone. They stay encrypted by WhatsApp: they cannot be read here, only restored.",
+ 'whatsappsync.folder_needed': "WhatsApp's folder is not chosen yet.",
+ 'whatsappsync.folder_chosen': "WhatsApp's folder is chosen.",
+ 'whatsappsync.folder_choose': "Choose WhatsApp's folder",
+ 'whatsappsync.folder_hint': "The picker opens on it: Android/media/com.whatsapp/WhatsApp. Press “Use this folder”.",
+ 'whatsappsync.media': "Media too",
+ 'whatsappsync.media_hint': "Photos, videos, voice notes and documents received and sent: often several gigabytes. Those the photo backup already sends are not sent twice.",
+ 'whatsappsync.refused': "This is not WhatsApp's folder: choose Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "To restore on a new phone: copy the Databases and Backups folders of the latest copy into Android/media/com.whatsapp/WhatsApp before installing WhatsApp, then restore with the same phone number (and the password or 64-digit key if end-to-end backup is on).",
+ 'whatsappsync.notif_progress': "Backing up WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Where backups go",
'androidsync.dest_intro': "Backups go into a folder of a group you own and are the only member of. Each kind of backup gets its own folder there.",
'androidsync.dest_summary': "{group} → {folder}",
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 c53e3e4..e8ea3e7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -378,6 +378,11 @@ export default {
one: '{n} foto',
other: '{n} fotos',
},
+ 'photo.n_videos': {
+ one: "{n} vídeo",
+ other: "{n} vídeos",
+ },
+ 'photo.play_video': "Reproducir el vídeo",
'photo.back': 'Atrás',
'photo.prev': 'Anterior (←)',
'photo.next': 'Siguiente (→)',
@@ -1307,6 +1312,13 @@ export default {
'photosync.albums': "Álbumes de este teléfono",
'photosync.album_camera': "Cámara",
'photosync.album_count': "{n} fotos · {size}",
+ 'photosync.videos_not_included': "No se hace copia de los vídeos: «Cambiar» para añadirlos.",
+ 'photosync.videos_included': "También se hace copia de los vídeos.",
+ 'photosync.include_videos': "También los vídeos",
+ 'photosync.include_videos_hint': "Los vídeos ocupan mucho: se envían por Wi-Fi como las fotos, y la primera copia puede tardar.",
+ 'photosync.album_count_videos': "{n} fotos, {v} vídeos · {size}",
+ 'photosync.confirm_videos': "Se enviarán {count} fotos y vídeos ({size}), de ellos {videos} vídeos ({videoSize}), a {dir} y luego los nuevos una vez al día.",
+ 'photosync.video_permission_needed': "MeshBay también necesita acceso a los vídeos de este teléfono.",
'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",
@@ -1334,9 +1346,13 @@ export default {
'contactsync.heading': "Copia de seguridad de contactos",
'contactsync.intro': "Enviar una copia de los contactos de este teléfono 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.",
+ 'calendarsync.heading': "Copia de seguridad del calendario",
+ 'calendarsync.intro': "Enviar una copia de los calendarios de este teléfono una vez al día si han cambiado: los que puedes editar, no aquellos a los que solo estás suscrito. Cada copia es un archivo .ics que cualquier calendario puede importar.",
+ 'calendarsync.permission_needed': "MeshBay necesita acceso a los calendarios de este teléfono.",
'messagesync.heading': "Copia de seguridad de SMS",
'messagesync.intro': "Enviar los SMS de este teléfono una vez al día: la primera copia los contiene todos y cada una de las siguientes solo los nuevos. Los archivos están en el formato XML que restauran las aplicaciones de copia de seguridad de SMS. Los MMS no se incluyen.",
'messagesync.permission_needed': "MeshBay necesita acceso a los SMS de este teléfono.",
+ 'messagesync.restricted_hint': "Si Android habla de un ajuste restringido: abre los ajustes de la aplicación, toca ⋮ arriba, «Permitir ajustes restringidos» y vuelve para activarlo. Android lo exige a las aplicaciones instaladas fuera de una tienda.",
'phonesync.state_sending': "Enviando…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Copias enviadas: {n}",
@@ -1353,6 +1369,30 @@ export default {
'phonesync.fail_not_private': "Detenida: otra persona se ha unido al grupo.",
'phonesync.fail_permission': "Detenida: MeshBay ya no tiene acceso.",
'phonesync.fail_other': "Detenida.",
+ 'phonesync.open_settings': "Abrir los ajustes de la aplicación",
+ 'drivesync.heading': "Copia de seguridad de archivos",
+ 'drivesync.intro': "Enviar los archivos de las carpetas que elijas en este teléfono, una vez al día, por Wi-Fi. Un archivo modificado en el teléfono se envía de nuevo junto a la copia anterior; nunca se reemplaza nada.",
+ 'drivesync.folders': "Carpetas con copia",
+ 'drivesync.no_folders': "Aún no has elegido ninguna carpeta.",
+ 'drivesync.folders_hint': "Las fotos y los vídeos son cosa de la copia de fotos: no se pueden elegir DCIM, Pictures ni Movies. Android no deja que una aplicación tome todo Download, solo carpetas dentro.",
+ 'drivesync.add_folder': "Añadir una carpeta",
+ 'drivesync.remove': "Quitar",
+ 'drivesync.refused_media': "Esta carpeta contiene fotos y vídeos, que envía la copia de fotos: elige otra.",
+ 'drivesync.refused_root': "Android no permite elegir un almacenamiento entero: elige una carpeta dentro.",
+ 'drivesync.notif_progress': "Copia de archivos: {done} / {total}",
+ 'drivesync.metered_confirm': "Estás usando datos móviles. ¿Enviar los archivos de todos modos?",
+ 'drivesync.sent_total': "Archivos enviados: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copiar las copias de seguridad de chats que hace WhatsApp, una vez al día por Wi-Fi, para poder restaurarlas en un teléfono nuevo. Siguen cifradas por WhatsApp: aquí no se pueden leer, solo restaurar.",
+ 'whatsappsync.folder_needed': "Aún no se ha elegido la carpeta de WhatsApp.",
+ 'whatsappsync.folder_chosen': "La carpeta de WhatsApp está elegida.",
+ 'whatsappsync.folder_choose': "Elegir la carpeta de WhatsApp",
+ 'whatsappsync.folder_hint': "El selector se abre en ella: Android/media/com.whatsapp/WhatsApp. Pulsa «Usar esta carpeta».",
+ 'whatsappsync.media': "También los archivos multimedia",
+ 'whatsappsync.media_hint': "Fotos, vídeos, notas de voz y documentos recibidos y enviados: a menudo varios gigabytes. Los que ya envía la copia de fotos no se envían dos veces.",
+ 'whatsappsync.refused': "Esta no es la carpeta de WhatsApp: elige Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Para restaurar en un teléfono nuevo: copia las carpetas Databases y Backups de la última copia en Android/media/com.whatsapp/WhatsApp antes de instalar WhatsApp y restaura con el mismo número (y la contraseña o la clave de 64 dígitos si el cifrado de extremo a extremo está activado).",
+ 'whatsappsync.notif_progress': "Copia de WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Destino de las copias de seguridad",
'androidsync.dest_intro': "Las copias de seguridad van a una carpeta de un grupo del que eres propietario y único miembro. Cada tipo de copia tiene allí su propia carpeta.",
'androidsync.dest_summary': "{group} → {folder}",
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 52b40b0..0d927ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -379,6 +379,11 @@ export default {
one: '{n} photo',
other: '{n} photos',
},
+ 'photo.n_videos': {
+ one: "{n} vidéo",
+ other: "{n} vidéos",
+ },
+ 'photo.play_video': "Lire la vidéo",
'photo.back': 'Retour',
'photo.prev': 'Précédent (←)',
'photo.next': 'Suivant (→)',
@@ -1322,6 +1327,13 @@ export default {
'photosync.albums': "Albums de ce téléphone",
'photosync.album_camera': "Appareil photo",
'photosync.album_count': "{n} photos · {size}",
+ 'photosync.videos_not_included': "Les vidéos ne sont pas sauvegardées : « Modifier » pour les ajouter.",
+ 'photosync.videos_included': "Les vidéos sont sauvegardées aussi.",
+ 'photosync.include_videos': "Les vidéos aussi",
+ 'photosync.include_videos_hint': "Les vidéos sont lourdes : elles partent en Wi-Fi comme les photos, et la première sauvegarde peut être longue.",
+ 'photosync.album_count_videos': "{n} photos, {v} vidéos · {size}",
+ 'photosync.confirm_videos': "{count} photos et vidéos ({size}), dont {videos} vidéos ({videoSize}), seront envoyées dans {dir}, puis les nouvelles une fois par jour.",
+ 'photosync.video_permission_needed': "MeshBay a aussi besoin d'accéder aux vidéos de ce téléphone.",
'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",
@@ -1349,9 +1361,13 @@ export default {
'contactsync.heading': "Sauvegarde des contacts",
'contactsync.intro': "Envoyer une copie des contacts de ce téléphone 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.",
+ 'calendarsync.heading': "Sauvegarde de l'agenda",
+ 'calendarsync.intro': "Envoyer une copie des agendas de ce téléphone une fois par jour s'ils ont changé : ceux que vous pouvez modifier, pas ceux auxquels vous êtes seulement abonné. Chaque copie est un fichier .ics que tout agenda sait importer.",
+ 'calendarsync.permission_needed': "MeshBay a besoin d'accéder aux agendas de ce téléphone.",
'messagesync.heading': "Sauvegarde des SMS",
'messagesync.intro': "Envoyer les SMS de ce téléphone une fois par jour : la première copie les contient tous, les suivantes seulement les nouveaux. Les fichiers sont au format XML que les applis de sauvegarde SMS savent restaurer. Les MMS ne sont pas inclus.",
'messagesync.permission_needed': "MeshBay a besoin d'accéder aux SMS de ce téléphone.",
+ 'messagesync.restricted_hint': "Si Android parle d'un paramètre restreint : ouvrez les réglages de l'appli, touchez ⋮ en haut, « Autoriser les paramètres restreints », puis revenez l'activer. Android l'exige des applis installées hors d'un store.",
'phonesync.state_sending': "Envoi…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Copies envoyées : {n}",
@@ -1368,6 +1384,30 @@ export default {
'phonesync.fail_not_private': "Arrêtée : quelqu'un d'autre a rejoint le groupe.",
'phonesync.fail_permission': "Arrêtée : MeshBay n'y a plus accès.",
'phonesync.fail_other': "Arrêtée.",
+ 'phonesync.open_settings': "Ouvrir les réglages de l'appli",
+ 'drivesync.heading': "Sauvegarde des fichiers",
+ 'drivesync.intro': "Envoyer les fichiers des dossiers que vous choisissez sur ce téléphone, une fois par jour, en Wi-Fi. Un fichier modifié sur le téléphone est renvoyé à côté de la copie précédente ; rien n'est jamais remplacé.",
+ 'drivesync.folders': "Dossiers sauvegardés",
+ 'drivesync.no_folders': "Aucun dossier choisi pour l'instant.",
+ 'drivesync.folders_hint': "Les photos et vidéos relèvent de la sauvegarde des photos : DCIM, Pictures et Movies ne peuvent pas être choisis. Android ne laisse pas une appli prendre tout Download, seulement des dossiers à l'intérieur.",
+ 'drivesync.add_folder': "Ajouter un dossier",
+ 'drivesync.remove': "Retirer",
+ 'drivesync.refused_media': "Ce dossier contient des photos et vidéos, que la sauvegarde des photos envoie : choisissez-en un autre.",
+ 'drivesync.refused_root': "Android ne permet pas de choisir un stockage entier : choisissez un dossier à l'intérieur.",
+ 'drivesync.notif_progress': "Sauvegarde des fichiers : {done} / {total}",
+ 'drivesync.metered_confirm': "Vous êtes en données mobiles. Envoyer les fichiers quand même ?",
+ 'drivesync.sent_total': "Fichiers envoyés : {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copier les sauvegardes de conversations que fait WhatsApp, une fois par jour en Wi-Fi, pour pouvoir les restaurer sur un nouveau téléphone. Elles restent chiffrées par WhatsApp : on ne peut pas les lire ici, seulement les restaurer.",
+ 'whatsappsync.folder_needed': "Le dossier de WhatsApp n'est pas encore choisi.",
+ 'whatsappsync.folder_chosen': "Le dossier de WhatsApp est choisi.",
+ 'whatsappsync.folder_choose': "Choisir le dossier de WhatsApp",
+ 'whatsappsync.folder_hint': "Le sélecteur s'ouvre dessus : Android/media/com.whatsapp/WhatsApp. Appuyez sur « Utiliser ce dossier ».",
+ 'whatsappsync.media': "Les médias aussi",
+ 'whatsappsync.media_hint': "Photos, vidéos, messages vocaux et documents reçus et envoyés : souvent plusieurs gigaoctets. Ceux que la sauvegarde des photos envoie déjà ne partent pas deux fois.",
+ 'whatsappsync.refused': "Ce n'est pas le dossier de WhatsApp : choisissez Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Pour restaurer sur un nouveau téléphone : copiez les dossiers Databases et Backups de la dernière copie dans Android/media/com.whatsapp/WhatsApp avant d'installer WhatsApp, puis restaurez avec le même numéro (et le mot de passe ou la clé de 64 chiffres si la sauvegarde chiffrée de bout en bout est activée).",
+ 'whatsappsync.notif_progress': "Sauvegarde de WhatsApp : {done} / {total}",
'androidsync.dest_heading': "Destination des sauvegardes",
'androidsync.dest_intro': "Les sauvegardes vont dans un dossier d'un groupe dont vous êtes le propriétaire et le seul membre. Chaque type de sauvegarde y a son propre dossier.",
'androidsync.dest_summary': "{group} → {folder}",
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 9387ddd..e2c6df3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -379,6 +379,11 @@ export default {
one: '{n} foto',
other: '{n} foto',
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} video",
+ },
+ 'photo.play_video': "Riproduci il video",
'photo.back': 'Indietro',
'photo.prev': 'Precedente (←)',
'photo.next': 'Successiva (→)',
@@ -1321,6 +1326,13 @@ export default {
'photosync.albums': "Album su questo telefono",
'photosync.album_camera': "Fotocamera",
'photosync.album_count': "{n} foto · {size}",
+ 'photosync.videos_not_included': "I video non vengono salvati: «Modifica» per aggiungerli.",
+ 'photosync.videos_included': "Anche i video vengono salvati.",
+ 'photosync.include_videos': "Anche i video",
+ 'photosync.include_videos_hint': "I video sono pesanti: partono in Wi-Fi come le foto, e il primo backup può richiedere tempo.",
+ 'photosync.album_count_videos': "{n} foto, {v} video · {size}",
+ 'photosync.confirm_videos': "{count} foto e video ({size}), di cui {videos} video ({videoSize}), verranno inviati in {dir}, poi quelli nuovi una volta al giorno.",
+ 'photosync.video_permission_needed': "MeshBay ha bisogno di accedere anche ai video di questo telefono.",
'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",
@@ -1348,9 +1360,13 @@ export default {
'contactsync.heading': "Backup dei contatti",
'contactsync.intro': "Invia una copia dei contatti di questo telefono 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.",
+ 'calendarsync.heading': "Backup del calendario",
+ 'calendarsync.intro': "Invia una copia dei calendari di questo telefono una volta al giorno se sono cambiati: quelli che puoi modificare, non quelli a cui sei solo iscritto. Ogni copia è un file .ics che qualsiasi calendario può importare.",
+ 'calendarsync.permission_needed': "MeshBay ha bisogno di accedere ai calendari di questo telefono.",
'messagesync.heading': "Backup degli SMS",
'messagesync.intro': "Invia gli SMS di questo telefono una volta al giorno: la prima copia li contiene tutti, le successive solo quelli nuovi. I file sono nel formato XML che le app di backup degli SMS sanno ripristinare. Gli MMS non sono inclusi.",
'messagesync.permission_needed': "MeshBay ha bisogno di accedere agli SMS di questo telefono.",
+ 'messagesync.restricted_hint': "Se Android parla di un'impostazione con restrizioni: apri le impostazioni dell'app, tocca ⋮ in alto, «Consenti impostazioni con restrizioni», poi torna qui ad attivarlo. Android lo richiede alle app installate fuori da uno store.",
'phonesync.state_sending': "Invio…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Copie inviate: {n}",
@@ -1367,6 +1383,30 @@ export default {
'phonesync.fail_not_private': "Interrotto: qualcun altro si è unito al gruppo.",
'phonesync.fail_permission': "Interrotto: MeshBay non ha più accesso.",
'phonesync.fail_other': "Interrotto.",
+ 'phonesync.open_settings': "Apri le impostazioni dell'app",
+ 'drivesync.heading': "Backup dei file",
+ 'drivesync.intro': "Invia i file delle cartelle che scegli su questo telefono, una volta al giorno, in Wi-Fi. Un file modificato sul telefono viene inviato di nuovo accanto alla copia precedente; non viene mai sostituito nulla.",
+ 'drivesync.folders': "Cartelle salvate",
+ 'drivesync.no_folders': "Nessuna cartella scelta per ora.",
+ 'drivesync.folders_hint': "Foto e video spettano al backup delle foto: DCIM, Pictures e Movies non si possono scegliere. Android non lascia che un'app prenda tutto Download, solo le cartelle al suo interno.",
+ 'drivesync.add_folder': "Aggiungi una cartella",
+ 'drivesync.remove': "Rimuovi",
+ 'drivesync.refused_media': "Questa cartella contiene foto e video, che invia il backup delle foto: scegline un'altra.",
+ 'drivesync.refused_root': "Android non permette di scegliere un'intera memoria: scegli una cartella al suo interno.",
+ 'drivesync.notif_progress': "Backup dei file: {done} / {total}",
+ 'drivesync.metered_confirm': "Stai usando i dati mobili. Inviare comunque i file?",
+ 'drivesync.sent_total': "File inviati: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copia i backup delle chat creati da WhatsApp, una volta al giorno in Wi-Fi, per poterli ripristinare su un nuovo telefono. Restano cifrati da WhatsApp: qui non si possono leggere, solo ripristinare.",
+ 'whatsappsync.folder_needed': "La cartella di WhatsApp non è ancora scelta.",
+ 'whatsappsync.folder_chosen': "La cartella di WhatsApp è scelta.",
+ 'whatsappsync.folder_choose': "Scegli la cartella di WhatsApp",
+ 'whatsappsync.folder_hint': "Il selettore si apre su di essa: Android/media/com.whatsapp/WhatsApp. Premi «Usa questa cartella».",
+ 'whatsappsync.media': "Anche i media",
+ 'whatsappsync.media_hint': "Foto, video, note vocali e documenti ricevuti e inviati: spesso diversi gigabyte. Quelli che il backup delle foto invia già non partono due volte.",
+ 'whatsappsync.refused': "Questa non è la cartella di WhatsApp: scegli Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Per ripristinare su un nuovo telefono: copia le cartelle Databases e Backups dell'ultima copia in Android/media/com.whatsapp/WhatsApp prima di installare WhatsApp, poi ripristina con lo stesso numero (e la password o la chiave di 64 cifre se il backup crittografato end-to-end è attivo).",
+ 'whatsappsync.notif_progress': "Backup di WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Destinazione dei backup",
'androidsync.dest_intro': "I backup vanno in una cartella di un gruppo di cui sei proprietario e unico membro. Ogni tipo di backup vi ha la sua cartella.",
'androidsync.dest_summary': "{group} → {folder}",
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 29296d1..9aa4833 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -376,6 +376,11 @@ export default {
one: '{n}枚',
other: '{n}枚',
},
+ 'photo.n_videos': {
+ one: "動画 {n} 本",
+ other: "動画 {n} 本",
+ },
+ 'photo.play_video': "動画を再生",
'photo.back': '戻る',
'photo.prev': '前へ(←)',
'photo.next': '次へ(→)',
@@ -1305,6 +1310,13 @@ export default {
'photosync.albums': "この電話のアルバム",
'photosync.album_camera': "カメラ",
'photosync.album_count': "{n} 枚 · {size}",
+ 'photosync.videos_not_included': "動画はバックアップされていません。追加するには「変更」を押してください。",
+ 'photosync.videos_included': "動画もバックアップされます。",
+ 'photosync.include_videos': "動画も含める",
+ 'photosync.include_videos_hint': "動画はサイズが大きいため、写真と同じく Wi-Fi で送信され、最初のバックアップには時間がかかることがあります。",
+ 'photosync.album_count_videos': "写真 {n} 枚、動画 {v} 本 · {size}",
+ 'photosync.confirm_videos': "写真と動画 {count} 件 ({size}、うち動画 {videos} 本 {videoSize}) を {dir} に送り、その後は新しいものを1日1回送ります。",
+ 'photosync.video_permission_needed': "MeshBay にこのスマートフォンの動画へのアクセスも許可してください。",
'photosync.scope_existing': "この電話にすでにある写真と、これからの写真",
'photosync.scope_new': "これから撮る写真のみ",
'photosync.start': "バックアップを開始",
@@ -1332,9 +1344,13 @@ export default {
'contactsync.heading': "連絡先のバックアップ",
'contactsync.intro': "このスマートフォンの連絡先のコピーを、変更があった日に1日1回送ります。各コピーはどのスマートフォンでも読み込める .vcf ファイルです。",
'contactsync.permission_needed': "MeshBay にこのスマートフォンの連絡先へのアクセスを許可してください。",
+ 'calendarsync.heading': "カレンダーのバックアップ",
+ 'calendarsync.intro': "このスマートフォンのカレンダーのコピーを、変更があった日に1日1回送ります。対象は編集できるカレンダーで、購読しているだけのものは含みません。各コピーはどのカレンダーでも読み込める .ics ファイルです。",
+ 'calendarsync.permission_needed': "MeshBay にこのスマートフォンのカレンダーへのアクセスを許可してください。",
'messagesync.heading': "SMS のバックアップ",
'messagesync.intro': "このスマートフォンの SMS を1日1回送ります。最初のコピーにはすべてが含まれ、以降は新しいものだけが含まれます。ファイルは SMS バックアップアプリで復元できる XML 形式です。MMS は含まれません。",
'messagesync.permission_needed': "MeshBay にこのスマートフォンの SMS へのアクセスを許可してください。",
+ 'messagesync.restricted_hint': "Android が制限付き設定と表示した場合は、アプリの設定を開き、上部の ⋮ から「制限付き設定を許可」を選び、ここに戻ってもう一度オンにしてください。ストア以外からインストールしたアプリには Android がこれを求めます。",
'phonesync.state_sending': "送信中…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "送信したコピー: {n}",
@@ -1351,6 +1367,30 @@ export default {
'phonesync.fail_not_private': "停止しました: 他の人がグループに参加しました。",
'phonesync.fail_permission': "停止しました: MeshBay はアクセスできなくなりました。",
'phonesync.fail_other': "停止しました。",
+ 'phonesync.open_settings': "アプリの設定を開く",
+ 'drivesync.heading': "ファイルのバックアップ",
+ 'drivesync.intro': "このスマートフォンで選んだフォルダーのファイルを、1日1回 Wi-Fi で送ります。スマートフォンで変更したファイルは、以前のコピーの横にもう一度送られます。何も置き換えられません。",
+ 'drivesync.folders': "バックアップするフォルダー",
+ 'drivesync.no_folders': "まだフォルダーが選ばれていません。",
+ 'drivesync.folders_hint': "写真と動画は写真のバックアップが扱います。DCIM、Pictures、Movies は選べません。Android ではアプリが Download 全体を選ぶことはできず、その中のフォルダーだけを選べます。",
+ 'drivesync.add_folder': "フォルダーを追加",
+ 'drivesync.remove': "削除",
+ 'drivesync.refused_media': "このフォルダーには写真のバックアップが送る写真や動画が入っています。別のフォルダーを選んでください。",
+ 'drivesync.refused_root': "Android ではストレージ全体を選べません。その中のフォルダーを選んでください。",
+ 'drivesync.notif_progress': "ファイルをバックアップ中: {done} / {total}",
+ 'drivesync.metered_confirm': "モバイルデータ通信中です。それでもファイルを送りますか?",
+ 'drivesync.sent_total': "送信したファイル: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "WhatsApp が作成するチャットのバックアップを1日1回 Wi-Fi でコピーし、新しいスマートフォンで復元できるようにします。WhatsApp による暗号化はそのままで、ここでは読めず、復元だけができます。",
+ 'whatsappsync.folder_needed': "WhatsApp のフォルダーはまだ選ばれていません。",
+ 'whatsappsync.folder_chosen': "WhatsApp のフォルダーが選ばれています。",
+ 'whatsappsync.folder_choose': "WhatsApp のフォルダーを選ぶ",
+ 'whatsappsync.folder_hint': "選択画面はそのフォルダーで開きます: Android/media/com.whatsapp/WhatsApp。「このフォルダを使用」を押してください。",
+ 'whatsappsync.media': "メディアも含める",
+ 'whatsappsync.media_hint': "送受信した写真、動画、ボイスメッセージ、ドキュメント。数ギガバイトになることもあります。写真のバックアップがすでに送っているものは二重に送りません。",
+ 'whatsappsync.refused': "これは WhatsApp のフォルダーではありません。Android/media/com.whatsapp/WhatsApp を選んでください。",
+ 'whatsappsync.restore_hint': "新しいスマートフォンで復元するには、WhatsApp をインストールする前に、最新のコピーの Databases と Backups フォルダーを Android/media/com.whatsapp/WhatsApp にコピーし、同じ電話番号で復元します (エンドツーエンド暗号化バックアップが有効な場合はパスワードか 64 桁の暗号鍵も必要です)。",
+ 'whatsappsync.notif_progress': "WhatsApp をバックアップ中: {done} / {total}",
'androidsync.dest_heading': "バックアップの保存先",
'androidsync.dest_intro': "バックアップは、あなたが所有者で唯一のメンバーであるグループのフォルダーに保存されます。バックアップの種類ごとに専用のフォルダーが作られます。",
'androidsync.dest_summary': "{group} → {folder}",
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 ada6813..863f7c9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -380,6 +380,11 @@ export default {
one: '{n} foto',
other: "{n} foto's",
},
+ 'photo.n_videos': {
+ one: "{n} video",
+ other: "{n} video's",
+ },
+ 'photo.play_video': "Video afspelen",
'photo.back': 'Terug',
'photo.prev': 'Vorige (←)',
'photo.next': 'Volgende (→)',
@@ -1323,6 +1328,13 @@ export default {
'photosync.albums': "Albums op deze telefoon",
'photosync.album_camera': "Camera",
'photosync.album_count': "{n} foto's · {size}",
+ 'photosync.videos_not_included': "Video's worden niet geback-upt: ‘Wijzigen’ om ze toe te voegen.",
+ 'photosync.videos_included': "Video's worden ook geback-upt.",
+ 'photosync.include_videos': "Ook video's",
+ 'photosync.include_videos_hint': "Video's zijn groot: ze gaan net als foto's via wifi, en de eerste back-up kan even duren.",
+ 'photosync.album_count_videos': "{n} foto's, {v} video's · {size}",
+ 'photosync.confirm_videos': "{count} foto's en video's ({size}), waarvan {videos} video's ({videoSize}), worden naar {dir} gestuurd, daarna eenmaal per dag de nieuwe.",
+ 'photosync.video_permission_needed': "MeshBay heeft ook toegang nodig tot de video's op deze telefoon.",
'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",
@@ -1350,9 +1362,13 @@ export default {
'contactsync.heading': "Back-up van contacten",
'contactsync.intro': "Stuur eenmaal per dag, als ze veranderd zijn, een kopie van de contacten op deze telefoon. Elke kopie is een .vcf-bestand dat elke telefoon kan importeren.",
'contactsync.permission_needed': "MeshBay heeft toegang nodig tot de contacten op deze telefoon.",
+ 'calendarsync.heading': "Back-up van de agenda",
+ 'calendarsync.intro': "Stuur eenmaal per dag, als ze veranderd zijn, een kopie van de agenda's op deze telefoon: de agenda's die je kunt bewerken, niet de agenda's waarop je alleen geabonneerd bent. Elke kopie is een .ics-bestand dat elke agenda kan importeren.",
+ 'calendarsync.permission_needed': "MeshBay heeft toegang nodig tot de agenda's op deze telefoon.",
'messagesync.heading': "Back-up van sms'jes",
'messagesync.intro': "Stuur de sms'jes op deze telefoon eenmaal per dag: de eerste kopie bevat ze allemaal, elke volgende alleen de nieuwe. De bestanden zijn in het XML-formaat dat sms-back-upapps kunnen terugzetten. Mms'jes zijn niet inbegrepen.",
'messagesync.permission_needed': "MeshBay heeft toegang nodig tot de sms'jes op deze telefoon.",
+ 'messagesync.restricted_hint': "Als Android het over een beperkte instelling heeft: open de instellingen van de app, tik bovenaan op ⋮, ‘Beperkte instellingen toestaan’, en zet het daarna hier weer aan. Android vraagt dit van apps die buiten een winkel zijn geïnstalleerd.",
'phonesync.state_sending': "Bezig met verzenden…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Verzonden kopieën: {n}",
@@ -1369,6 +1385,30 @@ export default {
'phonesync.fail_not_private': "Gestopt: iemand anders is lid geworden van de groep.",
'phonesync.fail_permission': "Gestopt: MeshBay heeft geen toegang meer.",
'phonesync.fail_other': "Gestopt.",
+ 'phonesync.open_settings': "Instellingen van de app openen",
+ 'drivesync.heading': "Back-up van bestanden",
+ 'drivesync.intro': "Stuur de bestanden van mappen die je op deze telefoon kiest, eenmaal per dag, via wifi. Een bestand dat op de telefoon is gewijzigd, wordt opnieuw verstuurd naast de eerdere kopie; er wordt nooit iets vervangen.",
+ 'drivesync.folders': "Geback-upte mappen",
+ 'drivesync.no_folders': "Nog geen map gekozen.",
+ 'drivesync.folders_hint': "Foto's en video's zijn voor de fotoback-up: DCIM, Pictures en Movies kunnen niet gekozen worden. Android laat een app niet de hele map Download nemen, alleen mappen daarin.",
+ 'drivesync.add_folder': "Map toevoegen",
+ 'drivesync.remove': "Verwijderen",
+ 'drivesync.refused_media': "Deze map bevat foto's en video's, die de fotoback-up verstuurt: kies een andere.",
+ 'drivesync.refused_root': "Android laat niet toe een hele opslag te kiezen: kies een map daarin.",
+ 'drivesync.notif_progress': "Bestanden back-uppen: {done} / {total}",
+ 'drivesync.metered_confirm': "Je gebruikt mobiele data. De bestanden toch versturen?",
+ 'drivesync.sent_total': "Verzonden bestanden: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Kopieer de chatback-ups die WhatsApp maakt, eenmaal per dag via wifi, zodat ze op een nieuwe telefoon teruggezet kunnen worden. Ze blijven door WhatsApp versleuteld: hier kunnen ze niet gelezen worden, alleen teruggezet.",
+ 'whatsappsync.folder_needed': "De map van WhatsApp is nog niet gekozen.",
+ 'whatsappsync.folder_chosen': "De map van WhatsApp is gekozen.",
+ 'whatsappsync.folder_choose': "Map van WhatsApp kiezen",
+ 'whatsappsync.folder_hint': "De kiezer opent erop: Android/media/com.whatsapp/WhatsApp. Tik op ‘Deze map gebruiken’.",
+ 'whatsappsync.media': "Ook media",
+ 'whatsappsync.media_hint': "Ontvangen en verstuurde foto's, video's, spraakberichten en documenten: vaak enkele gigabytes. Wat de fotoback-up al verstuurt, gaat niet twee keer.",
+ 'whatsappsync.refused': "Dit is niet de map van WhatsApp: kies Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Terugzetten op een nieuwe telefoon: kopieer de mappen Databases en Backups van de laatste kopie naar Android/media/com.whatsapp/WhatsApp voordat je WhatsApp installeert, en zet terug met hetzelfde nummer (en het wachtwoord of de sleutel van 64 cijfers als end-to-end-versleutelde back-up aanstaat).",
+ 'whatsappsync.notif_progress': "WhatsApp back-uppen: {done} / {total}",
'androidsync.dest_heading': "Bestemming van back-ups",
'androidsync.dest_intro': "Back-ups gaan naar een map van een groep waarvan jij eigenaar en het enige lid bent. Elk soort back-up krijgt daar een eigen map.",
'androidsync.dest_summary': "{group} → {folder}",
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 3e712bb..2dc2e74 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -391,6 +391,13 @@ export default {
many: '{n} zdjęć',
other: '{n} zdjęcia',
},
+ 'photo.n_videos': {
+ one: "{n} film",
+ few: "{n} filmy",
+ many: "{n} filmów",
+ other: "{n} filmu",
+ },
+ 'photo.play_video': "Odtwórz film",
'photo.back': 'Wstecz',
'photo.prev': 'Poprzednie (←)',
'photo.next': 'Następne (→)',
@@ -1349,6 +1356,13 @@ export default {
'photosync.albums': "Albumy na tym telefonie",
'photosync.album_camera': "Aparat",
'photosync.album_count': "Zdjęcia: {n} · {size}",
+ 'photosync.videos_not_included': "Filmy nie są kopiowane: „Zmień”, aby je dodać.",
+ 'photosync.videos_included': "Filmy też są kopiowane.",
+ 'photosync.include_videos': "Również filmy",
+ 'photosync.include_videos_hint': "Filmy są duże: wysyłane są przez Wi-Fi jak zdjęcia, a pierwsza kopia może potrwać.",
+ 'photosync.album_count_videos': "Zdjęcia: {n}, filmy: {v} · {size}",
+ 'photosync.confirm_videos': "Zdjęcia i filmy ({count}, {size}), w tym filmy: {videos} ({videoSize}), zostaną wysłane do {dir}, a potem nowe raz dziennie.",
+ 'photosync.video_permission_needed': "MeshBay potrzebuje też dostępu do filmów w tym telefonie.",
'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ę",
@@ -1376,9 +1390,13 @@ export default {
'contactsync.heading': "Kopia kontaktów",
'contactsync.intro': "Wysyłaj kopię kontaktów z tego telefonu 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.",
+ 'calendarsync.heading': "Kopia kalendarza",
+ 'calendarsync.intro': "Wysyłaj kopię kalendarzy z tego telefonu raz dziennie, jeśli się zmieniły: tych, które możesz edytować, a nie tylko subskrybowanych. Każda kopia to plik .ics, który zaimportuje każdy kalendarz.",
+ 'calendarsync.permission_needed': "MeshBay potrzebuje dostępu do kalendarzy w tym telefonie.",
'messagesync.heading': "Kopia SMS-ów",
'messagesync.intro': "Wysyłaj SMS-y z tego telefonu raz dziennie: pierwsza kopia zawiera wszystkie, każda kolejna tylko nowe. Pliki są w formacie XML, który przywracają aplikacje do kopii SMS-ów. MMS-y nie są uwzględniane.",
'messagesync.permission_needed': "MeshBay potrzebuje dostępu do SMS-ów w tym telefonie.",
+ 'messagesync.restricted_hint': "Jeśli Android mówi o ustawieniu z ograniczeniami: otwórz ustawienia aplikacji, dotknij ⋮ u góry, „Zezwól na ustawienia z ograniczeniami”, a potem wróć i włącz to ponownie. Android wymaga tego od aplikacji zainstalowanych spoza sklepu.",
'phonesync.state_sending': "Wysyłanie…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Wysłane kopie: {n}",
@@ -1395,6 +1413,30 @@ export default {
'phonesync.fail_not_private': "Zatrzymano: ktoś inny dołączył do grupy.",
'phonesync.fail_permission': "Zatrzymano: MeshBay nie ma już dostępu.",
'phonesync.fail_other': "Zatrzymano.",
+ 'phonesync.open_settings': "Otwórz ustawienia aplikacji",
+ 'drivesync.heading': "Kopia plików",
+ 'drivesync.intro': "Wysyłaj pliki z folderów wybranych w tym telefonie raz dziennie przez Wi-Fi. Plik zmieniony w telefonie jest wysyłany ponownie obok wcześniejszej kopii; nic nie jest nigdy zastępowane.",
+ 'drivesync.folders': "Kopiowane foldery",
+ 'drivesync.no_folders': "Nie wybrano jeszcze żadnego folderu.",
+ 'drivesync.folders_hint': "Zdjęcia i filmy należą do kopii zdjęć: nie można wybrać DCIM, Pictures ani Movies. Android nie pozwala aplikacji wziąć całego folderu Download, tylko foldery w nim.",
+ 'drivesync.add_folder': "Dodaj folder",
+ 'drivesync.remove': "Usuń",
+ 'drivesync.refused_media': "Ten folder zawiera zdjęcia i filmy, które wysyła kopia zdjęć: wybierz inny.",
+ 'drivesync.refused_root': "Android nie pozwala wybrać całej pamięci: wybierz folder w niej.",
+ 'drivesync.notif_progress': "Kopia plików: {done} / {total}",
+ 'drivesync.metered_confirm': "Używasz danych komórkowych. Wysłać pliki mimo to?",
+ 'drivesync.sent_total': "Wysłane pliki: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Kopiuj kopie zapasowe czatów tworzone przez WhatsApp raz dziennie przez Wi-Fi, aby można je było przywrócić na nowym telefonie. Pozostają zaszyfrowane przez WhatsApp: nie da się ich tu odczytać, tylko przywrócić.",
+ 'whatsappsync.folder_needed': "Folder WhatsApp nie jest jeszcze wybrany.",
+ 'whatsappsync.folder_chosen': "Folder WhatsApp jest wybrany.",
+ 'whatsappsync.folder_choose': "Wybierz folder WhatsApp",
+ 'whatsappsync.folder_hint': "Okno wyboru otworzy się na nim: Android/media/com.whatsapp/WhatsApp. Naciśnij „Użyj tego folderu”.",
+ 'whatsappsync.media': "Również multimedia",
+ 'whatsappsync.media_hint': "Odebrane i wysłane zdjęcia, filmy, wiadomości głosowe i dokumenty: często kilka gigabajtów. To, co wysyła już kopia zdjęć, nie jest wysyłane drugi raz.",
+ 'whatsappsync.refused': "To nie jest folder WhatsApp: wybierz Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Aby przywrócić na nowym telefonie: skopiuj foldery Databases i Backups z ostatniej kopii do Android/media/com.whatsapp/WhatsApp przed instalacją WhatsApp, a potem przywróć z tym samym numerem (i hasłem lub 64-cyfrowym kluczem, jeśli włączona jest kopia szyfrowana end-to-end).",
+ 'whatsappsync.notif_progress': "Kopia WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Miejsce kopii zapasowych",
'androidsync.dest_intro': "Kopie trafiają do folderu grupy, której jesteś właścicielem i jedynym członkiem. Każdy rodzaj kopii ma tam własny folder.",
'androidsync.dest_summary': "{group} → {folder}",
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 8fe39e5..6c4f03b 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
@@ -380,6 +380,11 @@ export default {
one: '{n} foto',
other: '{n} fotos',
},
+ 'photo.n_videos': {
+ one: "{n} vídeo",
+ other: "{n} vídeos",
+ },
+ 'photo.play_video': "Reproduzir o vídeo",
'photo.back': 'Voltar',
'photo.prev': 'Anterior (←)',
'photo.next': 'Próxima (→)',
@@ -1308,6 +1313,13 @@ export default {
'photosync.albums': "Álbuns deste celular",
'photosync.album_camera': "Câmera",
'photosync.album_count': "{n} fotos · {size}",
+ 'photosync.videos_not_included': "Os vídeos não têm backup: “Alterar” para adicioná-los.",
+ 'photosync.videos_included': "Os vídeos também têm backup.",
+ 'photosync.include_videos': "Vídeos também",
+ 'photosync.include_videos_hint': "Vídeos são pesados: vão pelo Wi-Fi como as fotos, e o primeiro backup pode demorar.",
+ 'photosync.album_count_videos': "{n} fotos, {v} vídeos · {size}",
+ 'photosync.confirm_videos': "{count} fotos e vídeos ({size}), sendo {videos} vídeos ({videoSize}), serão enviados para {dir} e depois os novos uma vez por dia.",
+ 'photosync.video_permission_needed': "O MeshBay também precisa de acesso aos vídeos deste celular.",
'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",
@@ -1335,9 +1347,13 @@ export default {
'contactsync.heading': "Backup de contatos",
'contactsync.intro': "Enviar uma cópia dos contatos deste celular 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.",
+ 'calendarsync.heading': "Backup da agenda",
+ 'calendarsync.intro': "Enviar uma cópia das agendas deste celular uma vez por dia se tiverem mudado: as que você pode editar, não as que você só assina. Cada cópia é um arquivo .ics que qualquer agenda consegue importar.",
+ 'calendarsync.permission_needed': "O MeshBay precisa de acesso às agendas deste celular.",
'messagesync.heading': "Backup de SMS",
'messagesync.intro': "Enviar os SMS deste celular uma vez por dia: a primeira cópia contém todos, cada uma das seguintes só os novos. Os arquivos estão no formato XML que os apps de backup de SMS conseguem restaurar. MMS não são incluídos.",
'messagesync.permission_needed': "O MeshBay precisa de acesso aos SMS deste celular.",
+ 'messagesync.restricted_hint': "Se o Android falar de uma configuração restrita: abra as configurações do app, toque em ⋮ no alto, “Permitir configurações restritas” e volte para ativar. O Android exige isso de apps instalados fora de uma loja.",
'phonesync.state_sending': "Enviando…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "Cópias enviadas: {n}",
@@ -1354,6 +1370,30 @@ export default {
'phonesync.fail_not_private': "Parado: outra pessoa entrou no grupo.",
'phonesync.fail_permission': "Parado: o MeshBay não tem mais acesso.",
'phonesync.fail_other': "Parado.",
+ 'phonesync.open_settings': "Abrir as configurações do app",
+ 'drivesync.heading': "Backup de arquivos",
+ 'drivesync.intro': "Enviar os arquivos das pastas que você escolher neste celular, uma vez por dia, pelo Wi-Fi. Um arquivo alterado no celular é enviado de novo ao lado da cópia anterior; nada é substituído.",
+ 'drivesync.folders': "Pastas com backup",
+ 'drivesync.no_folders': "Nenhuma pasta escolhida ainda.",
+ 'drivesync.folders_hint': "Fotos e vídeos ficam com o backup de fotos: DCIM, Pictures e Movies não podem ser escolhidas. O Android não deixa um app pegar toda a pasta Download, só pastas dentro dela.",
+ 'drivesync.add_folder': "Adicionar uma pasta",
+ 'drivesync.remove': "Remover",
+ 'drivesync.refused_media': "Esta pasta contém fotos e vídeos, que o backup de fotos envia: escolha outra.",
+ 'drivesync.refused_root': "O Android não permite escolher um armazenamento inteiro: escolha uma pasta dentro dele.",
+ 'drivesync.notif_progress': "Backup de arquivos: {done} / {total}",
+ 'drivesync.metered_confirm': "Você está usando dados móveis. Enviar os arquivos mesmo assim?",
+ 'drivesync.sent_total': "Arquivos enviados: {n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "Copiar os backups de conversas que o WhatsApp faz, uma vez por dia pelo Wi-Fi, para poder restaurá-los em um celular novo. Eles continuam criptografados pelo WhatsApp: não dá para lê-los aqui, só restaurar.",
+ 'whatsappsync.folder_needed': "A pasta do WhatsApp ainda não foi escolhida.",
+ 'whatsappsync.folder_chosen': "A pasta do WhatsApp está escolhida.",
+ 'whatsappsync.folder_choose': "Escolher a pasta do WhatsApp",
+ 'whatsappsync.folder_hint': "O seletor abre nela: Android/media/com.whatsapp/WhatsApp. Toque em “Usar esta pasta”.",
+ 'whatsappsync.media': "Mídia também",
+ 'whatsappsync.media_hint': "Fotos, vídeos, mensagens de voz e documentos recebidos e enviados: muitas vezes vários gigabytes. O que o backup de fotos já envia não é enviado duas vezes.",
+ 'whatsappsync.refused': "Esta não é a pasta do WhatsApp: escolha Android/media/com.whatsapp/WhatsApp.",
+ 'whatsappsync.restore_hint': "Para restaurar em um celular novo: copie as pastas Databases e Backups da última cópia para Android/media/com.whatsapp/WhatsApp antes de instalar o WhatsApp e restaure com o mesmo número (e a senha ou a chave de 64 dígitos se o backup criptografado de ponta a ponta estiver ativado).",
+ 'whatsappsync.notif_progress': "Backup do WhatsApp: {done} / {total}",
'androidsync.dest_heading': "Destino dos backups",
'androidsync.dest_intro': "Os backups vão para uma pasta de um grupo do qual você é dono e único membro. Cada tipo de backup tem a sua própria pasta lá.",
'androidsync.dest_summary': "{group} → {folder}",
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 e294fe5..8899ed0 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
@@ -373,6 +373,11 @@ export default {
one: '{n} 张照片',
other: '{n} 张照片',
},
+ 'photo.n_videos': {
+ one: "{n} 个视频",
+ other: "{n} 个视频",
+ },
+ 'photo.play_video': "播放视频",
'photo.back': '返回',
'photo.prev': '上一张(←)',
'photo.next': '下一张(→)',
@@ -1294,6 +1299,13 @@ export default {
'photosync.albums': "这部手机上的相册",
'photosync.album_camera': "相机",
'photosync.album_count': "{n} 张照片 · {size}",
+ 'photosync.videos_not_included': "视频未备份:点“更改”添加。",
+ 'photosync.videos_included': "视频也会备份。",
+ 'photosync.include_videos': "包括视频",
+ 'photosync.include_videos_hint': "视频体积较大:它们和照片一样通过 Wi-Fi 发送,首次备份可能需要一些时间。",
+ 'photosync.album_count_videos': "{n} 张照片,{v} 个视频 · {size}",
+ 'photosync.confirm_videos': "将把 {count} 个照片和视频({size},其中 {videos} 个视频,{videoSize})发送到 {dir},之后每天发送一次新的。",
+ 'photosync.video_permission_needed': "MeshBay 还需要访问本机的视频。",
'photosync.scope_existing': "手机上已有的照片,然后是新照片",
'photosync.scope_new': "仅从现在起拍摄的照片",
'photosync.start': "开始备份",
@@ -1321,9 +1333,13 @@ export default {
'contactsync.heading': "联系人备份",
'contactsync.intro': "在联系人有变动的日子里,每天一次发送本机联系人的副本。每份副本都是任何手机都能导入的 .vcf 文件。",
'contactsync.permission_needed': "MeshBay 需要访问本机的联系人。",
+ 'calendarsync.heading': "日历备份",
+ 'calendarsync.intro': "在日历有变动的日子里,每天一次发送本机日历的副本:包括你可以编辑的日历,不包括仅订阅的日历。每份副本都是任何日历都能导入的 .ics 文件。",
+ 'calendarsync.permission_needed': "MeshBay 需要访问本机的日历。",
'messagesync.heading': "短信备份",
'messagesync.intro': "每天一次发送本机的短信:第一份副本包含全部短信,之后每份只包含新短信。文件采用短信备份应用可以恢复的 XML 格式。不包括彩信。",
'messagesync.permission_needed': "MeshBay 需要访问本机的短信。",
+ 'messagesync.restricted_hint': "如果 Android 提示这是受限设置:打开应用设置,点按顶部的 ⋮,选择“允许受限设置”,然后回到这里再次开启。Android 对不是从应用商店安装的应用有此要求。",
'phonesync.state_sending': "正在发送…",
'phonesync.summary': "{group} → {dir}",
'phonesync.copies_sent': "已发送副本:{n}",
@@ -1340,6 +1356,30 @@ export default {
'phonesync.fail_not_private': "已停止:有其他人加入了群组。",
'phonesync.fail_permission': "已停止:MeshBay 已无法访问。",
'phonesync.fail_other': "已停止。",
+ 'phonesync.open_settings': "打开应用设置",
+ 'drivesync.heading': "文件备份",
+ 'drivesync.intro': "每天一次通过 Wi-Fi 发送你在本机选择的文件夹中的文件。在手机上修改过的文件会在之前的副本旁边再次发送;不会替换任何内容。",
+ 'drivesync.folders': "已备份的文件夹",
+ 'drivesync.no_folders': "尚未选择文件夹。",
+ 'drivesync.folders_hint': "照片和视频由照片备份负责:不能选择 DCIM、Pictures 和 Movies。Android 不允许应用选择整个 Download 文件夹,只能选择其中的文件夹。",
+ 'drivesync.add_folder': "添加文件夹",
+ 'drivesync.remove': "移除",
+ 'drivesync.refused_media': "这个文件夹包含照片备份会发送的照片和视频:请选择其他文件夹。",
+ 'drivesync.refused_root': "Android 不允许选择整个存储空间:请选择其中的文件夹。",
+ 'drivesync.notif_progress': "正在备份文件:{done} / {total}",
+ 'drivesync.metered_confirm': "你正在使用移动数据。仍要发送这些文件吗?",
+ 'drivesync.sent_total': "已发送文件:{n}",
+ 'whatsappsync.heading': "WhatsApp",
+ 'whatsappsync.intro': "每天一次通过 Wi-Fi 复制 WhatsApp 自己生成的聊天备份,以便在新手机上恢复。它们仍由 WhatsApp 加密:这里无法读取,只能用于恢复。",
+ 'whatsappsync.folder_needed': "尚未选择 WhatsApp 的文件夹。",
+ 'whatsappsync.folder_chosen': "已选择 WhatsApp 的文件夹。",
+ 'whatsappsync.folder_choose': "选择 WhatsApp 的文件夹",
+ 'whatsappsync.folder_hint': "选择器会直接打开它:Android/media/com.whatsapp/WhatsApp。点按“使用此文件夹”。",
+ 'whatsappsync.media': "包括媒体",
+ 'whatsappsync.media_hint': "收发的照片、视频、语音消息和文档:常常有好几 GB。照片备份已发送的内容不会重复发送。",
+ 'whatsappsync.refused': "这不是 WhatsApp 的文件夹:请选择 Android/media/com.whatsapp/WhatsApp。",
+ 'whatsappsync.restore_hint': "在新手机上恢复:安装 WhatsApp 之前,把最新副本中的 Databases 和 Backups 文件夹复制到 Android/media/com.whatsapp/WhatsApp,然后用同一个手机号恢复(如果开启了端到端加密备份,还需要密码或 64 位密钥)。",
+ 'whatsappsync.notif_progress': "正在备份 WhatsApp:{done} / {total}",
'androidsync.dest_heading': "备份保存位置",
'androidsync.dest_intro': "备份会保存到一个由你创建且只有你一个成员的群组的文件夹中。每种备份在那里都有自己的文件夹。",
'androidsync.dest_summary': "{group} → {folder}",
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
index 0602e54..998a3b2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
@@ -1,6 +1,6 @@
-// The contacts and messages sections of the Android Sync page, on the Android
-// application only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other
-// client has it. Where the copies go is the page's destination section; these
+// The contacts, calendar and messages sections of the Android Sync page, on
+// the Android application only (docs/MESHBAY_DESIGN.md §9.13). Loaded on
+// demand: no other client has it. Where the copies go is the page's destination section; these
// only turn each backup on or off.
import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
@@ -13,9 +13,16 @@ const TEXT = {
intro: 'contactsync.intro',
permission: 'contactsync.permission_needed',
},
+ calendar: {
+ intro: 'calendarsync.intro',
+ permission: 'calendarsync.permission_needed',
+ },
messages: {
intro: 'messagesync.intro',
permission: 'messagesync.permission_needed',
+ // Android keeps text messages behind "restricted settings" for an
+ // application installed outside a store: said, with the way to lift it.
+ restricted: 'messagesync.restricted_hint',
},
};
@@ -55,7 +62,7 @@ export function DocSyncSection({ kind, destination }) {
if (st.permission !== 'granted') st = await sync.permit();
if (st.permission !== 'granted') { setStatus(st); return; }
setStatus(await sync.configure({}));
- runDocSync(kind);
+ runDocSync(kind, { force: true });
} catch (e) {
setError(e.message);
} finally {
@@ -80,7 +87,12 @@ export function DocSyncSection({ kind, destination }) {
<p class="settings-hint">${t(TEXT[kind].intro)}</p>
${!destination && html`<p class="settings-hint">${t('androidsync.dest_needed')}</p>`}
${destination && status.permission !== 'granted' && html`
- <p class="settings-hint">${t(TEXT[kind].permission)}</p>`}
+ <p class="settings-hint">${t(TEXT[kind].permission)}</p>
+ ${TEXT[kind].restricted && html`
+ <p class="settings-hint">${t(TEXT[kind].restricted)}</p>
+ <button class="btn-secondary" style="margin-bottom:8px"
+ onClick=${() => sync.openSettings().catch((e) => setError(e.message))}>
+ ${t('phonesync.open_settings')}</button>`}`}
${destination && html`
<button class="admin-btn" disabled=${busy} onClick=${turnOn}>${t('phonesync.turn_on')}</button>`}
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
index 2c34af0..dcfb6f9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync.js
@@ -1,7 +1,8 @@
-// Contacts and messages backup: a copy of this phone's address book, and the
-// text messages added since the last copy, sent to `<folder>/<account>-contacts`
-// and `-messages` of the destination every backup shares (sync-destination.js),
-// once a day when there is something new (docs/MESHBAY_DESIGN.md §9.13).
+// Contacts, calendar and messages backup: a copy of this phone's address book
+// and calendars, and the text messages added since the last copy, sent to
+// `<folder>/<account>-contacts`, `-calendar` and `-messages` of the destination
+// every backup shares (sync-destination.js), once a day when there is
+// something new (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
@@ -30,12 +31,13 @@ const RETRY_MS = 15 * 60 * 1000;
*/
export const KINDS = {
contacts: { sync: platform.contactSync, heading: 'contactsync.heading', suffix: 'contacts' },
+ calendar: { sync: platform.calendarSync, heading: 'calendarsync.heading', suffix: 'calendar' },
messages: { sync: platform.messageSync, heading: 'messagesync.heading', suffix: 'messages' },
};
// 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 = {
+export const LASTING = {
disk_full: 'phonesync.fail_disk_full',
root_read_only: 'phonesync.fail_read_only',
no_writable_root: 'phonesync.fail_read_only',
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
index 7809efa..d2a839e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js
@@ -103,8 +103,15 @@ export function PhotoSyncSection({ destination }) {
? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() })
: t('photosync.never')}
</p>
+ <p class="settings-hint">
+ ${t(cfg.includeVideos ? 'photosync.videos_included' : 'photosync.videos_not_included')}</p>
${status.permission === 'partial' && html`
<p class="settings-hint">${t('photosync.permission_partial')}</p>`}
+ ${cfg.includeVideos && status.videoPermission === 'denied' && html`
+ <p class="settings-hint">${t('photosync.video_permission_needed')}</p>
+ <button class="btn-secondary" onClick=${async () => {
+ try { setStatus(await platform.photoSync.permit(true)); } catch (e) { setError(e.message); }
+ }}>${t('photosync.permission_allow')}</button>`}
${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>`}
@@ -118,7 +125,7 @@ export function PhotoSyncSection({ destination }) {
${editing && html`
<${Setup} status=${status}
onPermission=${setStatus}
- onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runPhotoSync(); } }} />`}
+ onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runPhotoSync({ force: true }); } }} />`}
${error && html`<p class="error-msg">${error}</p>`}
</div>
`;
@@ -129,19 +136,32 @@ function Setup({ status, onPermission, onDone }) {
const [albums, setAlbums] = useState(null);
const [chosen, setChosen] = useState(() => new Set(cfg ? cfg.albums : []));
const [existing, setExisting] = useState(cfg ? cfg.includeExisting : true);
+ const [videos, setVideos] = useState(cfg ? !!cfg.includeVideos : false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const permitted = status.permission !== 'denied';
+ const videosAllowed = status.videoPermission !== 'denied';
useEffect(() => {
if (!permitted) return;
- platform.photoSync.albums().then((list) => {
+ platform.photoSync.albums(videos && videosAllowed).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)));
+ if (!cfg) {
+ setChosen((prev) => (prev.size ? prev : new Set(list.filter((a) => a.camera).map((a) => a.id))));
+ }
}).catch((e) => setError(e.message));
- }, [permitted]);
+ }, [permitted, videos, videosAllowed]);
+
+ // Ticking videos asks for them then, not before: Android 13+ asks apart.
+ const toggleVideos = async () => {
+ const next = !videos;
+ setVideos(next);
+ if (next && !videosAllowed) {
+ try { onPermission(await platform.photoSync.permit(true)); } catch (e) { setError(e.message); }
+ }
+ };
const allow = async () => {
setError('');
@@ -159,14 +179,19 @@ function Setup({ status, onPermission, onDone }) {
setBusy(true);
setError('');
try {
- const settings = { albums: [...chosen], includeExisting: existing };
- // Asked when the photos would come from somewhere new in time; never
- // for a change of albums alone, which the person just made.
- if (!cfg || cfg.includeExisting !== existing) {
+ const settings = { albums: [...chosen], includeExisting: existing, includeVideos: videos };
+ // Asked when the photos would come from somewhere new in time, or when
+ // videos start going; never for a change of albums alone, which the
+ // person just made.
+ if (!cfg || cfg.includeExisting !== existing || (videos && !cfg.includeVideos)) {
const est = await platform.photoSync.estimate(settings);
- const params = { count: est.count, size: formatSize(est.bytes), dir: status.dir };
- const yes = await ask(t(existing && est.count > 0 ? 'photosync.confirm'
- : 'photosync.confirm_new', params));
+ const params = {
+ count: est.count, size: formatSize(est.bytes), dir: status.dir,
+ videos: est.videos || 0, videoSize: formatSize(est.videoBytes || 0),
+ };
+ const key = !(est.count > 0) ? 'photosync.confirm_new'
+ : est.videos > 0 ? 'photosync.confirm_videos' : 'photosync.confirm';
+ const yes = await ask(t(key, params));
if (!yes) { setBusy(false); return; }
}
onDone(await platform.photoSync.configure(settings));
@@ -194,11 +219,23 @@ function Setup({ status, onPermission, onDone }) {
<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>
+ ${videos && a.videos
+ ? t('photosync.album_count_videos',
+ { n: a.count, v: a.videos, size: formatSize(a.bytes + a.videoBytes) })
+ : t('photosync.album_count', { n: a.count, size: formatSize(a.bytes) })}</span>
</span>
</label>
`)}
<label class="settings-choice">
+ <input type="checkbox" checked=${videos} onChange=${toggleVideos} />
+ <span class="settings-name">
+ <strong>${t('photosync.include_videos')}</strong>
+ <span class="settings-hint">${t('photosync.include_videos_hint')}</span>
+ </span>
+ </label>
+ ${videos && !videosAllowed && html`
+ <p class="settings-hint">${t('photosync.video_permission_needed')}</p>`}
+ <label class="settings-choice">
<input type="radio" name="photosync-scope" checked=${existing}
onChange=${() => setExisting(true)} />
<span><strong>${t('photosync.scope_existing')}</strong></span>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
index 9c59c17..9a45cf8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js
@@ -25,6 +25,8 @@ 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;
+// The notification's progress line is updated at most this often.
+const NOTIFY_MS = 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.
@@ -40,204 +42,264 @@ const LASTING = {
permission: 'photosync.fail_permission',
};
-// ── What the settings section shows ─────────────────────────────────────────
+// ── A runner, for photos here and for chosen folders (drive-sync.js) ────────
-let state = { phase: 'idle', done: 0, total: 0 };
-const listeners = new Set();
+/**
+ * One backup's scheduling and sending: what is due, when, on which network,
+ * a file at a time under a slot, the ledger kept by the phone (`sync`, its
+ * bridge object). `configured(status)` says whether the person turned it on;
+ * `progress` is the notification line's key; `failures` holds the refusals
+ * that will hold tomorrow too, and `say(code)` the words for one.
+ */
+export function createBackup({
+ name, sync, configured, progress, failures, say,
+}) {
+ let state = { phase: 'idle', done: 0, total: 0 };
+ const listeners = new Set();
+ let ctx = null; // { hub, hubFetch, getUser, getBundleKey }
+ let running = null;
+ let timer = 0;
+ let lastAttempt = 0;
+ let unmeteredNow = null;
-function setState(patch) {
- state = { ...state, ...patch };
- for (const fn of [...listeners]) {
- try { fn(state); } catch (e) { console.error('[MeshBay] photo backup listener threw:', e); }
+ // ── What the settings section shows ───────────────────────────────────────
+
+ function setState(patch) {
+ state = { ...state, ...patch };
+ for (const fn of [...listeners]) {
+ try { fn(state); } catch (e) { console.error(`[MeshBay] ${name} backup listener threw:`, e); }
+ }
}
-}
-export function subscribe(fn) {
- listeners.add(fn);
- fn(state);
- return () => listeners.delete(fn);
-}
+ function subscribe(fn) {
+ listeners.add(fn);
+ fn(state);
+ return () => listeners.delete(fn);
+ }
-// ── When it runs ────────────────────────────────────────────────────────────
+ // ── When it runs ──────────────────────────────────────────────────────────
-let ctx = null; // { hub, hubFetch, getUser, getBundleKey }
-let running = null;
-let timer = 0;
-let lastAttempt = 0;
-let unmeteredNow = null;
+ /** True when `status` says a scheduled run should go now. */
+ function isDue(status, now = Date.now()) {
+ if (!status || !configured(status) || !status.destination) 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;
+ }
-/** True when `status` says a scheduled run should go now. */
-export function isDue(status, now = Date.now()) {
- if (!status || !status.config || !status.destination) 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.
+ */
+ function start(context) {
+ if (!sync.available || ctx) return;
+ ctx = context;
+ setSyncContext(context);
+ document.addEventListener('visibilitychange', onVisible);
+ window.addEventListener('meshbay-network', onNetwork);
+ runNow();
+ }
-/**
- * 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;
- setSyncContext(context);
- document.addEventListener('visibilitychange', onVisible);
- window.addEventListener('meshbay-network', onNetwork);
- runPhotoSync();
-}
+ function stop() {
+ if (!ctx) return;
+ ctx = null;
+ clearTimeout(timer);
+ document.removeEventListener('visibilitychange', onVisible);
+ window.removeEventListener('meshbay-network', onNetwork);
+ }
-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') runNow();
+ }
-function onVisible() {
- if (document.visibilityState === 'visible') runPhotoSync();
-}
+ function onNetwork(e) {
+ unmeteredNow = !!(e.detail && e.detail.unmetered);
+ if (unmeteredNow) runNow();
+ }
-function onNetwork(e) {
- unmeteredNow = !!(e.detail && e.detail.unmetered);
- if (unmeteredNow) runPhotoSync();
-}
+ function scheduleNext(status) {
+ clearTimeout(timer);
+ if (!ctx || !status || !configured(status) || !status.destination) 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(runNow, Math.min(wait, DAY_MS));
+ }
-function scheduleNext(status) {
- clearTimeout(timer);
- if (!ctx || !status || !status.config || !status.destination) 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.
+ */
+ function runNow({ force = false, metered = false } = {}) {
+ if (!ctx) return Promise.resolve();
+ if (running) return running;
+ running = (async () => {
+ let status = null;
+ try {
+ status = await sync.status();
+ await run(status, force, metered);
+ status = await sync.status();
+ } catch (e) {
+ console.warn(`[MeshBay] ${name} backup:`, e.message);
+ setState({ phase: 'interrupted', detail: e.message });
+ } finally {
+ running = null;
+ scheduleNext(status);
+ }
+ })();
+ return running;
+ }
-/**
- * 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;
+ async function run(status, force, metered) {
+ const dest = status.destination;
+ const user = ctx && ctx.getUser();
+ if (!configured(status) || !dest) { setState({ phase: 'off' }); return; }
+ if (!user || dest.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();
+
+ // Before anything is read: still this account's own group, and nobody else's.
+ setState({ phase: 'connecting', done: 0, total: 0 });
+ const refused = await checkDestination(dest, user);
+ if (refused === 'offline') { setState({ phase: 'offline' }); return; }
+ if (refused) { await lasting(refused); return; }
+
+ const { items, manifest } = await sync.plan();
+ if (!items.length && !manifest) {
+ await sync.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 {
- status = await platform.photoSync.status();
- await run(status, force, metered);
- status = await platform.photoSync.status();
+ const bundleKey = await ctx.getBundleKey();
+ conn = await pool.connect(dest.groupId, user.token, bundleKey, user.username, user.userId);
} catch (e) {
- console.warn('[MeshBay] photo backup:', e.message);
- setState({ phase: 'interrupted', detail: e.message });
- } finally {
- running = null;
- scheduleNext(status);
+ pool.closeAll();
+ if (e.reason === 'not_a_member') { await lasting('not_a_member'); return; }
+ setState({ phase: 'offline' });
+ return;
}
- })();
- return running;
-}
-async function run(status, force, metered) {
- const dest = status.destination;
- const user = ctx && ctx.getUser();
- if (!status.config || !dest) { setState({ phase: 'off' }); return; }
- if (!user || dest.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;
+ try {
+ await send(conn.transport, dest, items, metered);
+ } finally {
+ sync.keepAlive(false).catch(() => {});
+ pool.closeAll();
+ }
}
- unmeteredNow = status.unmetered;
- if (!status.unmetered && !metered) { setState({ phase: 'waiting_wifi' }); return; }
- if (!force && Date.now() - lastAttempt < RETRY_MS) return;
- lastAttempt = Date.now();
- // Before anything is read: still this account's own group, and nobody else's.
- setState({ phase: 'connecting', done: 0, total: 0 });
- const refused = await checkDestination(dest, user);
- if (refused === 'offline') { setState({ phase: 'offline' }); return; }
- if (refused) { await lasting(refused); return; }
+ async function send(transport, dest, items, metered) {
+ const index = await transport.fetchIndex();
+ const problem = destinationProblem(dest.folder, index);
+ if (problem) { await lasting(problem); return; }
- const { items } = await platform.photoSync.plan();
- if (!items.length) {
- await platform.photoSync.completed();
- setState({ phase: 'up_to_date', done: 0, total: 0 });
- 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);
- 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(dest.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;
- }
+ let done = 0;
+ // The page follows every file; the notification at most once a second,
+ // which is all Android shows anyway: files already on the node go by
+ // faster than that, and a burst of updates is dropped by the system.
+ let reported = 0;
+ const report = async () => {
+ setState({ phase: 'sending', done, total: items.length });
+ const now = Date.now();
+ if (done && done < items.length && now - reported < NOTIFY_MS) return;
+ reported = now;
+ await sync.keepAlive(true, t(progress, { done, total: items.length }));
+ };
+ await report();
- try {
- await send(conn.transport, dest, items, metered);
- } finally {
- platform.photoSync.keepAlive(false).catch(() => {});
- pool.closeAll();
+ 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 sync.sent(item.token, item.dir, known);
+ } else {
+ try {
+ await makeDirs(transport, dest.folder, item.dir, dirs);
+ const ack = await upload(transport, item, sync.file);
+ await sync.sent(item.token, ack.dir || item.dir, ack.stored_as || item.name);
+ } catch (e) {
+ if (e.code && failures[e.code]) { await lasting(e.code); return; }
+ throw e;
+ }
+ }
+ done += 1;
+ await report();
+ }
+ if (!await sendManifest(transport, dest, dirs)) return;
+ await sync.completed();
+ setState({ phase: 'up_to_date', done, total: items.length });
}
-}
-async function send(transport, dest, items, metered) {
- const index = await transport.fetchIndex();
- const problem = destinationProblem(dest.folder, index);
- if (problem) { await lasting(problem); return; }
+ // What the phone knew of what was sent (its path and album, its dates and
+ // hash), for a restore or a merge later: one file per run that sent
+ // anything, beside the files, kept by the phone until the node has it.
+ // False when a lasting refusal stopped it, which is then said.
+ async function sendManifest(transport, dest, dirs) {
+ const { item } = await sync.manifest();
+ if (!item) return true;
+ try {
+ await makeDirs(transport, dest.folder, item.dir, dirs);
+ await upload(transport, item, sync.file);
+ } catch (e) {
+ if (e.code && failures[e.code]) { await lasting(e.code); return false; }
+ throw e;
+ }
+ await sync.manifestSent(item.token);
+ return true;
+ }
- // 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);
+ async function lasting(code) {
+ setState({ phase: 'failed', reason: code });
+ await sync.failed(code, say(code)).catch(() => {});
}
- 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());
+ return { start, stop, run: runNow, subscribe, isDue };
+}
- 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 photos = createBackup({
+ name: 'photo',
+ sync: platform.photoSync,
+ configured: (status) => Boolean(status.config),
+ progress: 'photosync.notif_progress',
+ failures: LASTING,
+ say: (code) => t(LASTING[code] || 'photosync.fail_other'),
+});
- 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, dest.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 });
-}
+export const startPhotoSync = (context) => photos.start(context);
+export const stopPhotoSync = () => photos.stop();
+export const runPhotoSync = (opts) => photos.run(opts);
+export const subscribe = (fn) => photos.subscribe(fn);
+export const isDue = (status, now) => photos.isDue(status, now);
/**
* One photo, under a slot the node granted: the backup queues like anyone.
@@ -253,7 +315,7 @@ export async function upload(transport, item, file = platform.photoSync.file) {
timeout = setTimeout(() => reject(new Error('The node is busy')), LEASE_WAIT_MS);
}),
]).finally(() => clearTimeout(timeout));
- const bytes = await file(item.token, item.name);
+ const bytes = await file(item.token, item.name, item.size);
return await transport.uploadFile(bytes, {
dir: item.dir, root: item.dir.split('/')[0], tr: lease.tr,
});
@@ -290,7 +352,3 @@ export function destinationProblem(folder, index) {
return null;
}
-async function lasting(code) {
- setState({ phase: 'failed', reason: code });
- await platform.photoSync.failed(code, t(LASTING[code] || 'photosync.fail_other')).catch(() => {});
-}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index f18acd2..df207ab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -36,7 +36,9 @@ function underAnyPhotoRoot(entry, photoRoots) {
function groupPhotoAlbums(entries, photoRoots) {
const byDir = new Map();
for (const e of entries) {
- if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue;
+ // A video in a photo folder is a phone's clip, backed up beside its
+ // photos (§9.12): it belongs to the album as it did on the phone.
+ if ((e.type !== 'image' && e.type !== 'video') || !underAnyPhotoRoot(e, photoRoots)) continue;
// e.path is already the file's containing directory, not the full
// path+filename (files-app.js's own convention, also relied on by
// zipstream.js's entriesUnder) — it must not be stripped a second time,
@@ -85,6 +87,25 @@ function albumYearLabel(photos) {
// ── landing grid: one card per album (directory containing images) ─────────
+/** A clip's length as a phone shows it: `0:42`, `1:02:05`. */
+function clipLength(seconds) {
+ if (!seconds) return '';
+ const total = Math.round(seconds);
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ const sec = String(total % 60).padStart(2, '0');
+ return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`;
+}
+
+/** "12 photos", "12 photos · 3 videos", or "3 videos". */
+function albumCount(items) {
+ const videos = items.filter((e) => e.type === 'video').length;
+ const photos = items.length - videos;
+ if (!videos) return t('photo.n_photos', { n: photos });
+ const clips = t('photo.n_videos', { n: videos });
+ return photos ? `${t('photo.n_photos', { n: photos })} · ${clips}` : clips;
+}
+
function AlbumCard({ album, transportRef, gekRef, onOpen }) {
const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0];
const tRef = cover._tRef || transportRef;
@@ -98,7 +119,7 @@ function AlbumCard({ album, transportRef, gekRef, onOpen }) {
<div class="photo-album-info">
<div class="photo-album-title">${albumTitle(album.dir)}</div>
<div class="photo-album-sub">
- ${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })}
+ ${year}${year ? ' · ' : ''}${albumCount(album.photos)}
</div>
<${SourceTag} entries=${album.photos} cls="photo-card-group" />
</div>
@@ -128,7 +149,9 @@ function PhotoTile({ entry, transportRef, gekRef, onOpen, onMenu }) {
<div class="photo-tile" onClick=${onOpen} onContextMenu=${onMenu}>
<${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name}
cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef}
- emptyIcon="image" />
+ emptyIcon=${entry.type === 'video' ? 'video' : 'image'} />
+ ${entry.type === 'video' && html`
+ <span class="photo-tile-video"><${Icon} name="play" />${clipLength(entry.duration)}</span>`}
</div>
`;
}
@@ -156,8 +179,13 @@ const ZOOM_MAX = 400;
// a slow node must not skip a photo nobody saw.
const SLIDESHOW_MS = 5000;
-function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete }) {
+function Lightbox({
+ photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete, onPlay,
+}) {
const entry = photos[index];
+ // A video is shown by its thumbnail and played in the group's video
+ // player: never downloaded whole into this page.
+ const isVideo = entry.type === 'video';
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
const [blobUrl, setBlobUrl] = useState(() => _fullBlobCache.get(entry.id) || null);
@@ -174,8 +202,9 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
const castDevice = useCastDevice();
useEffect(() => {
+ if (isVideo) { setBlobUrl(null); setLoading(false); return undefined; }
const cached = _fullBlobCache.get(entry.id);
- if (cached) { setBlobUrl(cached); setLoading(false); return; }
+ if (cached) { setBlobUrl(cached); setLoading(false); return undefined; }
setBlobUrl(null);
setLoading(true);
let cancelled = false;
@@ -211,6 +240,14 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
if (castDevice && blobUrl && blob) showPhoto(blob);
}, [castDevice, blobUrl, entry.id]);
+ // A slideshow is of photos: it passes a video by rather than wait on a
+ // clip nobody started.
+ useEffect(() => {
+ if (!slideshow || !isVideo) return;
+ if (index < photos.length - 1) onNav(1);
+ else setSlideshow(false);
+ }, [slideshow, isVideo, index, photos.length, onNav]);
+
// Next photo once this one has been on screen for SLIDESHOW_MS; the last
// photo ends the slideshow rather than wrapping round.
useEffect(() => {
@@ -224,6 +261,8 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
useEffect(() => {
const onKey = (e) => {
+ // The video player is over this, and its keys are its own.
+ if (document.querySelector('.video-player-overlay')) return;
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft' && index > 0) onNav(-1);
else if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(1);
@@ -304,6 +343,15 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
<${Icon} name="chevron" cls="photo-nav-icon photo-nav-prev-icon" /></button>
<div ref=${slotRef} class="photo-lightbox-image-slot ${zoomed ? 'zoomed' : ''}">
${loading && html`<span class="spinner"></span>`}
+ ${isVideo && html`
+ <div class="photo-lightbox-video">
+ <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name}
+ cls="photo-lightbox-poster" transportRef=${tRef} gekRef=${gRef} emptyIcon="video" />
+ ${onPlay && html`
+ <button class="photo-lightbox-play" onClick=${() => onPlay(entry)}
+ title=${t('photo.play_video')} aria-label=${t('photo.play_video')}>
+ <${Icon} name="play" /></button>`}
+ </div>`}
${blobUrl && html`<img class="photo-lightbox-image ${zoomed ? 'zoomed' : ''}"
style=${imgStyle} src=${blobUrl} alt=${entry.name} onLoad=${handleImgLoad} />`}
</div>
@@ -313,6 +361,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
</div>
<div class="photo-lightbox-info">
${entry.width && entry.height && html`<span>${entry.width}×${entry.height}</span>`}
+ ${isVideo && entry.duration && html`<span>${clipLength(entry.duration)}</span>`}
<span>${formatSize(entry.size)}</span>
${entry.taken_at && html`<span>${formatTakenAt(entry.taken_at)}</span>`}
${entry.camera && html`<span>${entry.camera}</span>`}
@@ -323,7 +372,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
}
function AlbumView({
- album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete,
+ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete, onPlay,
}) {
const [lightboxIndex, setLightboxIndex] = useState(null);
// Right-click a photo for its link, or to delete it. A tile has no dots to
@@ -393,7 +442,7 @@ function AlbumView({
<${Lightbox} photos=${album.photos} index=${lightboxIndex}
transportRef=${transportRef} gekRef=${gekRef}
onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor}
- onDelete=${onDelete} />
+ onDelete=${onDelete} onPlay=${onPlay} />
`}
${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
`;
@@ -405,7 +454,7 @@ function PhotosApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
photoDirectories, setError,
hideFilter, readOnly, linkFor,
- isNodeAdmin, userId, applyIndex,
+ isNodeAdmin, userId, applyIndex, onPreview,
}) {
const [openDir, setOpenDir] = useState(null);
const [filter, setFilter] = useState('');
@@ -479,7 +528,7 @@ function PhotosApp({
<${AlbumView} album=${openAlbum} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} setError=${setError}
onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor}
- onDelete=${deleteFor} />
+ onDelete=${deleteFor} onPlay=${readOnly ? null : onPreview} />
`}
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 4b507dc..47e3a72 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -238,21 +238,84 @@ export const push = {
export const photoSync = {
available: Boolean(bridge && bridge.photoSync),
status() { return bridge.photoSync.status(); },
- permit() { return bridge.photoSync.permit(); },
- albums() { return bridge.photoSync.albums(); },
+ permit(withVideos) { return bridge.photoSync.permit(withVideos); },
+ albums(withVideos) { return bridge.photoSync.albums(withVideos); },
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); },
+ manifest() { return bridge.photoSync.manifest(); },
+ manifestSent(token) { return bridge.photoSync.manifestSent(token); },
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);
- },
+ file: (token, name, size) => rangedFile('/photosync/', token, name, size),
+};
+
+/**
+ * A file the phone issued under `token`, as what an upload reads (`name`,
+ * `size`, `slice(a, b).arrayBuffer()`): each slice is fetched from the phone
+ * when it is read, so a video of gigabytes is never whole in this page. The
+ * slice is in the address (`?range=a-b`), not a Range header: the WebView
+ * does not hand that header to the application that answers.
+ */
+function rangedFile(prefix, token, name, size) {
+ const url = `${prefix}${encodeURIComponent(token)}`;
+ const part = (start, end) => ({
+ async arrayBuffer() {
+ if (end <= start) return new ArrayBuffer(0);
+ const r = await fetch(`${url}?range=${start}-${end - 1}`, { cache: 'no-store' });
+ if (!r.ok) throw new Error('This file is no longer on the phone');
+ return r.arrayBuffer();
+ },
+ });
+ return {
+ name, size,
+ slice: (a = 0, b = size) => part(Math.max(0, a), Math.min(b, size)),
+ };
+}
+
+/**
+ * Backing up folders the person chose through the system's picker
+ * (docs/MESHBAY_DESIGN.md §9.13), to the destination every kind shares.
+ * Android only, like `photoSync`. See drive-sync.js.
+ */
+const driveBridge = bridge && bridge.driveSync;
+export const driveSync = {
+ available: Boolean(driveBridge),
+ status() { return driveBridge.status(); },
+ addFolder() { return driveBridge.addFolder(); },
+ removeFolder(id) { return driveBridge.removeFolder(id); },
+ configure(settings) { return driveBridge.configure(settings); },
+ plan() { return driveBridge.plan(); },
+ sent(token, dir, name) { return driveBridge.sent(token, dir, name); },
+ manifest() { return driveBridge.manifest(); },
+ manifestSent(token) { return driveBridge.manifestSent(token); },
+ completed() { return driveBridge.completed(); },
+ failed(code, text) { return driveBridge.failed(code, text); },
+ keepAlive(on, text) { return driveBridge.keepAlive(on, text); },
+ file: (token, name, size) => rangedFile('/drivesync/', token, name, size),
+};
+
+/**
+ * Backing up WhatsApp's own folder, chosen once through the picker (its
+ * encrypted chat backups, and its media if asked), like `driveSync`.
+ */
+const waBridge = bridge && bridge.whatsappSync;
+export const whatsappSync = {
+ available: Boolean(waBridge),
+ status() { return waBridge.status(); },
+ chooseFolder() { return waBridge.chooseFolder(); },
+ forgetFolder(id) { return waBridge.forgetFolder(id); },
+ configure(settings) { return waBridge.configure(settings); },
+ plan() { return waBridge.plan(); },
+ sent(token, dir, name) { return waBridge.sent(token, dir, name); },
+ manifest() { return waBridge.manifest(); },
+ manifestSent(token) { return waBridge.manifestSent(token); },
+ completed() { return waBridge.completed(); },
+ failed(code, text) { return waBridge.failed(code, text); },
+ keepAlive(on, text) { return waBridge.keepAlive(on, text); },
+ file: (token, name, size) => rangedFile('/wasync/', token, name, size),
};
/**
@@ -267,7 +330,7 @@ export const phoneSync = {
};
/**
- * Backing up one kind of this phone's data (contacts, messages) to the destination
+ * Backing up one kind of this phone's data (contacts, calendar, messages) to the destination
* every kind shares (docs/MESHBAY_DESIGN.md §9.13). Android only, like
* `photoSync`. See phone-sync.js.
*/
@@ -282,6 +345,7 @@ function docSync(name) {
sent(token, dir, n) { return b.sent(token, dir, n); },
completed() { return b.completed(); },
failed(code, text) { return b.failed(code, text); },
+ openSettings() { return b.openSettings(); },
/** 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' });
@@ -292,6 +356,7 @@ function docSync(name) {
}
export const contactSync = docSync('contactSync');
+export const calendarSync = docSync('calendarSync');
/** Absent from the Play build, whose policy keeps text messages from it. */
export const messageSync = docSync('messageSync');
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index ea017fb..1b8dd8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -5104,6 +5104,29 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
border: 1px solid var(--border);
}
.photo-tile:hover { border-color: var(--accent); }
+.photo-tile { position: relative; }
+/* A video among the photos: a play mark and its length over the thumbnail. */
+.photo-tile-video {
+ position: absolute; left: 6px; bottom: 6px;
+ display: inline-flex; align-items: center; gap: 3px;
+ padding: 2px 6px; border-radius: 10px;
+ background: rgba(0, 0, 0, 0.6); color: #fff;
+ font-size: 0.75em; font-variant-numeric: tabular-nums;
+ pointer-events: none;
+}
+.photo-tile-video .icon { width: 0.9em; height: 0.9em; }
+.photo-lightbox-video {
+ position: relative; display: flex; align-items: center; justify-content: center;
+ max-width: 100%; max-height: 100%;
+}
+.photo-lightbox-poster { max-width: 100%; max-height: 70vh; object-fit: contain; display: block; }
+.photo-lightbox-play {
+ position: absolute; width: 64px; height: 64px; border-radius: 50%;
+ border: 0; background: rgba(0, 0, 0, 0.6); color: #fff; cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+}
+.photo-lightbox-play:hover { background: var(--accent); }
+.photo-lightbox-play .icon { width: 28px; height: 28px; }
.photo-tile-thumb {
width: 100%;
aspect-ratio: 1 / 1;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/whatsapp-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/whatsapp-sync-settings.js
new file mode 100644
index 0000000..2bd2c44
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/whatsapp-sync-settings.js
@@ -0,0 +1,125 @@
+// The WhatsApp 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. WhatsApp's chats are only ever in its own encrypted backups, which this
+// copies for a restore onto a new phone; its media too, if asked.
+
+import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ask } from './ask.js';
+import * as platform from './platform.js';
+import { subscribeWhatsApp, runWhatsAppSync } from './drive-sync.js';
+import { failureText } from './phone-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') : '';
+ }
+}
+
+export function WhatsAppSyncSection({ destination }) {
+ const sync = platform.whatsappSync;
+ const [status, setStatus] = useState(null);
+ const [run, setRun] = useState({ phase: 'idle' });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+
+ const refresh = useCallback(() => sync.status().then(setStatus).catch(() => {}), [sync]);
+ useEffect(() => { refresh(); }, [refresh]);
+ useEffect(() => subscribeWhatsApp((st) => {
+ setRun(st);
+ if (st.phase !== 'sending' && st.phase !== 'connecting') refresh();
+ }), [refresh]);
+
+ if (!status) return null;
+ const chosen = (status.folders || []).length > 0;
+ const media = Boolean(status.options && status.options.media);
+ const running = run.phase === 'sending' || run.phase === 'connecting';
+
+ const act = async (fn) => {
+ setBusy(true);
+ setError('');
+ try {
+ const next = await fn();
+ if (next) setStatus(next);
+ } catch (e) {
+ setError(/Refused: not_whatsapp/.test(e.message || '') ? t('whatsappsync.refused') : e.message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const turnOn = (withMedia) => act(async () => {
+ const next = await sync.configure({ media: withMedia });
+ runWhatsAppSync({ force: true });
+ return next;
+ });
+
+ const backUpNow = () => act(async () => {
+ if (!status.unmetered && !await ask(t('drivesync.metered_confirm'))) return null;
+ await runWhatsAppSync({ force: true, metered: !status.unmetered });
+ return null;
+ });
+
+ return html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('whatsappsync.heading')}</h3>
+ ${!status.on && html`<p class="settings-hint">${t('whatsappsync.intro')}</p>`}
+ ${!destination && html`<p class="settings-hint">${t('androidsync.dest_needed')}</p>`}
+ ${destination && html`
+ <div class="settings-row">
+ <span class="settings-label settings-name">
+ ${chosen ? t('whatsappsync.folder_chosen') : t('whatsappsync.folder_needed')}
+ </span>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.chooseFolder())}>
+ ${chosen ? t('photosync.change') : t('whatsappsync.folder_choose')}</button>
+ </div>
+ ${!chosen && html`<p class="settings-hint">${t('whatsappsync.folder_hint')}</p>`}
+ <label class="settings-choice">
+ <input type="checkbox" checked=${media} disabled=${busy || !chosen}
+ onChange=${() => (status.on ? turnOn(!media)
+ : act(async () => ({ ...status, options: { media: !media } })))} />
+ <span class="settings-name">
+ <strong>${t('whatsappsync.media')}</strong>
+ <span class="settings-hint">${t('whatsappsync.media_hint')}</span>
+ </span>
+ </label>
+ `}
+ ${destination && chosen && !status.on && html`
+ <button class="admin-btn" disabled=${busy}
+ onClick=${() => turnOn(media)}>${t('phonesync.turn_on')}</button>
+ `}
+ ${destination && status.on && html`
+ <div class="settings-row" style="margin-top:8px">
+ <span class="settings-label settings-name">
+ ${t('phonesync.summary', { group: destination.groupName, dir: status.dir })}
+ </span>
+ </div>
+ <p class="settings-hint">
+ ${t('drivesync.sent_total', { n: status.sent })}
+ ${' · '}
+ ${status.lastCompleted
+ ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() })
+ : t('photosync.never')}
+ </p>
+ ${status.failure
+ ? html`<p class="error-msg">${failureText(status.failure)}</p>`
+ : stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`}
+ <p class="settings-hint">${t('whatsappsync.restore_hint')}</p>
+ <div class="settings-row" style="gap:8px; justify-content:flex-start; flex-wrap:wrap">
+ <button class="admin-btn" disabled=${running || busy || !chosen}
+ onClick=${backUpNow}>${t('photosync.now')}</button>
+ <button class="btn-secondary" disabled=${busy}
+ onClick=${() => act(() => sync.configure(null))}>${t('photosync.turn_off')}</button>
+ </div>
+ `}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}