@@ -84,6 +84,18 @@ content_type TEXT NOT NULL DEFAULT 'text'
8484 {
8585 // Column already exists — ignore.
8686 }
87+
88+ // Migrate existing databases that lack the content_hash column (used for deduplication).
89+ try
90+ {
91+ using var alter = _connection . CreateCommand ( ) ;
92+ alter . CommandText = "ALTER TABLE clipboard_items ADD COLUMN content_hash TEXT;" ;
93+ alter . ExecuteNonQuery ( ) ;
94+ }
95+ catch ( SqliteException ex ) when ( ex . Message . Contains ( "duplicate column" ) )
96+ {
97+ // Column already exists — ignore.
98+ }
8799 }
88100
89101 /// <summary>Maximum non-pinned items retained. Updated when settings change.</summary>
@@ -95,28 +107,59 @@ public async Task AddAsync(string plaintext, string? sourceApp = null)
95107 ArgumentNullException . ThrowIfNull ( plaintext ) ;
96108 ThrowIfDisposed ( ) ;
97109
98- var ( ciphertext , iv , tag ) = _encryption . Encrypt ( plaintext ) ;
110+ // SHA-256 of the plaintext — used to detect duplicates without decrypting all rows.
111+ string hash = ComputeHash ( plaintext ) ;
99112
100113 await _lock . WaitAsync ( ) . ConfigureAwait ( false ) ;
101114 try
102115 {
116+ // If the same content already exists, bump its timestamp to the top instead
117+ // of inserting a duplicate entry.
118+ using var check = _connection . CreateCommand ( ) ;
119+ check . CommandText = """
120+ SELECT id FROM clipboard_items
121+ WHERE content_hash = $hash AND content_type = 'text'
122+ LIMIT 1;
123+ """ ;
124+ check . Parameters . AddWithValue ( "$hash" , hash ) ;
125+ var existingId = check . ExecuteScalar ( ) ;
126+
127+ if ( existingId is not null )
128+ {
129+ using var bump = _connection . CreateCommand ( ) ;
130+ bump . CommandText = "UPDATE clipboard_items SET created_at = $ca WHERE id = $id;" ;
131+ bump . Parameters . AddWithValue ( "$ca" , DateTime . UtcNow . ToString ( "O" ) ) ;
132+ bump . Parameters . AddWithValue ( "$id" , ( long ) existingId ) ;
133+ bump . ExecuteNonQuery ( ) ;
134+ return ;
135+ }
136+
137+ // New item — encrypt and insert.
138+ var ( ciphertext , iv , tag ) = _encryption . Encrypt ( plaintext ) ;
103139 using var cmd = _connection . CreateCommand ( ) ;
104140 cmd . CommandText = """
105- INSERT INTO clipboard_items (ciphertext, iv, tag, created_at, source_app, is_pinned, content_type)
106- VALUES ($ct, $iv, $tag, $ca, $sa, 0, 'text');
141+ INSERT INTO clipboard_items (ciphertext, iv, tag, created_at, source_app, is_pinned, content_type, content_hash )
142+ VALUES ($ct, $iv, $tag, $ca, $sa, 0, 'text', $hash );
107143 """ ;
108144 cmd . Parameters . AddWithValue ( "$ct" , ciphertext ) ;
109145 cmd . Parameters . AddWithValue ( "$iv" , iv ) ;
110146 cmd . Parameters . AddWithValue ( "$tag" , tag ) ;
111147 cmd . Parameters . AddWithValue ( "$ca" , DateTime . UtcNow . ToString ( "O" ) ) ;
112148 cmd . Parameters . AddWithValue ( "$sa" , sourceApp is null ? DBNull . Value : ( object ) sourceApp ) ;
149+ cmd . Parameters . AddWithValue ( "$hash" , hash ) ;
113150 cmd . ExecuteNonQuery ( ) ;
114151
115152 PurgeOverLimitUnlocked ( MaxHistoryCount ) ;
116153 }
117154 finally { _lock . Release ( ) ; }
118155 }
119156
157+ private static string ComputeHash ( string plaintext )
158+ {
159+ var bytes = System . Text . Encoding . UTF8 . GetBytes ( plaintext ) ;
160+ return Convert . ToHexString ( System . Security . Cryptography . SHA256 . HashData ( bytes ) ) ;
161+ }
162+
120163 /// <inheritdoc/>
121164 public async Task AddImageAsync ( byte [ ] imageBytes , string ? sourceApp = null , string ? ocrText = null )
122165 {
0 commit comments