Skip to content

Commit d0df576

Browse files
committed
add message-key deduplication mode
1 parent 041c522 commit d0df576

7 files changed

Lines changed: 235 additions & 18 deletions

File tree

cmd/slackdump/internal/diag/dedupe.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/rusq/slackdump/v4/cmd/slackdump/internal/cfg"
1212
dedupecmd "github.com/rusq/slackdump/v4/cmd/slackdump/internal/diag/dedupe"
1313
"github.com/rusq/slackdump/v4/cmd/slackdump/internal/golang/base"
14+
"github.com/rusq/slackdump/v4/internal/chunk/backend/dbase/repository"
1415
"github.com/rusq/slackdump/v4/source"
1516
)
1617

@@ -22,9 +23,10 @@ var cmdDedupe = &base.Command{
2223
Long: `
2324
Dedupe removes identical duplicate messages, users, channels, channel users,
2425
and files created by resume look-back overlap. The latest copy of each
25-
identical payload is kept. By default it only reports what would be removed.
26-
Pass the archive directory, not the slackdump.sqlite file. Use -execute to
27-
perform deduplication.
26+
identical payload is kept. Use -mode message-key to collapse message rows by
27+
channel and timestamp even when Slack-regenerated fields differ. By default it
28+
only reports what would be removed. Pass the archive directory, not the
29+
slackdump.sqlite file. Use -execute to perform deduplication.
2830
2931
The same cleanup can be run automatically after a successful resume with:
3032
@@ -34,11 +36,14 @@ The same cleanup can be run automatically after a successful resume with:
3436

3537
var dedupeFlags struct {
3638
execute bool
39+
mode repository.MessageDedupeMode
3740
}
3841

3942
func init() {
4043
cmdDedupe.Run = runDedupe
44+
dedupeFlags.mode = repository.MessageDedupeExact
4145
cmdDedupe.Flag.BoolVar(&dedupeFlags.execute, "execute", false, "actually remove duplicate entities")
46+
cmdDedupe.Flag.Var(&dedupeFlags.mode, "mode", "message dedupe mode: exact or message-key")
4247
}
4348

4449
func ensureDb(ctx context.Context, dir string) (*sqlx.DB, error) {
@@ -82,6 +87,7 @@ func runDedupe(ctx context.Context, cmd *base.Command, args []string) error {
8287
Execute: dedupeFlags.execute,
8388
Report: os.Stdout,
8489
Database: dir,
90+
Mode: dedupeFlags.mode,
8591
})
8692
return err
8793
}

cmd/slackdump/internal/diag/dedupe/dedupe.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,20 @@ type Options struct {
1515
Execute bool
1616
Report io.Writer
1717
Database string
18+
Mode repository.MessageDedupeMode
1819
}
1920

2021
type Result struct {
2122
Counts repository.DedupeCounts
2223
Removed repository.DedupeResult
2324
}
2425

25-
var newRepo = repository.NewDedupeRepository
26+
var newRepo = func(mode repository.MessageDedupeMode) repository.DedupeRepository {
27+
return repository.NewDedupeRepository(repository.WithMessageDedupeMode(mode))
28+
}
2629

2730
func Run(ctx context.Context, db *sqlx.DB, opts Options) (Result, error) {
28-
repo := newRepo()
31+
repo := newRepo(opts.Mode)
2932

3033
counts, err := repo.Preview(ctx, db)
3134
if err != nil {
@@ -34,6 +37,7 @@ func Run(ctx context.Context, db *sqlx.DB, opts Options) (Result, error) {
3437

3538
slog.DebugContext(ctx, "dedupe preview",
3639
"database", opts.Database,
40+
"mode", opts.Mode.String(),
3741
"duplicate_messages", counts.Messages,
3842
"duplicate_users", counts.Users,
3943
"duplicate_channels", counts.Channels,
@@ -68,6 +72,7 @@ func Run(ctx context.Context, db *sqlx.DB, opts Options) (Result, error) {
6872

6973
slog.InfoContext(ctx, "dedupe execute",
7074
"database", opts.Database,
75+
"mode", opts.Mode.String(),
7176
"removed_messages", removed.MessagesRemoved,
7277
"removed_users", removed.UsersRemoved,
7378
"removed_channels", removed.ChannelsRemoved,

cmd/slackdump/internal/diag/dedupe/dedupe_test.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestRun(t *testing.T) {
3232

3333
t.Run("preview only with report", func(t *testing.T) {
3434
var called bool
35-
newRepo = func() repository.DedupeRepository {
35+
newRepo = func(repository.MessageDedupeMode) repository.DedupeRepository {
3636
return stubRepo{
3737
previewFn: func(context.Context, *sqlx.DB) (repository.DedupeCounts, error) {
3838
return repository.DedupeCounts{Messages: 2, Chunks: 1}, nil
@@ -53,7 +53,7 @@ func TestRun(t *testing.T) {
5353
})
5454

5555
t.Run("execute with report", func(t *testing.T) {
56-
newRepo = func() repository.DedupeRepository {
56+
newRepo = func(repository.MessageDedupeMode) repository.DedupeRepository {
5757
return stubRepo{
5858
previewFn: func(context.Context, *sqlx.DB) (repository.DedupeCounts, error) {
5959
return repository.DedupeCounts{Messages: 1}, nil
@@ -71,7 +71,7 @@ func TestRun(t *testing.T) {
7171
})
7272

7373
t.Run("log only mode does not write report", func(t *testing.T) {
74-
newRepo = func() repository.DedupeRepository {
74+
newRepo = func(repository.MessageDedupeMode) repository.DedupeRepository {
7575
return stubRepo{
7676
previewFn: func(context.Context, *sqlx.DB) (repository.DedupeCounts, error) {
7777
return repository.DedupeCounts{}, nil
@@ -87,7 +87,7 @@ func TestRun(t *testing.T) {
8787
})
8888

8989
t.Run("execute returns dedupe error", func(t *testing.T) {
90-
newRepo = func() repository.DedupeRepository {
90+
newRepo = func(repository.MessageDedupeMode) repository.DedupeRepository {
9191
return stubRepo{
9292
previewFn: func(context.Context, *sqlx.DB) (repository.DedupeCounts, error) {
9393
return repository.DedupeCounts{}, nil
@@ -101,4 +101,22 @@ func TestRun(t *testing.T) {
101101
require.Error(t, err)
102102
assert.Contains(t, err.Error(), "deduplicate entities")
103103
})
104+
105+
t.Run("passes mode to repository", func(t *testing.T) {
106+
var got repository.MessageDedupeMode
107+
newRepo = func(mode repository.MessageDedupeMode) repository.DedupeRepository {
108+
got = mode
109+
return stubRepo{
110+
previewFn: func(context.Context, *sqlx.DB) (repository.DedupeCounts, error) {
111+
return repository.DedupeCounts{}, nil
112+
},
113+
dedupeFn: func(context.Context, *sqlx.DB) (repository.DedupeResult, error) {
114+
return repository.DedupeResult{}, nil
115+
},
116+
}
117+
}
118+
_, err := Run(t.Context(), nil, Options{Mode: repository.MessageDedupeKey})
119+
require.NoError(t, err)
120+
assert.Equal(t, repository.MessageDedupeKey, got)
121+
})
104122
}

doc/usage-dedupe.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Database Dedupe
22

33
The `dedupe` tool removes identical duplicate messages, users, channels,
4-
channel users, and files created by resume look-back overlap.
4+
channel users, and files created by resume look-back overlap. It can also
5+
collapse messages by channel and timestamp for merged exports where Slack
6+
regenerated volatile message fields between exports.
57

68
## Why use dedupe?
79

@@ -18,6 +20,9 @@ slackdump tools dedupe /path/to/archive
1820

1921
# Actually perform dedupe
2022
slackdump tools dedupe -execute /path/to/archive
23+
24+
# Collapse message rows by channel and timestamp
25+
slackdump tools dedupe -mode message-key -execute /path/to/archive
2126
```
2227

2328
Pass the archive directory that contains `slackdump.sqlite`. You do not need to
@@ -28,6 +33,14 @@ point the command at the database file itself.
2833
| Flag | Description |
2934
|------|-------------|
3035
| `-execute` | Required flag to actually remove duplicate entities |
36+
| `-mode` | Message dedupe mode: `exact` keeps current byte-for-byte behavior; `message-key` keeps the latest row per channel and timestamp |
37+
38+
`message-key` is useful after merging multiple Slack exports of the same
39+
workspace when duplicate messages differ only in volatile Slack-generated JSON
40+
fields, such as `blocks[].block_id`. It is intentionally opt-in because it can
41+
discard older edited or reaction variants for the same message timestamp. It
42+
also treats channel-history and thread-message copies of the same channel
43+
timestamp as one logical message and keeps the latest database row.
3144

3245
## Example
3346

internal/chunk/backend/dbase/repository/cleanup_test.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,16 @@ func insertChunkForTest(t *testing.T, db *sqlx.DB, chunkID, sessionID int64, typ
135135
}
136136

137137
func insertMessageWithChunkForTest(t *testing.T, db *sqlx.DB, msgID, chunkID int64, data []byte) {
138+
t.Helper()
139+
insertMessageInChannelWithChunkForTest(t, db, msgID, chunkID, "C001", data)
140+
}
141+
142+
func insertMessageInChannelWithChunkForTest(t *testing.T, db *sqlx.DB, msgID, chunkID int64, channelID string, data []byte) {
138143
t.Helper()
139144
_, err := db.ExecContext(context.Background(), `
140145
INSERT INTO MESSAGE (ID, CHUNK_ID, CHANNEL_ID, TS, IDX, DATA)
141-
VALUES (?, ?, 'C001', '1000000000.000001', 0, ?)`,
142-
msgID, chunkID, data)
146+
VALUES (?, ?, ?, '1000000000.000001', 0, ?)`,
147+
msgID, chunkID, channelID, data)
143148
require.NoError(t, err)
144149
}
145150

internal/chunk/backend/dbase/repository/dedupe.go

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,47 @@ type DedupeResult struct {
2828
ChunksRemoved int64
2929
}
3030

31+
type MessageDedupeMode string
32+
33+
const (
34+
MessageDedupeExact MessageDedupeMode = "exact"
35+
MessageDedupeKey MessageDedupeMode = "message-key"
36+
)
37+
38+
func (m *MessageDedupeMode) Set(v string) error {
39+
mode := MessageDedupeMode(strings.ToLower(v))
40+
switch mode {
41+
case MessageDedupeExact, MessageDedupeKey:
42+
*m = mode
43+
return nil
44+
default:
45+
return fmt.Errorf("unknown message dedupe mode: %s", v)
46+
}
47+
}
48+
49+
func (m MessageDedupeMode) String() string {
50+
if m == "" {
51+
return string(MessageDedupeExact)
52+
}
53+
return string(m)
54+
}
55+
3156
type DedupeRepository interface {
3257
Preview(ctx context.Context, db *sqlx.DB) (DedupeCounts, error)
3358
Deduplicate(ctx context.Context, db *sqlx.DB) (DedupeResult, error)
3459
}
3560

36-
type dedupeRepository struct{}
61+
type DedupeOption func(*dedupeRepository)
62+
63+
func WithMessageDedupeMode(mode MessageDedupeMode) DedupeOption {
64+
return func(r *dedupeRepository) {
65+
r.messageMode = mode
66+
}
67+
}
68+
69+
type dedupeRepository struct {
70+
messageMode MessageDedupeMode
71+
}
3772

3873
type dedupeMode int
3974

@@ -54,7 +89,7 @@ var dedupeEntities = []dedupeEntity{
5489
{
5590
name: "messages",
5691
table: "MESSAGE",
57-
keyColumns: []string{"ID"},
92+
keyColumns: []string{"CHANNEL_ID", "ID"},
5893
chunkTypes: []chunk.ChunkType{chunk.CMessages, chunk.CThreadMessages},
5994
mode: dedupeByData,
6095
},
@@ -88,13 +123,28 @@ var dedupeEntities = []dedupeEntity{
88123
},
89124
}
90125

91-
func NewDedupeRepository() DedupeRepository {
92-
return dedupeRepository{}
126+
func NewDedupeRepository(opts ...DedupeOption) DedupeRepository {
127+
r := dedupeRepository{messageMode: MessageDedupeExact}
128+
for _, opt := range opts {
129+
opt(&r)
130+
}
131+
return r
132+
}
133+
134+
func (r dedupeRepository) entities() []dedupeEntity {
135+
entities := make([]dedupeEntity, len(dedupeEntities))
136+
copy(entities, dedupeEntities)
137+
for i := range entities {
138+
if entities[i].name == "messages" && r.messageMode == MessageDedupeKey {
139+
entities[i].mode = dedupeByKey
140+
}
141+
}
142+
return entities
93143
}
94144

95145
func (r dedupeRepository) Preview(ctx context.Context, db *sqlx.DB) (DedupeCounts, error) {
96146
var counts DedupeCounts
97-
for _, entity := range dedupeEntities {
147+
for _, entity := range r.entities() {
98148
n, err := r.countDuplicates(ctx, db, entity)
99149
if err != nil {
100150
return DedupeCounts{}, fmt.Errorf("count duplicate %s: %w", entity.name, err)
@@ -118,7 +168,7 @@ func (r dedupeRepository) Deduplicate(ctx context.Context, db *sqlx.DB) (DedupeR
118168
defer tx.Rollback()
119169

120170
var result DedupeResult
121-
for _, entity := range dedupeEntities {
171+
for _, entity := range r.entities() {
122172
chunkIDs, err := r.prunableChunkIDs(ctx, tx, entity)
123173
if err != nil {
124174
return DedupeResult{}, fmt.Errorf("query prunable %s chunks: %w", entity.name, err)

0 commit comments

Comments
 (0)