From c146727a3df382b3b0afcce633522d0df036ed24 Mon Sep 17 00:00:00 2001 From: Jon Hadfield Date: Tue, 22 Sep 2026 21:15:48 +0100 Subject: [PATCH] Flag whether each duplicate's content matches the note being kept The listing said which notes would be deleted but not whether anything would be lost. The notes are already decrypted locally by the sync, so comparing them costs nothing. Each note to be deleted is now labelled identical or differs, comparing its title and text with the note being kept. --identical-only restricts deletion to exact copies and reports the rest. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 ++- cmd/sncli/delete.go | 6 ++- cmd/sncli/duplicates.go | 45 +++++++++++++++-- internal/sncli/duplicates.go | 61 +++++++++++++++++++++-- internal/sncli/duplicates_test.go | 80 +++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index caeaa6f..8264865 100644 --- a/README.md +++ b/README.md @@ -224,10 +224,14 @@ sn get notes --title "Meeting Notes" --rich # Edit a note in $EDITOR sn edit note --title "Meeting Notes" -# Review duplicate notes, then delete all but the newest of each set +# Review duplicate notes, then delete all but the newest of each set. +# The listing flags whether each note's content is identical to the one kept. sn delete duplicates --dry-run sn delete duplicates +# Only delete exact copies, leaving any that have been edited since +sn delete duplicates --identical-only + # Tag every note whose title matches, in one go sn tag --find-title "invoice" --title finance --ignore-case diff --git a/cmd/sncli/delete.go b/cmd/sncli/delete.go index e37bebd..f8db4e8 100644 --- a/cmd/sncli/delete.go +++ b/cmd/sncli/delete.go @@ -104,7 +104,7 @@ func cmdDelete() *cli.Command { Name: "duplicates", Usage: "delete notes that Standard Notes marked as copies of another note", BashComplete: func(c *cli.Context) { - delDupeOpts := []string{"--dry-run", "--yes"} + delDupeOpts := []string{"--dry-run", "--identical-only", "--yes"} if c.NArg() > 0 { return } @@ -117,6 +117,10 @@ func cmdDelete() *cli.Command { Name: "dry-run", Usage: "list the duplicates without deleting them", }, + &cli.BoolFlag{ + Name: "identical-only", + Usage: "only delete notes whose title and text match the note being kept", + }, &cli.BoolFlag{ Name: "yes", Usage: "delete without asking for confirmation", diff --git a/cmd/sncli/duplicates.go b/cmd/sncli/duplicates.go index 0c39766..8454708 100644 --- a/cmd/sncli/duplicates.go +++ b/cmd/sncli/duplicates.go @@ -25,10 +25,16 @@ func processDeleteDuplicates(c *cli.Context, opts configOptsOutput) error { } dryRun := c.Bool("dry-run") + identicalOnly := c.Bool("identical-only") // find the duplicates first, so they can be shown before anything is // deleted - find := sncli.DeleteDuplicateNotesConfig{Session: &sess, DryRun: true, Debug: opts.debug} + find := sncli.DeleteDuplicateNotesConfig{ + Session: &sess, + DryRun: true, + IdenticalOnly: identicalOnly, + Debug: opts.debug, + } found, err := find.Run() if err != nil { @@ -36,6 +42,7 @@ func processDeleteDuplicates(c *cli.Context, opts configOptsOutput) error { } writeDuplicatesKept(c, found.KeptNoOriginal) + writeDuplicatesDiffering(c, found.KeptDiffering) toDelete := found.Deleted() @@ -55,7 +62,7 @@ func processDeleteDuplicates(c *cli.Context, opts configOptsOutput) error { return nil } - del := sncli.DeleteDuplicateNotesConfig{Session: &sess, Debug: opts.debug} + del := sncli.DeleteDuplicateNotesConfig{Session: &sess, IdenticalOnly: identicalOnly, Debug: opts.debug} out, err := del.Run() if err != nil { @@ -86,9 +93,39 @@ func writeDuplicateGroups(c *cli.Context, groups []sncli.DuplicateGroup, numToDe group.Keep.UpdatedDate(), group.Keep.UUID, group.Keep.Title) for _, dup := range group.Delete { - _, _ = fmt.Fprintf(c.App.Writer, " %s %s %s %s\n", - color.Red.Sprint("delete"), dup.UpdatedDate(), dup.UUID, dup.Title) + _, _ = fmt.Fprintf(c.App.Writer, " %s %s %s %s %s\n", + color.Red.Sprint("delete"), dup.UpdatedDate(), dup.UUID, dup.Title, contentLabel(dup)) + } + } +} + +// contentLabel says whether a note about to be deleted holds the same content +// as the one being kept, so an identical copy can be told from one that has +// been edited since. +func contentLabel(dup sncli.NoteSummary) string { + if dup.Identical { + return color.Gray.Sprint("identical") + } + + return color.Yellow.Sprint("differs") +} + +func writeDuplicatesDiffering(c *cli.Context, kept []sncli.NoteSummary) { + if len(kept) == 0 { + return + } + + _, _ = fmt.Fprintln(c.App.Writer, color.Yellow.Sprintf( + "%d notes kept, as their content differs from the note being kept:", len(kept))) + + for x, dup := range kept { + if x == maxDuplicateGroupsListed { + _, _ = fmt.Fprintln(c.App.Writer, color.Gray.Sprintf(" ... and %d more", len(kept)-maxDuplicateGroupsListed)) + + break } + + _, _ = fmt.Fprintf(c.App.Writer, " %s %s\n", dup.UUID, dup.Title) } } diff --git a/internal/sncli/duplicates.go b/internal/sncli/duplicates.go index 094d67e..487d71e 100644 --- a/internal/sncli/duplicates.go +++ b/internal/sncli/duplicates.go @@ -20,6 +20,10 @@ type NoteSummary struct { // Marked reports whether the note carries duplicate_of, meaning Standard // Notes created it by duplicating another note. Marked bool + // Identical reports whether this note's title and text match the note + // being kept in its set. It is only meaningful for notes being deleted: + // when true, deleting the note loses nothing. + Identical bool } // UpdatedDate returns the note's last-updated time as a date and time for @@ -51,6 +55,15 @@ func summariseNote(note *items.Note) NoteSummary { } } +// sameContent reports whether two notes hold the same title and text. +func sameContent(a, b *items.Note) bool { + if a == nil || b == nil { + return false + } + + return a.Content.GetTitle() == b.Content.GetTitle() && a.Content.GetText() == b.Content.GetText() +} + // newerThan reports whether a should be kept in preference to b. The most // recently updated wins; on a tie an original is preferred over a copy, and // then the lower UUID, so the choice is stable. @@ -133,10 +146,15 @@ func FindDuplicateGroups(in items.Items) (groups []DuplicateGroup, keptNoOrigina var toDelete []NoteSummary + kept := live[keep.UUID] + for _, candidate := range group { - if candidate.UUID != keep.UUID { - toDelete = append(toDelete, candidate) + if candidate.UUID == keep.UUID { + continue } + + candidate.Identical = sameContent(live[candidate.UUID], kept) + toDelete = append(toDelete, candidate) } sort.Slice(toDelete, func(i, j int) bool { return toDelete[i].UUID < toDelete[j].UUID }) @@ -150,13 +168,43 @@ func FindDuplicateGroups(in items.Items) (groups []DuplicateGroup, keptNoOrigina return groups, keptNoOriginal } +// KeepOnlyIdentical drops notes whose content differs from the note being kept, +// returning the remaining groups and the notes left alone. A group with +// nothing left to delete is dropped. +func KeepOnlyIdentical(groups []DuplicateGroup) (filtered []DuplicateGroup, skipped []NoteSummary) { + for _, group := range groups { + var identical []NoteSummary + + for _, dup := range group.Delete { + if dup.Identical { + identical = append(identical, dup) + + continue + } + + skipped = append(skipped, dup) + } + + if len(identical) == 0 { + continue + } + + filtered = append(filtered, DuplicateGroup{Keep: group.Keep, Delete: identical}) + } + + return filtered, skipped +} + // DeleteDuplicateNotesConfig deletes the superseded notes in each set of // duplicates. type DeleteDuplicateNotesConfig struct { Session *cache.Session // DryRun reports what would be deleted without deleting anything. DryRun bool - Debug bool + // IdenticalOnly restricts deletion to notes whose title and text match the + // note being kept, leaving those that have diverged. + IdenticalOnly bool + Debug bool } // DeleteDuplicateNotesOutput describes what was, or would be, deleted. @@ -167,6 +215,9 @@ type DeleteDuplicateNotesOutput struct { // KeptNoOriginal holds copies left alone because the note they were made // from is no longer in the account. KeptNoOriginal []NoteSummary + // KeptDiffering holds notes left alone under IdenticalOnly, because their + // content differs from the note being kept. + KeptDiffering []NoteSummary } // Deleted returns every note deleted, across all groups. @@ -212,6 +263,10 @@ func (i *DeleteDuplicateNotesConfig) Run() (DeleteDuplicateNotesOutput, error) { out.Groups, out.KeptNoOriginal = FindDuplicateGroups(all) + if i.IdenticalOnly { + out.Groups, out.KeptDiffering = KeepOnlyIdentical(out.Groups) + } + toDelete := out.Deleted() if i.DryRun || len(toDelete) == 0 { diff --git a/internal/sncli/duplicates_test.go b/internal/sncli/duplicates_test.go index b859f90..421ca45 100644 --- a/internal/sncli/duplicates_test.go +++ b/internal/sncli/duplicates_test.go @@ -23,6 +23,13 @@ func testNote(uuid, title, duplicateOf string, updatedAt int64, deleted bool) *i return note } +func testNoteWithText(uuid, title, text, duplicateOf string, updatedAt int64) *items.Note { + note := testNote(uuid, title, duplicateOf, updatedAt, false) + note.Content.SetText(text) + + return note +} + func TestFindDuplicateGroupsKeepsNewerCopy(t *testing.T) { // the copy was edited after it was made, so it holds the latest work in := items.Items{ @@ -174,3 +181,76 @@ func TestNoteSummaryUpdatedDate(t *testing.T) { // an unparseable value is shown as it came from the API require.Equal(t, "not a time", NoteSummary{UpdatedAtText: "not a time"}.UpdatedDate()) } + +func TestFindDuplicateGroupsFlagsIdenticalContent(t *testing.T) { + in := items.Items{ + testNoteWithText("original", "Shopping", "milk", "", 100), + testNoteWithText("copy", "Shopping", "milk", "original", 200), + } + + groups, _ := FindDuplicateGroups(in) + + require.Len(t, groups, 1) + require.Equal(t, "copy", groups[0].Keep.UUID) + require.True(t, groups[0].Delete[0].Identical) +} + +func TestFindDuplicateGroupsFlagsDifferingText(t *testing.T) { + in := items.Items{ + testNoteWithText("original", "Shopping", "milk", "", 100), + testNoteWithText("copy", "Shopping", "milk and eggs", "original", 200), + } + + groups, _ := FindDuplicateGroups(in) + + require.Len(t, groups, 1) + require.False(t, groups[0].Delete[0].Identical) +} + +// A note with the same text under a different title has not been left +// untouched, so it does not count as identical. +func TestFindDuplicateGroupsFlagsDifferingTitle(t *testing.T) { + in := items.Items{ + testNoteWithText("original", "Shopping", "milk", "", 100), + testNoteWithText("copy", "Shopping copy", "milk", "original", 200), + } + + groups, _ := FindDuplicateGroups(in) + + require.Len(t, groups, 1) + require.False(t, groups[0].Delete[0].Identical) +} + +func TestKeepOnlyIdentical(t *testing.T) { + groups := []DuplicateGroup{ + { + Keep: NoteSummary{UUID: "a"}, + Delete: []NoteSummary{ + {UUID: "a-same", Identical: true}, + {UUID: "a-diff"}, + }, + }, + { + // nothing identical, so the whole set is dropped + Keep: NoteSummary{UUID: "b"}, + Delete: []NoteSummary{{UUID: "b-diff"}}, + }, + } + + filtered, skipped := KeepOnlyIdentical(groups) + + require.Len(t, filtered, 1) + require.Equal(t, "a", filtered[0].Keep.UUID) + require.Len(t, filtered[0].Delete, 1) + require.Equal(t, "a-same", filtered[0].Delete[0].UUID) + + require.Len(t, skipped, 2) + require.Equal(t, []string{"a-diff", "b-diff"}, []string{skipped[0].UUID, skipped[1].UUID}) +} + +func TestKeepOnlyIdenticalNoGroups(t *testing.T) { + filtered, skipped := KeepOnlyIdentical(nil) + + require.Empty(t, filtered) + require.Empty(t, skipped) +}