diff options
Diffstat (limited to 'packages/meshbay-hub')
15 files changed, 150 insertions, 22 deletions
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 5f508da..ca15345 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1313,6 +1313,11 @@ export default { 'photosync.albums': "Alben auf diesem Telefon", 'photosync.album_camera': "Kamera", 'photosync.album_count': "{n} Fotos · {size}", + '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", 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 6b7d73f..8619bdb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1294,6 +1294,11 @@ export default { 'photosync.albums': "Albums on this phone", 'photosync.album_camera': "Camera", 'photosync.album_count': "{n} photos · {size}", + '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", 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 f2c1cfc..19db8f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1307,6 +1307,11 @@ export default { 'photosync.albums': "Álbumes de este teléfono", 'photosync.album_camera': "Cámara", 'photosync.album_count': "{n} fotos · {size}", + '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", 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 3b5b881..3f3cd42 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1322,6 +1322,11 @@ export default { 'photosync.albums': "Albums de ce téléphone", 'photosync.album_camera': "Appareil photo", 'photosync.album_count': "{n} photos · {size}", + '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", 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 9935edb..f238495 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1321,6 +1321,11 @@ export default { 'photosync.albums': "Album su questo telefono", 'photosync.album_camera': "Fotocamera", 'photosync.album_count': "{n} foto · {size}", + '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", 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 2af2f49..6a59d1d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1305,6 +1305,11 @@ export default { 'photosync.albums': "この電話のアルバム", 'photosync.album_camera': "カメラ", 'photosync.album_count': "{n} 枚 · {size}", + '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': "バックアップを開始", 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 9e83c86..4b68975 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1323,6 +1323,11 @@ export default { 'photosync.albums': "Albums op deze telefoon", 'photosync.album_camera': "Camera", 'photosync.album_count': "{n} foto's · {size}", + '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", 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 cb0d0b6..8b1b5db 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1349,6 +1349,11 @@ export default { 'photosync.albums': "Albumy na tym telefonie", 'photosync.album_camera': "Aparat", 'photosync.album_count': "Zdjęcia: {n} · {size}", + '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ę", 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 cb3aa70..9fc634b 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 @@ -1308,6 +1308,11 @@ export default { 'photosync.albums': "Álbuns deste celular", 'photosync.album_camera': "Câmera", 'photosync.album_count': "{n} fotos · {size}", + '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", 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 bb47b00..5859934 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 @@ -1294,6 +1294,11 @@ export default { 'photosync.albums': "这部手机上的相册", 'photosync.album_camera': "相机", 'photosync.album_count': "{n} 张照片 · {size}", + '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': "开始备份", 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..088de76 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 @@ -105,6 +105,11 @@ export function PhotoSyncSection({ destination }) { </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>`} @@ -129,19 +134,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 +177,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 +217,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..ffb07aa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync.js @@ -253,7 +253,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, }); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 2cfa673..8effc21 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -238,8 +238,8 @@ 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(); }, @@ -247,11 +247,25 @@ export const photoSync = { 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); + /** + * A photo or video the last plan issued, 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. + */ + file(token, name, size) { + const url = `/photosync/${encodeURIComponent(token)}`; + const part = (start, end) => ({ + async arrayBuffer() { + if (end <= start) return new ArrayBuffer(0); + const r = await fetch(url, { cache: 'no-store', headers: { Range: `bytes=${start}-${end - 1}` } }); + if (r.status !== 206) throw new Error('This photo 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)), + }; }, }; diff --git a/packages/meshbay-hub/tests/test_photo_sync.py b/packages/meshbay-hub/tests/test_photo_sync.py index 98f49ba..c422f22 100644 --- a/packages/meshbay-hub/tests/test_photo_sync.py +++ b/packages/meshbay-hub/tests/test_photo_sync.py @@ -57,9 +57,13 @@ doc.visibilityState = 'visible'; globalThis.window = win; globalThis.document = doc; globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} }; -globalThis.fetch = async (url) => { - fake.log.push(['fetch', url]); - return { ok: true, blob: async () => new Blob([new Uint8Array(4)]) }; +globalThis.fetch = async (url, opts) => { + const range = opts && opts.headers && opts.headers.Range; + fake.log.push(['fetch', url, range || null]); + const m = /bytes=(\d+)-(\d+)/.exec(range || ''); + const n = m ? Number(m[2]) - Number(m[1]) + 1 : 4; + return { ok: true, status: m ? 206 : 200, arrayBuffer: async () => new ArrayBuffer(n), + blob: async () => new Blob([new Uint8Array(n)]) }; }; const NOW = Date.now(); @@ -95,6 +99,12 @@ fake.transport = { }, async uploadFile(file, opts) { fake.log.push(['upload', opts.dir, file.name, opts.tr]); + if (fake.readChunk) { + for (let at = 0; at < file.size; at += fake.readChunk) { + const buf = await file.slice(at, at + fake.readChunk).arrayBuffer(); + fake.log.push(['read', at, buf.byteLength]); + } + } if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; } if (fake.afterUpload) fake.afterUpload(); return { stored_as: file.name, dir: opts.dir }; @@ -164,6 +174,20 @@ def test_a_due_run_sends_newest_first_and_records_each(tmp_path): "the screen-off keep-alive was not released after the run") +def test_a_file_is_read_from_the_phone_a_chunk_at_a_time(tmp_path): + """A video of gigabytes is never whole in the page: each slice the upload + reads is its own ranged request to the phone.""" + log = _run(_start(""" +fake.items = [{ token: 'v1', name: 'clip.mp4', dir: 'Media/Photos/Bob/2026/2026-10', size: 25, + edited: false, video: true }]; +fake.readChunk = 10; +"""), tmp_path) + assert _of(log, "fetch") == [["/photosync/v1", "bytes=0-9"], ["/photosync/v1", "bytes=10-19"], + ["/photosync/v1", "bytes=20-24"]] + assert _of(log, "read") == [[0, 10], [10, 10], [20, 5]] + assert ["sent", "v1", "Media/Photos/Bob/2026/2026-10", "clip.mp4"] in log + + def test_only_missing_folders_are_made_and_parents_first(tmp_path): log = _run(_start(), tmp_path) assert _of(log, "mkdir") == [["Media/Photos/Bob/2026/2026-10"]] diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py index cbac80a..8cc9417 100644 --- a/packages/meshbay-hub/tests/test_photo_sync_ui.py +++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py @@ -74,3 +74,8 @@ def test_the_messages_backup_says_where_its_copies_go(probe): def test_the_calendar_backup_says_where_its_copies_go(probe): assert "bob-calendar/calendar-2026-10-10-0900.ics" in probe["configured360"]["text"] + + +def test_videos_are_offered_and_off_by_default(probe): + assert "Videos too" in probe["setup360"]["text"] + assert "Videos too" not in probe["setup360"]["checked"] |