Skip to content

Commit 0b630fd

Browse files
committed
feat: add file handling capabilities to clipboard manager and UI components
1 parent cc35167 commit 0b630fd

9 files changed

Lines changed: 323 additions & 18 deletions

File tree

src/main/clipboardManager.ts

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export class ClipboardManager {
99
private history: ClipboardItem[] = [];
1010
private lastClipboardText = "";
1111
private lastClipboardImage = "";
12+
private lastClipboardFile = "";
1213
private intervalId: NodeJS.Timeout | null = null;
1314
private window: BrowserWindow | null = null;
1415
private db: DatabaseManager;
@@ -44,6 +45,8 @@ export class ClipboardManager {
4445
this.lastClipboardText = lastItem.text;
4546
} else if (lastItem && lastItem.type === "image" && lastItem.image) {
4647
this.lastClipboardImage = lastItem.image;
48+
} else if (lastItem && lastItem.type === "file" && lastItem.filePath) {
49+
this.lastClipboardFile = lastItem.filePath;
4750
}
4851
}
4952
} catch (error) {
@@ -68,6 +71,121 @@ export class ClipboardManager {
6871

6972
start() {
7073
this.intervalId = setInterval(async () => {
74+
const availableFormats = clipboard.availableFormats();
75+
76+
// Check for files FIRST (PDFs and other files have image previews, so check file path before image)
77+
try {
78+
if (
79+
process.platform === "darwin" &&
80+
clipboard.has("public.file-url")
81+
) {
82+
const buffer = clipboard.readBuffer("public.file-url");
83+
if (buffer && buffer.length > 0) {
84+
// Convert buffer to file path (remove file:// prefix and decode URI)
85+
let filePath = buffer.toString("utf8").trim();
86+
87+
// Remove any null bytes
88+
filePath = filePath.replace(/\0/g, "");
89+
// Decode URL encoding
90+
if (filePath.startsWith("file://")) {
91+
filePath = decodeURIComponent(filePath.substring(7));
92+
}
93+
94+
// Skip temporary file paths - look for real path in NSFilenamesPboardType
95+
if (filePath.startsWith("/.file/")) {
96+
let realFilePath = null;
97+
98+
// Try macOS NSFilenamesPboardType (contains real file path as XML plist)
99+
if (clipboard.has("NSFilenamesPboardType")) {
100+
try {
101+
const buffer = clipboard.readBuffer("NSFilenamesPboardType");
102+
if (buffer && buffer.length > 0) {
103+
const xmlContent = buffer.toString("utf8");
104+
// Parse the XML plist to extract file path
105+
// Format: <array><string>/path/to/file.pdf</string></array>
106+
const stringMatch = xmlContent.match(/<string>([^<]+)<\/string>/);
107+
if (stringMatch && stringMatch[1]) {
108+
realFilePath = stringMatch[1].trim();
109+
}
110+
}
111+
} catch (error) {
112+
log.error("Error reading NSFilenamesPboardType:", error);
113+
}
114+
}
115+
116+
if (realFilePath) {
117+
if (realFilePath !== this.lastClipboardFile) {
118+
this.lastClipboardFile = realFilePath;
119+
const fileName = realFilePath.split("/").pop() || realFilePath;
120+
121+
const item: ClipboardItem = {
122+
type: "file",
123+
filePath: realFilePath,
124+
fileName,
125+
timestamp: Date.now(),
126+
embedding: [],
127+
};
128+
129+
try {
130+
const id = this.db.addItem(item);
131+
item.id = id;
132+
this.history.unshift(item);
133+
134+
if (this.window) {
135+
this.window.webContents.send("clipboard-update", item);
136+
}
137+
log.info(`File saved: ${fileName}`);
138+
} catch (error) {
139+
log.error("Failed to save file to database:", error);
140+
this.history.unshift(item);
141+
if (this.window) {
142+
this.window.webContents.send("clipboard-update", item);
143+
}
144+
}
145+
return;
146+
}
147+
}
148+
// Could not find real file path, skip to avoid saving preview image
149+
return;
150+
} else if (filePath && filePath !== this.lastClipboardFile) {
151+
this.lastClipboardFile = filePath;
152+
const fileName = filePath.split("/").pop() || filePath;
153+
154+
const item: ClipboardItem = {
155+
type: "file",
156+
filePath,
157+
fileName,
158+
timestamp: Date.now(),
159+
embedding: [],
160+
};
161+
162+
try {
163+
const id = this.db.addItem(item);
164+
item.id = id;
165+
this.history.unshift(item);
166+
167+
if (this.window) {
168+
this.window.webContents.send("clipboard-update", item);
169+
}
170+
log.info(`File saved: ${fileName}`);
171+
} catch (error) {
172+
log.error("Failed to save file to database:", error);
173+
this.history.unshift(item);
174+
if (this.window) {
175+
this.window.webContents.send("clipboard-update", item);
176+
}
177+
}
178+
return;
179+
} else if (filePath) {
180+
return;
181+
}
182+
}
183+
}
184+
} catch (error) {
185+
log.error("Failed to read file from clipboard:", error);
186+
}
187+
188+
// No file detected, now check for images
71189
const image = clipboard.readImage();
72190

73191
if (!image.isEmpty()) {
@@ -91,16 +209,17 @@ export class ClipboardManager {
91209
if (this.window) {
92210
this.window.webContents.send("clipboard-update", item);
93211
}
212+
log.info("Image saved");
94213
} catch (error) {
95214
log.error("Failed to save image to database:", error);
96-
// Still add to memory even if DB fails
97215
this.history.unshift(item);
98216
if (this.window) {
99217
this.window.webContents.send("clipboard-update", item);
100218
}
101219
}
102220
}
103221
} else {
222+
// No image, check for text
104223
const text = clipboard.readText();
105224
const trimmedText = text.trim();
106225

@@ -121,7 +240,6 @@ export class ClipboardManager {
121240
embedding,
122241
};
123242

124-
// Save to database
125243
try {
126244
const id = this.db.addItem(item);
127245
item.id = id;
@@ -130,9 +248,9 @@ export class ClipboardManager {
130248
if (this.window) {
131249
this.window.webContents.send("clipboard-update", item);
132250
}
251+
log.info("Text saved");
133252
} catch (error) {
134253
log.error("Failed to save text to database:", error);
135-
// Still add to memory even if DB fails
136254
this.history.unshift(item);
137255
if (this.window) {
138256
this.window.webContents.send("clipboard-update", item);

src/main/database.ts

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export class DatabaseManager {
5757
type TEXT NOT NULL,
5858
text TEXT,
5959
image TEXT,
60+
file_path TEXT,
61+
file_name TEXT,
6062
timestamp INTEGER NOT NULL,
6163
embedding BLOB,
6264
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -71,6 +73,8 @@ export class DatabaseManager {
7173
type TEXT NOT NULL,
7274
text TEXT,
7375
image TEXT,
76+
file_path TEXT,
77+
file_name TEXT,
7478
timestamp INTEGER NOT NULL,
7579
embedding BLOB,
7680
archived_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -81,15 +85,45 @@ export class DatabaseManager {
8185
CREATE INDEX IF NOT EXISTS idx_archived_at ON archived_items(archived_at DESC);
8286
CREATE INDEX IF NOT EXISTS idx_archived_type ON archived_items(type);
8387
`);
88+
89+
// Add columns to existing tables if they don't exist
90+
try {
91+
this.db.exec(`ALTER TABLE clipboard_items ADD COLUMN file_path TEXT`);
92+
log.info("Added file_path column to clipboard_items");
93+
} catch (error) {
94+
// Column already exists, ignore
95+
}
96+
97+
try {
98+
this.db.exec(`ALTER TABLE clipboard_items ADD COLUMN file_name TEXT`);
99+
log.info("Added file_name column to clipboard_items");
100+
} catch (error) {
101+
// Column already exists, ignore
102+
}
103+
104+
try {
105+
this.db.exec(`ALTER TABLE archived_items ADD COLUMN file_path TEXT`);
106+
log.info("Added file_path column to archived_items");
107+
} catch (error) {
108+
// Column already exists, ignore
109+
}
110+
111+
try {
112+
this.db.exec(`ALTER TABLE archived_items ADD COLUMN file_name TEXT`);
113+
log.info("Added file_name column to archived_items");
114+
} catch (error) {
115+
// Column already exists, ignore
116+
}
117+
84118
log.info("Database tables initialized");
85119
}
86120

87121
addItem(item: Omit<ClipboardItem, "id">): number {
88122
// If embedding exists, insert with vec_f32, otherwise insert without it
89123
if (item.embedding && item.embedding.length > 0) {
90124
const stmt = this.db.prepare(`
91-
INSERT INTO clipboard_items (type, text, image, timestamp, embedding)
92-
VALUES (?, ?, ?, ?, vec_f32(?))
125+
INSERT INTO clipboard_items (type, text, image, file_path, file_name, timestamp, embedding)
126+
VALUES (?, ?, ?, ?, ?, ?, vec_f32(?))
93127
`);
94128

95129
const embeddingBlob = Buffer.from(
@@ -100,21 +134,25 @@ export class DatabaseManager {
100134
item.type,
101135
item.text || null,
102136
item.image || null,
137+
item.filePath || null,
138+
item.fileName || null,
103139
item.timestamp,
104140
embeddingBlob
105141
);
106142

107143
return result.lastInsertRowid as number;
108144
} else {
109145
const stmt = this.db.prepare(`
110-
INSERT INTO clipboard_items (type, text, image, timestamp)
111-
VALUES (?, ?, ?, ?)
146+
INSERT INTO clipboard_items (type, text, image, file_path, file_name, timestamp)
147+
VALUES (?, ?, ?, ?, ?, ?)
112148
`);
113149

114150
const result = stmt.run(
115151
item.type,
116152
item.text || null,
117153
item.image || null,
154+
item.filePath || null,
155+
item.fileName || null,
118156
item.timestamp
119157
);
120158

@@ -124,7 +162,7 @@ export class DatabaseManager {
124162

125163
getItems(limit: number = 1000, offset: number = 0): ClipboardItem[] {
126164
const stmt = this.db.prepare(`
127-
SELECT id, type, text, image, timestamp
165+
SELECT id, type, text, image, file_path as filePath, file_name as fileName, timestamp
128166
FROM clipboard_items
129167
ORDER BY timestamp DESC
130168
LIMIT ? OFFSET ?
@@ -135,7 +173,7 @@ export class DatabaseManager {
135173

136174
getItemById(id: number): ClipboardItem | undefined {
137175
const stmt = this.db.prepare(`
138-
SELECT id, type, text, image, timestamp
176+
SELECT id, type, text, image, file_path as filePath, file_name as fileName, timestamp
139177
FROM clipboard_items
140178
WHERE id = ?
141179
`);
@@ -172,7 +210,7 @@ export class DatabaseManager {
172210

173211
searchItems(query: string, limit: number = 100): ClipboardItem[] {
174212
const stmt = this.db.prepare(`
175-
SELECT id, type, text, image, timestamp
213+
SELECT id, type, text, image, file_path as filePath, file_name as fileName, timestamp
176214
FROM clipboard_items
177215
WHERE text LIKE ?
178216
ORDER BY timestamp DESC
@@ -197,6 +235,8 @@ export class DatabaseManager {
197235
type,
198236
text,
199237
image,
238+
file_path as filePath,
239+
file_name as fileName,
200240
timestamp,
201241
vec_distance_cosine(embedding, vec_f32(?)) as distance
202242
FROM clipboard_items
@@ -212,8 +252,8 @@ export class DatabaseManager {
212252
const cutoffTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
213253

214254
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
255+
INSERT INTO archived_items (original_id, type, text, image, file_path, file_name, timestamp, embedding, created_at)
256+
SELECT id, type, text, image, file_path, file_name, timestamp, embedding, created_at
217257
FROM clipboard_items
218258
WHERE timestamp < ?
219259
`);
@@ -235,7 +275,7 @@ export class DatabaseManager {
235275

236276
getArchivedItems(limit: number = 1000, offset: number = 0): ClipboardItem[] {
237277
const stmt = this.db.prepare(`
238-
SELECT id, type, text, image, timestamp
278+
SELECT id, type, text, image, file_path as filePath, file_name as fileName, timestamp
239279
FROM archived_items
240280
ORDER BY timestamp DESC
241281
LIMIT ? OFFSET ?
@@ -246,8 +286,8 @@ export class DatabaseManager {
246286

247287
unarchiveItem(id: number): boolean {
248288
const insertStmt = this.db.prepare(`
249-
INSERT INTO clipboard_items (type, text, image, timestamp, embedding)
250-
SELECT type, text, image, timestamp, embedding
289+
INSERT INTO clipboard_items (type, text, image, file_path, file_name, timestamp, embedding)
290+
SELECT type, text, image, file_path, file_name, timestamp, embedding
251291
FROM archived_items
252292
WHERE id = ?
253293
`);
@@ -306,6 +346,8 @@ export class DatabaseManager {
306346
type,
307347
text,
308348
image,
349+
file_path as filePath,
350+
file_name as fileName,
309351
timestamp,
310352
vec_distance_cosine(embedding, vec_f32(?)) as distance
311353
FROM archived_items

src/main/main.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,28 @@ ipcMain.handle("open-image-in-viewer", async (_event, dataURL: string) => {
328328
}
329329
});
330330

331+
ipcMain.handle("open-file-by-path", async (_event, filePath: string) => {
332+
try {
333+
if (!filePath || typeof filePath !== "string") {
334+
throw new Error("Invalid file path: must be a non-empty string");
335+
}
336+
337+
log.info("Opening file:", filePath);
338+
339+
// Open the file with the default application
340+
const result = await shell.openPath(filePath);
341+
if (result) {
342+
log.error("Failed to open file:", result);
343+
throw new Error(result);
344+
}
345+
346+
log.info("File opened successfully");
347+
} catch (error) {
348+
log.error("Failed to open file:", error);
349+
throw error;
350+
}
351+
});
352+
331353
ipcMain.handle("get-config", () => {
332354
return (
333355
configManager?.getConfig() || {

src/main/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
1919
ipcRenderer.invoke("open-external-url", url),
2020
openImageInViewer: (dataURL: string) =>
2121
ipcRenderer.invoke("open-image-in-viewer", dataURL),
22+
openFileByPath: (filePath: string) =>
23+
ipcRenderer.invoke("open-file-by-path", filePath),
2224
getConfig: () => ipcRenderer.invoke("get-config"),
2325
setGlobalShortcut: (shortcut: string) =>
2426
ipcRenderer.invoke("set-global-shortcut", shortcut),

src/models/ClipboardItem.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
export interface ClipboardItem {
22
id?: number;
3-
type: "text" | "image";
3+
type: "text" | "image" | "file";
44
text?: string;
55
image?: string;
6+
filePath?: string;
7+
fileName?: string;
68
timestamp: number;
79
embedding?: number[];
810
}

0 commit comments

Comments
 (0)