From f5b2d654d296a63d064579abdb79619c3e95842b Mon Sep 17 00:00:00 2001 From: mrjo118 Date: Tue, 20 Oct 2026 21:00:54 +0100 Subject: [PATCH] Desktop, Mobile, Cli: Fix sync eligible conflicts not being synced, if the conflict is for a note in a share (#16726) Co-authored-by: Laurent Cozic --- packages/lib/models/Folder.sharing.test.ts | 17 +++++++++++++++++ packages/lib/models/Folder.ts | 12 ++++++++---- packages/lib/services/ReportService.test.ts | 14 +++++++++++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/lib/models/Folder.sharing.test.ts b/packages/lib/models/Folder.sharing.test.ts index 6e39ce72ab..9011ded786 100644 --- a/packages/lib/models/Folder.sharing.test.ts +++ b/packages/lib/models/Folder.sharing.test.ts @@ -7,6 +7,7 @@ import Resource from '../models/Resource'; import { FolderEntity, NoteEntity, ResourceEntity } from '../services/database/types'; import ResourceService from '../services/ResourceService'; import { StateShare } from '../services/share/reducer'; +import ItemChange from './ItemChange'; const testImagePath = `${supportDir}/photo.jpg`; @@ -333,6 +334,22 @@ describe('models/Folder.sharing', () => { expect(note4.share_id).toBe(''); })); + it('should clear the share ID from conflict notes', async () => { + const folder = await Folder.save({ title: 'Shared folder', share_id: 'abcd1234' }); + const note = await Note.save({ title: 'Note', parent_id: folder.id, share_id: folder.share_id }); + const newConflictNote = await Note.createConflictNote(note, ItemChange.SOURCE_SYNC); + const existingConflictNote = await Note.createConflictNote(note, ItemChange.SOURCE_SYNC); + await Note.save({ id: existingConflictNote.id, share_id: folder.share_id }); + + expect(newConflictNote.share_id).toBe(''); + expect((await Note.load(existingConflictNote.id)).share_id).toBe(folder.share_id); + + await Folder.updateAllShareIds(resourceService(), []); + + expect((await Note.load(newConflictNote.id)).share_id).toBe(''); + expect((await Note.load(existingConflictNote.id)).share_id).toBe(''); + }); + it('should remove the share ID when a note is moved in or out of shared folder', (async () => { const folder1 = await createFolderTree('', [ { diff --git a/packages/lib/models/Folder.ts b/packages/lib/models/Folder.ts index 06c5b730ed..c37eb23702 100644 --- a/packages/lib/models/Folder.ts +++ b/packages/lib/models/Folder.ts @@ -581,13 +581,17 @@ export default class Folder extends BaseItem { } public static async updateNoteShareIds() { - // Find all the notes where the share_id is not the same as the - // parent share_id because we only need to update those. + // Find notes whose share_id differs from the desired value. Conflict + // notes must remain outside the share so they can sync only between the + // current user's devices. const rows = await this.db().selectAll(` - SELECT notes.id, folders.share_id, notes.parent_id + SELECT + notes.id, + CASE WHEN notes.is_conflict = 1 THEN '' ELSE folders.share_id END AS share_id, + notes.parent_id FROM notes LEFT JOIN folders ON notes.parent_id = folders.id - WHERE notes.share_id != folders.share_id + WHERE notes.share_id != CASE WHEN notes.is_conflict = 1 THEN '' ELSE folders.share_id END `); logger.debug('updateNoteShareIds: notes to update:', rows.length); diff --git a/packages/lib/services/ReportService.test.ts b/packages/lib/services/ReportService.test.ts index 577bcf8a2d..a12e858c19 100644 --- a/packages/lib/services/ReportService.test.ts +++ b/packages/lib/services/ReportService.test.ts @@ -120,17 +120,25 @@ describe('ReportService', () => { is_conflict: 1, deleted_time: Date.now(), }); - await Note.save({ - title: 'Shared conflict', + const conflictWithStaleShareId = await Note.save({ + title: 'Conflict with stale share ID', is_conflict: 1, conflict_original_id: originalNote.id, share_id: 'share-id', }); + // While the conflict has a share ID, it is excluded from sync. This prevents + // it from being sent to share recipients or rejected by a read-only share. + const statusBeforeShareIdUpdate = await new ReportService().syncStatus(syncTargetId()); + expect(statusBeforeShareIdUpdate.items.Note).toEqual({ total: 2, synced: 0 }); + await synchronizerStart(); + // Synchronisation clears the stale share ID, keeping the conflict private to + // the current account while allowing it to sync between that account's devices. + expect((await Note.load(conflictWithStaleShareId.id)).share_id).toBe(''); const status = await new ReportService().syncStatus(syncTargetId()); - expect(status.items.Note).toEqual({ total: 2, synced: 2 }); + expect(status.items.Note).toEqual({ total: 3, synced: 3 }); expect(status.conflicted.total).toBe(3); expect(status.total.synced).toBe(status.total.total); });