feat(sync): include avatar in sync snapshot + auto-backup (multi-device)
The profile picture (users.avatar_b64, cosmetic/unencrypted) travelled only
in the manual export. Now it's also in buildSyncSnapshot and the auto-backup
container, so a new device / a restore picks it up.
- Restore is ADDITIVE (mirrors the import path): applyRemoteSnapshot adopts
remote.avatar_b64 only when the local device has no avatar — never clobbers
a locally-set picture. No per-avatar timestamp to arbitrate, so changing an
existing avatar doesn't propagate (cosmetic, accepted).
- +2 merge tests (adopt-when-empty, don't-clobber-when-set). 65/65 green.
- Server /avatar endpoint unchanged (already accepts {avatar_b64}).
Closes the avatar item of CODE_AUDIT §4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,9 @@ async function runAutoBackupNow(silent) {
|
||||
color: f.color || '',
|
||||
icon: f.icon || '',
|
||||
})),
|
||||
// Same as the manual export container — keep the avatar so a
|
||||
// restore from an auto-backup brings the profile picture back.
|
||||
avatar_b64: state.avatarDataUri || '',
|
||||
entries: [],
|
||||
};
|
||||
let _bkDone = 0;
|
||||
|
||||
@@ -197,6 +197,9 @@ async function buildSyncSnapshot() {
|
||||
folders: (state.folders || [])
|
||||
.filter(f => f && f.name && f.name !== 'All')
|
||||
.map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '' })),
|
||||
// Cosmetic, unencrypted — travels so a new device picks up the avatar
|
||||
// (adopted only if that device has none; see applyRemoteSnapshot).
|
||||
avatar_b64: state.avatarDataUri || '',
|
||||
entries: [],
|
||||
tombstones: [],
|
||||
};
|
||||
@@ -355,6 +358,21 @@ async function applyRemoteSnapshot(remote) {
|
||||
await loadFolders();
|
||||
}
|
||||
|
||||
// Avatar (cosmetic, unencrypted) — adopt the remote one only if this
|
||||
// device has none, mirroring the import path. Additive so we never fight
|
||||
// over / clobber a locally-set picture (no per-avatar timestamp to
|
||||
// arbitrate). A device that already has an avatar keeps it.
|
||||
if (typeof remote.avatar_b64 === 'string' && remote.avatar_b64 && !state.avatarDataUri) {
|
||||
try {
|
||||
await api('/avatar', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ avatar_b64: remote.avatar_b64 }),
|
||||
});
|
||||
state.avatarDataUri = remote.avatar_b64;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Per-entry merge.
|
||||
for (const r of remote.entries) {
|
||||
if (!r.uuid) continue;
|
||||
|
||||
@@ -49,6 +49,10 @@ async function freshMerge() {
|
||||
db.folders.push({ name: body.name, color: body.color || '', icon: body.icon || '' });
|
||||
return { ok: true };
|
||||
}
|
||||
if (path === '/avatar' && method === 'POST') {
|
||||
db.avatar = body.avatar_b64 || '';
|
||||
return { ok: true };
|
||||
}
|
||||
if (path === '/entries/tombstones' && method === 'GET') {
|
||||
return db.tombstones.map(t => Object.assign({}, t));
|
||||
}
|
||||
@@ -220,6 +224,28 @@ test('merge: missing remote folders are added additively', async () => {
|
||||
assert.equal(added.color, '#0f0');
|
||||
});
|
||||
|
||||
test('merge: remote avatar is adopted when the local device has none', async () => {
|
||||
const { ctx, T, db } = await freshMerge();
|
||||
ctx.__test.state.avatarDataUri = ''; // this device has no picture
|
||||
await T.applyRemoteSnapshot({
|
||||
entries: [], tombstones: [], folders: [],
|
||||
avatar_b64: 'data:image/jpeg;base64,AAAA',
|
||||
});
|
||||
assert.equal(db.avatar, 'data:image/jpeg;base64,AAAA', 'avatar POSTed to server');
|
||||
assert.equal(ctx.__test.state.avatarDataUri, 'data:image/jpeg;base64,AAAA');
|
||||
});
|
||||
|
||||
test('merge: remote avatar does NOT clobber an existing local avatar (additive)', async () => {
|
||||
const { ctx, T, db } = await freshMerge();
|
||||
ctx.__test.state.avatarDataUri = 'data:image/jpeg;base64,LOCAL';
|
||||
await T.applyRemoteSnapshot({
|
||||
entries: [], tombstones: [], folders: [],
|
||||
avatar_b64: 'data:image/jpeg;base64,REMOTE',
|
||||
});
|
||||
assert.equal(db.avatar, undefined, 'no /avatar POST when a local avatar exists');
|
||||
assert.equal(ctx.__test.state.avatarDataUri, 'data:image/jpeg;base64,LOCAL');
|
||||
});
|
||||
|
||||
test('merge: empty/invalid remote snapshot is a no-op', async () => {
|
||||
const { T, db } = await freshMerge();
|
||||
db.seedEntry({ uuid: 'u1' });
|
||||
|
||||
Reference in New Issue
Block a user