copyhub-cli 1.0.0 → 1.0.3
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 +25 -24
- package/README.md +219 -122
- package/package.json +39 -39
- package/src/cli.js +373 -337
- package/src/config.js +20 -0
- package/src/electron-launcher.js +22 -11
- 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 +581 -331
- package/ui/preload.cjs +4 -1
- package/ui/renderer/index.html +256 -7
package/ui/main.mjs
CHANGED
|
@@ -1,331 +1,581 @@
|
|
|
1
|
-
import 'dotenv/config';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
5
|
-
app,
|
|
6
|
-
BrowserWindow,
|
|
7
|
-
globalShortcut,
|
|
8
|
-
clipboard,
|
|
9
|
-
ipcMain,
|
|
10
|
-
screen,
|
|
11
|
-
Tray,
|
|
12
|
-
Menu,
|
|
13
|
-
nativeImage,
|
|
14
|
-
} from 'electron';
|
|
15
|
-
import { readRecentHistorySync } from '../src/storage.js';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
let
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*/
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
win.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
app,
|
|
6
|
+
BrowserWindow,
|
|
7
|
+
globalShortcut,
|
|
8
|
+
clipboard,
|
|
9
|
+
ipcMain,
|
|
10
|
+
screen,
|
|
11
|
+
Tray,
|
|
12
|
+
Menu,
|
|
13
|
+
nativeImage,
|
|
14
|
+
} from 'electron';
|
|
15
|
+
import { readRecentHistorySync } from '../src/storage.js';
|
|
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
|
+
const gotLock = app.requestSingleInstanceLock();
|
|
24
|
+
if (!gotLock) {
|
|
25
|
+
app.quit();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
|
|
30
|
+
/** Set to 1 so the window does not hide on blur (only Esc / pick row to copy). */
|
|
31
|
+
const STICKY_NO_BLUR = process.env.COPYHUB_OVERLAY_STICKY === '1';
|
|
32
|
+
|
|
33
|
+
/** Electron Accelerator: use `Control`, not `Ctrl`; `CommandOrControl` = Ctrl (Win) / Cmd (Mac). */
|
|
34
|
+
function normalizeAccelerator(raw) {
|
|
35
|
+
if (!raw || typeof raw !== 'string') return '';
|
|
36
|
+
let s = raw.trim();
|
|
37
|
+
s = s.replace(/\bCtrl\b/gi, 'Control');
|
|
38
|
+
s = s.replace(/\bCmd\b/gi, 'Command');
|
|
39
|
+
s = s.replace(/\s*\+\s*/g, '+');
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_ACCEL = 'CommandOrControl+Shift+H';
|
|
44
|
+
const HIDE_ON_START = process.env.COPYHUB_OVERLAY_HIDE_ON_START === '1';
|
|
45
|
+
|
|
46
|
+
/** Overlay size (slightly larger than earlier ~70% width). */
|
|
47
|
+
const OVERLAY_WIDTH = Math.round(460 * 0.84);
|
|
48
|
+
const OVERLAY_HEIGHT = 590;
|
|
49
|
+
|
|
50
|
+
/** For UI / IPC: registered shortcut and raw value from .env */
|
|
51
|
+
let overlayHotkeyMeta = {
|
|
52
|
+
accelerator: '',
|
|
53
|
+
usedFallback: false,
|
|
54
|
+
requestedRaw: '',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let win = null;
|
|
58
|
+
let tray = null;
|
|
59
|
+
/** Avoid hiding immediately after show (WM quirks). */
|
|
60
|
+
let blurHideEnabled = false;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* After showing the overlay, enable blur→hide after a short grace period so clicks outside close it reliably.
|
|
64
|
+
* @param {BrowserWindow} w
|
|
65
|
+
*/
|
|
66
|
+
function armBlurHideEnable(w) {
|
|
67
|
+
if (STICKY_NO_BLUR || !w || w.isDestroyed()) return;
|
|
68
|
+
blurHideEnabled = false;
|
|
69
|
+
let armed = false;
|
|
70
|
+
const arm = () => {
|
|
71
|
+
if (armed || !w || w.isDestroyed()) return;
|
|
72
|
+
armed = true;
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
if (!STICKY_NO_BLUR && w && !w.isDestroyed()) {
|
|
75
|
+
blurHideEnabled = true;
|
|
76
|
+
}
|
|
77
|
+
}, 320);
|
|
78
|
+
};
|
|
79
|
+
w.once('focus', arm);
|
|
80
|
+
setTimeout(arm, 420);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stay above other apps: screen-saver level (highest in Electron), moveTop, all workspaces.
|
|
85
|
+
* @param {BrowserWindow} w
|
|
86
|
+
*/
|
|
87
|
+
function applyAlwaysOnTopStack(w) {
|
|
88
|
+
if (!w || w.isDestroyed()) return;
|
|
89
|
+
try {
|
|
90
|
+
w.setAlwaysOnTop(true, 'screen-saver');
|
|
91
|
+
} catch {
|
|
92
|
+
try {
|
|
93
|
+
w.setAlwaysOnTop(true, 'floating');
|
|
94
|
+
} catch {
|
|
95
|
+
w.setAlwaysOnTop(true);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
w.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
100
|
+
} catch {
|
|
101
|
+
/* unsupported on some Linux builds */
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
if (typeof w.moveTop === 'function') {
|
|
105
|
+
w.moveTop();
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createWindow() {
|
|
113
|
+
win = new BrowserWindow({
|
|
114
|
+
width: OVERLAY_WIDTH,
|
|
115
|
+
height: OVERLAY_HEIGHT,
|
|
116
|
+
alwaysOnTop: true,
|
|
117
|
+
show: false,
|
|
118
|
+
/** Frameless: no title bar + menu (Windows/macOS). */
|
|
119
|
+
frame: false,
|
|
120
|
+
roundedCorners: true,
|
|
121
|
+
/** Show on taskbar for visibility (COPYHUB_OVERLAY_SKIP_TASKBAR=1 hides from taskbar). */
|
|
122
|
+
skipTaskbar: process.env.COPYHUB_OVERLAY_SKIP_TASKBAR === '1',
|
|
123
|
+
title: 'CopyHub',
|
|
124
|
+
backgroundColor: '#ffffff',
|
|
125
|
+
webPreferences: {
|
|
126
|
+
preload: path.join(__dirname, 'preload.cjs'),
|
|
127
|
+
contextIsolation: true,
|
|
128
|
+
nodeIntegration: false,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
|
133
|
+
|
|
134
|
+
win.on('show', () => {
|
|
135
|
+
applyAlwaysOnTopStack(win);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!STICKY_NO_BLUR) {
|
|
139
|
+
win.on('blur', () => {
|
|
140
|
+
if (!blurHideEnabled) return;
|
|
141
|
+
if (win && !win.webContents.isDevToolsOpened()) {
|
|
142
|
+
win.hide();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
win.on('close', (e) => {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
win?.hide();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
win.webContents.on('before-input-event', (_event, input) => {
|
|
153
|
+
if (input.type === 'keyDown' && input.key === 'Escape') {
|
|
154
|
+
win?.hide();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
win.once('ready-to-show', () => {
|
|
159
|
+
if (HIDE_ON_START) {
|
|
160
|
+
blurHideEnabled = true;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
placeWindowAtCursor(win);
|
|
164
|
+
win.show();
|
|
165
|
+
applyAlwaysOnTopStack(win);
|
|
166
|
+
win.focus();
|
|
167
|
+
win.webContents.send('overlay:open');
|
|
168
|
+
setTimeout(() => applyAlwaysOnTopStack(win), 120);
|
|
169
|
+
armBlurHideEnable(win);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Position window near cursor (display containing pointer).
|
|
175
|
+
* @param {BrowserWindow} w
|
|
176
|
+
*/
|
|
177
|
+
function placeWindowAtCursor(w) {
|
|
178
|
+
if (!w || w.isDestroyed()) return;
|
|
179
|
+
const point = screen.getCursorScreenPoint();
|
|
180
|
+
const display = screen.getDisplayNearestPoint(point);
|
|
181
|
+
const { workArea } = display;
|
|
182
|
+
const { width, height } = w.getBounds();
|
|
183
|
+
const margin = 10;
|
|
184
|
+
let x = Math.round(point.x - width / 2);
|
|
185
|
+
let y = Math.round(point.y - 40);
|
|
186
|
+
x = Math.max(
|
|
187
|
+
workArea.x + margin,
|
|
188
|
+
Math.min(x, workArea.x + workArea.width - width - margin),
|
|
189
|
+
);
|
|
190
|
+
y = Math.max(
|
|
191
|
+
workArea.y + margin,
|
|
192
|
+
Math.min(y, workArea.y + workArea.height - height - margin),
|
|
193
|
+
);
|
|
194
|
+
w.setPosition(x, y);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function toggleOverlay() {
|
|
198
|
+
if (!win) return;
|
|
199
|
+
if (win.isVisible()) {
|
|
200
|
+
win.hide();
|
|
201
|
+
} else {
|
|
202
|
+
blurHideEnabled = false;
|
|
203
|
+
placeWindowAtCursor(win);
|
|
204
|
+
win.show();
|
|
205
|
+
applyAlwaysOnTopStack(win);
|
|
206
|
+
win.focus();
|
|
207
|
+
win.webContents.send('overlay:open');
|
|
208
|
+
setTimeout(() => applyAlwaysOnTopStack(win), 120);
|
|
209
|
+
armBlurHideEnable(win);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Register global shortcut: try .env (normalized) then default CommandOrControl+Shift+H.
|
|
215
|
+
* @returns {{ accelerator: string, usedFallback: boolean }}
|
|
216
|
+
*/
|
|
217
|
+
function registerHotkeys() {
|
|
218
|
+
const raw =
|
|
219
|
+
process.env.COPYHUB_OVERLAY_ACCELERATOR?.trim() ||
|
|
220
|
+
loadOverlayAcceleratorFromConfigSync();
|
|
221
|
+
const candidates = [];
|
|
222
|
+
if (raw) {
|
|
223
|
+
const n = normalizeAccelerator(raw);
|
|
224
|
+
if (n) candidates.push(n);
|
|
225
|
+
}
|
|
226
|
+
candidates.push(DEFAULT_ACCEL);
|
|
227
|
+
|
|
228
|
+
let usedFallback = false;
|
|
229
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
230
|
+
const acc = candidates[i];
|
|
231
|
+
try {
|
|
232
|
+
if (globalShortcut.register(acc, () => toggleOverlay())) {
|
|
233
|
+
if (i > 0) usedFallback = true;
|
|
234
|
+
return { accelerator: acc, usedFallback };
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.warn('Invalid accelerator:', acc, /** @type {Error} */ (e).message);
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
globalShortcut.unregister(acc);
|
|
241
|
+
} catch {
|
|
242
|
+
/* ignore */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { accelerator: '', usedFallback: false };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function mergeHistoryForOverlay(localItems, sheetItems, cap) {
|
|
249
|
+
const seen = new Set();
|
|
250
|
+
/** @type {typeof localItems} */
|
|
251
|
+
const out = [];
|
|
252
|
+
/** Sheet rows first so duplicates dedupe keeps sheet metadata when timestamps tie. */
|
|
253
|
+
const combined = [...sheetItems, ...localItems];
|
|
254
|
+
combined.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
|
|
255
|
+
for (const it of combined) {
|
|
256
|
+
const key = `${it.ts}\u0000${it.text}`;
|
|
257
|
+
if (seen.has(key)) continue;
|
|
258
|
+
seen.add(key);
|
|
259
|
+
out.push(it);
|
|
260
|
+
}
|
|
261
|
+
return out.slice(0, cap);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** @type {{ merged: Array<{ ts: string, text: string, synced: boolean }> }} */
|
|
265
|
+
const historyMergedCache = {
|
|
266
|
+
merged: [],
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Recent local lines only — Sheet supplies older / cross-device rows so they are not crowded out. */
|
|
270
|
+
const HISTORY_LOCAL_LINES = 700;
|
|
271
|
+
/** Max merged entries after dedupe (pagination slices this list). */
|
|
272
|
+
const HISTORY_MERGE_CAP = 4000;
|
|
273
|
+
|
|
274
|
+
/** @type {{ sheetFetched: number, sheetHint: string }} */
|
|
275
|
+
let lastHistorySheetMeta = { sheetFetched: 0, sheetHint: '' };
|
|
276
|
+
|
|
277
|
+
/** Sequential Sheet fetch: one daily tab per step until overlay has enough merged rows. */
|
|
278
|
+
let sheetIncrementalState = {
|
|
279
|
+
accumulatedItems: [],
|
|
280
|
+
nextDaysAgo: 0,
|
|
281
|
+
daysBackLimit: 30,
|
|
282
|
+
exhausted: false,
|
|
283
|
+
maxRowsPerTab: 500,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function resetSheetIncrementalState() {
|
|
287
|
+
sheetIncrementalState = {
|
|
288
|
+
accumulatedItems: [],
|
|
289
|
+
nextDaysAgo: 0,
|
|
290
|
+
daysBackLimit: 30,
|
|
291
|
+
exhausted: false,
|
|
292
|
+
maxRowsPerTab: 500,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function fetchNextDailyTabIntoState() {
|
|
297
|
+
if (sheetIncrementalState.exhausted) return;
|
|
298
|
+
if (sheetIncrementalState.nextDaysAgo > sheetIncrementalState.daysBackLimit) {
|
|
299
|
+
sheetIncrementalState.exhausted = true;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
const items = await fetchOverlayDailyTabRows(
|
|
304
|
+
sheetIncrementalState.nextDaysAgo,
|
|
305
|
+
sheetIncrementalState.maxRowsPerTab,
|
|
306
|
+
);
|
|
307
|
+
sheetIncrementalState.accumulatedItems.push(...items);
|
|
308
|
+
sheetIncrementalState.accumulatedItems.sort(
|
|
309
|
+
(a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0),
|
|
310
|
+
);
|
|
311
|
+
if (sheetIncrementalState.accumulatedItems.length > HISTORY_MERGE_CAP) {
|
|
312
|
+
sheetIncrementalState.accumulatedItems =
|
|
313
|
+
sheetIncrementalState.accumulatedItems.slice(0, HISTORY_MERGE_CAP);
|
|
314
|
+
}
|
|
315
|
+
} catch (e) {
|
|
316
|
+
const msg = /** @type {Error} */ (e).message || String(e);
|
|
317
|
+
lastHistorySheetMeta.sheetHint = `Google Sheet error: ${msg.slice(0, 140)}`;
|
|
318
|
+
console.warn('[CopyHub overlay]', lastHistorySheetMeta.sheetHint);
|
|
319
|
+
sheetIncrementalState.exhausted = true;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
sheetIncrementalState.nextDaysAgo += 1;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Ensure merged history covers at least `page * pageSize` items (capped), fetching extra Sheet tabs only if needed.
|
|
327
|
+
*/
|
|
328
|
+
async function ensureMergedHistoryCoversPage(page, pageSize) {
|
|
329
|
+
const localItems = buildLocalHistoryItems();
|
|
330
|
+
const sheetTarget = await loadSheetSyncTarget();
|
|
331
|
+
const tok = await loadTokens();
|
|
332
|
+
const sheetOk =
|
|
333
|
+
Boolean(sheetTarget) && Boolean(tok?.refresh_token || tok?.access_token);
|
|
334
|
+
|
|
335
|
+
if (!sheetOk) {
|
|
336
|
+
if (!sheetTarget) {
|
|
337
|
+
lastHistorySheetMeta = {
|
|
338
|
+
sheetFetched: 0,
|
|
339
|
+
sheetHint: 'Google Sheet: not configured — run copyhub login',
|
|
340
|
+
};
|
|
341
|
+
} else {
|
|
342
|
+
lastHistorySheetMeta = {
|
|
343
|
+
sheetFetched: 0,
|
|
344
|
+
sheetHint: 'Google Sheet: not signed in — run copyhub login',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
sheetIncrementalState.exhausted = true;
|
|
348
|
+
historyMergedCache.merged = mergeHistoryForOverlay(localItems, [], HISTORY_MERGE_CAP);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const targetMin = Math.min(page * pageSize, HISTORY_MERGE_CAP);
|
|
353
|
+
|
|
354
|
+
while (true) {
|
|
355
|
+
const merged = mergeHistoryForOverlay(
|
|
356
|
+
localItems,
|
|
357
|
+
sheetIncrementalState.accumulatedItems,
|
|
358
|
+
HISTORY_MERGE_CAP,
|
|
359
|
+
);
|
|
360
|
+
historyMergedCache.merged = merged;
|
|
361
|
+
|
|
362
|
+
if (merged.length >= HISTORY_MERGE_CAP) break;
|
|
363
|
+
if (sheetIncrementalState.exhausted) break;
|
|
364
|
+
/** Merge Sheet at least once when configured so dedupe / synced flags match Sheet. */
|
|
365
|
+
if (merged.length >= targetMin && sheetIncrementalState.nextDaysAgo > 0) break;
|
|
366
|
+
|
|
367
|
+
await fetchNextDailyTabIntoState();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const preservedErr =
|
|
371
|
+
typeof lastHistorySheetMeta.sheetHint === 'string' &&
|
|
372
|
+
lastHistorySheetMeta.sheetHint.startsWith('Google Sheet error:');
|
|
373
|
+
|
|
374
|
+
lastHistorySheetMeta.sheetFetched = sheetIncrementalState.accumulatedItems.length;
|
|
375
|
+
|
|
376
|
+
if (!preservedErr) {
|
|
377
|
+
if (!sheetIncrementalState.exhausted) {
|
|
378
|
+
lastHistorySheetMeta.sheetHint = `Google Sheet: ${sheetIncrementalState.accumulatedItems.length} rows · more when you page`;
|
|
379
|
+
} else if (sheetIncrementalState.accumulatedItems.length === 0) {
|
|
380
|
+
lastHistorySheetMeta.sheetHint =
|
|
381
|
+
'Google Sheet: 0 rows in last 31 days (check COPYHUB-YYYY-MM-DD tabs / timezone)';
|
|
382
|
+
} else {
|
|
383
|
+
lastHistorySheetMeta.sheetHint = `Google Sheet: ${sheetIncrementalState.accumulatedItems.length} rows loaded`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function buildLocalHistoryItems() {
|
|
389
|
+
return readRecentHistorySync(HISTORY_LOCAL_LINES).map((row) => ({
|
|
390
|
+
ts: row.ts || '',
|
|
391
|
+
text: typeof row.text === 'string' ? row.text : '',
|
|
392
|
+
synced: Boolean(row.syncedToSheet || row.syncedToGmail),
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** @param {ReturnType<typeof buildLocalHistoryItems>} items */
|
|
397
|
+
function paginateHistoryItems(items, page, pageSize) {
|
|
398
|
+
const total = items.length;
|
|
399
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
400
|
+
const safePage = Math.min(Math.max(page, 1), totalPages);
|
|
401
|
+
const start = (safePage - 1) * pageSize;
|
|
402
|
+
return {
|
|
403
|
+
items: items.slice(start, start + pageSize),
|
|
404
|
+
page: safePage,
|
|
405
|
+
pageSize,
|
|
406
|
+
total,
|
|
407
|
+
totalPages,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function registerIpc() {
|
|
412
|
+
ipcMain.handle('overlay:meta', () => ({
|
|
413
|
+
...overlayHotkeyMeta,
|
|
414
|
+
platform: process.platform,
|
|
415
|
+
defaultAccelerator: DEFAULT_ACCEL,
|
|
416
|
+
sticky: STICKY_NO_BLUR,
|
|
417
|
+
}));
|
|
418
|
+
|
|
419
|
+
/** Fast path: local history.jsonl only (overlay shows this while Sheet loads). */
|
|
420
|
+
ipcMain.handle('history:getLocal', (_e, opts = {}) => {
|
|
421
|
+
try {
|
|
422
|
+
const pageSize = Math.min(Math.max(Number(opts.pageSize) || 10, 1), 50);
|
|
423
|
+
let page = Math.max(Number(opts.page) || 1, 1);
|
|
424
|
+
const localItems = buildLocalHistoryItems();
|
|
425
|
+
const paginated = paginateHistoryItems(localItems, page, pageSize);
|
|
426
|
+
return {
|
|
427
|
+
...paginated,
|
|
428
|
+
provisional: true,
|
|
429
|
+
sheetHint:
|
|
430
|
+
localItems.length > 0
|
|
431
|
+
? 'Showing local copies · loading Google Sheet…'
|
|
432
|
+
: 'Loading Google Sheet…',
|
|
433
|
+
sheetFetched: 0,
|
|
434
|
+
sheetHasMore: false,
|
|
435
|
+
};
|
|
436
|
+
} catch (e) {
|
|
437
|
+
return {
|
|
438
|
+
error: /** @type {Error} */ (e).message,
|
|
439
|
+
items: [],
|
|
440
|
+
page: 1,
|
|
441
|
+
pageSize: 10,
|
|
442
|
+
total: 0,
|
|
443
|
+
totalPages: 1,
|
|
444
|
+
provisional: true,
|
|
445
|
+
sheetHint: '',
|
|
446
|
+
sheetFetched: 0,
|
|
447
|
+
sheetHasMore: false,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
ipcMain.handle('history:get', async (_e, opts = {}) => {
|
|
453
|
+
try {
|
|
454
|
+
const pageSize = Math.min(Math.max(Number(opts.pageSize) || 10, 1), 50);
|
|
455
|
+
let page = Math.max(Number(opts.page) || 1, 1);
|
|
456
|
+
const refresh = Boolean(opts.refresh);
|
|
457
|
+
|
|
458
|
+
if (refresh) {
|
|
459
|
+
resetSheetIncrementalState();
|
|
460
|
+
lastHistorySheetMeta = { sheetFetched: 0, sheetHint: '' };
|
|
461
|
+
historyMergedCache.merged = [];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
await ensureMergedHistoryCoversPage(page, pageSize);
|
|
465
|
+
|
|
466
|
+
const total = historyMergedCache.merged.length;
|
|
467
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
468
|
+
page = Math.min(page, totalPages);
|
|
469
|
+
const start = (page - 1) * pageSize;
|
|
470
|
+
const items = historyMergedCache.merged.slice(start, start + pageSize);
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
items,
|
|
474
|
+
page,
|
|
475
|
+
pageSize,
|
|
476
|
+
total,
|
|
477
|
+
totalPages,
|
|
478
|
+
sheetHint: lastHistorySheetMeta.sheetHint,
|
|
479
|
+
sheetFetched: lastHistorySheetMeta.sheetFetched,
|
|
480
|
+
sheetHasMore: !sheetIncrementalState.exhausted,
|
|
481
|
+
};
|
|
482
|
+
} catch (e) {
|
|
483
|
+
return {
|
|
484
|
+
error: /** @type {Error} */ (e).message,
|
|
485
|
+
items: [],
|
|
486
|
+
page: 1,
|
|
487
|
+
pageSize: 10,
|
|
488
|
+
total: 0,
|
|
489
|
+
totalPages: 1,
|
|
490
|
+
sheetHint: '',
|
|
491
|
+
sheetFetched: 0,
|
|
492
|
+
sheetHasMore: false,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
ipcMain.handle('history:copy', (_e, text) => {
|
|
498
|
+
if (typeof text === 'string') {
|
|
499
|
+
clipboard.writeText(text);
|
|
500
|
+
}
|
|
501
|
+
win?.hide();
|
|
502
|
+
return true;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function registerTray() {
|
|
507
|
+
const icon = nativeImage.createFromDataURL(
|
|
508
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhn1IGOMJoAmBGOSMDEwMmABWDWHJjBCSpBKGBSDjBAAAeoRBIEs/x0AAAAASUVORK5CYII=',
|
|
509
|
+
);
|
|
510
|
+
tray = new Tray(icon);
|
|
511
|
+
tray.setToolTip('CopyHub overlay');
|
|
512
|
+
const accLabel = overlayHotkeyMeta.accelerator
|
|
513
|
+
? `Shortcut: ${overlayHotkeyMeta.accelerator}`
|
|
514
|
+
: 'Shortcut: (see terminal)';
|
|
515
|
+
tray.setContextMenu(
|
|
516
|
+
Menu.buildFromTemplate([
|
|
517
|
+
{ label: accLabel, enabled: false },
|
|
518
|
+
{ label: 'Open history (always on top)', click: () => toggleOverlay() },
|
|
519
|
+
{ type: 'separator' },
|
|
520
|
+
{ label: 'Quit', click: () => app.quit() },
|
|
521
|
+
]),
|
|
522
|
+
);
|
|
523
|
+
tray.on('click', () => toggleOverlay());
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (gotLock) {
|
|
527
|
+
app.on('second-instance', () => {
|
|
528
|
+
toggleOverlay();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
app.whenReady().then(() => {
|
|
532
|
+
Menu.setApplicationMenu(null);
|
|
533
|
+
createWindow();
|
|
534
|
+
registerIpc();
|
|
535
|
+
|
|
536
|
+
const { accelerator, usedFallback } = registerHotkeys();
|
|
537
|
+
overlayHotkeyMeta = {
|
|
538
|
+
accelerator,
|
|
539
|
+
usedFallback,
|
|
540
|
+
requestedRaw:
|
|
541
|
+
process.env.COPYHUB_OVERLAY_ACCELERATOR?.trim() ||
|
|
542
|
+
loadOverlayAcceleratorFromConfigSync() ||
|
|
543
|
+
'',
|
|
544
|
+
};
|
|
545
|
+
if (accelerator) {
|
|
546
|
+
console.log('CopyHub overlay — shortcut in use:', accelerator);
|
|
547
|
+
console.log('Windows tip: Ctrl+Shift+H (CommandOrControl+Shift+H).');
|
|
548
|
+
if (usedFallback) {
|
|
549
|
+
console.warn(
|
|
550
|
+
'COPYHUB_OVERLAY_ACCELERATOR could not be registered. Using default CommandOrControl+Shift+H.',
|
|
551
|
+
);
|
|
552
|
+
console.warn('Leave COPYHUB_OVERLAY_ACCELERATOR unset in .env to always use Ctrl+Shift+H.');
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
console.error(
|
|
556
|
+
'Could not register a global shortcut. Open history from the tray or taskbar icon.',
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
console.log(
|
|
561
|
+
STICKY_NO_BLUR
|
|
562
|
+
? 'COPYHUB_OVERLAY_STICKY=1 — window does not close on outside click (Esc / row pick only).'
|
|
563
|
+
: 'Overlay: opens near cursor; click outside the window to close. Esc closes too. COPYHUB_OVERLAY_STICKY=1 keeps it open on blur.',
|
|
564
|
+
);
|
|
565
|
+
console.log(
|
|
566
|
+
HIDE_ON_START
|
|
567
|
+
? 'COPYHUB_OVERLAY_HIDE_ON_START=1 — window opens only via shortcut / tray.'
|
|
568
|
+
: 'Window shows on startup; check taskbar or tray if you do not see it.',
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
registerTray();
|
|
573
|
+
} catch (e) {
|
|
574
|
+
console.warn('Could not create system tray icon:', /** @type {Error} */ (e).message);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
app.on('will-quit', () => {
|
|
579
|
+
globalShortcut.unregisterAll();
|
|
580
|
+
});
|
|
581
|
+
}
|