├── package.json ├── .gitignore ├── LICENSE ├── README.md └── index.js /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hain-plugin-clipboard", 3 | "version": "0.6.2", 4 | "description": "Restore clipboard history", 5 | "main": "index.js", 6 | "author": "Luke Plaster", 7 | "license": "MIT", 8 | "keywords": [ 9 | "hain-0.1.0" 10 | ], 11 | "hain": { 12 | "prefix": "/clipboard", 13 | "usage": "type /clipboard to show clipboard history", 14 | "icon": "#fa fa-clipboard", 15 | "redirect": "/clipboard ", 16 | "group": "Clipboard History" 17 | }, 18 | "dependencies": { 19 | "copy-paste-win32fix": "^1.2.0", 20 | "eskape": "^1.2.0", 21 | "s-ago": "^1.0.6" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Luke Plaster 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hain-plugin-clipboard 2 | > Restore clipboard history in Windows like in Alfred for Mac OS! 3 | 4 |   5 | 6 |  7 | 8 | ## Installation 9 | 10 | [Install](https://github.com/appetizermonster/hain/releases) and open [Hain](https://github.com/appetizermonster/hain) (alt+space), then type this command: 11 | ``` 12 | /hpm install hain-plugin-clipboard 13 | ``` 14 | then, once installed: 15 | ``` 16 | /reload 17 | ``` 18 | 19 | ## How it works 20 | 21 | This plugin polls the clipboard every few seconds to look for changes in clipboard content. 22 | If the current clipboard content is not present in the clipboard history it's added to the list that appears when you type `/clipboard`. 23 | 24 | ## Limitations 25 | 26 | A maximum of 100 entries are saved in order to conserve memory usage. 27 | 28 | This limit will be soon be configurable in the preferences pane! 29 | 30 | ## Future work 31 | 32 | - Add preferences 33 | - Support replaying rich text and/or images 34 | - Allow clearing by time range (5 mins, 1 hour, 1 day, ...) 35 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function(){ 4 | 5 | const ncp = require('copy-paste-win32fix'); 6 | const ago = require('s-ago'); 7 | const eskape = require('eskape'); 8 | 9 | const MAX_CLIPS = 100; 10 | const POLL_INTERVAL_MS = 3000; 11 | const DEFAULT_CLIP_DISPLAY_MAX_CHARS = 100; 12 | const CLIP_PREVIEW_WHEN_MAX_CHARS = 100; 13 | const PREVIEW_HTML = (text) => eskape` 14 | 15 | 16 |
17 | 26 | 27 |${text}
28 | `;
29 |
30 | module.exports = (context) => {
31 | const CURRENT_API_VERSION = context.CURRENT_API_VERSION;
32 |
33 | const app = context.app;
34 | const toast = context.toast;
35 | const matchutil = context.matchutil;
36 |
37 | const isLegacyAPIVersion =
38 | ! CURRENT_API_VERSION || ['hain0', 'hain-0.1.0', 'hain-0.3.0', 'hain-0.4.0']
39 | .indexOf(CURRENT_API_VERSION) !== -1;
40 |
41 | const clips = [];
42 | let lastClips; // a shallow copy of `clips`, kept between search/execute
43 |
44 | function abbr(num) {
45 | if (num >= 1000000) {
46 | return `${num/1000000}m`;
47 | } else if (num >= 1000) {
48 | return `${num/1000}k`;
49 | } else {
50 | return num;
51 | }
52 | }
53 |
54 | function startup() {
55 | var lastClip = null;
56 | var saveClipFn;
57 | setInterval(saveClipFn = () => {
58 | ncp.paste((err, clip) => {
59 | if (err) return;
60 | if (typeof clip !== 'string'
61 | || ! clip.trim().length
62 | || lastClip === clip) {
63 | return;
64 | }
65 | clip = clip.toString();
66 | clips.unshift({
67 | content: lastClip = clip,
68 | size: clip.length,
69 | time: new Date()
70 | });
71 | if (clips.length > MAX_CLIPS) {
72 | clips.pop();
73 | }
74 | });
75 | }, POLL_INTERVAL_MS);
76 | saveClipFn();
77 | }
78 |
79 | function search(query, res) {
80 | const querytrim = query.replace(' ', '');
81 | let results;
82 | lastClips = clips.concat(); // shallow copy
83 | if (querytrim.length) {
84 | results = matchutil.fuzzy(clips, querytrim, x => x.content);
85 | } else {
86 | results = clips;
87 | res.add([{
88 | id: 'clear',
89 | payload: 'clear',
90 | title: 'Clear this list',
91 | icon: '#fa fa-trash'
92 | }]);
93 | }
94 | res.add(results.map((clip, idx) => {
95 | let title, isTrimmed, isPreviewable;
96 |
97 | // only trim down the string for old hain APIs
98 | const maxTitleCharsToDisplay = isLegacyAPIVersion ? DEFAULT_CLIP_DISPLAY_MAX_CHARS : 1024;
99 |
100 | if (clip.elem) {
101 | // fuzzy match
102 | const trimmed = clip.elem.content.trim();
103 | const clipped = trimmed.substr(0, maxTitleCharsToDisplay);
104 | isPreviewable = trimmed.length >= CLIP_PREVIEW_WHEN_MAX_CHARS;
105 | if (maxTitleCharsToDisplay === clipped.length) {
106 | title = clipped; // no bold - it looks strange when clipped
107 | } else {
108 | title = matchutil.makeStringBoldHtml(clipped, clip.matches);
109 | }
110 | idx = clips.indexOf(clip = clip.elem);
111 | } else {
112 | // normal result
113 | title = clip.content.substr(0, maxTitleCharsToDisplay);
114 | isPreviewable = clip.content.length >= CLIP_PREVIEW_WHEN_MAX_CHARS;
115 | }
116 | isTrimmed = maxTitleCharsToDisplay === title.length;
117 |
118 | // TODO: remove hack to escape html but keep bolds
119 | title = title.replace(//gi, '%%B%%');
120 | title = title.replace(/<\/b>/gi, '%%EB%%');
121 | title = title.replace(/&/g, '&');
122 | title = title.replace(/');
124 | title = title.replace(/%%EB%%/g, '');
125 |
126 | if (isTrimmed) {
127 | title += '…';
128 | }
129 | title = title.replace(/[\r\n]/g, '');
130 |
131 | return {
132 | id: idx,
133 | payload: '',
134 | title: isLegacyAPIVersion ? title : { singleLine: true, text: title },
135 | icon: '#fa fa-clipboard',
136 | desc: `Copy to clipboard (${abbr(clip.size)} characters, ${ago(clip.time)})`,
137 | preview: isPreviewable
138 | };
139 | }));
140 | }
141 |
142 | function execute(id, payload) {
143 | if (payload === 'clear') {
144 | clips.length = lastClips.length = 0;
145 | toast.enqueue('Clipboard history cleared!');
146 | setTimeout(() => {
147 | app.close();
148 | }, 1000);
149 | } else {
150 | ncp.copy(lastClips[id].content, () => {
151 | app.close();
152 | });
153 | }
154 | }
155 |
156 | function renderPreview(id, payload, render) {
157 | if (payload.length) return;
158 | render(PREVIEW_HTML(lastClips[id].content));
159 | }
160 |
161 | return { startup, search, execute, renderPreview };
162 | };
163 |
164 | })();
165 |
--------------------------------------------------------------------------------