copyhub-cli 1.0.1 → 1.0.4
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.
- package/.env.example +3 -1
- package/README.md +73 -8
- package/package.json +1 -1
- package/src/cli.js +36 -3
- package/src/config.js +20 -0
- package/src/load-env.js +39 -0
- package/src/oauth.js +270 -3
- package/src/sheet-overlay-history.js +79 -0
- package/src/sheets.js +1 -1
- package/src/start-daemon-logic.js +4 -1
- package/src/storage.js +11 -0
- package/src/wipe-data.js +10 -0
- package/ui/main.mjs +272 -20
- package/ui/preload.cjs +4 -1
- package/ui/renderer/index.html +256 -7
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendHistory } from './storage.js';
|
|
1
|
+
import { appendHistory, isDuplicateOfLatestHistory } from './storage.js';
|
|
2
2
|
import { startClipboardWatcher } from './clipboard-watcher.js';
|
|
3
3
|
import { appendClipboardToSheet } from './sheets.js';
|
|
4
4
|
import { loadTokens } from './tokens.js';
|
|
@@ -73,6 +73,9 @@ export async function runCopyhubDaemon(opts, io = console) {
|
|
|
73
73
|
let lastSheetLogAt = 0;
|
|
74
74
|
|
|
75
75
|
const watcher = startClipboardWatcher(async (text) => {
|
|
76
|
+
if (isDuplicateOfLatestHistory(text)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
76
79
|
let synced = false;
|
|
77
80
|
if (useSheet && sheetTarget && (tokens?.refresh_token || tokens?.access_token)) {
|
|
78
81
|
try {
|
package/src/storage.js
CHANGED
|
@@ -57,3 +57,14 @@ export function readRecentHistorySync(maxLines = 200) {
|
|
|
57
57
|
}
|
|
58
58
|
return out.reverse();
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True when `text` equals the latest saved history row (skip consecutive identical copies).
|
|
63
|
+
* @param {string} text
|
|
64
|
+
*/
|
|
65
|
+
export function isDuplicateOfLatestHistory(text) {
|
|
66
|
+
if (typeof text !== 'string' || text.length === 0) return false;
|
|
67
|
+
const recent = readRecentHistorySync(1);
|
|
68
|
+
const last = recent[0];
|
|
69
|
+
return typeof last?.text === 'string' && last.text === text;
|
|
70
|
+
}
|
package/src/wipe-data.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { DIR } from './paths.js';
|
|
4
|
+
|
|
5
|
+
/** Delete the entire ~/.copyhub directory (all local CopyHub data). */
|
|
6
|
+
export async function wipeCopyhubDirectory() {
|
|
7
|
+
if (existsSync(DIR)) {
|
|
8
|
+
await rm(DIR, { recursive: true, force: true });
|
|
9
|
+
}
|
|
10
|
+
}
|
package/ui/main.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import 'dotenv/config';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
import { fileURLToPath } from 'node:url';
|
|
4
3
|
import {
|
|
@@ -12,8 +11,16 @@ import {
|
|
|
12
11
|
Menu,
|
|
13
12
|
nativeImage,
|
|
14
13
|
} from 'electron';
|
|
14
|
+
import { loadCopyhubEnv } from '../src/load-env.js';
|
|
15
15
|
import { readRecentHistorySync } from '../src/storage.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
loadOverlayAcceleratorFromConfigSync,
|
|
18
|
+
loadSheetSyncTarget,
|
|
19
|
+
} from '../src/config.js';
|
|
20
|
+
import { loadTokens } from '../src/tokens.js';
|
|
21
|
+
import { fetchOverlayDailyTabRows } from '../src/sheet-overlay-history.js';
|
|
22
|
+
|
|
23
|
+
loadCopyhubEnv();
|
|
17
24
|
|
|
18
25
|
const gotLock = app.requestSingleInstanceLock();
|
|
19
26
|
if (!gotLock) {
|
|
@@ -38,8 +45,9 @@ function normalizeAccelerator(raw) {
|
|
|
38
45
|
const DEFAULT_ACCEL = 'CommandOrControl+Shift+H';
|
|
39
46
|
const HIDE_ON_START = process.env.COPYHUB_OVERLAY_HIDE_ON_START === '1';
|
|
40
47
|
|
|
41
|
-
/** Overlay
|
|
42
|
-
const OVERLAY_WIDTH = Math.round(460 * 0.
|
|
48
|
+
/** Overlay size (slightly larger than earlier ~70% width). */
|
|
49
|
+
const OVERLAY_WIDTH = Math.round(460 * 0.84);
|
|
50
|
+
const OVERLAY_HEIGHT = 590;
|
|
43
51
|
|
|
44
52
|
/** For UI / IPC: registered shortcut and raw value from .env */
|
|
45
53
|
let overlayHotkeyMeta = {
|
|
@@ -53,6 +61,27 @@ let tray = null;
|
|
|
53
61
|
/** Avoid hiding immediately after show (WM quirks). */
|
|
54
62
|
let blurHideEnabled = false;
|
|
55
63
|
|
|
64
|
+
/**
|
|
65
|
+
* After showing the overlay, enable blur→hide after a short grace period so clicks outside close it reliably.
|
|
66
|
+
* @param {BrowserWindow} w
|
|
67
|
+
*/
|
|
68
|
+
function armBlurHideEnable(w) {
|
|
69
|
+
if (STICKY_NO_BLUR || !w || w.isDestroyed()) return;
|
|
70
|
+
blurHideEnabled = false;
|
|
71
|
+
let armed = false;
|
|
72
|
+
const arm = () => {
|
|
73
|
+
if (armed || !w || w.isDestroyed()) return;
|
|
74
|
+
armed = true;
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
if (!STICKY_NO_BLUR && w && !w.isDestroyed()) {
|
|
77
|
+
blurHideEnabled = true;
|
|
78
|
+
}
|
|
79
|
+
}, 320);
|
|
80
|
+
};
|
|
81
|
+
w.once('focus', arm);
|
|
82
|
+
setTimeout(arm, 420);
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
/**
|
|
57
86
|
* Stay above other apps: screen-saver level (highest in Electron), moveTop, all workspaces.
|
|
58
87
|
* @param {BrowserWindow} w
|
|
@@ -85,7 +114,7 @@ function applyAlwaysOnTopStack(w) {
|
|
|
85
114
|
function createWindow() {
|
|
86
115
|
win = new BrowserWindow({
|
|
87
116
|
width: OVERLAY_WIDTH,
|
|
88
|
-
height:
|
|
117
|
+
height: OVERLAY_HEIGHT,
|
|
89
118
|
alwaysOnTop: true,
|
|
90
119
|
show: false,
|
|
91
120
|
/** Frameless: no title bar + menu (Windows/macOS). */
|
|
@@ -138,10 +167,8 @@ function createWindow() {
|
|
|
138
167
|
applyAlwaysOnTopStack(win);
|
|
139
168
|
win.focus();
|
|
140
169
|
win.webContents.send('overlay:open');
|
|
141
|
-
setTimeout(() =>
|
|
142
|
-
|
|
143
|
-
blurHideEnabled = true;
|
|
144
|
-
}, 800);
|
|
170
|
+
setTimeout(() => applyAlwaysOnTopStack(win), 120);
|
|
171
|
+
armBlurHideEnable(win);
|
|
145
172
|
});
|
|
146
173
|
}
|
|
147
174
|
|
|
@@ -180,10 +207,8 @@ function toggleOverlay() {
|
|
|
180
207
|
applyAlwaysOnTopStack(win);
|
|
181
208
|
win.focus();
|
|
182
209
|
win.webContents.send('overlay:open');
|
|
183
|
-
setTimeout(() =>
|
|
184
|
-
|
|
185
|
-
blurHideEnabled = true;
|
|
186
|
-
}, 800);
|
|
210
|
+
setTimeout(() => applyAlwaysOnTopStack(win), 120);
|
|
211
|
+
armBlurHideEnable(win);
|
|
187
212
|
}
|
|
188
213
|
}
|
|
189
214
|
|
|
@@ -222,6 +247,169 @@ function registerHotkeys() {
|
|
|
222
247
|
return { accelerator: '', usedFallback: false };
|
|
223
248
|
}
|
|
224
249
|
|
|
250
|
+
function mergeHistoryForOverlay(localItems, sheetItems, cap) {
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
/** @type {typeof localItems} */
|
|
253
|
+
const out = [];
|
|
254
|
+
/** Sheet rows first so duplicates dedupe keeps sheet metadata when timestamps tie. */
|
|
255
|
+
const combined = [...sheetItems, ...localItems];
|
|
256
|
+
combined.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
|
|
257
|
+
for (const it of combined) {
|
|
258
|
+
const key = `${it.ts}\u0000${it.text}`;
|
|
259
|
+
if (seen.has(key)) continue;
|
|
260
|
+
seen.add(key);
|
|
261
|
+
out.push(it);
|
|
262
|
+
}
|
|
263
|
+
return out.slice(0, cap);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @type {{ merged: Array<{ ts: string, text: string, synced: boolean }> }} */
|
|
267
|
+
const historyMergedCache = {
|
|
268
|
+
merged: [],
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** Recent local lines only — Sheet supplies older / cross-device rows so they are not crowded out. */
|
|
272
|
+
const HISTORY_LOCAL_LINES = 700;
|
|
273
|
+
/** Max merged entries after dedupe (pagination slices this list). */
|
|
274
|
+
const HISTORY_MERGE_CAP = 4000;
|
|
275
|
+
|
|
276
|
+
/** @type {{ sheetFetched: number, sheetHint: string }} */
|
|
277
|
+
let lastHistorySheetMeta = { sheetFetched: 0, sheetHint: '' };
|
|
278
|
+
|
|
279
|
+
/** Sequential Sheet fetch: one daily tab per step until overlay has enough merged rows. */
|
|
280
|
+
let sheetIncrementalState = {
|
|
281
|
+
accumulatedItems: [],
|
|
282
|
+
nextDaysAgo: 0,
|
|
283
|
+
daysBackLimit: 30,
|
|
284
|
+
exhausted: false,
|
|
285
|
+
maxRowsPerTab: 500,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
function resetSheetIncrementalState() {
|
|
289
|
+
sheetIncrementalState = {
|
|
290
|
+
accumulatedItems: [],
|
|
291
|
+
nextDaysAgo: 0,
|
|
292
|
+
daysBackLimit: 30,
|
|
293
|
+
exhausted: false,
|
|
294
|
+
maxRowsPerTab: 500,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function fetchNextDailyTabIntoState() {
|
|
299
|
+
if (sheetIncrementalState.exhausted) return;
|
|
300
|
+
if (sheetIncrementalState.nextDaysAgo > sheetIncrementalState.daysBackLimit) {
|
|
301
|
+
sheetIncrementalState.exhausted = true;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const items = await fetchOverlayDailyTabRows(
|
|
306
|
+
sheetIncrementalState.nextDaysAgo,
|
|
307
|
+
sheetIncrementalState.maxRowsPerTab,
|
|
308
|
+
);
|
|
309
|
+
sheetIncrementalState.accumulatedItems.push(...items);
|
|
310
|
+
sheetIncrementalState.accumulatedItems.sort(
|
|
311
|
+
(a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0),
|
|
312
|
+
);
|
|
313
|
+
if (sheetIncrementalState.accumulatedItems.length > HISTORY_MERGE_CAP) {
|
|
314
|
+
sheetIncrementalState.accumulatedItems =
|
|
315
|
+
sheetIncrementalState.accumulatedItems.slice(0, HISTORY_MERGE_CAP);
|
|
316
|
+
}
|
|
317
|
+
} catch (e) {
|
|
318
|
+
const msg = /** @type {Error} */ (e).message || String(e);
|
|
319
|
+
lastHistorySheetMeta.sheetHint = `Google Sheet error: ${msg.slice(0, 140)}`;
|
|
320
|
+
console.warn('[CopyHub overlay]', lastHistorySheetMeta.sheetHint);
|
|
321
|
+
sheetIncrementalState.exhausted = true;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
sheetIncrementalState.nextDaysAgo += 1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Ensure merged history covers at least `page * pageSize` items (capped), fetching extra Sheet tabs only if needed.
|
|
329
|
+
*/
|
|
330
|
+
async function ensureMergedHistoryCoversPage(page, pageSize) {
|
|
331
|
+
const localItems = buildLocalHistoryItems();
|
|
332
|
+
const sheetTarget = await loadSheetSyncTarget();
|
|
333
|
+
const tok = await loadTokens();
|
|
334
|
+
const sheetOk =
|
|
335
|
+
Boolean(sheetTarget) && Boolean(tok?.refresh_token || tok?.access_token);
|
|
336
|
+
|
|
337
|
+
if (!sheetOk) {
|
|
338
|
+
if (!sheetTarget) {
|
|
339
|
+
lastHistorySheetMeta = {
|
|
340
|
+
sheetFetched: 0,
|
|
341
|
+
sheetHint: 'Google Sheet: not configured — run copyhub login',
|
|
342
|
+
};
|
|
343
|
+
} else {
|
|
344
|
+
lastHistorySheetMeta = {
|
|
345
|
+
sheetFetched: 0,
|
|
346
|
+
sheetHint: 'Google Sheet: not signed in — run copyhub login',
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
sheetIncrementalState.exhausted = true;
|
|
350
|
+
historyMergedCache.merged = mergeHistoryForOverlay(localItems, [], HISTORY_MERGE_CAP);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const targetMin = Math.min(page * pageSize, HISTORY_MERGE_CAP);
|
|
355
|
+
|
|
356
|
+
while (true) {
|
|
357
|
+
const merged = mergeHistoryForOverlay(
|
|
358
|
+
localItems,
|
|
359
|
+
sheetIncrementalState.accumulatedItems,
|
|
360
|
+
HISTORY_MERGE_CAP,
|
|
361
|
+
);
|
|
362
|
+
historyMergedCache.merged = merged;
|
|
363
|
+
|
|
364
|
+
if (merged.length >= HISTORY_MERGE_CAP) break;
|
|
365
|
+
if (sheetIncrementalState.exhausted) break;
|
|
366
|
+
/** Merge Sheet at least once when configured so dedupe / synced flags match Sheet. */
|
|
367
|
+
if (merged.length >= targetMin && sheetIncrementalState.nextDaysAgo > 0) break;
|
|
368
|
+
|
|
369
|
+
await fetchNextDailyTabIntoState();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const preservedErr =
|
|
373
|
+
typeof lastHistorySheetMeta.sheetHint === 'string' &&
|
|
374
|
+
lastHistorySheetMeta.sheetHint.startsWith('Google Sheet error:');
|
|
375
|
+
|
|
376
|
+
lastHistorySheetMeta.sheetFetched = sheetIncrementalState.accumulatedItems.length;
|
|
377
|
+
|
|
378
|
+
if (!preservedErr) {
|
|
379
|
+
if (!sheetIncrementalState.exhausted) {
|
|
380
|
+
lastHistorySheetMeta.sheetHint = `Google Sheet: ${sheetIncrementalState.accumulatedItems.length} rows · more when you page`;
|
|
381
|
+
} else if (sheetIncrementalState.accumulatedItems.length === 0) {
|
|
382
|
+
lastHistorySheetMeta.sheetHint =
|
|
383
|
+
'Google Sheet: 0 rows in last 31 days (check COPYHUB-YYYY-MM-DD tabs / timezone)';
|
|
384
|
+
} else {
|
|
385
|
+
lastHistorySheetMeta.sheetHint = `Google Sheet: ${sheetIncrementalState.accumulatedItems.length} rows loaded`;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function buildLocalHistoryItems() {
|
|
391
|
+
return readRecentHistorySync(HISTORY_LOCAL_LINES).map((row) => ({
|
|
392
|
+
ts: row.ts || '',
|
|
393
|
+
text: typeof row.text === 'string' ? row.text : '',
|
|
394
|
+
synced: Boolean(row.syncedToSheet || row.syncedToGmail),
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** @param {ReturnType<typeof buildLocalHistoryItems>} items */
|
|
399
|
+
function paginateHistoryItems(items, page, pageSize) {
|
|
400
|
+
const total = items.length;
|
|
401
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
402
|
+
const safePage = Math.min(Math.max(page, 1), totalPages);
|
|
403
|
+
const start = (safePage - 1) * pageSize;
|
|
404
|
+
return {
|
|
405
|
+
items: items.slice(start, start + pageSize),
|
|
406
|
+
page: safePage,
|
|
407
|
+
pageSize,
|
|
408
|
+
total,
|
|
409
|
+
totalPages,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
225
413
|
function registerIpc() {
|
|
226
414
|
ipcMain.handle('overlay:meta', () => ({
|
|
227
415
|
...overlayHotkeyMeta,
|
|
@@ -230,17 +418,81 @@ function registerIpc() {
|
|
|
230
418
|
sticky: STICKY_NO_BLUR,
|
|
231
419
|
}));
|
|
232
420
|
|
|
233
|
-
|
|
421
|
+
/** Fast path: local history.jsonl only (overlay shows this while Sheet loads). */
|
|
422
|
+
ipcMain.handle('history:getLocal', (_e, opts = {}) => {
|
|
423
|
+
try {
|
|
424
|
+
const pageSize = Math.min(Math.max(Number(opts.pageSize) || 10, 1), 50);
|
|
425
|
+
let page = Math.max(Number(opts.page) || 1, 1);
|
|
426
|
+
const localItems = buildLocalHistoryItems();
|
|
427
|
+
const paginated = paginateHistoryItems(localItems, page, pageSize);
|
|
428
|
+
return {
|
|
429
|
+
...paginated,
|
|
430
|
+
provisional: true,
|
|
431
|
+
sheetHint:
|
|
432
|
+
localItems.length > 0
|
|
433
|
+
? 'Showing local copies · loading Google Sheet…'
|
|
434
|
+
: 'Loading Google Sheet…',
|
|
435
|
+
sheetFetched: 0,
|
|
436
|
+
sheetHasMore: false,
|
|
437
|
+
};
|
|
438
|
+
} catch (e) {
|
|
439
|
+
return {
|
|
440
|
+
error: /** @type {Error} */ (e).message,
|
|
441
|
+
items: [],
|
|
442
|
+
page: 1,
|
|
443
|
+
pageSize: 10,
|
|
444
|
+
total: 0,
|
|
445
|
+
totalPages: 1,
|
|
446
|
+
provisional: true,
|
|
447
|
+
sheetHint: '',
|
|
448
|
+
sheetFetched: 0,
|
|
449
|
+
sheetHasMore: false,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
ipcMain.handle('history:get', async (_e, opts = {}) => {
|
|
234
455
|
try {
|
|
456
|
+
const pageSize = Math.min(Math.max(Number(opts.pageSize) || 10, 1), 50);
|
|
457
|
+
let page = Math.max(Number(opts.page) || 1, 1);
|
|
458
|
+
const refresh = Boolean(opts.refresh);
|
|
459
|
+
|
|
460
|
+
if (refresh) {
|
|
461
|
+
resetSheetIncrementalState();
|
|
462
|
+
lastHistorySheetMeta = { sheetFetched: 0, sheetHint: '' };
|
|
463
|
+
historyMergedCache.merged = [];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
await ensureMergedHistoryCoversPage(page, pageSize);
|
|
467
|
+
|
|
468
|
+
const total = historyMergedCache.merged.length;
|
|
469
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
470
|
+
page = Math.min(page, totalPages);
|
|
471
|
+
const start = (page - 1) * pageSize;
|
|
472
|
+
const items = historyMergedCache.merged.slice(start, start + pageSize);
|
|
473
|
+
|
|
235
474
|
return {
|
|
236
|
-
items
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
475
|
+
items,
|
|
476
|
+
page,
|
|
477
|
+
pageSize,
|
|
478
|
+
total,
|
|
479
|
+
totalPages,
|
|
480
|
+
sheetHint: lastHistorySheetMeta.sheetHint,
|
|
481
|
+
sheetFetched: lastHistorySheetMeta.sheetFetched,
|
|
482
|
+
sheetHasMore: !sheetIncrementalState.exhausted,
|
|
241
483
|
};
|
|
242
484
|
} catch (e) {
|
|
243
|
-
return {
|
|
485
|
+
return {
|
|
486
|
+
error: /** @type {Error} */ (e).message,
|
|
487
|
+
items: [],
|
|
488
|
+
page: 1,
|
|
489
|
+
pageSize: 10,
|
|
490
|
+
total: 0,
|
|
491
|
+
totalPages: 1,
|
|
492
|
+
sheetHint: '',
|
|
493
|
+
sheetFetched: 0,
|
|
494
|
+
sheetHasMore: false,
|
|
495
|
+
};
|
|
244
496
|
}
|
|
245
497
|
});
|
|
246
498
|
|
package/ui/preload.cjs
CHANGED
|
@@ -2,7 +2,10 @@ const { contextBridge, ipcRenderer } = require('electron');
|
|
|
2
2
|
|
|
3
3
|
contextBridge.exposeInMainWorld('copyhub', {
|
|
4
4
|
getMeta: () => ipcRenderer.invoke('overlay:meta'),
|
|
5
|
-
|
|
5
|
+
/** @param {{ page?: number, pageSize?: number, refresh?: boolean }} [opts] */
|
|
6
|
+
getHistory: (opts) => ipcRenderer.invoke('history:get', opts ?? {}),
|
|
7
|
+
/** Local history only — instant; used while Sheet sync runs. */
|
|
8
|
+
getHistoryLocal: (opts) => ipcRenderer.invoke('history:getLocal', opts ?? {}),
|
|
6
9
|
copyPick: (text) => ipcRenderer.invoke('history:copy', text),
|
|
7
10
|
onOpen: (fn) => {
|
|
8
11
|
ipcRenderer.on('overlay:open', (_e) => {
|