Skip to content

Commit 8a6573f

Browse files
committed
feat: implement archive functionality with retention period management and UI updates
1 parent 57bab70 commit 8a6573f

14 files changed

Lines changed: 700 additions & 12 deletions

src/main/clipboardManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class ClipboardManager {
2828
this.loadHistoryFromDB();
2929
}
3030

31-
private loadHistoryFromDB() {
31+
public loadHistoryFromDB() {
3232
try {
3333
const stats = this.db.getStats();
3434
log.info(`Database stats:`, stats);

src/main/configManager.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ export interface AppConfig {
77
globalShortcut: string;
88
transparency: boolean;
99
openaiApiKey?: string;
10+
retentionPeriodDays: number;
1011
}
1112

1213
const DEFAULT_CONFIG: AppConfig = {
1314
globalShortcut: "CommandOrControl+Shift+V",
1415
transparency: true,
1516
openaiApiKey: undefined,
17+
retentionPeriodDays: 30,
1618
};
1719

1820
export class ConfigManager {
@@ -80,4 +82,17 @@ export class ConfigManager {
8082
this.config.openaiApiKey = apiKey.trim();
8183
this.saveConfig();
8284
}
85+
86+
getRetentionPeriodDays(): number {
87+
return this.config.retentionPeriodDays;
88+
}
89+
90+
setRetentionPeriodDays(days: number): void {
91+
// Validate: minimum 1 day, maximum 365 days
92+
if (days < 1 || days > 365) {
93+
throw new Error("Retention period must be between 1 and 365 days");
94+
}
95+
this.config.retentionPeriodDays = days;
96+
this.saveConfig();
97+
}
8398
}

src/main/database.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ export class DatabaseManager {
6464
6565
CREATE INDEX IF NOT EXISTS idx_timestamp ON clipboard_items(timestamp DESC);
6666
CREATE INDEX IF NOT EXISTS idx_type ON clipboard_items(type);
67+
68+
CREATE TABLE IF NOT EXISTS archived_items (
69+
id INTEGER PRIMARY KEY AUTOINCREMENT,
70+
original_id INTEGER NOT NULL,
71+
type TEXT NOT NULL,
72+
text TEXT,
73+
image TEXT,
74+
timestamp INTEGER NOT NULL,
75+
embedding BLOB,
76+
archived_at DATETIME DEFAULT CURRENT_TIMESTAMP,
77+
created_at DATETIME
78+
);
79+
80+
CREATE INDEX IF NOT EXISTS idx_archived_timestamp ON archived_items(timestamp DESC);
81+
CREATE INDEX IF NOT EXISTS idx_archived_at ON archived_items(archived_at DESC);
82+
CREATE INDEX IF NOT EXISTS idx_archived_type ON archived_items(type);
6783
`);
6884
log.info("Database tables initialized");
6985
}
@@ -192,6 +208,115 @@ export class DatabaseManager {
192208
return stmt.all(JSON.stringify(queryEmbedding), limit) as ClipboardItem[];
193209
}
194210

211+
archiveOldItems(retentionDays: number): number {
212+
const cutoffTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
213+
214+
const insertStmt = this.db.prepare(`
215+
INSERT INTO archived_items (original_id, type, text, image, timestamp, embedding, created_at)
216+
SELECT id, type, text, image, timestamp, embedding, created_at
217+
FROM clipboard_items
218+
WHERE timestamp < ?
219+
`);
220+
221+
const result = insertStmt.run(cutoffTimestamp);
222+
223+
if (result.changes > 0) {
224+
const deleteStmt = this.db.prepare(`
225+
DELETE FROM clipboard_items WHERE timestamp < ?
226+
`);
227+
deleteStmt.run(cutoffTimestamp);
228+
}
229+
230+
log.info(
231+
`Archived ${result.changes} items older than ${retentionDays} days`
232+
);
233+
return result.changes;
234+
}
235+
236+
getArchivedItems(limit: number = 1000, offset: number = 0): ClipboardItem[] {
237+
const stmt = this.db.prepare(`
238+
SELECT id, type, text, image, timestamp
239+
FROM archived_items
240+
ORDER BY timestamp DESC
241+
LIMIT ? OFFSET ?
242+
`);
243+
244+
return stmt.all(limit, offset) as ClipboardItem[];
245+
}
246+
247+
unarchiveItem(id: number): boolean {
248+
const insertStmt = this.db.prepare(`
249+
INSERT INTO clipboard_items (type, text, image, timestamp, embedding)
250+
SELECT type, text, image, timestamp, embedding
251+
FROM archived_items
252+
WHERE id = ?
253+
`);
254+
255+
const result = insertStmt.run(id);
256+
257+
if (result.changes > 0) {
258+
const deleteStmt = this.db.prepare(`
259+
DELETE FROM archived_items WHERE id = ?
260+
`);
261+
deleteStmt.run(id);
262+
log.info(`Unarchived item ${id}`);
263+
return true;
264+
}
265+
266+
return false;
267+
}
268+
269+
deleteArchivedItem(id: number): boolean {
270+
const stmt = this.db.prepare("DELETE FROM archived_items WHERE id = ?");
271+
const result = stmt.run(id);
272+
if (result.changes > 0) {
273+
log.info(`Permanently deleted archived item ${id}`);
274+
}
275+
return result.changes > 0;
276+
}
277+
278+
clearArchive(): void {
279+
this.db.exec("DELETE FROM archived_items");
280+
log.info("All archived items cleared");
281+
}
282+
283+
getArchiveStats(): { total: number; text: number; image: number } {
284+
const result = this.db
285+
.prepare(
286+
`
287+
SELECT
288+
COUNT(*) as total,
289+
SUM(CASE WHEN type = 'text' THEN 1 ELSE 0 END) as text,
290+
SUM(CASE WHEN type = 'image' THEN 1 ELSE 0 END) as image
291+
FROM archived_items
292+
`
293+
)
294+
.get() as { total: number; text: number; image: number };
295+
296+
return result;
297+
}
298+
299+
semanticSearchArchive(
300+
queryEmbedding: number[],
301+
limit: number = 10
302+
): ClipboardItem[] {
303+
const stmt = this.db.prepare(`
304+
SELECT
305+
id,
306+
type,
307+
text,
308+
image,
309+
timestamp,
310+
vec_distance_cosine(embedding, vec_f32(?)) as distance
311+
FROM archived_items
312+
WHERE embedding IS NOT NULL
313+
ORDER BY distance ASC
314+
LIMIT ?
315+
`);
316+
317+
return stmt.all(JSON.stringify(queryEmbedding), limit) as ClipboardItem[];
318+
}
319+
195320
close() {
196321
this.db.close();
197322
log.info("Database closed");

src/main/main.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,14 @@ function createTray() {
180180
}
181181
},
182182
},
183+
{
184+
label: "Archive",
185+
click: () => {
186+
win?.show();
187+
win?.focus();
188+
win?.webContents.send("navigate", "archive");
189+
},
190+
},
183191
{
184192
label: "Settings",
185193
click: () => {
@@ -330,6 +338,101 @@ ipcMain.handle("navigate-to", (_event, page: string) => {
330338
}
331339
});
332340

341+
// Archive-related IPC handlers
342+
ipcMain.handle(
343+
"get-archived-history",
344+
(_event, limit: number = 50, offset: number = 0) => {
345+
return databaseManager?.getArchivedItems(limit, offset) || [];
346+
}
347+
);
348+
349+
ipcMain.handle("unarchive-item", (_event, id: number) => {
350+
try {
351+
if (!databaseManager) {
352+
return { success: false, error: "Database not initialized" };
353+
}
354+
const success = databaseManager.unarchiveItem(id);
355+
if (success) {
356+
// Reload clipboard manager's history
357+
clipboardManager?.loadHistoryFromDB();
358+
}
359+
return { success };
360+
} catch (error) {
361+
log.error("Failed to unarchive item:", error);
362+
return { success: false, error: String(error) };
363+
}
364+
});
365+
366+
ipcMain.handle("delete-archived-item", (_event, id: number) => {
367+
try {
368+
if (!databaseManager) {
369+
return { success: false, error: "Database not initialized" };
370+
}
371+
const success = databaseManager.deleteArchivedItem(id);
372+
return { success };
373+
} catch (error) {
374+
log.error("Failed to delete archived item:", error);
375+
return { success: false, error: String(error) };
376+
}
377+
});
378+
379+
ipcMain.handle("clear-archive", () => {
380+
try {
381+
if (!databaseManager) {
382+
return { success: false, error: "Database not initialized" };
383+
}
384+
databaseManager.clearArchive();
385+
return { success: true };
386+
} catch (error) {
387+
log.error("Failed to clear archive:", error);
388+
return { success: false, error: String(error) };
389+
}
390+
});
391+
392+
ipcMain.handle("set-retention-period", (_event, days: number) => {
393+
try {
394+
configManager?.setRetentionPeriodDays(days);
395+
return { success: true };
396+
} catch (error) {
397+
log.error("Failed to set retention period:", error);
398+
return { success: false, error: String(error) };
399+
}
400+
});
401+
402+
ipcMain.handle("archive-old-items", () => {
403+
try {
404+
if (!databaseManager || !configManager) {
405+
return { success: false, error: "Services not initialized" };
406+
}
407+
const retentionDays = configManager.getRetentionPeriodDays();
408+
const count = databaseManager.archiveOldItems(retentionDays);
409+
410+
// Reload clipboard manager to reflect changes
411+
clipboardManager?.loadHistoryFromDB();
412+
413+
return { success: true, count };
414+
} catch (error) {
415+
log.error("Failed to archive items:", error);
416+
return { success: false, error: String(error) };
417+
}
418+
});
419+
420+
ipcMain.handle(
421+
"semantic-search-archive",
422+
async (_event, query: string, limit: number = 10) => {
423+
try {
424+
if (!databaseManager || !embeddingService) {
425+
throw new Error("Services not initialized");
426+
}
427+
const queryEmbedding = await embeddingService.getEmbedding(query);
428+
return databaseManager.semanticSearchArchive(queryEmbedding, limit);
429+
} catch (error) {
430+
log.error("Failed to perform archive semantic search:", error);
431+
throw error;
432+
}
433+
}
434+
);
435+
333436
function registerGlobalShortcut(shortcut: string = "CommandOrControl+Shift+V") {
334437
globalShortcut.unregisterAll();
335438

@@ -371,6 +474,10 @@ app.whenReady().then(() => {
371474
databaseManager = new DatabaseManager();
372475
embeddingService = new EmbeddingService(configManager);
373476

477+
const retentionDays = configManager.getRetentionPeriodDays();
478+
const archivedCount = databaseManager.archiveOldItems(retentionDays);
479+
log.info(`Auto-archival complete: ${archivedCount} items archived`);
480+
374481
app.setLoginItemSettings({
375482
openAtLogin: true,
376483
});

src/main/preload.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
2424
ipcRenderer.invoke("set-openai-api-key", apiKey),
2525
clearHistory: () => ipcRenderer.invoke("clear-history"),
2626
navigate: (page: string) => ipcRenderer.invoke("navigate-to", page),
27+
// Archive methods
28+
getArchivedHistory: (limit: number, offset: number) =>
29+
ipcRenderer.invoke("get-archived-history", limit, offset),
30+
unarchiveItem: (id: number) => ipcRenderer.invoke("unarchive-item", id),
31+
deleteArchivedItem: (id: number) =>
32+
ipcRenderer.invoke("delete-archived-item", id),
33+
clearArchive: () => ipcRenderer.invoke("clear-archive"),
34+
setRetentionPeriod: (days: number) =>
35+
ipcRenderer.invoke("set-retention-period", days),
36+
archiveOldItems: () => ipcRenderer.invoke("archive-old-items"),
37+
semanticSearchArchive: (query: string, limit?: number) =>
38+
ipcRenderer.invoke("semantic-search-archive", query, limit),
2739
});

src/renderer/App.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { useState, useEffect } from "react";
22
import ClipboardHistory from "./pages/ClipboardHistory";
33
import Settings from "./pages/Settings";
4+
import Archive from "./pages/Archive";
45

56
export default function App() {
6-
const [currentPage, setCurrentPage] = useState<"history" | "settings">(
7+
const [currentPage, setCurrentPage] = useState<"history" | "settings" | "archive">(
78
"history"
89
);
910
const [isTransparent, setIsTransparent] = useState(false);
1011

1112
useEffect(() => {
1213
window.electronAPI.onNavigate((page) => {
13-
setCurrentPage(page as "history" | "settings");
14+
setCurrentPage(page as "history" | "settings" | "archive");
1415
});
1516
}, []);
1617

@@ -27,6 +28,7 @@ export default function App() {
2728
onTransparencyChange={setIsTransparent}
2829
/>
2930
)}
31+
{currentPage === "archive" && <Archive />}
3032
</>
3133
);
3234
}

src/renderer/components/ClipboardItem.css

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/* Modern Clipboard Item Card */
22
.history-item {
33
display: flex;
4-
gap: 0.875rem;
4+
gap: 0.75rem;
55
background: rgba(255, 255, 255, 0.06);
6-
padding: 1rem 1.25rem;
7-
border-radius: 12px;
6+
padding: 0.75rem 1rem;
7+
border-radius: 10px;
88
border: 1px solid rgba(255, 255, 255, 0.08);
99
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
1010
animation: itemSlideIn 0.3s ease-out backwards;
@@ -213,6 +213,28 @@
213213
color: #4ade80;
214214
}
215215

216+
.action-btn--unarchive {
217+
background: rgba(52, 199, 89, 0.1);
218+
color: #34C759;
219+
border-color: rgba(52, 199, 89, 0.3);
220+
}
221+
222+
.action-btn--unarchive:hover {
223+
background: rgba(52, 199, 89, 0.2);
224+
color: #4ade80;
225+
}
226+
227+
.action-btn--delete {
228+
background: rgba(255, 59, 48, 0.1);
229+
color: #FF3B30;
230+
border-color: rgba(255, 59, 48, 0.3);
231+
}
232+
233+
.action-btn--delete:hover {
234+
background: rgba(255, 59, 48, 0.2);
235+
color: #ff5a50;
236+
}
237+
216238
/* Code Block */
217239
.item-code {
218240
background: rgba(0, 0, 0, 0.4);

0 commit comments

Comments
 (0)