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) +}