copyhub-cli 1.0.1 → 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 +1 -0
- package/README.md +73 -8
- package/package.json +1 -1
- package/src/cli.js +33 -2
- package/src/config.js +20 -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 +269 -19
- package/ui/preload.cjs +4 -1
- package/ui/renderer/index.html +256 -7
package/.env.example
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
# copyhub start opens the Electron overlay by default; set to 1 for clipboard + Sheet only:
|
|
11
11
|
# COPYHUB_START_NO_OVERLAY=1
|
|
12
12
|
|
|
13
|
+
# OAuth Client ID / Secret: optional here — running copyhub login opens a browser wizard that saves them to ~/.copyhub/config.json instead.
|
|
13
14
|
COPYHUB_GOOGLE_CLIENT_ID=
|
|
14
15
|
COPYHUB_GOOGLE_CLIENT_SECRET=
|
|
15
16
|
COPYHUB_OAUTH_REDIRECT_PORT=19999
|
package/README.md
CHANGED
|
@@ -45,6 +45,68 @@ Or run without linking:
|
|
|
45
45
|
node src/cli.js <command>
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
## Updating
|
|
49
|
+
|
|
50
|
+
Your settings (`~/.copyhub/config.json`, tokens, history) are kept when you upgrade the CLI.
|
|
51
|
+
|
|
52
|
+
**Recommended:** stop the background daemon before upgrading so Electron can reinstall cleanly; after upgrading start again:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
copyhub stop
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
*(Skip `stop` if you only run `--foreground` or `overlay` and nothing is in the background.)*
|
|
59
|
+
|
|
60
|
+
### Global install (`npm install -g copyhub-cli`)
|
|
61
|
+
|
|
62
|
+
Check what npm considers latest vs what you have:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm view copyhub-cli version
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Upgrade to the latest published release:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm install -g copyhub-cli@latest
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Alternatively:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm update -g copyhub-cli
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Restart CopyHub:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
copyhub start
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
To see the installed package version: `npm list -g copyhub-cli --depth=0`.
|
|
87
|
+
|
|
88
|
+
### From source
|
|
89
|
+
|
|
90
|
+
Pull latest commits and reinstall modules:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
copyhub stop
|
|
94
|
+
git pull
|
|
95
|
+
npm install
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If you previously ran **`npm link`**, linking stays tied to this folder — after `npm install` you usually **do not** need to link again unless npm warns otherwise:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm link
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Then:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
copyhub start
|
|
108
|
+
```
|
|
109
|
+
|
|
48
110
|
## Google Cloud setup
|
|
49
111
|
|
|
50
112
|
1. Enable **[Google Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com)** on your OAuth project.
|
|
@@ -54,13 +116,13 @@ node src/cli.js <command>
|
|
|
54
116
|
http://127.0.0.1:19999/oauth2callback
|
|
55
117
|
```
|
|
56
118
|
|
|
57
|
-
3.
|
|
119
|
+
3. Provide OAuth credentials in **any one** of these ways:
|
|
58
120
|
|
|
59
|
-
- `COPYHUB_GOOGLE_CLIENT_ID`
|
|
60
|
-
- `COPYHUB_GOOGLE_CLIENT_SECRET`
|
|
61
|
-
- Optionally `COPYHUB_OAUTH_REDIRECT_PORT` (default **19999**)
|
|
121
|
+
- **Recommended for first setup:** run **`copyhub login`** with no secrets configured — your browser opens a **localhost wizard** where you paste **Client ID** and **Client secret** (the same values as `COPYHUB_GOOGLE_CLIENT_ID` / `COPYHUB_GOOGLE_CLIENT_SECRET`); they are stored in `~/.copyhub/config.json`.
|
|
62
122
|
|
|
63
|
-
|
|
123
|
+
- Copy `.env.example` to `.env` and set `COPYHUB_GOOGLE_CLIENT_ID`, `COPYHUB_GOOGLE_CLIENT_SECRET`, and optionally `COPYHUB_OAUTH_REDIRECT_PORT` (default **19999**).
|
|
124
|
+
|
|
125
|
+
- Or run **`copyhub config`**:
|
|
64
126
|
|
|
65
127
|
```bash
|
|
66
128
|
copyhub config --client-id "<ID>" --client-secret "<SECRET>" [--sheet-id "<SPREADSHEET_ID>"] [--redirect-port 19999]
|
|
@@ -68,13 +130,16 @@ copyhub config --client-id "<ID>" --client-secret "<SECRET>" [--sheet-id "<SPREA
|
|
|
68
130
|
|
|
69
131
|
## First run
|
|
70
132
|
|
|
71
|
-
1. **Login**
|
|
133
|
+
1. **Login** — opens the browser:
|
|
72
134
|
|
|
73
135
|
```bash
|
|
74
136
|
copyhub login
|
|
75
137
|
```
|
|
76
138
|
|
|
77
|
-
|
|
139
|
+
- If Client ID / Secret are **not** already in `.env` or `~/.copyhub/config.json`, the first screen collects them (**Google OAuth credentials** page). Submit it to save into config.
|
|
140
|
+
- After Google sign-in, another setup page asks for **Spreadsheet ID**, **platform**, and optional **overlay shortcut**.
|
|
141
|
+
|
|
142
|
+
2. On that spreadsheet/setup page, enter your **Spreadsheet ID** (from the URL `…/d/<SPREADSHEET_ID>/edit`), choose **platform** (Windows / macOS / Linux) for shortcut hints, set the **overlay accelerator** if you want, and save.
|
|
78
143
|
|
|
79
144
|
3. **Start** the background watcher (clipboard + Sheets + overlay by default):
|
|
80
145
|
|
|
@@ -116,7 +181,7 @@ copyhub --help
|
|
|
116
181
|
| Command | What it does |
|
|
117
182
|
|---------|----------------|
|
|
118
183
|
| `copyhub config` | Writes OAuth client ID/secret (and optional Sheet ID, redirect port) to `~/.copyhub/config.json`. `--client-id` and `--client-secret` are required. |
|
|
119
|
-
| `copyhub login` | Opens browser for
|
|
184
|
+
| `copyhub login` | Opens browser: localhost wizard for Client ID/secret if missing, then Google OAuth, then spreadsheet/platform setup. |
|
|
120
185
|
| `copyhub logout` | Deletes saved OAuth tokens (`~/.copyhub/tokens.json`). |
|
|
121
186
|
| `copyhub status` | Prints OAuth config source, sheet target, tokens, overlay settings, and whether the background daemon is running. |
|
|
122
187
|
| `copyhub start` | Starts clipboard watcher + optional Sheets sync + Electron overlay in the **background** (closing the terminal does not stop it). Only one instance at a time. |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { killDaemonTree } from './stop-process.js';
|
|
32
32
|
import { ensureDir } from './storage.js';
|
|
33
33
|
import { runCopyhubDaemon } from './start-daemon-logic.js';
|
|
34
|
+
import { wipeCopyhubDirectory } from './wipe-data.js';
|
|
34
35
|
|
|
35
36
|
const CLI_JS = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
36
37
|
|
|
@@ -72,7 +73,7 @@ program
|
|
|
72
73
|
program
|
|
73
74
|
.command('login')
|
|
74
75
|
.description(
|
|
75
|
-
`
|
|
76
|
+
`OAuth Sheets flow (opens browser). If Client ID/Secret are missing, a localhost wizard saves them first — callback port ${DEFAULT_OAUTH_REDIRECT_PORT} or ${ENV_OAUTH_REDIRECT_PORT}`,
|
|
76
77
|
)
|
|
77
78
|
.action(async () => {
|
|
78
79
|
await runLoginFlow();
|
|
@@ -86,6 +87,35 @@ program
|
|
|
86
87
|
console.log(`Removed tokens: ${TOKENS_PATH}`);
|
|
87
88
|
});
|
|
88
89
|
|
|
90
|
+
program
|
|
91
|
+
.command('reset')
|
|
92
|
+
.description(
|
|
93
|
+
'Stop background daemon if running; delete ~/.copyhub entirely (config, tokens, history, run state). Requires --yes',
|
|
94
|
+
)
|
|
95
|
+
.option('--yes', 'Required confirmation flag (destructive)')
|
|
96
|
+
.action(async (opts) => {
|
|
97
|
+
if (!opts.yes) {
|
|
98
|
+
console.error(
|
|
99
|
+
'Refusing to wipe without --yes. Deletes ~/.copyhub (Windows: %USERPROFILE%\\.copyhub). Example: copyhub reset --yes',
|
|
100
|
+
);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
pruneStaleRunState();
|
|
104
|
+
const s = readRunState();
|
|
105
|
+
if (s?.pid && isPidAlive(s.pid)) {
|
|
106
|
+
killDaemonTree(s.pid);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await wipeCopyhubDirectory();
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.error((/** @type {Error} */ (e)).message || String(e));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
console.log(`Removed CopyHub data directory: ${DIR}`);
|
|
115
|
+
console.log('.env is not changed — remove OAuth vars there if you want.');
|
|
116
|
+
console.log('If an overlay window was open separately, close it manually.');
|
|
117
|
+
});
|
|
118
|
+
|
|
89
119
|
program
|
|
90
120
|
.command('overlay')
|
|
91
121
|
.description(
|
|
@@ -163,7 +193,7 @@ program
|
|
|
163
193
|
if (!cfg) {
|
|
164
194
|
console.log('OAuth config: missing');
|
|
165
195
|
console.log(
|
|
166
|
-
` Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env (see .env.example),
|
|
196
|
+
` Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env (see .env.example), run copyhub config, or copyhub login (browser wizard)`,
|
|
167
197
|
);
|
|
168
198
|
} else {
|
|
169
199
|
const srcLabel =
|
|
@@ -333,6 +363,7 @@ program
|
|
|
333
363
|
.action(() => {
|
|
334
364
|
console.log(`copyhub config [--client-id ID] [--client-secret SEC] [--redirect-port P] [--sheet-id ID]
|
|
335
365
|
copyhub login | copyhub logout | copyhub status
|
|
366
|
+
copyhub reset --yes (delete ~/.copyhub — stop daemon first is recommended)
|
|
336
367
|
copyhub start [--no-sheet] [--no-overlay] [--foreground]
|
|
337
368
|
Default runs in background (terminal can close). Single instance — second start is blocked.
|
|
338
369
|
copyhub list (ls) | copyhub stop
|
package/src/config.js
CHANGED
|
@@ -22,6 +22,26 @@ function parseRedirectPortFromEnv() {
|
|
|
22
22
|
return n;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Port for the OAuth HTTP listener (env wins, then saved config, then default).
|
|
27
|
+
* Does not require Client ID / Secret (used before credential bootstrap).
|
|
28
|
+
*/
|
|
29
|
+
export function resolveOAuthListenPort() {
|
|
30
|
+
const envPort = parseRedirectPortFromEnv();
|
|
31
|
+
if (envPort != null) return envPort;
|
|
32
|
+
if (existsSync(CONFIG_PATH)) {
|
|
33
|
+
try {
|
|
34
|
+
const j = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
35
|
+
if (typeof j.redirectPort === 'number' && Number.isFinite(j.redirectPort)) {
|
|
36
|
+
return j.redirectPort;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return DEFAULT_OAUTH_REDIRECT_PORT;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
/** Both Client ID and Secret come from environment (or .env). */
|
|
26
46
|
export function hasOAuthCredentialsInEnv() {
|
|
27
47
|
const id = process.env[ENV_GOOGLE_CLIENT_ID]?.trim();
|
package/src/oauth.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
DEFAULT_OAUTH_REDIRECT_PORT,
|
|
9
9
|
ENV_GOOGLE_CLIENT_ID,
|
|
10
10
|
ENV_GOOGLE_CLIENT_SECRET,
|
|
11
|
+
ENV_OAUTH_REDIRECT_PORT,
|
|
11
12
|
mergeConfigPartial,
|
|
13
|
+
resolveOAuthListenPort,
|
|
12
14
|
loadSheetSyncTarget,
|
|
13
15
|
loadOverlayAcceleratorFromConfigSync,
|
|
14
16
|
loadOverlayPlatformFromConfigSync,
|
|
@@ -34,7 +36,7 @@ export async function getAuthorizedClient() {
|
|
|
34
36
|
const cfg = await loadConfig();
|
|
35
37
|
if (!cfg) {
|
|
36
38
|
throw new Error(
|
|
37
|
-
`OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} (.env or environment),
|
|
39
|
+
`OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} (.env or environment), run copyhub config, or copyhub login (browser wizard).`,
|
|
38
40
|
);
|
|
39
41
|
}
|
|
40
42
|
const client = createOAuthClient(cfg);
|
|
@@ -75,6 +77,256 @@ function escapeHtml(s) {
|
|
|
75
77
|
.replace(/'/g, ''');
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} bootstrapToken
|
|
82
|
+
* @param {number} listenPort
|
|
83
|
+
*/
|
|
84
|
+
function credentialSetupPageHtml(bootstrapToken, listenPort) {
|
|
85
|
+
const tVal = escapeHtml(bootstrapToken);
|
|
86
|
+
const callbackUri = `http://127.0.0.1:${listenPort}/oauth2callback`;
|
|
87
|
+
return `<!DOCTYPE html>
|
|
88
|
+
<html lang="en">
|
|
89
|
+
<head>
|
|
90
|
+
<meta charset="utf-8" />
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
92
|
+
<title>CopyHub — Google OAuth credentials</title>
|
|
93
|
+
<style>
|
|
94
|
+
:root {
|
|
95
|
+
--text: #141824;
|
|
96
|
+
--muted: #5a6272;
|
|
97
|
+
--line: #e2e8f1;
|
|
98
|
+
--accent: #2563eb;
|
|
99
|
+
--accent-soft: #eff6ff;
|
|
100
|
+
--radius: 14px;
|
|
101
|
+
--shadow: 0 4px 24px rgba(20, 24, 36, 0.08), 0 1px 3px rgba(20, 24, 36, 0.04);
|
|
102
|
+
}
|
|
103
|
+
* { box-sizing: border-box; }
|
|
104
|
+
body {
|
|
105
|
+
margin: 0;
|
|
106
|
+
min-height: 100vh;
|
|
107
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
108
|
+
background: linear-gradient(160deg, #e8eef9 0%, #f5f7fb 45%, #eef2f8 100%);
|
|
109
|
+
color: var(--text);
|
|
110
|
+
padding: 32px 16px 48px;
|
|
111
|
+
line-height: 1.55;
|
|
112
|
+
}
|
|
113
|
+
.wrap { max-width: 520px; margin: 0 auto; }
|
|
114
|
+
.brand {
|
|
115
|
+
font-size: 13px;
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
letter-spacing: 0.06em;
|
|
118
|
+
text-transform: uppercase;
|
|
119
|
+
color: var(--accent);
|
|
120
|
+
margin-bottom: 8px;
|
|
121
|
+
}
|
|
122
|
+
h1 { font-size: 1.55rem; font-weight: 700; margin: 0 0 8px; letter-spacing: -0.02em; }
|
|
123
|
+
.sub { color: var(--muted); font-size: 15px; margin-bottom: 24px; }
|
|
124
|
+
.card {
|
|
125
|
+
background: #fff;
|
|
126
|
+
border-radius: var(--radius);
|
|
127
|
+
box-shadow: var(--shadow);
|
|
128
|
+
border: 1px solid rgba(255,255,255,0.8);
|
|
129
|
+
padding: 26px 24px;
|
|
130
|
+
margin-bottom: 18px;
|
|
131
|
+
}
|
|
132
|
+
label.field-label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 8px; }
|
|
133
|
+
input[type="password"], input[type="text"] {
|
|
134
|
+
width: 100%;
|
|
135
|
+
padding: 12px 14px;
|
|
136
|
+
font-size: 15px;
|
|
137
|
+
border: 1px solid var(--line);
|
|
138
|
+
border-radius: 10px;
|
|
139
|
+
background: #fafbfd;
|
|
140
|
+
}
|
|
141
|
+
input:focus {
|
|
142
|
+
outline: none;
|
|
143
|
+
border-color: var(--accent);
|
|
144
|
+
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
|
145
|
+
background: #fff;
|
|
146
|
+
}
|
|
147
|
+
.hint { font-size: 13px; color: var(--muted); margin-top: 10px; line-height: 1.5; }
|
|
148
|
+
.hint code {
|
|
149
|
+
font-size: 12px;
|
|
150
|
+
background: var(--accent-soft);
|
|
151
|
+
color: #1d4ed8;
|
|
152
|
+
padding: 2px 7px;
|
|
153
|
+
border-radius: 6px;
|
|
154
|
+
font-weight: 500;
|
|
155
|
+
}
|
|
156
|
+
button.submit {
|
|
157
|
+
width: 100%;
|
|
158
|
+
margin-top: 20px;
|
|
159
|
+
padding: 14px 20px;
|
|
160
|
+
font-size: 16px;
|
|
161
|
+
font-weight: 600;
|
|
162
|
+
color: #fff;
|
|
163
|
+
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
|
|
164
|
+
border: none;
|
|
165
|
+
border-radius: 12px;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.35);
|
|
168
|
+
}
|
|
169
|
+
button.submit:hover { filter: brightness(1.05); }
|
|
170
|
+
.callback-box {
|
|
171
|
+
font-size: 13px;
|
|
172
|
+
padding: 12px 14px;
|
|
173
|
+
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
|
174
|
+
border-radius: 10px;
|
|
175
|
+
border: 1px solid var(--line);
|
|
176
|
+
word-break: break-all;
|
|
177
|
+
}
|
|
178
|
+
</style>
|
|
179
|
+
</head>
|
|
180
|
+
<body>
|
|
181
|
+
<div class="wrap">
|
|
182
|
+
<div class="brand">CopyHub</div>
|
|
183
|
+
<h1>Google Cloud OAuth</h1>
|
|
184
|
+
<p class="sub">Paste your OAuth 2.0 Client ID and Client Secret (same as <code>${ENV_GOOGLE_CLIENT_ID}</code> / <code>${ENV_GOOGLE_CLIENT_SECRET}</code> in <code>.env</code>). Stored in <code>~/.copyhub/config.json</code>.</p>
|
|
185
|
+
|
|
186
|
+
<div class="card">
|
|
187
|
+
<p class="hint" style="margin-top:0;"><strong>Authorized redirect URI</strong> in Google Cloud Console must include:</p>
|
|
188
|
+
<div class="callback-box"><code>${escapeHtml(callbackUri)}</code></div>
|
|
189
|
+
<p class="hint">Port comes from <code>${ENV_OAUTH_REDIRECT_PORT}</code> (currently <strong>${listenPort}</strong>) or your saved config.</p>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<form method="POST" action="/credentials">
|
|
193
|
+
<input type="hidden" name="t" value="${tVal}" />
|
|
194
|
+
<div class="card">
|
|
195
|
+
<label class="field-label" for="cid">Client ID</label>
|
|
196
|
+
<input id="cid" type="text" name="clientId" autocomplete="off" spellcheck="false" required />
|
|
197
|
+
<label class="field-label" for="csec" style="margin-top:16px;">Client secret</label>
|
|
198
|
+
<input id="csec" type="password" name="clientSecret" autocomplete="new-password" spellcheck="false" required />
|
|
199
|
+
</div>
|
|
200
|
+
<button type="submit" class="submit">Save and continue to Google sign-in</button>
|
|
201
|
+
</form>
|
|
202
|
+
</div>
|
|
203
|
+
</body>
|
|
204
|
+
</html>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const credentialSavedHtml = `<!DOCTYPE html>
|
|
208
|
+
<html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>CopyHub</title>
|
|
209
|
+
<style>
|
|
210
|
+
body{font-family:system-ui,sans-serif;min-height:100vh;margin:0;display:flex;align-items:center;justify-content:center;background:linear-gradient(160deg,#e8eef9,#f5f7fb);padding:24px;}
|
|
211
|
+
.box{background:#fff;padding:32px 36px;border-radius:16px;box-shadow:0 8px 32px rgba(20,24,36,.1);max-width:420px;text-align:center;line-height:1.65;color:#141824;}
|
|
212
|
+
.box h2{margin:0 0 12px;font-size:1.25rem;}
|
|
213
|
+
.box p{margin:.6rem 0;color:#5a6272;font-size:15px;}
|
|
214
|
+
.box code{background:#eff6ff;color:#1d4ed8;padding:2px 8px;border-radius:6px;font-size:13px;}
|
|
215
|
+
</style></head>
|
|
216
|
+
<body><div class="box"><h2>Credentials saved</h2>
|
|
217
|
+
<p>The login flow will open Google next (this tab can stay open).</p></div></body></html>`;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Localhost wizard when Client ID / Secret are missing from env and config file.
|
|
221
|
+
* @returns {Promise<void>}
|
|
222
|
+
*/
|
|
223
|
+
async function runCredentialBootstrap() {
|
|
224
|
+
const listenPort = resolveOAuthListenPort();
|
|
225
|
+
await new Promise((resolve, reject) => {
|
|
226
|
+
/** @type {string | null} */
|
|
227
|
+
let bootstrapToken = randomBytes(24).toString('hex');
|
|
228
|
+
let settled = false;
|
|
229
|
+
|
|
230
|
+
const finish = () => {
|
|
231
|
+
if (settled) return;
|
|
232
|
+
settled = true;
|
|
233
|
+
server.close(() => resolve(undefined));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const server = createServer(async (req, res) => {
|
|
237
|
+
try {
|
|
238
|
+
if (!req.url) {
|
|
239
|
+
res.writeHead(400);
|
|
240
|
+
res.end('Bad request');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const u = new URL(req.url, `http://127.0.0.1:${listenPort}`);
|
|
244
|
+
|
|
245
|
+
if (u.pathname === '/credentials' && req.method === 'GET') {
|
|
246
|
+
const t = u.searchParams.get('t');
|
|
247
|
+
if (!bootstrapToken || t !== bootstrapToken) {
|
|
248
|
+
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
249
|
+
res.end('<p>Invalid session. Run <code>copyhub login</code> again.</p>');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
253
|
+
res.end(credentialSetupPageHtml(bootstrapToken, listenPort));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (u.pathname === '/credentials' && req.method === 'POST') {
|
|
258
|
+
let body = '';
|
|
259
|
+
try {
|
|
260
|
+
body = await readRequestBody(req);
|
|
261
|
+
} catch {
|
|
262
|
+
res.writeHead(413);
|
|
263
|
+
res.end('Payload too large');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const params = new URLSearchParams(body);
|
|
267
|
+
const t = params.get('t');
|
|
268
|
+
if (!bootstrapToken || t !== bootstrapToken) {
|
|
269
|
+
res.writeHead(403);
|
|
270
|
+
res.end('Forbidden');
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const clientId = (params.get('clientId') || '').trim();
|
|
274
|
+
const clientSecret = (params.get('clientSecret') || '').trim();
|
|
275
|
+
if (!clientId || !clientSecret) {
|
|
276
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
277
|
+
res.end('<p>Client ID and Client secret are required.</p>');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await mergeConfigPartial({ clientId, clientSecret });
|
|
282
|
+
} catch {
|
|
283
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
284
|
+
res.end('<p>Could not write config. Check write permissions on ~/.copyhub/</p>');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
bootstrapToken = null;
|
|
288
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
289
|
+
res.end(credentialSavedHtml);
|
|
290
|
+
setTimeout(finish, 400);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
res.writeHead(404);
|
|
295
|
+
res.end('Not found');
|
|
296
|
+
} catch (e) {
|
|
297
|
+
res.writeHead(500);
|
|
298
|
+
res.end('Server error');
|
|
299
|
+
server.close(() => reject(/** @type {Error} */ (e)));
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const idleMs = 20 * 60 * 1000;
|
|
304
|
+
const idleTimer = setTimeout(() => {
|
|
305
|
+
if (!settled) {
|
|
306
|
+
console.warn('Credential setup idle timeout (20 minutes).');
|
|
307
|
+
finish();
|
|
308
|
+
}
|
|
309
|
+
}, idleMs);
|
|
310
|
+
server.on('close', () => clearTimeout(idleTimer));
|
|
311
|
+
|
|
312
|
+
server.on('error', (err) => {
|
|
313
|
+
if (!settled) reject(err);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
server.listen(listenPort, '127.0.0.1', async () => {
|
|
317
|
+
const credUrl = `http://127.0.0.1:${listenPort}/credentials?t=${encodeURIComponent(bootstrapToken)}`;
|
|
318
|
+
console.log('Opening browser for Google OAuth credentials (localhost wizard)...');
|
|
319
|
+
console.log(`If it does not open: ${credUrl}`);
|
|
320
|
+
try {
|
|
321
|
+
await open(credUrl);
|
|
322
|
+
} catch {
|
|
323
|
+
console.log('Open this URL manually:');
|
|
324
|
+
console.log(credUrl);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
78
330
|
/** Shortcut presets (Electron Accelerator) per platform — embedded as JSON in the setup page. */
|
|
79
331
|
const PLATFORM_PRESETS = {
|
|
80
332
|
win: [
|
|
@@ -423,10 +675,25 @@ body{font-family:system-ui,sans-serif;min-height:100vh;margin:0;display:flex;ali
|
|
|
423
675
|
* @returns {Promise<void>}
|
|
424
676
|
*/
|
|
425
677
|
export async function runLoginFlow() {
|
|
426
|
-
|
|
678
|
+
let cfg = await loadConfig();
|
|
679
|
+
if (!cfg) {
|
|
680
|
+
const listenPort = resolveOAuthListenPort();
|
|
681
|
+
try {
|
|
682
|
+
await runCredentialBootstrap();
|
|
683
|
+
} catch (e) {
|
|
684
|
+
const code = /** @type {NodeJS.ErrnoException} */ (e)?.code;
|
|
685
|
+
if (code === 'EADDRINUSE') {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`Port ${listenPort} is already in use. Stop the other process or set ${ENV_OAUTH_REDIRECT_PORT} to a free port.`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
throw /** @type {Error} */ (e);
|
|
691
|
+
}
|
|
692
|
+
cfg = await loadConfig();
|
|
693
|
+
}
|
|
427
694
|
if (!cfg) {
|
|
428
695
|
throw new Error(
|
|
429
|
-
`
|
|
696
|
+
`OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env, run copyhub config, or complete the browser credential wizard.`,
|
|
430
697
|
);
|
|
431
698
|
}
|
|
432
699
|
const port = cfg.redirectPort ?? DEFAULT_OAUTH_REDIRECT_PORT;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { getAuthorizedClient } from './oauth.js';
|
|
3
|
+
import { loadSheetSyncTarget } from './config.js';
|
|
4
|
+
import { formatGoogleSheetUserMessage } from './sheet-api-errors.js';
|
|
5
|
+
|
|
6
|
+
/** @param {unknown} v */
|
|
7
|
+
function cellToString(v) {
|
|
8
|
+
if (v == null) return '';
|
|
9
|
+
if (typeof v === 'string') return v;
|
|
10
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
11
|
+
if (v instanceof Date) return v.toISOString();
|
|
12
|
+
return String(v);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {string} tabName */
|
|
16
|
+
function escapeTabName(tabName) {
|
|
17
|
+
return /[^A-Za-z0-9_]/.test(tabName)
|
|
18
|
+
? `'${tabName.replace(/'/g, "''")}'`
|
|
19
|
+
: tabName;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {number} daysAgo */
|
|
23
|
+
function overlayDailyTabNameDaysAgo(daysAgo) {
|
|
24
|
+
const d = new Date();
|
|
25
|
+
d.setDate(d.getDate() - daysAgo);
|
|
26
|
+
const y = d.getFullYear();
|
|
27
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
28
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
29
|
+
return `COPYHUB-${y}-${m}-${day}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Clipboard rows from one COPYHUB-YYYY-MM-DD tab (newest timestamps first).
|
|
34
|
+
* Reads used cells in A:B from row 2 onward (append puts newer rows at the bottom).
|
|
35
|
+
* @param {number} daysAgo 0 = today
|
|
36
|
+
* @param {number} [maxRowsCap] Keep only this many newest rows (after sort).
|
|
37
|
+
*/
|
|
38
|
+
export async function fetchOverlayDailyTabRows(daysAgo, maxRowsCap = 500) {
|
|
39
|
+
const day = Math.min(Math.max(Number(daysAgo), 0), 90);
|
|
40
|
+
const cap = Math.min(Math.max(Number(maxRowsCap), 1), 2000);
|
|
41
|
+
|
|
42
|
+
const target = await loadSheetSyncTarget();
|
|
43
|
+
if (!target) return [];
|
|
44
|
+
|
|
45
|
+
const auth = await getAuthorizedClient();
|
|
46
|
+
if (!auth.credentials.refresh_token && !auth.credentials.access_token) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sheets = google.sheets({ version: 'v4', auth });
|
|
51
|
+
const tab = overlayDailyTabNameDaysAgo(day);
|
|
52
|
+
const range = `${escapeTabName(tab)}!A2:B`;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const res = await sheets.spreadsheets.values.get({
|
|
56
|
+
spreadsheetId: target.spreadsheetId,
|
|
57
|
+
range,
|
|
58
|
+
valueRenderOption: 'FORMATTED_VALUE',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const values = res.data.values ?? [];
|
|
62
|
+
|
|
63
|
+
/** @type {Array<{ ts: string, text: string, synced: boolean }>} */
|
|
64
|
+
const items = [];
|
|
65
|
+
for (const row of values) {
|
|
66
|
+
if (!Array.isArray(row) || row.length < 2) continue;
|
|
67
|
+
const ts = cellToString(row[0]).trim();
|
|
68
|
+
const text = cellToString(row[1]).trim();
|
|
69
|
+
if (!text) continue;
|
|
70
|
+
if (/^(time|thời gian)$/i.test(ts)) continue;
|
|
71
|
+
items.push({ ts: ts || '', text, synced: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
items.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
|
|
75
|
+
return items.slice(0, cap);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new Error(formatGoogleSheetUserMessage(e));
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/sheets.js
CHANGED
|
@@ -7,7 +7,7 @@ import { formatGoogleSheetUserMessage } from './sheet-api-errors.js';
|
|
|
7
7
|
/**
|
|
8
8
|
* @param {string} tabName
|
|
9
9
|
*/
|
|
10
|
-
function a1RangeForTab(tabName) {
|
|
10
|
+
export function a1RangeForTab(tabName) {
|
|
11
11
|
const escaped = /[^A-Za-z0-9_]/.test(tabName)
|
|
12
12
|
? `'${tabName.replace(/'/g, "''")}'`
|
|
13
13
|
: tabName;
|
|
@@ -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
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
nativeImage,
|
|
14
14
|
} from 'electron';
|
|
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';
|
|
17
22
|
|
|
18
23
|
const gotLock = app.requestSingleInstanceLock();
|
|
19
24
|
if (!gotLock) {
|
|
@@ -38,8 +43,9 @@ function normalizeAccelerator(raw) {
|
|
|
38
43
|
const DEFAULT_ACCEL = 'CommandOrControl+Shift+H';
|
|
39
44
|
const HIDE_ON_START = process.env.COPYHUB_OVERLAY_HIDE_ON_START === '1';
|
|
40
45
|
|
|
41
|
-
/** Overlay
|
|
42
|
-
const OVERLAY_WIDTH = Math.round(460 * 0.
|
|
46
|
+
/** Overlay size (slightly larger than earlier ~70% width). */
|
|
47
|
+
const OVERLAY_WIDTH = Math.round(460 * 0.84);
|
|
48
|
+
const OVERLAY_HEIGHT = 590;
|
|
43
49
|
|
|
44
50
|
/** For UI / IPC: registered shortcut and raw value from .env */
|
|
45
51
|
let overlayHotkeyMeta = {
|
|
@@ -53,6 +59,27 @@ let tray = null;
|
|
|
53
59
|
/** Avoid hiding immediately after show (WM quirks). */
|
|
54
60
|
let blurHideEnabled = false;
|
|
55
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
|
+
|
|
56
83
|
/**
|
|
57
84
|
* Stay above other apps: screen-saver level (highest in Electron), moveTop, all workspaces.
|
|
58
85
|
* @param {BrowserWindow} w
|
|
@@ -85,7 +112,7 @@ function applyAlwaysOnTopStack(w) {
|
|
|
85
112
|
function createWindow() {
|
|
86
113
|
win = new BrowserWindow({
|
|
87
114
|
width: OVERLAY_WIDTH,
|
|
88
|
-
height:
|
|
115
|
+
height: OVERLAY_HEIGHT,
|
|
89
116
|
alwaysOnTop: true,
|
|
90
117
|
show: false,
|
|
91
118
|
/** Frameless: no title bar + menu (Windows/macOS). */
|
|
@@ -138,10 +165,8 @@ function createWindow() {
|
|
|
138
165
|
applyAlwaysOnTopStack(win);
|
|
139
166
|
win.focus();
|
|
140
167
|
win.webContents.send('overlay:open');
|
|
141
|
-
setTimeout(() =>
|
|
142
|
-
|
|
143
|
-
blurHideEnabled = true;
|
|
144
|
-
}, 800);
|
|
168
|
+
setTimeout(() => applyAlwaysOnTopStack(win), 120);
|
|
169
|
+
armBlurHideEnable(win);
|
|
145
170
|
});
|
|
146
171
|
}
|
|
147
172
|
|
|
@@ -180,10 +205,8 @@ function toggleOverlay() {
|
|
|
180
205
|
applyAlwaysOnTopStack(win);
|
|
181
206
|
win.focus();
|
|
182
207
|
win.webContents.send('overlay:open');
|
|
183
|
-
setTimeout(() =>
|
|
184
|
-
|
|
185
|
-
blurHideEnabled = true;
|
|
186
|
-
}, 800);
|
|
208
|
+
setTimeout(() => applyAlwaysOnTopStack(win), 120);
|
|
209
|
+
armBlurHideEnable(win);
|
|
187
210
|
}
|
|
188
211
|
}
|
|
189
212
|
|
|
@@ -222,6 +245,169 @@ function registerHotkeys() {
|
|
|
222
245
|
return { accelerator: '', usedFallback: false };
|
|
223
246
|
}
|
|
224
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
|
+
|
|
225
411
|
function registerIpc() {
|
|
226
412
|
ipcMain.handle('overlay:meta', () => ({
|
|
227
413
|
...overlayHotkeyMeta,
|
|
@@ -230,17 +416,81 @@ function registerIpc() {
|
|
|
230
416
|
sticky: STICKY_NO_BLUR,
|
|
231
417
|
}));
|
|
232
418
|
|
|
233
|
-
|
|
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 = {}) => {
|
|
234
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
|
+
|
|
235
472
|
return {
|
|
236
|
-
items
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
473
|
+
items,
|
|
474
|
+
page,
|
|
475
|
+
pageSize,
|
|
476
|
+
total,
|
|
477
|
+
totalPages,
|
|
478
|
+
sheetHint: lastHistorySheetMeta.sheetHint,
|
|
479
|
+
sheetFetched: lastHistorySheetMeta.sheetFetched,
|
|
480
|
+
sheetHasMore: !sheetIncrementalState.exhausted,
|
|
241
481
|
};
|
|
242
482
|
} catch (e) {
|
|
243
|
-
return {
|
|
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
|
+
};
|
|
244
494
|
}
|
|
245
495
|
});
|
|
246
496
|
|
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) => {
|
package/ui/renderer/index.html
CHANGED
|
@@ -58,6 +58,53 @@
|
|
|
58
58
|
cursor: grabbing;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
#list-wrap {
|
|
62
|
+
flex: 1;
|
|
63
|
+
min-height: 0;
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
position: relative;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#list-loading {
|
|
70
|
+
flex-shrink: 0;
|
|
71
|
+
display: none;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 10px;
|
|
74
|
+
padding: 8px 14px;
|
|
75
|
+
font-size: 12px;
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
color: var(--accent);
|
|
78
|
+
background: linear-gradient(90deg, var(--accent-soft), rgba(61, 90, 254, 0.02));
|
|
79
|
+
border-bottom: 1px solid var(--border);
|
|
80
|
+
-webkit-app-region: no-drag;
|
|
81
|
+
app-region: no-drag;
|
|
82
|
+
animation: hint-pulse 1.4s ease-in-out infinite;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#list-loading.visible {
|
|
86
|
+
display: flex;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#list-loading .spinner {
|
|
90
|
+
width: 16px;
|
|
91
|
+
height: 16px;
|
|
92
|
+
flex-shrink: 0;
|
|
93
|
+
border: 2px solid rgba(61, 90, 254, 0.35);
|
|
94
|
+
border-top-color: var(--accent);
|
|
95
|
+
border-radius: 50%;
|
|
96
|
+
animation: spin 0.65s linear infinite;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@keyframes spin {
|
|
100
|
+
to { transform: rotate(360deg); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@keyframes hint-pulse {
|
|
104
|
+
0%, 100% { opacity: 1; }
|
|
105
|
+
50% { opacity: 0.82; }
|
|
106
|
+
}
|
|
107
|
+
|
|
61
108
|
#list {
|
|
62
109
|
flex: 1;
|
|
63
110
|
min-height: 0;
|
|
@@ -173,33 +220,173 @@
|
|
|
173
220
|
border-radius: var(--radius);
|
|
174
221
|
font-size: 13px;
|
|
175
222
|
}
|
|
223
|
+
|
|
224
|
+
#pager {
|
|
225
|
+
flex-shrink: 0;
|
|
226
|
+
display: none;
|
|
227
|
+
align-items: center;
|
|
228
|
+
justify-content: center;
|
|
229
|
+
gap: 12px;
|
|
230
|
+
padding: 8px 12px 12px;
|
|
231
|
+
border-top: 1px solid var(--border);
|
|
232
|
+
background: linear-gradient(180deg, var(--bg) 0%, #fafbfc 100%);
|
|
233
|
+
-webkit-app-region: no-drag;
|
|
234
|
+
app-region: no-drag;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#pager.visible {
|
|
238
|
+
display: flex;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#pager button {
|
|
242
|
+
font: inherit;
|
|
243
|
+
font-size: 13px;
|
|
244
|
+
font-weight: 600;
|
|
245
|
+
padding: 6px 14px;
|
|
246
|
+
border-radius: 8px;
|
|
247
|
+
border: 1px solid var(--border);
|
|
248
|
+
background: var(--surface);
|
|
249
|
+
color: var(--text);
|
|
250
|
+
cursor: pointer;
|
|
251
|
+
transition: background 0.15s, border-color 0.15s;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#pager button:hover:not(:disabled) {
|
|
255
|
+
background: var(--surface-hover);
|
|
256
|
+
border-color: rgba(61, 90, 254, 0.35);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#pager button:disabled {
|
|
260
|
+
opacity: 0.45;
|
|
261
|
+
cursor: default;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#pg-info {
|
|
265
|
+
font-size: 12px;
|
|
266
|
+
color: var(--text-muted);
|
|
267
|
+
min-width: 0;
|
|
268
|
+
text-align: center;
|
|
269
|
+
flex: 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.sheet-hint {
|
|
273
|
+
flex-shrink: 0;
|
|
274
|
+
font-size: 11px;
|
|
275
|
+
line-height: 1.45;
|
|
276
|
+
color: var(--text-muted);
|
|
277
|
+
padding: 6px 14px 4px;
|
|
278
|
+
text-align: center;
|
|
279
|
+
border-top: 1px solid var(--border);
|
|
280
|
+
background: var(--bg);
|
|
281
|
+
-webkit-app-region: no-drag;
|
|
282
|
+
app-region: no-drag;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.sheet-hint[hidden] {
|
|
286
|
+
display: none !important;
|
|
287
|
+
}
|
|
176
288
|
</style>
|
|
177
289
|
</head>
|
|
178
290
|
<body>
|
|
179
291
|
<div id="drag-strip" title="Drag to move window"></div>
|
|
180
|
-
<div id="list"
|
|
292
|
+
<div id="list-wrap">
|
|
293
|
+
<div id="list-loading" aria-live="polite">
|
|
294
|
+
<div class="spinner" aria-hidden="true"></div>
|
|
295
|
+
<span>Syncing Google Sheet…</span>
|
|
296
|
+
</div>
|
|
297
|
+
<div id="list"></div>
|
|
298
|
+
</div>
|
|
299
|
+
<div id="sheet-hint" class="sheet-hint" hidden></div>
|
|
300
|
+
<div id="pager" aria-label="Pagination">
|
|
301
|
+
<button type="button" id="pg-prev">Previous</button>
|
|
302
|
+
<span id="pg-info"></span>
|
|
303
|
+
<button type="button" id="pg-next">Next</button>
|
|
304
|
+
</div>
|
|
181
305
|
<script>
|
|
182
306
|
const listEl = document.getElementById('list');
|
|
307
|
+
const listLoadingEl = document.getElementById('list-loading');
|
|
308
|
+
const pagerEl = document.getElementById('pager');
|
|
309
|
+
const pgPrev = document.getElementById('pg-prev');
|
|
310
|
+
const pgNext = document.getElementById('pg-next');
|
|
311
|
+
const pgInfo = document.getElementById('pg-info');
|
|
312
|
+
const sheetHintEl = document.getElementById('sheet-hint');
|
|
313
|
+
|
|
314
|
+
const pageSize = 10;
|
|
315
|
+
let currentPage = 1;
|
|
316
|
+
/** @type {{ total: number, totalPages: number }} */
|
|
317
|
+
let lastPaging = { total: 0, totalPages: 1 };
|
|
318
|
+
let lastSheetHasMore = false;
|
|
319
|
+
let sheetSyncInFlight = false;
|
|
183
320
|
|
|
184
|
-
|
|
321
|
+
function setLoadingUi(on) {
|
|
322
|
+
listLoadingEl.classList.toggle('visible', on);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function syncPagerBar() {
|
|
326
|
+
const { total, totalPages } = lastPaging;
|
|
327
|
+
if (!total) return;
|
|
328
|
+
pgPrev.disabled = sheetSyncInFlight || currentPage <= 1;
|
|
329
|
+
pgNext.disabled =
|
|
330
|
+
sheetSyncInFlight ||
|
|
331
|
+
(currentPage * pageSize >= total && !lastSheetHasMore);
|
|
332
|
+
const from = (currentPage - 1) * pageSize + 1;
|
|
333
|
+
const to = Math.min(currentPage * pageSize, total);
|
|
334
|
+
const moreMark = lastSheetHasMore && currentPage * pageSize >= total ? '+' : '';
|
|
335
|
+
const pagesShown =
|
|
336
|
+
lastSheetHasMore && currentPage * pageSize >= total
|
|
337
|
+
? Math.max(totalPages, currentPage + 1)
|
|
338
|
+
: totalPages;
|
|
339
|
+
pgInfo.textContent = `Page ${currentPage} / ${pagesShown} · ${from}–${to} of ${total}${moreMark}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renderHistoryResponse(res) {
|
|
185
343
|
listEl.innerHTML = '';
|
|
186
|
-
|
|
344
|
+
|
|
187
345
|
if (res && res.error) {
|
|
346
|
+
pagerEl.classList.remove('visible');
|
|
347
|
+
sheetHintEl.hidden = true;
|
|
188
348
|
const e = document.createElement('div');
|
|
189
349
|
e.id = 'err';
|
|
190
350
|
e.textContent = res.error;
|
|
191
351
|
listEl.appendChild(e);
|
|
352
|
+
lastPaging = { total: 0, totalPages: 1 };
|
|
353
|
+
lastSheetHasMore = false;
|
|
192
354
|
return;
|
|
193
355
|
}
|
|
356
|
+
|
|
357
|
+
lastSheetHasMore = Boolean(res.sheetHasMore);
|
|
358
|
+
currentPage = res.page ?? currentPage;
|
|
194
359
|
const rows = res && Array.isArray(res.items) ? res.items : [];
|
|
195
|
-
|
|
360
|
+
const total = res.total ?? 0;
|
|
361
|
+
const totalPages = res.totalPages ?? 1;
|
|
362
|
+
lastPaging = { total, totalPages };
|
|
363
|
+
|
|
364
|
+
if (!total) {
|
|
365
|
+
pagerEl.classList.remove('visible');
|
|
366
|
+
if (res.sheetHint) {
|
|
367
|
+
sheetHintEl.hidden = false;
|
|
368
|
+
sheetHintEl.textContent = res.sheetHint;
|
|
369
|
+
} else {
|
|
370
|
+
sheetHintEl.hidden = true;
|
|
371
|
+
}
|
|
196
372
|
const d = document.createElement('div');
|
|
197
373
|
d.id = 'empty';
|
|
198
374
|
d.textContent =
|
|
199
|
-
'No history yet — run copyhub start and copy something. Click a row to copy again; click outside or Esc to close.';
|
|
375
|
+
'No local history yet — run copyhub start and copy something. Click a row to copy again; click outside or Esc to close.';
|
|
200
376
|
listEl.appendChild(d);
|
|
201
377
|
return;
|
|
202
378
|
}
|
|
379
|
+
|
|
380
|
+
if (res.sheetHint) {
|
|
381
|
+
sheetHintEl.hidden = false;
|
|
382
|
+
sheetHintEl.textContent = res.sheetHint;
|
|
383
|
+
} else {
|
|
384
|
+
sheetHintEl.hidden = true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
pagerEl.classList.add('visible');
|
|
388
|
+
syncPagerBar();
|
|
389
|
+
|
|
203
390
|
for (const r of rows) {
|
|
204
391
|
const div = document.createElement('div');
|
|
205
392
|
div.className = 'row' + (r.synced ? ' synced' : '');
|
|
@@ -215,10 +402,72 @@
|
|
|
215
402
|
}
|
|
216
403
|
}
|
|
217
404
|
|
|
405
|
+
async function loadHistory(opt = {}) {
|
|
406
|
+
const refreshCache = Boolean(opt.refreshCache);
|
|
407
|
+
if (refreshCache) currentPage = 1;
|
|
408
|
+
if (typeof opt.page === 'number' && opt.page >= 1) {
|
|
409
|
+
currentPage = opt.page;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (refreshCache) {
|
|
413
|
+
sheetSyncInFlight = true;
|
|
414
|
+
setLoadingUi(true);
|
|
415
|
+
pgPrev.disabled = true;
|
|
416
|
+
pgNext.disabled = true;
|
|
417
|
+
|
|
418
|
+
const quick = await window.copyhub.getHistoryLocal({
|
|
419
|
+
page: currentPage,
|
|
420
|
+
pageSize,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (quick.error) {
|
|
424
|
+
sheetSyncInFlight = false;
|
|
425
|
+
setLoadingUi(false);
|
|
426
|
+
renderHistoryResponse(quick);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
currentPage = quick.page ?? currentPage;
|
|
431
|
+
renderHistoryResponse(quick);
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const full = await window.copyhub.getHistory({
|
|
435
|
+
page: currentPage,
|
|
436
|
+
pageSize,
|
|
437
|
+
refresh: true,
|
|
438
|
+
});
|
|
439
|
+
currentPage = full.page ?? currentPage;
|
|
440
|
+
renderHistoryResponse(full);
|
|
441
|
+
} finally {
|
|
442
|
+
sheetSyncInFlight = false;
|
|
443
|
+
setLoadingUi(false);
|
|
444
|
+
syncPagerBar();
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const res = await window.copyhub.getHistory({
|
|
450
|
+
page: currentPage,
|
|
451
|
+
pageSize,
|
|
452
|
+
refresh: false,
|
|
453
|
+
});
|
|
454
|
+
renderHistoryResponse(res);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
pgPrev.addEventListener('click', () => {
|
|
458
|
+
if (sheetSyncInFlight || currentPage <= 1) return;
|
|
459
|
+
void loadHistory({ page: currentPage - 1 });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
pgNext.addEventListener('click', () => {
|
|
463
|
+
if (sheetSyncInFlight) return;
|
|
464
|
+
void loadHistory({ page: currentPage + 1 });
|
|
465
|
+
});
|
|
466
|
+
|
|
218
467
|
window.copyhub.onOpen(() => {
|
|
219
|
-
void
|
|
468
|
+
void loadHistory({ refreshCache: true });
|
|
220
469
|
});
|
|
221
|
-
void refresh();
|
|
222
470
|
</script>
|
|
223
471
|
</body>
|
|
224
472
|
</html>
|
|
473
|
+
|