clipspin 0.1.1
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/README.md +66 -0
- package/index.js +246 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# ClipSpin
|
|
2
|
+
|
|
3
|
+
ClipSpin ist ein temporärer macOS-Clipboard-Cycler. Du gibst eine JSON-Liste mit Texten an; bei jedem `Cmd+V` wird der aktuelle Text eingefügt und direkt danach der nächste vorbereitet.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
brew install oliverjessner/tap/clipspin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
ClipSpin nutzt einen globalen Keyboard-Hook. Falls `Cmd+V` nicht erkannt wird, erlaube deiner Terminal-App unter **Systemeinstellungen -> Datenschutz & Sicherheit -> Bedienungshilfen** den Zugriff. Je nach System kann auch **Eingabeüberwachung** nötig sein.
|
|
12
|
+
|
|
13
|
+
## Verwendung
|
|
14
|
+
|
|
15
|
+
Inline:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
clipspin '["Erster Text", "Zweiter Text", "Dritter Text"]'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Aus einer Datei:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
clipspin snippets.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Per Pipe:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cat snippets.json | clipspin
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`snippets.json` muss ein JSON-Array aus Strings enthalten:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
["Erster Text", "Zweiter Text", "Dritter Text"]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Nach dem Start liegt der erste Eintrag in der Zwischenablage. Mit jedem `Cmd+V` wird der nächste Eintrag vorbereitet; nach dem letzten beginnt ClipSpin wieder von vorne.
|
|
40
|
+
|
|
41
|
+
Stoppen kannst du ClipSpin mit `Ctrl+C` im Terminal. Die vorherige Zwischenablage wird danach wiederhergestellt.
|
|
42
|
+
|
|
43
|
+
Wenn `Cmd+V` nicht erkannt wird, starte ClipSpin im Debug-Modus:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
clipspin --debug '["Erster Text", "Zweiter Text"]'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Beim Drücken von Tasten sollten dann `[debug]`-Zeilen erscheinen. Wenn keine erscheinen, fehlen macOS-Berechtigungen für die Terminal-App.
|
|
50
|
+
|
|
51
|
+
## Veröffentlichen
|
|
52
|
+
|
|
53
|
+
Vor dem Publish die Version in `package.json` erhöhen. Danach:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm run publish:npm
|
|
57
|
+
npm run publish:brew
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Oder beides nacheinander:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm run publish:all
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`publish:brew` erwartet, dass die npm-Version bereits veröffentlicht ist. Der Befehl aktualisiert `oliverjessner/tap/clipspin`, setzt die npm-Tarball-URL samt SHA256, führt Homebrew-Checks aus, committet die Formula und pusht den Tap.
|
package/index.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { uIOhook, UiohookKey } from 'uiohook-napi';
|
|
7
|
+
|
|
8
|
+
function parseArgs() {
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const inputArgs = [];
|
|
11
|
+
let debug = false;
|
|
12
|
+
|
|
13
|
+
for (const arg of args) {
|
|
14
|
+
if (arg === '--debug') {
|
|
15
|
+
debug = true;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
inputArgs.push(arg);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { debug, inputArgs };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readStdin() {
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
let data = '';
|
|
28
|
+
|
|
29
|
+
process.stdin.setEncoding('utf8');
|
|
30
|
+
|
|
31
|
+
process.stdin.on('data', chunk => {
|
|
32
|
+
data += chunk;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
process.stdin.on('end', () => {
|
|
36
|
+
resolve(data.trim());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (process.stdin.isTTY) {
|
|
40
|
+
resolve('');
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setClipboard(text) {
|
|
46
|
+
const result = spawnSync('pbcopy', {
|
|
47
|
+
input: text,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (result.error) {
|
|
52
|
+
throw result.error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (result.status !== 0) {
|
|
56
|
+
throw new Error(result.stderr || 'pbcopy failed.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getClipboard() {
|
|
61
|
+
const result = spawnSync('pbpaste', {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (result.error || result.status !== 0) {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result.stdout ?? '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function getInput(args) {
|
|
73
|
+
if (args.length > 0) {
|
|
74
|
+
const first = args[0];
|
|
75
|
+
|
|
76
|
+
if (first.endsWith('.json')) {
|
|
77
|
+
return readFileSync(first, 'utf8');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return args.join(' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return await readStdin();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseItems(raw) {
|
|
87
|
+
let parsed;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
console.error('Invalid JSON. Expected: ["Text 1", "Text 2", "Text 3"]');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!Array.isArray(parsed)) {
|
|
97
|
+
console.error('Input must be a JSON array.');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (parsed.length === 0) {
|
|
102
|
+
console.error('JSON array must not be empty.');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!parsed.every(item => typeof item === 'string')) {
|
|
107
|
+
console.error('Every array item must be a string.');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return parsed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { debug, inputArgs } = parseArgs();
|
|
115
|
+
const rawInput = await getInput(inputArgs);
|
|
116
|
+
|
|
117
|
+
if (!rawInput) {
|
|
118
|
+
console.error('Usage: clipspin \'["A", "B", "C"]\'');
|
|
119
|
+
console.error('Debug: clipspin --debug \'["A", "B", "C"]\'');
|
|
120
|
+
console.error(' or: cat snippets.json | clipspin');
|
|
121
|
+
console.error(' or: clipspin snippets.json');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const items = parseItems(rawInput);
|
|
126
|
+
|
|
127
|
+
let index = 0;
|
|
128
|
+
let lastPasteAt = 0;
|
|
129
|
+
let pendingPaste = false;
|
|
130
|
+
let pasteFallbackTimer = null;
|
|
131
|
+
const originalClipboard = getClipboard();
|
|
132
|
+
|
|
133
|
+
function currentLabel() {
|
|
134
|
+
return `${index + 1}/${items.length}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function primeClipboard() {
|
|
138
|
+
setClipboard(items[index]);
|
|
139
|
+
console.log(`Clipboard primed: ${currentLabel()}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function moveToNextItem() {
|
|
143
|
+
index = (index + 1) % items.length;
|
|
144
|
+
primeClipboard();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function advanceAfterPaste(delayMs) {
|
|
148
|
+
pendingPaste = false;
|
|
149
|
+
|
|
150
|
+
if (pasteFallbackTimer !== null) {
|
|
151
|
+
clearTimeout(pasteFallbackTimer);
|
|
152
|
+
pasteFallbackTimer = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
moveToNextItem();
|
|
157
|
+
}, delayMs);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function restoreAndExit(code = 0) {
|
|
161
|
+
try {
|
|
162
|
+
setClipboard(originalClipboard);
|
|
163
|
+
console.log('\nClipboard restored.');
|
|
164
|
+
} catch {
|
|
165
|
+
console.error('\nCould not restore clipboard.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
process.exit(code);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
process.on('SIGINT', () => restoreAndExit(0));
|
|
172
|
+
process.on('SIGTERM', () => restoreAndExit(0));
|
|
173
|
+
|
|
174
|
+
primeClipboard();
|
|
175
|
+
|
|
176
|
+
console.log('paste-cycle active.');
|
|
177
|
+
console.log('Press Cmd + V anywhere to paste/cycle.');
|
|
178
|
+
console.log('Press Ctrl + C here to stop.\n');
|
|
179
|
+
|
|
180
|
+
function isVKey(event) {
|
|
181
|
+
return event.keycode === UiohookKey.V;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function logKeyEvent(name, event) {
|
|
185
|
+
if (!debug) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(
|
|
190
|
+
`[debug] ${name}: keycode=${event.keycode} meta=${event.metaKey} ctrl=${event.ctrlKey} alt=${event.altKey} shift=${event.shiftKey}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
uIOhook.on('keydown', event => {
|
|
195
|
+
logKeyEvent('keydown', event);
|
|
196
|
+
|
|
197
|
+
const isCommandV = event.metaKey === true && isVKey(event);
|
|
198
|
+
|
|
199
|
+
if (!isCommandV) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
|
|
205
|
+
// Prevent repeat events if Cmd+V is held down.
|
|
206
|
+
if (now - lastPasteAt < 250) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (pendingPaste) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
lastPasteAt = now;
|
|
215
|
+
pendingPaste = true;
|
|
216
|
+
|
|
217
|
+
if (debug) {
|
|
218
|
+
console.log('[debug] Cmd+V detected.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
pasteFallbackTimer = setTimeout(() => {
|
|
222
|
+
if (pendingPaste) {
|
|
223
|
+
advanceAfterPaste(0);
|
|
224
|
+
}
|
|
225
|
+
}, 450);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
uIOhook.on('keyup', event => {
|
|
229
|
+
logKeyEvent('keyup', event);
|
|
230
|
+
|
|
231
|
+
if (!pendingPaste || !isVKey(event)) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Let the application finish handling Cmd+V before replacing the clipboard.
|
|
236
|
+
advanceAfterPaste(80);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
uIOhook.start();
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.error('Could not start keyboard hook.');
|
|
243
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
244
|
+
console.error('On macOS, allow your terminal app in Accessibility and Input Monitoring.');
|
|
245
|
+
restoreAndExit(1);
|
|
246
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clipspin",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A temporary macOS clipboard cycler CLI that iterates through JSON text snippets on every Cmd+V.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"clipspin": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node index.js",
|
|
15
|
+
"dev": "node index.js",
|
|
16
|
+
"lint": "node --check index.js",
|
|
17
|
+
"prepublishOnly": "npm run lint",
|
|
18
|
+
"publish:npm": "npm publish --access public",
|
|
19
|
+
"publish:brew": "node scripts/update-homebrew-formula.js",
|
|
20
|
+
"publish": "npm run publish:npm && npm run publish:brew"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"clipboard",
|
|
24
|
+
"paste",
|
|
25
|
+
"macos",
|
|
26
|
+
"cli",
|
|
27
|
+
"snippet",
|
|
28
|
+
"cmd-v",
|
|
29
|
+
"productivity"
|
|
30
|
+
],
|
|
31
|
+
"author": "Oliver Jessner",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"os": [
|
|
37
|
+
"darwin"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"uiohook-napi": "^1.5.5"
|
|
41
|
+
}
|
|
42
|
+
}
|