Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion cmd/sncli/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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",
Expand Down
45 changes: 41 additions & 4 deletions cmd/sncli/duplicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,24 @@ 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 {
return fmt.Errorf("failed to find duplicate notes: %w", err)
}

writeDuplicatesKept(c, found.KeptNoOriginal)
writeDuplicatesDiffering(c, found.KeptDiffering)

toDelete := found.Deleted()

Comment on lines 44 to 48
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down
61 changes: 58 additions & 3 deletions internal/sncli/duplicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 })
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions internal/sncli/duplicates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
Loading