@tb.p/dd 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ import sqlite3 from 'sqlite3';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+
5
+ /**
6
+ * Database connection manager for the file deduplication tool
7
+ * Handles SQLite database initialization, connection, and cleanup
8
+ */
9
+ class DatabaseConnection {
10
+ constructor(dbPath) {
11
+ this.dbPath = dbPath;
12
+ this.db = null;
13
+ this.isConnected = false;
14
+ }
15
+
16
+ /**
17
+ * Initialize database connection and create tables if needed
18
+ * @returns {Promise<void>}
19
+ */
20
+ async connect() {
21
+ return new Promise((resolve, reject) => {
22
+ try {
23
+ // Ensure directory exists
24
+ const dir = path.dirname(this.dbPath);
25
+ if (!fs.existsSync(dir)) {
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ }
28
+
29
+ this.db = new sqlite3.Database(this.dbPath, (err) => {
30
+ if (err) {
31
+ reject(new Error(`Failed to connect to database: ${err.message}`));
32
+ return;
33
+ }
34
+
35
+ this.isConnected = true;
36
+ resolve();
37
+ });
38
+
39
+ // Enable foreign key constraints
40
+ this.db.run('PRAGMA foreign_keys = ON');
41
+
42
+ // Create tables if they don't exist
43
+ this.createTables()
44
+ .then(() => resolve())
45
+ .catch(reject);
46
+
47
+ } catch (error) {
48
+ reject(error);
49
+ }
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Create database tables if they don't exist
55
+ * @returns {Promise<void>}
56
+ */
57
+ async createTables() {
58
+ return new Promise((resolve, reject) => {
59
+ const createTablesSQL = `
60
+ -- Meta table for storing configuration parameters
61
+ CREATE TABLE IF NOT EXISTS meta (
62
+ key TEXT PRIMARY KEY,
63
+ value TEXT NOT NULL
64
+ );
65
+
66
+ -- Copies table for storing file paths and their metadata
67
+ CREATE TABLE IF NOT EXISTS copies (
68
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ dir_group TEXT,
70
+ file_path TEXT NOT NULL,
71
+ file_name TEXT NOT NULL,
72
+ file_extension TEXT,
73
+ file_size INTEGER NOT NULL,
74
+ file_hash TEXT,
75
+ active BOOLEAN DEFAULT 1,
76
+ priority INTEGER DEFAULT 0
77
+ );
78
+ `;
79
+
80
+ this.db.exec(createTablesSQL, (err) => {
81
+ if (err) {
82
+ reject(new Error(`Failed to create tables: ${err.message}`));
83
+ return;
84
+ }
85
+
86
+ // Create indexes for better performance
87
+ this.createIndexes()
88
+ .then(() => resolve())
89
+ .catch(reject);
90
+ });
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Create database indexes for better performance
96
+ * @returns {Promise<void>}
97
+ */
98
+ async createIndexes() {
99
+ return new Promise((resolve, reject) => {
100
+ const createIndexesSQL = `
101
+ CREATE INDEX IF NOT EXISTS idx_copies_file_hash ON copies(file_hash);
102
+ CREATE INDEX IF NOT EXISTS idx_copies_file_path ON copies(file_path);
103
+ CREATE INDEX IF NOT EXISTS idx_copies_file_name ON copies(file_name);
104
+ CREATE INDEX IF NOT EXISTS idx_copies_dir_group ON copies(dir_group);
105
+ CREATE INDEX IF NOT EXISTS idx_copies_extension ON copies(file_extension);
106
+ `;
107
+
108
+ this.db.exec(createIndexesSQL, (err) => {
109
+ if (err) {
110
+ reject(new Error(`Failed to create indexes: ${err.message}`));
111
+ return;
112
+ }
113
+ resolve();
114
+ });
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Get the database instance
120
+ * @returns {sqlite3.Database|null}
121
+ */
122
+ getDatabase() {
123
+ if (!this.isConnected) {
124
+ throw new Error('Database not connected. Call connect() first.');
125
+ }
126
+ return this.db;
127
+ }
128
+
129
+ /**
130
+ * Execute a query with parameters
131
+ * @param {string} sql - SQL query
132
+ * @param {Array} params - Query parameters
133
+ * @returns {Promise<Array>} Query results
134
+ */
135
+ async query(sql, params = []) {
136
+ return new Promise((resolve, reject) => {
137
+ this.db.all(sql, params, (err, rows) => {
138
+ if (err) {
139
+ reject(new Error(`Query failed: ${err.message}`));
140
+ return;
141
+ }
142
+ resolve(rows);
143
+ });
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Execute a single query with parameters
149
+ * @param {string} sql - SQL query
150
+ * @param {Array} params - Query parameters
151
+ * @returns {Promise<Object>} Single query result
152
+ */
153
+ async queryOne(sql, params = []) {
154
+ return new Promise((resolve, reject) => {
155
+ this.db.get(sql, params, (err, row) => {
156
+ if (err) {
157
+ reject(new Error(`Query failed: ${err.message}`));
158
+ return;
159
+ }
160
+ resolve(row);
161
+ });
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Execute a statement (INSERT, UPDATE, DELETE)
167
+ * @param {string} sql - SQL statement
168
+ * @param {Array} params - Statement parameters
169
+ * @returns {Promise<Object>} Statement result with lastID and changes
170
+ */
171
+ async run(sql, params = []) {
172
+ return new Promise((resolve, reject) => {
173
+ this.db.run(sql, params, function(err) {
174
+ if (err) {
175
+ reject(new Error(`Statement failed: ${err.message}`));
176
+ return;
177
+ }
178
+ resolve({
179
+ lastID: this.lastID,
180
+ changes: this.changes
181
+ });
182
+ });
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Begin a transaction
188
+ * @returns {Promise<void>}
189
+ */
190
+ async beginTransaction() {
191
+ return new Promise((resolve, reject) => {
192
+ this.db.run('BEGIN TRANSACTION', (err) => {
193
+ if (err) {
194
+ reject(new Error(`Failed to begin transaction: ${err.message}`));
195
+ return;
196
+ }
197
+ resolve();
198
+ });
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Commit a transaction
204
+ * @returns {Promise<void>}
205
+ */
206
+ async commit() {
207
+ return new Promise((resolve, reject) => {
208
+ this.db.run('COMMIT', (err) => {
209
+ if (err) {
210
+ reject(new Error(`Failed to commit transaction: ${err.message}`));
211
+ return;
212
+ }
213
+ resolve();
214
+ });
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Rollback a transaction
220
+ * @returns {Promise<void>}
221
+ */
222
+ async rollback() {
223
+ return new Promise((resolve, reject) => {
224
+ this.db.run('ROLLBACK', (err) => {
225
+ if (err) {
226
+ reject(new Error(`Failed to rollback transaction: ${err.message}`));
227
+ return;
228
+ }
229
+ resolve();
230
+ });
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Close database connection
236
+ * @returns {Promise<void>}
237
+ */
238
+ async close() {
239
+ return new Promise((resolve, reject) => {
240
+ if (!this.db) {
241
+ resolve();
242
+ return;
243
+ }
244
+
245
+ this.db.close((err) => {
246
+ if (err) {
247
+ reject(new Error(`Failed to close database: ${err.message}`));
248
+ return;
249
+ }
250
+
251
+ this.isConnected = false;
252
+ this.db = null;
253
+ resolve();
254
+ });
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Check if database file exists
260
+ * @returns {boolean}
261
+ */
262
+ exists() {
263
+ return fs.existsSync(this.dbPath);
264
+ }
265
+
266
+ /**
267
+ * Get database file size
268
+ * @returns {number} File size in bytes
269
+ */
270
+ getFileSize() {
271
+ if (!this.exists()) {
272
+ return 0;
273
+ }
274
+ const stats = fs.statSync(this.dbPath);
275
+ return stats.size;
276
+ }
277
+
278
+ /**
279
+ * Get database information
280
+ * @returns {Promise<Object>} Database info
281
+ */
282
+ async getInfo() {
283
+ if (!this.isConnected) {
284
+ throw new Error('Database not connected');
285
+ }
286
+
287
+ const [metaCount, copiesCount] = await Promise.all([
288
+ this.queryOne('SELECT COUNT(*) as count FROM meta'),
289
+ this.queryOne('SELECT COUNT(*) as count FROM copies')
290
+ ]);
291
+
292
+ return {
293
+ path: this.dbPath,
294
+ fileSize: this.getFileSize(),
295
+ exists: this.exists(),
296
+ connected: this.isConnected,
297
+ tables: {
298
+ meta: metaCount.count,
299
+ copies: copiesCount.count
300
+ }
301
+ };
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Create a new database connection
307
+ * @param {string} dbPath - Path to database file
308
+ * @returns {DatabaseConnection} Database connection instance
309
+ */
310
+ function createConnection(dbPath) {
311
+ return new DatabaseConnection(dbPath);
312
+ }
313
+
314
+ export { DatabaseConnection, createConnection };
@@ -0,0 +1,332 @@
1
+ import path from 'path';
2
+
3
+ class DatabaseOperations {
4
+ constructor(dbConnection) {
5
+ this.db = dbConnection;
6
+ }
7
+
8
+ async setMeta(key, value) {
9
+ try {
10
+ const result = await this.db.run(
11
+ 'INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)',
12
+ [key, value]
13
+ );
14
+ return { success: true, changes: result.changes };
15
+ } catch (error) {
16
+ return { success: false, error: error.message };
17
+ }
18
+ }
19
+
20
+
21
+ async getAllMeta() {
22
+ try {
23
+ const results = await this.db.query('SELECT key, value FROM meta');
24
+ const meta = {};
25
+ results.forEach(row => {
26
+ meta[row.key] = row.value;
27
+ });
28
+ return meta;
29
+ } catch (error) {
30
+ throw new Error(`Failed to get all meta values: ${error.message}`);
31
+ }
32
+ }
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+ async addCopy(fileInfo) {
41
+ try {
42
+ const {
43
+ path,
44
+ name,
45
+ extension,
46
+ size,
47
+ hash = null,
48
+ active = true,
49
+ priority = 0,
50
+ dirGroup = null
51
+ } = fileInfo;
52
+
53
+ const result = await this.db.run(
54
+ 'INSERT INTO copies (dir_group, file_path, file_name, file_extension, file_size, file_hash, active, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
55
+ [dirGroup, path, name, extension, size, hash, active, priority]
56
+ );
57
+ return { success: true, id: result.lastID, changes: result.changes };
58
+ } catch (error) {
59
+ return { success: false, error: error.message };
60
+ }
61
+ }
62
+
63
+
64
+
65
+
66
+ async getUnhashedCopies(options = {}) {
67
+ try {
68
+ const { limit, offset } = options;
69
+ let sql = 'SELECT * FROM copies WHERE file_hash IS NULL ORDER BY file_path';
70
+
71
+ if (limit) {
72
+ sql += ` LIMIT ${limit}`;
73
+ if (offset) {
74
+ sql += ` OFFSET ${offset}`;
75
+ }
76
+ }
77
+
78
+ const results = await this.db.query(sql);
79
+ return results;
80
+ } catch (error) {
81
+ throw new Error(`Failed to get unhashed copies: ${error.message}`);
82
+ }
83
+ }
84
+
85
+ async updateFileHash(copyId, hash) {
86
+ try {
87
+ const result = await this.db.run(
88
+ 'UPDATE copies SET file_hash = ? WHERE id = ?',
89
+ [hash, copyId]
90
+ );
91
+ return { success: true, changes: result.changes };
92
+ } catch (error) {
93
+ return { success: false, error: error.message };
94
+ }
95
+ }
96
+
97
+
98
+
99
+
100
+ async getDuplicateGroups(options = {}) {
101
+ try {
102
+ const sql = `
103
+ SELECT
104
+ file_hash,
105
+ file_size,
106
+ COUNT(id) as duplicate_count,
107
+ GROUP_CONCAT(file_path, ';') as file_paths
108
+ FROM copies
109
+ WHERE file_hash IS NOT NULL AND active = 1
110
+ GROUP BY file_hash
111
+ HAVING COUNT(id) > 1
112
+ ORDER BY COUNT(id) DESC, file_size DESC
113
+ `;
114
+
115
+ const results = await this.db.query(sql);
116
+ return results;
117
+ } catch (error) {
118
+ throw new Error(`Failed to get duplicate groups: ${error.message}`);
119
+ }
120
+ }
121
+
122
+
123
+ async getStatistics() {
124
+ try {
125
+ const [metaCount, copiesCount, unhashedCount, duplicateGroups, uniqueFiles] = await Promise.all([
126
+ this.db.queryOne('SELECT COUNT(*) as count FROM meta'),
127
+ this.db.queryOne('SELECT COUNT(*) as count FROM copies'),
128
+ this.db.queryOne('SELECT COUNT(*) as count FROM copies WHERE file_hash IS NULL'),
129
+ this.db.queryOne(`
130
+ SELECT COUNT(*) as count FROM (
131
+ SELECT file_hash FROM copies
132
+ WHERE file_hash IS NOT NULL
133
+ GROUP BY file_hash
134
+ HAVING COUNT(*) > 1
135
+ )
136
+ `),
137
+ this.db.queryOne('SELECT COUNT(DISTINCT file_size) as count FROM copies WHERE file_hash IS NULL')
138
+ ]);
139
+
140
+ const totalSize = await this.db.queryOne(`
141
+ SELECT SUM(file_size) as total_size FROM copies
142
+ `);
143
+
144
+ const duplicateSize = await this.db.queryOne(`
145
+ SELECT SUM(file_size * (count - 1)) as duplicate_size FROM (
146
+ SELECT file_hash, file_size, COUNT(*) as count FROM copies
147
+ WHERE file_hash IS NOT NULL
148
+ GROUP BY file_hash
149
+ HAVING COUNT(*) > 1
150
+ )
151
+ `);
152
+
153
+ return {
154
+ meta: metaCount.count,
155
+ copies: copiesCount.count,
156
+ unhashed: unhashedCount.count,
157
+ uniqueFiles: uniqueFiles.count,
158
+ duplicateGroups: duplicateGroups.count,
159
+ totalSize: totalSize.total_size || 0,
160
+ duplicateSize: duplicateSize.duplicate_size || 0,
161
+ spaceSavings: duplicateSize.duplicate_size || 0
162
+ };
163
+ } catch (error) {
164
+ throw new Error(`Failed to get statistics: ${error.message}`);
165
+ }
166
+ }
167
+
168
+ async getAllCopies() {
169
+ try {
170
+ const results = await this.db.query('SELECT * FROM copies ORDER BY file_path');
171
+ return results;
172
+ } catch (error) {
173
+ throw new Error(`Failed to get all copies: ${error.message}`);
174
+ }
175
+ }
176
+
177
+ async getExistingFilePaths() {
178
+ try {
179
+ const results = await this.db.query('SELECT file_path FROM copies');
180
+ return new Set(results.map(row => row.file_path));
181
+ } catch (error) {
182
+ throw new Error(`Failed to get existing file paths: ${error.message}`);
183
+ }
184
+ }
185
+
186
+
187
+ async setAllActive(active) {
188
+ try {
189
+ const result = await this.db.run(
190
+ 'UPDATE copies SET active = ?',
191
+ [active]
192
+ );
193
+ return { success: true, changes: result.changes };
194
+ } catch (error) {
195
+ return { success: false, error: error.message };
196
+ }
197
+ }
198
+
199
+ async setActiveByPath(filePath, active) {
200
+ try {
201
+ const result = await this.db.run(
202
+ 'UPDATE copies SET active = ? WHERE file_path = ?',
203
+ [active, filePath]
204
+ );
205
+ return { success: true, changes: result.changes };
206
+ } catch (error) {
207
+ return { success: false, error: error.message };
208
+ }
209
+ }
210
+
211
+
212
+ async getActiveFiles() {
213
+ try {
214
+ const results = await this.db.query('SELECT * FROM copies WHERE active = 1 ORDER BY file_path');
215
+ return results;
216
+ } catch (error) {
217
+ throw new Error(`Failed to get active files: ${error.message}`);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Step 1: Get files with duplicates based on file size, excluding inactive and already-hashed files
223
+ * @returns {Promise<Array>} Array of file size groups with potential duplicates
224
+ */
225
+ async getFilesWithSizeDuplicates() {
226
+ try {
227
+ const sql = `
228
+ SELECT
229
+ file_size,
230
+ COUNT(id) as count,
231
+ GROUP_CONCAT(id, ',') as file_ids,
232
+ GROUP_CONCAT(file_path, '|') as file_paths
233
+ FROM copies
234
+ WHERE active = 1 AND file_hash IS NULL
235
+ GROUP BY file_size
236
+ HAVING COUNT(id) > 1
237
+ ORDER BY file_size DESC
238
+ `;
239
+
240
+ const results = await this.db.query(sql);
241
+ return results.map(row => ({
242
+ size: row.file_size,
243
+ count: parseInt(row.count),
244
+ fileIds: row.file_ids ? row.file_ids.split(',').map(id => parseInt(id)) : [],
245
+ filePaths: row.file_paths ? row.file_paths.split('|') : []
246
+ }));
247
+ } catch (error) {
248
+ throw new Error(`Failed to get files with size duplicates: ${error.message}`);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Step 2: Get active files grouped by hash to find actual duplicates
254
+ * @returns {Promise<Array>} Array of hash groups with duplicates
255
+ */
256
+ async getActiveFilesGroupedByHash() {
257
+ try {
258
+ const sql = `
259
+ SELECT
260
+ file_hash,
261
+ file_size,
262
+ COUNT(id) as count,
263
+ GROUP_CONCAT(id, ',') as file_ids,
264
+ GROUP_CONCAT(file_path, '|') as file_paths
265
+ FROM copies
266
+ WHERE active = 1 AND file_hash IS NOT NULL
267
+ GROUP BY file_hash
268
+ HAVING COUNT(id) > 1
269
+ ORDER BY file_size DESC, COUNT(id) DESC
270
+ `;
271
+
272
+ const results = await this.db.query(sql);
273
+ return results.map(row => ({
274
+ hash: row.file_hash,
275
+ size: row.file_size,
276
+ count: parseInt(row.count),
277
+ fileIds: row.file_ids ? row.file_ids.split(',').map(id => parseInt(id)) : [],
278
+ filePaths: row.file_paths ? row.file_paths.split('|') : []
279
+ }));
280
+ } catch (error) {
281
+ throw new Error(`Failed to get active files grouped by hash: ${error.message}`);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Get statistics for resume process
287
+ * @returns {Promise<Object>} Resume statistics
288
+ */
289
+ async getResumeStatistics() {
290
+ try {
291
+ const [totalFiles, activeFiles, unhashedActiveFiles, sizeDuplicates, hashDuplicates] = await Promise.all([
292
+ this.db.queryOne('SELECT COUNT(*) as count FROM copies'),
293
+ this.db.queryOne('SELECT COUNT(*) as count FROM copies WHERE active = 1'),
294
+ this.db.queryOne('SELECT COUNT(*) as count FROM copies WHERE active = 1 AND file_hash IS NULL'),
295
+ this.db.queryOne(`
296
+ SELECT COUNT(*) as count FROM (
297
+ SELECT file_size FROM copies
298
+ WHERE active = 1 AND file_hash IS NULL
299
+ GROUP BY file_size
300
+ HAVING COUNT(*) > 1
301
+ )
302
+ `),
303
+ this.db.queryOne(`
304
+ SELECT COUNT(*) as count FROM (
305
+ SELECT file_hash FROM copies
306
+ WHERE active = 1 AND file_hash IS NOT NULL
307
+ GROUP BY file_hash
308
+ HAVING COUNT(*) > 1
309
+ )
310
+ `)
311
+ ]);
312
+
313
+ return {
314
+ totalFiles: totalFiles.count,
315
+ activeFiles: activeFiles.count,
316
+ unhashedActiveFiles: unhashedActiveFiles.count,
317
+ sizeDuplicateGroups: sizeDuplicates.count,
318
+ hashDuplicateGroups: hashDuplicates.count
319
+ };
320
+ } catch (error) {
321
+ throw new Error(`Failed to get resume statistics: ${error.message}`);
322
+ }
323
+ }
324
+
325
+
326
+ }
327
+
328
+ function createOperations(dbConnection) {
329
+ return new DatabaseOperations(dbConnection);
330
+ }
331
+
332
+ export { DatabaseOperations, createOperations };